@geraldmaron/construct 1.0.5 → 1.0.7

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 (268) hide show
  1. package/README.md +24 -18
  2. package/agents/contracts.json +617 -2
  3. package/agents/contracts.schema.json +83 -0
  4. package/agents/prompts/cx-accessibility.md +5 -3
  5. package/agents/prompts/cx-ai-engineer.md +9 -7
  6. package/agents/prompts/cx-architect.md +8 -6
  7. package/agents/prompts/cx-business-strategist.md +8 -6
  8. package/agents/prompts/cx-data-analyst.md +9 -7
  9. package/agents/prompts/cx-data-engineer.md +5 -3
  10. package/agents/prompts/cx-debugger.md +8 -6
  11. package/agents/prompts/cx-designer.md +7 -5
  12. package/agents/prompts/cx-devil-advocate.md +6 -4
  13. package/agents/prompts/cx-docs-keeper.md +7 -5
  14. package/agents/prompts/cx-engineer.md +7 -5
  15. package/agents/prompts/cx-evaluator.md +4 -2
  16. package/agents/prompts/cx-explorer.md +10 -8
  17. package/agents/prompts/cx-legal-compliance.md +4 -2
  18. package/agents/prompts/cx-operations.md +7 -5
  19. package/agents/prompts/cx-orchestrator.md +12 -10
  20. package/agents/prompts/cx-platform-engineer.md +7 -5
  21. package/agents/prompts/cx-product-manager.md +6 -4
  22. package/agents/prompts/cx-qa.md +10 -8
  23. package/agents/prompts/cx-rd-lead.md +11 -9
  24. package/agents/prompts/cx-release-manager.md +7 -5
  25. package/agents/prompts/cx-researcher.md +24 -22
  26. package/agents/prompts/cx-reviewer.md +9 -7
  27. package/agents/prompts/cx-security.md +12 -10
  28. package/agents/prompts/cx-sre.md +9 -7
  29. package/agents/prompts/cx-test-automation.md +5 -3
  30. package/agents/prompts/cx-trace-reviewer.md +10 -8
  31. package/agents/prompts/cx-ux-researcher.md +5 -3
  32. package/agents/registry.json +8 -3
  33. package/bin/construct +444 -53
  34. package/commands/build/feature.md +4 -4
  35. package/commands/build/fix.md +8 -8
  36. package/commands/design/access.md +3 -3
  37. package/commands/design/flow.md +3 -3
  38. package/commands/design/ui.md +4 -4
  39. package/commands/measure/experiment.md +5 -5
  40. package/commands/measure/metrics.md +3 -3
  41. package/commands/measure/results.md +4 -4
  42. package/commands/plan/api.md +3 -3
  43. package/commands/plan/challenge.md +3 -3
  44. package/commands/plan/decide.md +3 -3
  45. package/commands/plan/feature.md +7 -7
  46. package/commands/plan/requirements.md +3 -3
  47. package/commands/remember/context.md +5 -5
  48. package/commands/remember/handoff.md +3 -3
  49. package/commands/remember/runbook.md +3 -3
  50. package/commands/review/code.md +8 -8
  51. package/commands/review/quality.md +4 -4
  52. package/commands/review/security.md +3 -3
  53. package/commands/ship/ready.md +3 -3
  54. package/commands/ship/release.md +3 -3
  55. package/commands/ship/status.md +4 -4
  56. package/commands/understand/docs.md +3 -3
  57. package/commands/understand/this.md +4 -4
  58. package/commands/understand/why.md +8 -8
  59. package/commands/work/clean.md +14 -14
  60. package/commands/work/drive.md +10 -10
  61. package/commands/work/optimize-prompts.md +9 -9
  62. package/commands/work/parallel-review.md +8 -8
  63. package/db/schema/006_graph.sql +24 -0
  64. package/examples/provider-plugin/README.md +7 -7
  65. package/examples/seed-observations/README.md +6 -6
  66. package/examples/seed-observations/anti-patterns.md +14 -14
  67. package/examples/seed-observations/decisions.md +4 -4
  68. package/examples/seed-observations/patterns.md +14 -14
  69. package/lib/auto-docs.mjs +13 -8
  70. package/lib/boundary.mjs +126 -0
  71. package/lib/cache-strategy-google.js +26 -31
  72. package/lib/cli-commands.mjs +2 -2
  73. package/lib/comment-lint.mjs +134 -0
  74. package/lib/contracts/validate.mjs +323 -0
  75. package/lib/daemons/contract.mjs +210 -0
  76. package/lib/docs-verify.mjs +59 -6
  77. package/lib/doctor/cli.mjs +16 -1
  78. package/lib/doctor/index.mjs +3 -1
  79. package/lib/doctor/watchers/consistency.mjs +310 -0
  80. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  81. package/lib/document-extract.mjs +211 -1
  82. package/lib/embed/cli.mjs +124 -3
  83. package/lib/embed/daemon.mjs +43 -4
  84. package/lib/embed/docs-lifecycle.mjs +1 -1
  85. package/lib/embed/inbox.mjs +2 -0
  86. package/lib/embed/scheduler.mjs +33 -5
  87. package/lib/evaluator-optimizer.mjs +2 -3
  88. package/lib/flavors/loader.mjs +1 -1
  89. package/lib/hooks/comment-lint.mjs +16 -0
  90. package/lib/hooks/mcp-audit.mjs +2 -1
  91. package/lib/hooks/proactive-activation.mjs +0 -14
  92. package/lib/hooks/rule-verifier.mjs +217 -0
  93. package/lib/hooks/session-optimize.mjs +2 -1
  94. package/lib/init-unified.mjs +55 -65
  95. package/lib/intake/classify.mjs +108 -24
  96. package/lib/intake/daemon.mjs +121 -0
  97. package/lib/intake/filesystem-queue.mjs +6 -1
  98. package/lib/intake/intake-config.mjs +2 -1
  99. package/lib/intake/prepare.mjs +0 -1
  100. package/lib/intake/session-prelude.mjs +7 -1
  101. package/lib/intake/traceability.mjs +90 -0
  102. package/lib/knowledge/graph.mjs +213 -0
  103. package/lib/knowledge/research-store.mjs +2 -0
  104. package/lib/maintenance/cleanup.mjs +315 -0
  105. package/lib/mcp/memory-bridge.mjs +276 -0
  106. package/lib/mcp/server.mjs +189 -1
  107. package/lib/mcp/tools/profile.mjs +270 -0
  108. package/lib/mcp/tools/workflow.mjs +25 -0
  109. package/lib/mcp-catalog.json +12 -8
  110. package/lib/mcp-platform-config.mjs +16 -8
  111. package/lib/migrations/index.mjs +106 -0
  112. package/lib/migrations/v1-baseline.mjs +33 -0
  113. package/lib/observation-store.mjs +9 -4
  114. package/lib/outcomes/record.mjs +2 -0
  115. package/lib/profiles/rebrand.mjs +46 -0
  116. package/lib/project-init-shared.mjs +12 -0
  117. package/lib/provider-capabilities.js +20 -7
  118. package/lib/providers/auth-manager.mjs +58 -17
  119. package/lib/reflect.mjs +49 -12
  120. package/lib/server/index.mjs +22 -28
  121. package/lib/session-store.mjs +6 -4
  122. package/lib/setup.mjs +14 -3
  123. package/lib/telemetry/client.mjs +5 -1
  124. package/lib/version.mjs +51 -0
  125. package/lib/worker/trace.mjs +5 -1
  126. package/package.json +4 -1
  127. package/personas/construct.md +3 -1
  128. package/platforms/claude/CLAUDE.md +6 -6
  129. package/rules/common/agents.md +2 -2
  130. package/rules/common/beads-hygiene.md +11 -11
  131. package/rules/common/code-review.md +1 -1
  132. package/rules/common/coding-style.md +1 -1
  133. package/rules/common/comments.md +8 -8
  134. package/rules/common/commit-approval.md +4 -4
  135. package/rules/common/cx-agent-routing.md +2 -2
  136. package/rules/common/cx-skill-routing.md +2 -2
  137. package/rules/common/development-workflow.md +3 -2
  138. package/rules/common/doc-ownership.md +2 -2
  139. package/rules/common/efficiency.md +3 -3
  140. package/rules/common/framing.md +1 -1
  141. package/rules/common/git-workflow.md +1 -1
  142. package/rules/common/no-fabrication.md +69 -0
  143. package/rules/common/patterns.md +1 -1
  144. package/rules/common/performance.md +1 -1
  145. package/rules/common/release-gates.md +7 -7
  146. package/rules/common/research.md +4 -4
  147. package/rules/common/review-before-change.md +58 -0
  148. package/rules/common/security.md +1 -1
  149. package/rules/common/skill-composition.md +8 -8
  150. package/rules/common/testing.md +1 -1
  151. package/rules/golang/coding-style.md +2 -2
  152. package/rules/golang/hooks.md +1 -1
  153. package/rules/golang/patterns.md +1 -1
  154. package/rules/golang/security.md +1 -1
  155. package/rules/golang/testing.md +1 -1
  156. package/rules/python/coding-style.md +1 -1
  157. package/rules/python/hooks.md +1 -1
  158. package/rules/python/patterns.md +1 -1
  159. package/rules/python/security.md +1 -1
  160. package/rules/python/testing.md +1 -1
  161. package/rules/swift/coding-style.md +3 -3
  162. package/rules/swift/hooks.md +2 -2
  163. package/rules/swift/patterns.md +2 -2
  164. package/rules/swift/security.md +4 -4
  165. package/rules/swift/testing.md +2 -2
  166. package/rules/typescript/coding-style.md +1 -1
  167. package/rules/typescript/hooks.md +1 -1
  168. package/rules/typescript/patterns.md +1 -1
  169. package/rules/typescript/security.md +1 -1
  170. package/rules/typescript/testing.md +1 -1
  171. package/rules/web/coding-style.md +1 -1
  172. package/rules/web/design-quality.md +1 -1
  173. package/rules/web/hooks.md +1 -1
  174. package/rules/web/patterns.md +1 -1
  175. package/rules/web/performance.md +1 -1
  176. package/rules/web/security.md +1 -1
  177. package/rules/web/testing.md +1 -1
  178. package/scripts/sync-agents.mjs +45 -14
  179. package/skills/ai/agent-dev.md +1 -1
  180. package/skills/ai/llm-security.md +1 -1
  181. package/skills/ai/ml-ops.md +6 -6
  182. package/skills/ai/orchestration-workflow.md +1 -1
  183. package/skills/ai/prompt-and-eval.md +1 -1
  184. package/skills/ai/prompt-optimizer.md +13 -13
  185. package/skills/ai/rag-system.md +1 -1
  186. package/skills/architecture/api-design.md +1 -1
  187. package/skills/architecture/caching.md +1 -1
  188. package/skills/architecture/cloud-native.md +1 -1
  189. package/skills/architecture/message-queue.md +1 -1
  190. package/skills/architecture/security-arch.md +1 -1
  191. package/skills/compliance/ai-disclosure.md +1 -1
  192. package/skills/compliance/data-privacy.md +1 -1
  193. package/skills/compliance/license-audit.md +2 -2
  194. package/skills/compliance/regulatory-review.md +1 -1
  195. package/skills/development/cpp.md +1 -1
  196. package/skills/development/go.md +1 -1
  197. package/skills/development/java.md +1 -1
  198. package/skills/development/kotlin.md +9 -9
  199. package/skills/development/mobile-crossplatform.md +13 -13
  200. package/skills/development/python.md +1 -1
  201. package/skills/development/rust.md +1 -1
  202. package/skills/development/shell.md +1 -1
  203. package/skills/development/swift.md +6 -6
  204. package/skills/development/typescript.md +1 -1
  205. package/skills/devops/ci-cd.md +5 -5
  206. package/skills/devops/containerization.md +9 -9
  207. package/skills/devops/cost-optimization.md +1 -1
  208. package/skills/devops/data-engineering.md +2 -2
  209. package/skills/devops/database.md +1 -1
  210. package/skills/devops/dependency-management.md +3 -3
  211. package/skills/devops/devsecops.md +1 -1
  212. package/skills/devops/git-workflow.md +1 -1
  213. package/skills/devops/incident-response.md +18 -18
  214. package/skills/devops/monorepo.md +5 -5
  215. package/skills/devops/observability.md +1 -1
  216. package/skills/devops/performance.md +1 -1
  217. package/skills/devops/testing.md +1 -1
  218. package/skills/docs/adr-workflow.md +2 -2
  219. package/skills/docs/backlog-proposal-workflow.md +1 -1
  220. package/skills/docs/customer-profile-workflow.md +1 -1
  221. package/skills/docs/document-ingest-workflow.md +1 -1
  222. package/skills/docs/evidence-ingest-workflow.md +1 -1
  223. package/skills/docs/init-docs.md +15 -15
  224. package/skills/docs/init-project.md +1 -1
  225. package/skills/docs/prd-workflow.md +3 -3
  226. package/skills/docs/prfaq-workflow.md +1 -1
  227. package/skills/docs/product-intelligence-review.md +1 -1
  228. package/skills/docs/product-intelligence-workflow.md +1 -1
  229. package/skills/docs/product-signal-workflow.md +9 -9
  230. package/skills/docs/research-workflow.md +10 -10
  231. package/skills/docs/runbook-workflow.md +2 -2
  232. package/skills/docs/strategy-workflow.md +3 -3
  233. package/skills/exploration/repo-map.md +11 -11
  234. package/skills/frameworks/django.md +15 -15
  235. package/skills/frameworks/nextjs.md +16 -16
  236. package/skills/frameworks/react.md +12 -12
  237. package/skills/frameworks/spring-boot.md +12 -12
  238. package/skills/frontend-design/accessibility.md +6 -6
  239. package/skills/frontend-design/component-patterns.md +1 -1
  240. package/skills/frontend-design/engineering.md +1 -1
  241. package/skills/frontend-design/state-management.md +1 -1
  242. package/skills/frontend-design/ui-aesthetics.md +1 -1
  243. package/skills/frontend-design/ux-principles.md +1 -1
  244. package/skills/operating/orchestration-reference.md +27 -27
  245. package/skills/quality-gates/review-work.md +3 -3
  246. package/skills/quality-gates/verify-change.md +1 -1
  247. package/skills/quality-gates/verify-module.md +1 -1
  248. package/skills/quality-gates/verify-quality.md +1 -1
  249. package/skills/quality-gates/verify-security.md +1 -1
  250. package/skills/routing.md +14 -14
  251. package/skills/security/blue-team.md +1 -1
  252. package/skills/security/code-audit.md +1 -1
  253. package/skills/security/pentest.md +1 -1
  254. package/skills/security/red-team.md +1 -1
  255. package/skills/security/threat-intel.md +1 -1
  256. package/skills/security/vuln-research.md +1 -1
  257. package/skills/utility/clean-code.md +2 -2
  258. package/templates/docs/changelog-entry.md +1 -1
  259. package/templates/docs/construct_guide.md +18 -18
  260. package/templates/docs/meta-prd.md +16 -16
  261. package/templates/docs/one-pager.md +1 -1
  262. package/templates/docs/prd-business.md +1 -1
  263. package/templates/docs/prd-platform.md +1 -1
  264. package/templates/docs/prd.md +17 -17
  265. package/templates/docs/research-brief.md +8 -8
  266. package/templates/docs/rfc.md +1 -1
  267. package/templates/docs/skill-artifact.md +1 -1
  268. package/templates/docs/strategy.md +1 -1
@@ -6,6 +6,13 @@
6
6
  * rdStage, primaryOwner, recommendedChain, recommendedAction, risk,
7
7
  * requiresApproval, confidence, rationale.
8
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.
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
  *
@@ -39,22 +46,13 @@ export const RECOMMENDED_ACTIONS = [
39
46
  'archive',
40
47
  ];
41
48
 
42
- // Static profile table map. New curated profiles register themselves here.
49
+ // Static profile to table map. New curated profiles register themselves here.
43
50
  // Custom profiles (escape hatch) declare classificationTable as a repo-relative
44
51
  // path; that path is loaded dynamically in classifyRdIntake when a profile arg
45
52
  // supplies it.
46
53
  import operationsTable from './tables/operations.mjs';
47
54
  import creativeTable from './tables/creative.mjs';
48
55
  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
56
 
59
57
  const TABLES = {
60
58
  rnd: rndTable,
@@ -63,6 +61,39 @@ const TABLES = {
63
61
  research: researchTable,
64
62
  };
65
63
 
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.
67
+ const FILENAME_BOOST = 0.4;
68
+ const FILENAME_HINTS = [
69
+ { re: /postmortem/i, intakeType: 'incident' },
70
+ { re: /incident-report|incident_report/i, intakeType: 'incident' },
71
+ { re: /^adr[-_]/i, intakeType: 'architecture' },
72
+ { re: /\badr[-_]\d/i, intakeType: 'architecture' },
73
+ { re: /^rfc[-_]/i, intakeType: 'architecture' },
74
+ { re: /^prd[-_]/i, intakeType: 'requirement' },
75
+ { re: /security|cve|vulnerability/i, intakeType: 'security' },
76
+ { re: /research|study|literature/i, intakeType: 'research' },
77
+ { re: /eval|metric|benchmark/i, intakeType: 'eval-finding' },
78
+ { re: /-bug\b|\bbug-/i, intakeType: 'bug' },
79
+ { re: /runbook/i, intakeType: 'ops' },
80
+ ];
81
+
82
+ // Title-level negative keywords flip a classification away from a misleading
83
+ // body match. A postmortem describes a bug in retrospect but is not a bug
84
+ // 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
+ const TITLE_PENALTY = 0.5;
88
+ const TITLE_OVERRIDES = [
89
+ { re: /^#\s*postmortem\b/im, penalize: 'bug' },
90
+ { re: /^#\s*incident\s+report\b/im, penalize: 'bug' },
91
+ { re: /^#\s*post-mortem\b/im, penalize: 'bug' },
92
+ { re: /^#\s*architecture\s+decision\b/im, penalize: 'bug' },
93
+ { re: /^#\s*adr[-:\s]/im, penalize: 'bug' },
94
+ { re: /^#\s*security\s+(advisory|finding)\b/im, penalize: 'bug' },
95
+ ];
96
+
66
97
  function resolveTable(profile) {
67
98
  if (!profile) return rndTable;
68
99
  const id = typeof profile === 'string' ? profile : profile.id;
@@ -93,34 +124,65 @@ function buildSignalText({ sourcePath, extractedText, related }) {
93
124
  return normalize(`${slug} ${body} ${relatedTitles}`);
94
125
  }
95
126
 
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
+ function filenameHintFor(sourcePath) {
130
+ if (!sourcePath) return null;
131
+ const base = path.basename(sourcePath);
132
+ for (const hint of FILENAME_HINTS) {
133
+ if (hint.re.test(base)) return hint.intakeType;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ // Detect title-level overrides on the raw extracted text (case-insensitive,
139
+ // multiline anchored to ^# so only true H1s match).
140
+ function titlePenaltiesFor(extractedText) {
141
+ const penalties = {};
142
+ const text = String(extractedText || '');
143
+ for (const rule of TITLE_OVERRIDES) {
144
+ if (rule.re.test(text)) {
145
+ penalties[rule.penalize] = (penalties[rule.penalize] || 0) + TITLE_PENALTY;
146
+ }
147
+ }
148
+ return penalties;
149
+ }
150
+
96
151
  /**
97
152
  * Classify an intake signal against the active profile (defaults to RND).
98
153
  *
99
154
  * Backward-compatible: callers that did not pass a `profile` arg keep getting
100
- * RND output, byte-identical to the pre-B2 behavior.
155
+ * 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.
101
159
  *
102
160
  * @param {object} input
103
161
  * @param {string} [input.sourcePath]
104
162
  * @param {string} [input.extractedText]
105
163
  * @param {Array} [input.related]
106
164
  * @param {string|object} [input.profile] - profile id (string) or full profile object
165
+ * @param {string} [input.cwd] - accepted for backward compatibility, ignored
107
166
  */
108
- export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null, cwd = null } = {}) {
167
+ export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null } = {}) {
109
168
  const table = resolveTable(profile);
110
169
  const signal = buildSignalText({ sourcePath, extractedText, related });
170
+ const filenameHint = filenameHintFor(sourcePath);
171
+ const titlePenalties = titlePenaltiesFor(extractedText);
111
172
 
112
- let best = null;
173
+ // Score every entry whose keywords contribute either a body hit or a
174
+ // filename hint. An entry with no signal at all is skipped.
175
+ const scored = [];
113
176
  for (const entry of table.CLASSIFICATION_TABLE) {
114
177
  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 };
120
- }
178
+ const filenameBoost = filenameHint === entry.intakeType ? FILENAME_BOOST : 0;
179
+ const penalty = titlePenalties[entry.intakeType] || 0;
180
+ const score = hits + filenameBoost - penalty;
181
+ if (hits === 0 && filenameBoost === 0) continue;
182
+ scored.push({ entry, hits, matched, score, filenameBoost, penalty });
121
183
  }
122
184
 
123
- if (!best) {
185
+ if (scored.length === 0) {
124
186
  return {
125
187
  ...table.UNKNOWN_TRIAGE,
126
188
  confidence: 0.3,
@@ -128,11 +190,33 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
128
190
  };
129
191
  }
130
192
 
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}.`;
193
+ // Deterministic ranking: highest score wins; ties broken by table order
194
+ // (stable sort preserves CLASSIFICATION_TABLE order, which is curated).
195
+ scored.sort((a, b) => b.score - a.score);
196
+ const best = scored[0];
197
+ 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.
202
+ 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;
206
+
207
+ const matchedList = best.matched.slice(0, 4).join(', ');
208
+ const rationaleParts = [];
209
+ if (best.hits > 0) {
210
+ rationaleParts.push(`matched ${best.hits} keyword${best.hits === 1 ? '' : 's'} (${matchedList || 'filename'})`);
211
+ }
212
+ if (best.filenameBoost > 0) rationaleParts.push('filename hint');
213
+ if (best.penalty > 0) rationaleParts.push('title override applied');
214
+ if (ambiguous && runnerUp) {
215
+ rationaleParts.push(`ambiguous vs ${runnerUp.entry.intakeType} (margin ${margin.toFixed(2)})`);
216
+ }
217
+ const rationale = `Classified as ${best.entry.intakeType}: ${rationaleParts.join('; ')}.`;
135
218
 
219
+ const { entry } = best;
136
220
  return {
137
221
  intakeType: entry.intakeType,
138
222
  rdStage: entry.rdStage,
@@ -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,7 @@
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';
13
14
 
14
15
  const QUEUE_SUBDIR = '.cx/intake';
15
16
 
@@ -55,6 +56,7 @@ export class FilesystemIntakeQueue {
55
56
  enqueue(entry) {
56
57
  if (!entry?.intake?.sourcePath) throw new Error('enqueue: entry.intake.sourcePath is required');
57
58
  const dir = pendingDir(this.rootDir);
59
+ ensureCxDir(this.rootDir);
58
60
  mkdirSync(dir, { recursive: true });
59
61
 
60
62
  const ts = timestamp();
@@ -112,6 +114,7 @@ export class FilesystemIntakeQueue {
112
114
  if (notes) data.notes = notes;
113
115
 
114
116
  const dst = path.join(processedDir(this.rootDir), `${id}.json`);
117
+ ensureCxDir(this.rootDir);
115
118
  mkdirSync(path.dirname(dst), { recursive: true });
116
119
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
117
120
  rmSync(src);
@@ -128,6 +131,7 @@ export class FilesystemIntakeQueue {
128
131
  if (reason) data.reason = reason;
129
132
 
130
133
  const dst = path.join(skippedDir(this.rootDir), `${id}.json`);
134
+ ensureCxDir(this.rootDir);
131
135
  mkdirSync(path.dirname(dst), { recursive: true });
132
136
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
133
137
  rmSync(src);
@@ -148,7 +152,8 @@ export class FilesystemIntakeQueue {
148
152
  delete data.reason;
149
153
 
150
154
  const dst = path.join(pendingDir(this.rootDir), `${id}.json`);
151
- mkdirSync(path.dirname(dst), { recursive: true });
155
+ ensureCxDir(this.rootDir);
156
+ mkdirSync(path.dirname(dst), { recursive: true });
152
157
  writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
153
158
  rmSync(src);
154
159
  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
  }
@@ -77,7 +77,6 @@ export async function prepareIntakeForIngestedFile({
77
77
  extractedText: extracted,
78
78
  related,
79
79
  profile: activeProfile?.id,
80
- cwd: rootDir,
81
80
  });
82
81
 
83
82
  // Detect customer mentions and update profiles
@@ -13,6 +13,7 @@
13
13
  import { createIntakeQueue } from './queue.mjs';
14
14
  import { formatTriageLine } from './classify.mjs';
15
15
  import { isBrokered } from '../mcp/broker.mjs';
16
+ import { getRebrand } from '../profiles/rebrand.mjs';
16
17
 
17
18
  export function buildIntakePrelude({ cwd, env = process.env } = {}) {
18
19
  if (!cwd) return '';
@@ -20,11 +21,16 @@ export function buildIntakePrelude({ cwd, env = process.env } = {}) {
20
21
  const queue = createIntakeQueue(cwd, env);
21
22
  const pending = queue.listPending();
22
23
  if (!pending.length) return '';
24
+ const { intakeQueueLabel, signalNoun } = getRebrand(cwd);
23
25
  const recent = pending.slice(-3).map((p) => {
24
26
  const src = p.intake?.sourcePath || p.id;
25
27
  return `- ${formatTriageLine(src, p.triage)}`;
26
28
  });
27
- return `\n## Pending R&D intake (${pending.length})\n${recent.join('\n')}\nEach packet at \`.cx/intake/pending/<id>.json\` carries the new signal, a triage block (intakeType, rdStage, primaryOwner, recommendedChain, recommendedAction, risk), related existing docs, and an excerpt. Process via the recommended chain, then close via \`construct intake done <id>\`.\n`;
29
+ // Strip a trailing " queue" / " intake" so the heading reads
30
+ // "## Pending R&D intake (N)" rather than the redundant
31
+ // "## Pending R&D intake queue queue (N)".
32
+ const heading = intakeQueueLabel.replace(/\s+queue$/i, '');
33
+ return `\n## Pending ${heading} (${pending.length})\n${recent.join('\n')}\nEach packet at \`.cx/intake/pending/<id>.json\` carries the new ${signalNoun}, a triage block (intakeType, rdStage, primaryOwner, recommendedChain, recommendedAction, risk), related existing docs, and an excerpt. Process via the recommended chain, then close via \`construct intake done <id>\`.\n`;
28
34
  } catch {
29
35
  return '';
30
36
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * lib/intake/traceability.mjs — stamps intake provenance into artifact frontmatter.
3
+ *
4
+ * Backs `construct intake done <id> --output=<path>`: reads the intake packet,
5
+ * extracts (id, confidence, rationale), and writes them into the artifact's
6
+ * YAML frontmatter. Refuses to overwrite a different intake_id (an artifact
7
+ * can be linked to exactly one intake source). Idempotent on re-stamping.
8
+ *
9
+ * Read by docs:verify checkIntakeTraceability to surface artifacts in
10
+ * intake-fed locations that lack a reference.
11
+ *
12
+ * Field schema (in artifact YAML frontmatter):
13
+ * intake_id: construct-xxx
14
+ * intake_confidence: 0.7
15
+ * intake_rationale: "Matched 3 keywords: dashboard, latency, p95"
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
19
+
20
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
21
+
22
+ export function parseArtifactFrontmatter(content) {
23
+ const m = content.match(FRONTMATTER_RE);
24
+ if (!m) return { frontmatter: {}, body: content, hasBlock: false };
25
+ const out = {};
26
+ for (const line of m[1].split('\n')) {
27
+ if (!line.trim() || line.trim().startsWith('#')) continue;
28
+ const idx = line.indexOf(':');
29
+ if (idx === -1) continue;
30
+ const key = line.slice(0, idx).trim();
31
+ const rawValue = line.slice(idx + 1).trim();
32
+ if (!key) continue;
33
+ const unquoted = rawValue.replace(/^["']|["']$/g, '');
34
+ if (/^-?\d+(\.\d+)?$/.test(unquoted)) out[key] = Number(unquoted);
35
+ else out[key] = unquoted;
36
+ }
37
+ return { frontmatter: out, body: content.slice(m[0].length), hasBlock: true };
38
+ }
39
+
40
+ function serializeFrontmatter(fm) {
41
+ const lines = ['---'];
42
+ for (const [k, v] of Object.entries(fm)) {
43
+ if (v === null || v === undefined) continue;
44
+ if (typeof v === 'number' || typeof v === 'boolean') {
45
+ lines.push(`${k}: ${v}`);
46
+ } else {
47
+ const s = String(v);
48
+ const needsQuoting = /[:#"'\n]|^\s|\s$/.test(s);
49
+ lines.push(`${k}: ${needsQuoting ? JSON.stringify(s) : s}`);
50
+ }
51
+ }
52
+ lines.push('---');
53
+ return lines.join('\n');
54
+ }
55
+
56
+ export function stampIntakeProvenance(artifactPath, { intakeId, confidence, rationale }) {
57
+ if (!existsSync(artifactPath)) {
58
+ throw new Error(`Output artifact not found: ${artifactPath}`);
59
+ }
60
+ if (!intakeId) throw new Error('intakeId required for stamping');
61
+ const content = readFileSync(artifactPath, 'utf8');
62
+ const { frontmatter, body, hasBlock } = parseArtifactFrontmatter(content);
63
+
64
+ const existing = frontmatter.intake_id;
65
+ if (existing && existing !== intakeId) {
66
+ throw new Error(
67
+ `Refusing to overwrite intake_id: artifact already references ${existing}, not ${intakeId}. Resolve manually or supersede.`,
68
+ );
69
+ }
70
+
71
+ const next = {
72
+ ...frontmatter,
73
+ intake_id: intakeId,
74
+ ...(confidence !== undefined && confidence !== null ? { intake_confidence: confidence } : {}),
75
+ ...(rationale ? { intake_rationale: rationale } : {}),
76
+ };
77
+
78
+ const fmText = serializeFrontmatter(next);
79
+ const out = hasBlock ? `${fmText}\n${body}` : `${fmText}\n\n${content}`;
80
+ writeFileSync(artifactPath, out, 'utf8');
81
+ return { intake_id: intakeId, intake_confidence: next.intake_confidence, intake_rationale: next.intake_rationale };
82
+ }
83
+
84
+ export function hasIntakeReference(artifactPath) {
85
+ if (!existsSync(artifactPath)) return false;
86
+ const { frontmatter } = parseArtifactFrontmatter(readFileSync(artifactPath, 'utf8'));
87
+ if (frontmatter.intake_id) return true;
88
+ if (frontmatter.intake === 'none') return true;
89
+ return false;
90
+ }