@geraldmaron/construct 1.0.6 → 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 (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
@@ -4,20 +4,39 @@
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
+ *
9
+ * Classification MUST be a pure function of (sourcePath, extractedText,
10
+ * related, profile). Same input must yield identical output across runs.
11
+ * Outcomes-aware re-ranking is deliberately not consulted here: cached
12
+ * success-rate history would make the same file classify differently
13
+ * across runs, which is a non-determinism bug. Outcomes can influence
14
+ * downstream routing, but never classify.
8
15
  *
9
16
  * NO LLM call. The daemon path must remain synchronous and cheap; intent
10
17
  * verification belongs in offline measurement, not inline classification.
11
18
  *
12
19
  * Default profile is `rnd` so existing R&D users see no behavior change.
13
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.
14
34
  */
15
35
 
16
36
  import path from 'node:path';
17
37
 
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).
38
+ // Backward-compatible exports. RND defaults preserve the symbol surface
39
+ // referenced by intake-config, prepare, and existing tests.
21
40
  import rndTable from './tables/rnd.mjs';
22
41
  export const INTAKE_TYPES = rndTable.INTAKE_TYPES;
23
42
  export const RD_STAGES = rndTable.STAGES;
@@ -39,22 +58,12 @@ export const RECOMMENDED_ACTIONS = [
39
58
  'archive',
40
59
  ];
41
60
 
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.
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.
46
64
  import operationsTable from './tables/operations.mjs';
47
65
  import creativeTable from './tables/creative.mjs';
48
66
  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
- }
58
67
 
59
68
  const TABLES = {
60
69
  rnd: rndTable,
@@ -63,26 +72,105 @@ const TABLES = {
63
72
  research: researchTable,
64
73
  };
65
74
 
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.
80
+ const FILENAME_BOOST = 0.4;
81
+ const FILENAME_HINTS = [
82
+ { re: /postmortem/i, intakeType: 'incident' },
83
+ { re: /incident-report|incident_report/i, intakeType: 'incident' },
84
+ { re: /^adr[-_]/i, intakeType: 'architecture' },
85
+ { re: /\badr[-_]\d/i, intakeType: 'architecture' },
86
+ { re: /^rfc[-_]/i, intakeType: 'architecture' },
87
+ { re: /^prd[-_]/i, intakeType: 'requirement' },
88
+ { re: /security|cve|vulnerability/i, intakeType: 'security' },
89
+ { re: /research|study|literature/i, intakeType: 'research' },
90
+ { re: /eval|metric|benchmark/i, intakeType: 'eval-finding' },
91
+ { re: /-bug\b|\bbug-/i, intakeType: 'bug' },
92
+ { re: /runbook/i, intakeType: 'ops' },
93
+ ];
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
+
116
+ // Title-level negative keywords flip a classification away from a misleading
117
+ // body match. A postmortem describes a bug in retrospect but is not a bug
118
+ // report; an incident-report uses crash vocabulary but is an incident.
119
+ const TITLE_PENALTY = 0.5;
120
+ const TITLE_OVERRIDES = [
121
+ { re: /^#\s*postmortem\b/im, penalize: 'bug' },
122
+ { re: /^#\s*incident\s+report\b/im, penalize: 'bug' },
123
+ { re: /^#\s*post-mortem\b/im, penalize: 'bug' },
124
+ { re: /^#\s*architecture\s+decision\b/im, penalize: 'bug' },
125
+ { re: /^#\s*adr[-:\s]/im, penalize: 'bug' },
126
+ { re: /^#\s*security\s+(advisory|finding)\b/im, penalize: 'bug' },
127
+ ];
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
+
66
161
  function resolveTable(profile) {
67
162
  if (!profile) return rndTable;
68
163
  const id = typeof profile === 'string' ? profile : profile.id;
69
164
  return TABLES[id] || rndTable;
70
165
  }
71
166
 
72
- function normalize(text) {
73
- return String(text || '').toLowerCase();
74
- }
75
-
76
- function countMatches(haystack, keywords) {
77
- let hits = 0;
78
- const matched = [];
79
- for (const kw of keywords) {
80
- if (haystack.includes(kw)) {
81
- hits += 1;
82
- matched.push(kw);
83
- }
84
- }
85
- 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 };
86
174
  }
87
175
 
88
176
  function buildSignalText({ sourcePath, extractedText, related }) {
@@ -90,60 +178,166 @@ function buildSignalText({ sourcePath, extractedText, related }) {
90
178
  const slug = basename.replace(/[._-]/g, ' ');
91
179
  const body = extractedText ? extractedText.slice(0, 4000) : '';
92
180
  const relatedTitles = (related || []).map((r) => r?.title || '').join(' ');
93
- return normalize(`${slug} ${body} ${relatedTitles}`);
181
+ return `${slug} ${body} ${relatedTitles}`;
182
+ }
183
+
184
+ function filenameHintFor(sourcePath) {
185
+ if (!sourcePath) return null;
186
+ const base = path.basename(sourcePath);
187
+ for (const hint of FILENAME_HINTS) {
188
+ if (hint.re.test(base)) return hint.intakeType;
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function titlePenaltiesFor(extractedText) {
194
+ const penalties = {};
195
+ const text = String(extractedText || '');
196
+ for (const rule of TITLE_OVERRIDES) {
197
+ if (rule.re.test(text)) {
198
+ penalties[rule.penalize] = (penalties[rule.penalize] || 0) + TITLE_PENALTY;
199
+ }
200
+ }
201
+ return penalties;
202
+ }
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
+ };
94
242
  }
95
243
 
96
244
  /**
97
245
  * Classify an intake signal against the active profile (defaults to RND).
98
246
  *
99
247
  * Backward-compatible: callers that did not pass a `profile` arg keep getting
100
- * RND output, byte-identical to the pre-B2 behavior.
248
+ * RND output for the same input. Output is deterministic. The optional `cwd`
249
+ * argument is accepted for backward compatibility but is not consulted.
101
250
  *
102
251
  * @param {object} input
103
252
  * @param {string} [input.sourcePath]
104
253
  * @param {string} [input.extractedText]
105
254
  * @param {Array} [input.related]
106
255
  * @param {string|object} [input.profile] - profile id (string) or full profile object
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
+ * }}
107
264
  */
108
- export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null, cwd = null } = {}) {
265
+ export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null } = {}) {
109
266
  const table = resolveTable(profile);
110
267
  const signal = buildSignalText({ sourcePath, extractedText, related });
268
+ const filenameHint = filenameHintFor(sourcePath);
269
+ const titlePenalties = titlePenaltiesFor(extractedText);
111
270
 
112
- let best = null;
113
- for (const entry of table.CLASSIFICATION_TABLE) {
114
- const { hits, matched } = countMatches(signal, entry.keywords);
115
- if (hits === 0) continue;
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 };
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
+ });
120
282
  }
121
283
  }
122
284
 
123
- if (!best) {
285
+ // Score every entry whose keywords contribute either a body hit or a
286
+ // filename hint. An entry with no signal at all is skipped.
287
+ const scored = [];
288
+ for (const entry of table.CLASSIFICATION_TABLE) {
289
+ const { hits, matched } = countMatches(signal, entry);
290
+ const filenameBoost = filenameHint === entry.intakeType ? FILENAME_BOOST : 0;
291
+ const penalty = titlePenalties[entry.intakeType] || 0;
292
+ const score = hits + filenameBoost - penalty;
293
+ if (hits === 0 && filenameBoost === 0) continue;
294
+ scored.push({ entry, hits, matched, score, filenameBoost, penalty });
295
+ }
296
+
297
+ if (scored.length === 0) {
124
298
  return {
125
299
  ...table.UNKNOWN_TRIAGE,
126
300
  confidence: 0.3,
127
301
  rationale: 'No classification keywords matched filename, content excerpt, or related-doc titles.',
302
+ candidates: [],
128
303
  };
129
304
  }
130
305
 
131
- const { entry, hits, matched } = best;
132
- const confidence = Math.min(1, 0.4 + 0.2 * hits);
133
- const matchedList = matched.slice(0, 4).join(', ');
134
- const rationale = `Matched ${hits} keyword${hits === 1 ? '' : 's'} for ${entry.intakeType}: ${matchedList}.`;
306
+ // Deterministic ranking: highest score wins; ties broken by table order
307
+ // (stable sort preserves CLASSIFICATION_TABLE order, which is curated).
308
+ scored.sort((a, b) => b.score - a.score);
309
+ const best = scored[0];
310
+ const runnerUp = scored[1] || null;
311
+ const topThree = scored.slice(0, 3);
312
+ const margin = runnerUp ? best.score - runnerUp.score : Infinity;
135
313
 
136
- return {
137
- intakeType: entry.intakeType,
138
- rdStage: entry.rdStage,
139
- primaryOwner: entry.primaryOwner,
140
- recommendedChain: [...entry.recommendedChain],
141
- recommendedAction: entry.recommendedAction,
142
- risk: entry.risk,
143
- requiresApproval: entry.requiresApproval,
144
- confidence,
145
- rationale,
146
- };
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;
320
+
321
+ const matchedList = best.matched.slice(0, 4).join(', ');
322
+ const rationaleParts = [];
323
+ if (best.hits > 0) {
324
+ rationaleParts.push(`matched ${best.hits} keyword${best.hits === 1 ? '' : 's'} (${matchedList || 'filename'})`);
325
+ }
326
+ if (best.filenameBoost > 0) rationaleParts.push('filename hint');
327
+ if (best.penalty > 0) rationaleParts.push('title override applied');
328
+ if (ambiguous && runnerUp) {
329
+ rationaleParts.push(`ambiguous vs ${runnerUp.entry.intakeType} (margin ${margin.toFixed(2)})`);
330
+ }
331
+ const rationale = `Classified as ${best.entry.intakeType}: ${rationaleParts.join('; ')}.`;
332
+
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 });
147
341
  }
148
342
 
149
343
  export function formatTriageLine(sourcePath, triage) {
@@ -154,3 +348,60 @@ export function formatTriageLine(sourcePath, triage) {
154
348
  const ownerLabel = triage.primaryOwner ?? 'unassigned';
155
349
  return `${basename} → ${triage.intakeType} / ${triage.rdStage} · owner: ${ownerLabel} · next: ${triage.recommendedAction}`;
156
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
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * lib/intake/daemon.mjs — Continuous intake daemon with safeguards.
3
+ *
4
+ * Polls `.cx/inbox/` for new files, classifies them via the existing intake
5
+ * classifier, and writes packets to `.cx/intake/pending/`. Packets past their
6
+ * TTL move to `.cx/intake/dead-letter/`; failed classification retries up to
7
+ * the budget then dead-letters with the failure reason.
8
+ *
9
+ * Built on lib/daemons/contract.mjs — every safeguard (bounded lifetime,
10
+ * idle shutdown, heartbeat, killswitch, single-writer lock) applies.
11
+ */
12
+
13
+ import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
14
+ import { join, basename } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+
17
+ import { createDaemon, classifyPacket } from '../daemons/contract.mjs';
18
+
19
+ const KILLSWITCH_ENV = 'CONSTRUCT_INTAKE_DAEMON';
20
+
21
+ function inboxDir(cwd) { return join(cwd, '.cx', 'inbox'); }
22
+ function pendingDir(cwd) { return join(cwd, '.cx', 'intake', 'pending'); }
23
+ function deadLetterDir(cwd) { return join(cwd, '.cx', 'intake', 'dead-letter'); }
24
+ function heartbeatPath() { return join(homedir(), '.construct', 'intake-daemon.heartbeat'); }
25
+
26
+ function ensureDir(dir) {
27
+ try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
28
+ }
29
+
30
+ function listInboxFiles(cwd) {
31
+ const dir = inboxDir(cwd);
32
+ if (!existsSync(dir)) return [];
33
+ let entries;
34
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
35
+ return entries
36
+ .filter((e) => e.isFile() && !e.name.startsWith('.'))
37
+ .map((e) => join(dir, e.name));
38
+ }
39
+
40
+ /**
41
+ * Process one inbox file: classify, persist packet to pending, remove the
42
+ * inbox source on success, or move to dead-letter on persistent failure.
43
+ *
44
+ * Returns one of:
45
+ * { didWork: true, route: 'pending'|'dead-letter', packetId }
46
+ * { didWork: false, reason }
47
+ */
48
+ export async function processInboxFile(filePath, { cwd, classify, now = () => new Date() }) {
49
+ ensureDir(pendingDir(cwd));
50
+ ensureDir(deadLetterDir(cwd));
51
+
52
+ let body;
53
+ try { body = readFileSync(filePath, 'utf8'); }
54
+ catch (err) { return { didWork: false, reason: `read failed: ${err.message}` }; }
55
+
56
+ const id = `intake-${now().getTime()}-${Math.random().toString(36).slice(2, 8)}`;
57
+ const packet = {
58
+ id,
59
+ firstSeenAt: new Date(statSync(filePath).mtimeMs).toISOString(),
60
+ sourcePath: filePath,
61
+ excerpt: body.slice(0, 500),
62
+ attempts: 0,
63
+ };
64
+
65
+ const decision = classifyPacket(packet);
66
+ if (decision.route === 'dead-letter') {
67
+ return persistDeadLetter(filePath, packet, decision.reason, cwd);
68
+ }
69
+
70
+ try {
71
+ packet.triage = await classify({ sourcePath: filePath, extractedText: body });
72
+ } catch (err) {
73
+ packet.attempts++;
74
+ packet.lastError = err.message;
75
+ if (packet.attempts >= 3) {
76
+ return persistDeadLetter(filePath, packet, 'retry-budget-exhausted', cwd);
77
+ }
78
+ return { didWork: false, reason: `classify failed, will retry: ${err.message}` };
79
+ }
80
+
81
+ const packetPath = join(pendingDir(cwd), `${id}.json`);
82
+ writeFileSync(packetPath, JSON.stringify(packet, null, 2));
83
+ try {
84
+ const consumedPath = join(pendingDir(cwd), `.${basename(filePath)}.consumed`);
85
+ renameSync(filePath, consumedPath);
86
+ } catch { /* ignore consume failure */ }
87
+ return { didWork: true, route: 'pending', packetId: id };
88
+ }
89
+
90
+ function persistDeadLetter(filePath, packet, reason, cwd) {
91
+ const dlPath = join(deadLetterDir(cwd), `${packet.id}.json`);
92
+ writeFileSync(dlPath, JSON.stringify({ ...packet, deadLetterReason: reason, deadLetteredAt: new Date().toISOString() }, null, 2));
93
+ try { renameSync(filePath, join(deadLetterDir(cwd), `.source.${basename(filePath)}`)); } catch { /* ignore */ }
94
+ return { didWork: true, route: 'dead-letter', packetId: packet.id, reason };
95
+ }
96
+
97
+ /**
98
+ * Build a DaemonRunner for the intake daemon. Caller is responsible for
99
+ * calling .run() — typically construct intake daemon start.
100
+ */
101
+ export function buildIntakeDaemon({ cwd = process.cwd(), intervalMs = 60_000, classify } = {}) {
102
+ const classifyFn = classify || (async () => ({ intakeType: 'unknown', rdStage: 'triage', primaryOwner: 'product-manager', recommendedAction: 'review' }));
103
+ return createDaemon({
104
+ name: 'intake',
105
+ intervalMs,
106
+ killswitchEnv: KILLSWITCH_ENV,
107
+ heartbeatPath: heartbeatPath(),
108
+ maxRuntimeMs: 24 * 60 * 60 * 1000,
109
+ maxIdleTicks: 6,
110
+ async tick() {
111
+ const files = listInboxFiles(cwd);
112
+ if (files.length === 0) return { didWork: false };
113
+ let workedAny = false;
114
+ for (const file of files) {
115
+ const result = await processInboxFile(file, { cwd, classify: classifyFn });
116
+ if (result.didWork) workedAny = true;
117
+ }
118
+ return { didWork: workedAny };
119
+ },
120
+ });
121
+ }
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import path from 'node:path';
13
+ import { ensureCxDir } from '../project-init-shared.mjs';
14
+ import { shouldQuarantine, writeQuarantinePacket, quarantineDir } from './quarantine.mjs';
13
15
 
14
16
  const QUEUE_SUBDIR = '.cx/intake';
15
17
 
@@ -29,6 +31,8 @@ export function skippedDir(rootDir) {
29
31
  return path.join(queueRoot(rootDir), 'skipped');
30
32
  }
31
33
 
34
+ export { quarantineDir };
35
+
32
36
  function slugify(value) {
33
37
  return String(value || 'untitled')
34
38
  .toLowerCase()
@@ -54,17 +58,29 @@ export class FilesystemIntakeQueue {
54
58
 
55
59
  enqueue(entry) {
56
60
  if (!entry?.intake?.sourcePath) throw new Error('enqueue: entry.intake.sourcePath is required');
57
- const dir = pendingDir(this.rootDir);
58
- mkdirSync(dir, { recursive: true });
61
+ ensureCxDir(this.rootDir);
59
62
 
60
63
  const ts = timestamp();
61
64
  const slug = slugify(path.basename(entry.intake.sourcePath, path.extname(entry.intake.sourcePath)));
62
65
  const id = `${ts}-${slug}`;
63
- const filePath = path.join(dir, `${id}.json`);
64
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`);
65
81
  const payload = { id, createdAt: new Date().toISOString(), status: 'pending', ...entry };
66
82
  writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
67
- return { id, filePath };
83
+ return { id, filePath, route: 'pending' };
68
84
  }
69
85
 
70
86
  listPending() {
@@ -92,7 +108,13 @@ export class FilesystemIntakeQueue {
92
108
  }
93
109
 
94
110
  read(id) {
95
- 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) {
96
118
  const filePath = path.join(dir, `${id}.json`);
97
119
  if (existsSync(filePath)) {
98
120
  const data = JSON.parse(readFileSync(filePath, 'utf8'));
@@ -112,6 +134,7 @@ export class FilesystemIntakeQueue {
112
134
  if (notes) data.notes = notes;
113
135
 
114
136
  const dst = path.join(processedDir(this.rootDir), `${id}.json`);
137
+ ensureCxDir(this.rootDir);
115
138
  mkdirSync(path.dirname(dst), { recursive: true });
116
139
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
117
140
  rmSync(src);
@@ -128,6 +151,7 @@ export class FilesystemIntakeQueue {
128
151
  if (reason) data.reason = reason;
129
152
 
130
153
  const dst = path.join(skippedDir(this.rootDir), `${id}.json`);
154
+ ensureCxDir(this.rootDir);
131
155
  mkdirSync(path.dirname(dst), { recursive: true });
132
156
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
133
157
  rmSync(src);
@@ -148,7 +172,8 @@ export class FilesystemIntakeQueue {
148
172
  delete data.reason;
149
173
 
150
174
  const dst = path.join(pendingDir(this.rootDir), `${id}.json`);
151
- mkdirSync(path.dirname(dst), { recursive: true });
175
+ ensureCxDir(this.rootDir);
176
+ mkdirSync(path.dirname(dst), { recursive: true });
152
177
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
153
178
  rmSync(src);
154
179
  return { id, filePath: dst, from: path.basename(dir) };
@@ -24,6 +24,7 @@
24
24
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
25
  import { isAbsolute, join, resolve } from 'node:path';
26
26
  import { shouldCreateCx } from '../project-detection.mjs';
27
+ import { ensureCxDir } from '../project-init-shared.mjs';
27
28
 
28
29
  export const INTAKE_CONFIG_FILE = '.cx/intake-config.json';
29
30
  export const INTAKE_DEFAULT_MAX_DEPTH = 4;
@@ -115,7 +116,7 @@ export function saveIntakeConfig(rootDir, patch = {}) {
115
116
  };
116
117
 
117
118
  const file = intakeConfigPath(rootDir);
118
- mkdirSync(join(rootDir, '.cx'), { recursive: true });
119
+ ensureCxDir(rootDir);
119
120
  writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
120
121
  return next;
121
122
  }