@geraldmaron/construct 1.0.6 → 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 (123) hide show
  1. package/README.md +19 -17
  2. package/agents/contracts.json +617 -2
  3. package/agents/contracts.schema.json +83 -0
  4. package/agents/prompts/cx-accessibility.md +2 -0
  5. package/agents/prompts/cx-ai-engineer.md +2 -0
  6. package/agents/prompts/cx-architect.md +2 -0
  7. package/agents/prompts/cx-business-strategist.md +2 -0
  8. package/agents/prompts/cx-data-analyst.md +2 -0
  9. package/agents/prompts/cx-data-engineer.md +2 -0
  10. package/agents/prompts/cx-debugger.md +2 -0
  11. package/agents/prompts/cx-designer.md +2 -0
  12. package/agents/prompts/cx-devil-advocate.md +2 -0
  13. package/agents/prompts/cx-docs-keeper.md +2 -0
  14. package/agents/prompts/cx-engineer.md +2 -0
  15. package/agents/prompts/cx-evaluator.md +2 -0
  16. package/agents/prompts/cx-explorer.md +2 -0
  17. package/agents/prompts/cx-legal-compliance.md +2 -0
  18. package/agents/prompts/cx-operations.md +2 -0
  19. package/agents/prompts/cx-orchestrator.md +2 -0
  20. package/agents/prompts/cx-platform-engineer.md +2 -0
  21. package/agents/prompts/cx-product-manager.md +2 -0
  22. package/agents/prompts/cx-qa.md +2 -0
  23. package/agents/prompts/cx-rd-lead.md +2 -0
  24. package/agents/prompts/cx-release-manager.md +2 -0
  25. package/agents/prompts/cx-researcher.md +2 -0
  26. package/agents/prompts/cx-reviewer.md +2 -0
  27. package/agents/prompts/cx-security.md +2 -0
  28. package/agents/prompts/cx-sre.md +2 -0
  29. package/agents/prompts/cx-test-automation.md +2 -0
  30. package/agents/prompts/cx-trace-reviewer.md +2 -0
  31. package/agents/prompts/cx-ux-researcher.md +2 -0
  32. package/agents/registry.json +8 -3
  33. package/bin/construct +209 -32
  34. package/commands/build/feature.md +1 -1
  35. package/commands/build/fix.md +1 -1
  36. package/commands/design/access.md +1 -1
  37. package/commands/design/flow.md +1 -1
  38. package/commands/design/ui.md +1 -1
  39. package/commands/measure/experiment.md +1 -1
  40. package/commands/measure/metrics.md +1 -1
  41. package/commands/measure/results.md +1 -1
  42. package/commands/plan/api.md +1 -1
  43. package/commands/plan/challenge.md +1 -1
  44. package/commands/plan/decide.md +1 -1
  45. package/commands/plan/feature.md +1 -1
  46. package/commands/plan/requirements.md +1 -1
  47. package/commands/remember/context.md +1 -1
  48. package/commands/remember/handoff.md +1 -1
  49. package/commands/remember/runbook.md +1 -1
  50. package/commands/review/code.md +1 -1
  51. package/commands/review/quality.md +1 -1
  52. package/commands/review/security.md +1 -1
  53. package/commands/ship/ready.md +1 -1
  54. package/commands/ship/release.md +1 -1
  55. package/commands/ship/status.md +1 -1
  56. package/commands/understand/docs.md +1 -1
  57. package/commands/understand/this.md +1 -1
  58. package/commands/understand/why.md +1 -1
  59. package/commands/work/clean.md +1 -1
  60. package/commands/work/drive.md +1 -1
  61. package/commands/work/optimize-prompts.md +1 -1
  62. package/commands/work/parallel-review.md +1 -1
  63. package/lib/auto-docs.mjs +7 -7
  64. package/lib/boundary.mjs +126 -0
  65. package/lib/cache-strategy-google.js +26 -31
  66. package/lib/comment-lint.mjs +134 -0
  67. package/lib/contracts/validate.mjs +323 -0
  68. package/lib/daemons/contract.mjs +210 -0
  69. package/lib/docs-verify.mjs +59 -6
  70. package/lib/doctor/cli.mjs +16 -1
  71. package/lib/doctor/index.mjs +3 -1
  72. package/lib/doctor/watchers/consistency.mjs +310 -0
  73. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  74. package/lib/document-extract.mjs +211 -1
  75. package/lib/embed/cli.mjs +124 -3
  76. package/lib/embed/daemon.mjs +43 -4
  77. package/lib/embed/docs-lifecycle.mjs +1 -1
  78. package/lib/embed/inbox.mjs +2 -0
  79. package/lib/embed/scheduler.mjs +33 -5
  80. package/lib/evaluator-optimizer.mjs +2 -3
  81. package/lib/hooks/comment-lint.mjs +16 -0
  82. package/lib/hooks/mcp-audit.mjs +2 -1
  83. package/lib/hooks/proactive-activation.mjs +0 -14
  84. package/lib/hooks/rule-verifier.mjs +217 -0
  85. package/lib/hooks/session-optimize.mjs +2 -1
  86. package/lib/init-unified.mjs +55 -65
  87. package/lib/intake/classify.mjs +108 -24
  88. package/lib/intake/daemon.mjs +121 -0
  89. package/lib/intake/filesystem-queue.mjs +6 -1
  90. package/lib/intake/intake-config.mjs +2 -1
  91. package/lib/intake/prepare.mjs +0 -1
  92. package/lib/intake/session-prelude.mjs +7 -1
  93. package/lib/intake/traceability.mjs +90 -0
  94. package/lib/knowledge/research-store.mjs +2 -0
  95. package/lib/maintenance/cleanup.mjs +315 -0
  96. package/lib/mcp/memory-bridge.mjs +276 -0
  97. package/lib/mcp/server.mjs +2 -0
  98. package/lib/mcp/tools/workflow.mjs +25 -0
  99. package/lib/mcp-catalog.json +12 -8
  100. package/lib/mcp-platform-config.mjs +16 -8
  101. package/lib/migrations/index.mjs +106 -0
  102. package/lib/migrations/v1-baseline.mjs +33 -0
  103. package/lib/observation-store.mjs +9 -4
  104. package/lib/outcomes/record.mjs +2 -0
  105. package/lib/profiles/rebrand.mjs +46 -0
  106. package/lib/project-init-shared.mjs +12 -0
  107. package/lib/provider-capabilities.js +20 -7
  108. package/lib/providers/auth-manager.mjs +58 -17
  109. package/lib/reflect.mjs +49 -12
  110. package/lib/server/index.mjs +22 -28
  111. package/lib/session-store.mjs +6 -4
  112. package/lib/setup.mjs +14 -3
  113. package/lib/telemetry/client.mjs +5 -1
  114. package/lib/version.mjs +51 -0
  115. package/lib/worker/trace.mjs +5 -1
  116. package/package.json +4 -1
  117. package/personas/construct.md +3 -1
  118. package/rules/common/development-workflow.md +2 -1
  119. package/rules/common/no-fabrication.md +69 -0
  120. package/rules/common/review-before-change.md +58 -0
  121. package/scripts/sync-agents.mjs +45 -14
  122. package/templates/docs/construct_guide.md +6 -6
  123. package/templates/docs/skill-artifact.md +1 -1
@@ -239,20 +239,6 @@ export function onScheduleCheck() {
239
239
  return events;
240
240
  }
241
241
 
242
- /**
243
- * Get all pending activations for a specialist.
244
- */
245
- export function getPendingActivations(specialist) {
246
- // In a full implementation, this would query a pending queue
247
- // For now, return recent activation history
248
- const state = getActivationState(specialist);
249
- return {
250
- pending: 0,
251
- recent: state.activations.length,
252
- total: state.totalActivations,
253
- };
254
- }
255
-
256
242
  /**
257
243
  * Clear activation state (for testing or manual reset).
258
244
  */
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lib/hooks/rule-verifier.mjs — Stop hook that audits the session for
4
+ * compliance with the construct rules the agent claimed to follow.
5
+ *
6
+ * Checks:
7
+ * - Each commit / push was preceded by an approval signal scoped to that
8
+ * branch + change.
9
+ * - Edits to protected files (CLAUDE.md table) had user confirmation.
10
+ * - Beads claims exist for any work that touched durable state.
11
+ *
12
+ * Approval detection is intent-based, not keyword-based. The verifier reads
13
+ * the conversational window before each consequential action and classifies
14
+ * the user's stance: APPROVED, REFUSED, INCONCLUSIVE. Direct affirmation,
15
+ * accepting a proposed plan that named the action, or "go ahead" all count
16
+ * as approval — no specific word is required.
17
+ *
18
+ * Output: { ok: 'pass' | 'fail' | 'inconclusive', findings[] } written to
19
+ * .cx/audit.jsonl. Inconclusive results surface as escalation questions to
20
+ * the user rather than silent passes or false-positive blocks.
21
+ *
22
+ * @p95ms 200
23
+ * @maxBlockingScope Stop
24
+ */
25
+
26
+ import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
28
+ import { logHookFailure } from './_lib/log.mjs';
29
+
30
+ const AUDIT_REL = '.cx/audit.jsonl';
31
+
32
+ function safeReadJsonLines(transcriptPath) {
33
+ try {
34
+ const raw = readFileSync(transcriptPath, 'utf8');
35
+ return raw.split('\n').map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
36
+ } catch { return []; }
37
+ }
38
+
39
+ function lastN(entries, count) {
40
+ return entries.slice(Math.max(0, entries.length - count));
41
+ }
42
+
43
+ /**
44
+ * Identify consequential actions in the transcript tail. Returns a list of
45
+ * { kind, branch, target, atIndex } items. Each one needs an approval signal
46
+ * within the window of conversation immediately preceding it.
47
+ *
48
+ * Detected actions:
49
+ * - 'commit' — git commit invocation
50
+ * - 'push' — git push invocation
51
+ * - 'protected' — edit to a CLAUDE.md-protected file
52
+ * - 'durable-state' — bd close / claim against durable state
53
+ */
54
+ export function findConsequentialActions(entries) {
55
+ const actions = [];
56
+ for (let i = 0; i < entries.length; i++) {
57
+ const e = entries[i];
58
+ const cmd = extractBashCommand(e);
59
+ if (cmd) {
60
+ if (/^git\s+commit\b/.test(cmd) && !/--dry-run/.test(cmd)) {
61
+ actions.push({ kind: 'commit', target: cmd.slice(0, 200), atIndex: i });
62
+ } else if (/^git\s+push\b/.test(cmd) && !/--dry-run/.test(cmd)) {
63
+ actions.push({ kind: 'push', target: cmd.slice(0, 200), atIndex: i });
64
+ } else if (/^gh\s+pr\s+(create|merge)\b/.test(cmd)) {
65
+ actions.push({ kind: 'pr-mutation', target: cmd.slice(0, 200), atIndex: i });
66
+ }
67
+ }
68
+ const editPath = extractEditPath(e);
69
+ if (editPath && /CLAUDE\.md|install\.sh|claude\/settings\.template\.json|agents\/registry\.json/.test(editPath)) {
70
+ actions.push({ kind: 'protected', target: editPath, atIndex: i });
71
+ }
72
+ }
73
+ return actions;
74
+ }
75
+
76
+ function extractBashCommand(entry) {
77
+ const toolUse = entry?.message?.content?.find?.((c) => c?.type === 'tool_use' && c?.name === 'Bash');
78
+ return toolUse?.input?.command || null;
79
+ }
80
+
81
+ function extractEditPath(entry) {
82
+ const toolUse = entry?.message?.content?.find?.((c) => c?.type === 'tool_use' && (c?.name === 'Edit' || c?.name === 'Write'));
83
+ return toolUse?.input?.file_path || null;
84
+ }
85
+
86
+ /**
87
+ * Classify a conversational window for approval intent. This is intentionally
88
+ * conservative — it does not require any keyword. If the window contains a
89
+ * user turn that directly addresses the action being taken (mentioning the
90
+ * branch, the file, or accepting a proposed plan), that counts as approval.
91
+ *
92
+ * The default classifier is deterministic: it scans for plausible approval
93
+ * tokens AND for the action target appearing earlier in the conversation.
94
+ * A pluggable classifier can replace this with an LLM-graded inference at
95
+ * runtime — register via setApprovalClassifier(fn).
96
+ *
97
+ * Returns: 'APPROVED' | 'REFUSED' | 'INCONCLUSIVE'.
98
+ */
99
+ let _approvalClassifier = defaultApprovalClassifier;
100
+
101
+ export function setApprovalClassifier(fn) {
102
+ _approvalClassifier = typeof fn === 'function' ? fn : defaultApprovalClassifier;
103
+ }
104
+
105
+ export function classifyApproval(window, action) {
106
+ return _approvalClassifier(window, action);
107
+ }
108
+
109
+ function defaultApprovalClassifier(window, action) {
110
+ if (!Array.isArray(window) || window.length === 0) return 'INCONCLUSIVE';
111
+
112
+ let assistantProposedAction = false;
113
+ let userResponded = false;
114
+ let userRefused = false;
115
+ let userAffirmed = false;
116
+
117
+ for (const entry of window) {
118
+ const role = entry?.message?.role || entry?.role;
119
+ const text = extractText(entry).toLowerCase();
120
+ if (!text) continue;
121
+
122
+ if (role === 'assistant') {
123
+ if (actionMentioned(text, action)) assistantProposedAction = true;
124
+ }
125
+ if (role === 'user') {
126
+ userResponded = true;
127
+ if (/\b(no|stop|don'?t|cancel|wait|abort|hold off|never mind)\b/.test(text)) userRefused = true;
128
+ if (/\b(yes|yep|yeah|go ahead|do it|proceed|ship it|approve|confirmed|sounds good|that works|lgtm|sgtm|ok|sure|please do|merge it|continue)\b/.test(text)) userAffirmed = true;
129
+ // Even without explicit affirmation words, accepting a named plan is approval.
130
+ if (assistantProposedAction && !userRefused && text.length > 0 && !/\?$/.test(text.trim())) {
131
+ userAffirmed = userAffirmed || /\b(thanks|great|perfect|sounds|works|good)\b/.test(text);
132
+ }
133
+ }
134
+ }
135
+
136
+ if (userRefused) return 'REFUSED';
137
+ if (userAffirmed && assistantProposedAction) return 'APPROVED';
138
+ if (userAffirmed) return 'APPROVED';
139
+ if (!userResponded) return 'INCONCLUSIVE';
140
+ return 'INCONCLUSIVE';
141
+ }
142
+
143
+ function extractText(entry) {
144
+ const msg = entry?.message;
145
+ if (!msg) return '';
146
+ if (typeof msg.content === 'string') return msg.content;
147
+ if (Array.isArray(msg.content)) {
148
+ return msg.content.map((c) => (typeof c === 'string' ? c : c?.text || '')).join(' ');
149
+ }
150
+ return '';
151
+ }
152
+
153
+ function actionMentioned(text, action) {
154
+ if (!text) return false;
155
+ if (action.kind === 'commit' || action.kind === 'push' || action.kind === 'pr-mutation') {
156
+ return /\b(commit|push|merge|ship|pr|pull request)\b/.test(text);
157
+ }
158
+ if (action.kind === 'protected') {
159
+ const fname = action.target.split('/').pop().toLowerCase();
160
+ return text.includes(fname.toLowerCase()) || text.includes(action.target.toLowerCase());
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * Verify a transcript against the rule set. Returns a structured audit
167
+ * result. Side-effect free; the runtime entry point below writes the result
168
+ * to .cx/audit.jsonl.
169
+ */
170
+ export function verifyTranscript(entries, { windowSize = 20 } = {}) {
171
+ const findings = [];
172
+ const actions = findConsequentialActions(entries);
173
+
174
+ for (const action of actions) {
175
+ const window = lastN(entries.slice(0, action.atIndex), windowSize);
176
+ const verdict = classifyApproval(window, action);
177
+ findings.push({
178
+ action: action.kind,
179
+ target: action.target,
180
+ verdict,
181
+ atIndex: action.atIndex,
182
+ });
183
+ }
184
+
185
+ const failed = findings.some((f) => f.verdict === 'REFUSED');
186
+ const inconclusive = findings.some((f) => f.verdict === 'INCONCLUSIVE');
187
+ let ok;
188
+ if (failed) ok = 'fail';
189
+ else if (inconclusive) ok = 'inconclusive';
190
+ else ok = 'pass';
191
+
192
+ return { ok, findings, actionCount: actions.length };
193
+ }
194
+
195
+ function writeAudit(cwd, payload) {
196
+ const path = join(cwd, AUDIT_REL);
197
+ try { mkdirSync(dirname(path), { recursive: true }); } catch { /* ignore */ }
198
+ try { appendFileSync(path, JSON.stringify(payload) + '\n'); }
199
+ catch (err) { logHookFailure({ hook: 'rule-verifier', err, phase: 'append-audit' }); }
200
+ }
201
+
202
+ if (import.meta.url === `file://${process.argv[1]}`) {
203
+ let input = {};
204
+ try { input = JSON.parse(readFileSync(0, 'utf8')); }
205
+ catch (err) { logHookFailure({ hook: 'rule-verifier', err, phase: 'parse-stdin' }); process.exit(0); }
206
+
207
+ const cwd = input?.cwd || process.cwd();
208
+ const transcriptPath = input?.transcript_path;
209
+ if (!transcriptPath || !existsSync(transcriptPath)) process.exit(0);
210
+
211
+ const entries = safeReadJsonLines(transcriptPath);
212
+ const result = verifyTranscript(entries);
213
+ writeAudit(cwd, { ts: new Date().toISOString(), hook: 'rule-verifier', ...result });
214
+
215
+ if (result.ok === 'fail' && process.env.CONSTRUCT_RULE_VERIFIER === 'block') process.exit(2);
216
+ process.exit(0);
217
+ }
@@ -237,7 +237,8 @@ function main() {
237
237
  console.log(` • ${agent.name}: avgScore=${agent.avgScore}, invocations=${agent.invocations}`);
238
238
  });
239
239
 
240
- // Run optimization for each agent (serial for now)
240
+ // Sequential optimization keeps prompt-tuning deterministic and avoids
241
+ // colliding writes to per-agent prompt files.
241
242
  let optimizedCount = 0;
242
243
  let failedCount = 0;
243
244
 
@@ -615,87 +615,76 @@ function discoverProjectDirs(targetPath) {
615
615
  return dirs.sort();
616
616
  }
617
617
 
618
- const INTAKE_DIR_PRESETS = [
619
- { value: 'src', label: 'src/', reason: 'Source code most projects put code here' },
620
- { value: 'lib', label: 'lib/', reason: 'Library code' },
621
- { value: 'packages', label: 'packages/', reason: 'Monorepo packages' },
622
- { value: 'services', label: 'services/', reason: 'Microservices or backend services' },
623
- { value: 'apps', label: 'apps/', reason: 'Monorepo apps' },
624
- { value: 'docs', label: 'docs/', reason: 'Documentation source' },
625
- { value: 'tests', label: 'tests/', reason: 'Test files' },
626
- { value: 'spec', label: 'spec/', reason: 'Specifications' },
627
- { value: 'infra', label: 'infra/', reason: 'Infrastructure code (Terraform, etc.)' },
628
- { value: 'config', label: 'config/', reason: 'Configuration files' },
629
- { value: 'scripts', label: 'scripts/', reason: 'Build/ops scripts' },
630
- { value: 'tools', label: 'tools/', reason: 'Internal tooling' },
631
- ];
618
+ // Extra inbox-style directories the user can opt into. These are NOT
619
+ // source-code, doc, or test directories: they're places where unsorted
620
+ // signals tend to land (downloads, scratch notes, exported research). The
621
+ // built-in `.cx/inbox/` and `docs/intake/` are always watched and not
622
+ // listed here.
623
+ //
624
+ // Historically this list included `src/`, `docs/`, `tests/`, etc., and
625
+ // `init --yes` auto-enabled every one that existed in the project. That
626
+ // turned every code change, doc edit, and test file into a synthetic
627
+ // "intake signal" and polluted the queue with hundreds of false positives.
628
+ // Watching project artifact directories is opt-in only; an empty list is
629
+ // the correct default.
630
+ const INTAKE_DIR_PRESETS = [];
632
631
 
633
632
  async function askIntakeCollection(targetPath, skipInteractive) {
633
+ // Non-interactive default: empty parentDirs. The built-in zones
634
+ // (.cx/inbox/ and docs/intake/ when present) handle every well-defined
635
+ // signal path. Users who want to watch additional directories opt in
636
+ // explicitly via `construct intake config set --add-dir=<path>`.
634
637
  if (skipInteractive) {
635
- // Non-interactive: auto-detect and suggest based on what exists
636
- const existingDirs = discoverProjectDirs(targetPath);
637
- const selected = INTAKE_DIR_PRESETS
638
- .filter(preset => existingDirs.includes(preset.value))
639
- .map(p => p.value);
640
-
641
- if (selected.length === 0) return null;
642
-
643
- console.log('');
644
- console.log('═══════════════════════════════════════════════════════════');
645
- console.log(' INTAKE COLLECTION');
646
- console.log('═══════════════════════════════════════════════════════════');
647
- console.log('');
648
- console.log(`Auto-detected ${selected.length} directory(ies) to watch for context:`);
649
- for (const d of selected) console.log(` • ${d}/`);
650
- console.log('');
651
- console.log('The inbox watcher will scan these directories for new files');
652
- console.log('and route them through R&D triage automatically.');
653
- console.log('');
654
-
655
- return { parentDirs: selected, maxDepth: 4 };
638
+ return null;
656
639
  }
657
-
658
- // Interactive mode
640
+
641
+ // Interactive mode: no presets to surface — the user must name the
642
+ // directory they want watched. Skipped entirely when the preset list
643
+ // is empty (the current default after the auto-include regression).
644
+ if (INTAKE_DIR_PRESETS.length === 0) {
645
+ return null;
646
+ }
647
+
659
648
  const existingDirs = discoverProjectDirs(targetPath);
660
649
  const presetOptions = INTAKE_DIR_PRESETS.map(p => ({
661
650
  label: p.label,
662
651
  value: p.value,
663
- checked: existingDirs.includes(p.value),
652
+ checked: false,
664
653
  description: p.reason,
665
654
  }));
666
-
655
+
667
656
  console.log('');
668
657
  console.log('═══════════════════════════════════════════════════════════');
669
658
  console.log(' INTAKE COLLECTION');
670
659
  console.log('═══════════════════════════════════════════════════════════');
671
660
  console.log('');
672
- console.log('Intake watches directories for new files and routes them');
673
- console.log('through R&D triage. Select which directories to watch.');
674
- console.log('(directories already found in your project are pre-checked)');
661
+ console.log('Intake watches `.cx/inbox/` and `docs/intake/` by default.');
662
+ console.log('Add extra directories ONLY if signals (customer notes, exports,');
663
+ console.log('PDFs, etc.) regularly land there. Do not select code or finished');
664
+ console.log('artifact directories like src/, docs/, or tests/.');
675
665
  console.log('');
676
-
666
+
667
+ void existingDirs;
677
668
  const selected = await multiSelect({
678
- title: 'Directories to Watch',
679
- instructions: 'Press Enter to confirm your selection',
669
+ title: 'Additional Inbox Directories',
670
+ instructions: 'Press Enter to confirm your selection (or pick none)',
680
671
  options: presetOptions,
681
672
  });
682
-
673
+
683
674
  if (selected.length === 0) {
684
675
  console.log('');
685
- console.log('No directories selected. The inbox watcher will only scan');
686
- console.log('.cx/inbox/ and docs/intake/ (built-in zones).');
687
- console.log('');
688
- console.log('You can always add more later with:');
689
- console.log(' construct intake config set --add-dir=src');
676
+ console.log('No extra directories selected. The inbox watcher will scan');
677
+ console.log('.cx/inbox/ and docs/intake/ only. Add more later with:');
678
+ console.log(' construct intake config set --add-dir=<path>');
690
679
  console.log('');
691
680
  return null;
692
681
  }
693
-
682
+
694
683
  console.log('');
695
- console.log(`Watching ${selected.length} directory(ies):`);
684
+ console.log(`Watching ${selected.length} extra directory(ies):`);
696
685
  for (const d of selected) console.log(` • ${d}/`);
697
686
  console.log('');
698
-
687
+
699
688
  return { parentDirs: selected, maxDepth: 4 };
700
689
  }
701
690
 
@@ -949,18 +938,19 @@ async function main() {
949
938
  console.log('');
950
939
  }
951
940
 
952
- // Intake collection — ask which directories to watch for context
941
+ // Intake collection — always write intake-config.json with safe defaults
942
+ // so the user can inspect what's watched and edit the file directly. Empty
943
+ // parentDirs is the correct default: the inbox watcher always scans
944
+ // .cx/inbox/ and docs/intake/ (when present); extra dirs are opt-in.
953
945
  console.log('[TRACE init:intake-ask]');
954
-
955
- const intakeConfig = await askIntakeCollection(target, skipInteractive);
956
- if (intakeConfig) {
957
- const { saveIntakeConfig } = await import('./intake/intake-config.mjs');
958
- try {
959
- saveIntakeConfig(target, intakeConfig);
960
- created.push('.cx/intake-config.json');
961
- } catch (err) {
962
- console.warn(`⚠️ Could not write intake config: ${err.message}`);
963
- }
946
+
947
+ const intakeConfig = (await askIntakeCollection(target, skipInteractive)) ?? { parentDirs: [], maxDepth: 4 };
948
+ const { saveIntakeConfig } = await import('./intake/intake-config.mjs');
949
+ try {
950
+ saveIntakeConfig(target, intakeConfig);
951
+ created.push('.cx/intake-config.json');
952
+ } catch (err) {
953
+ console.warn(`⚠️ Could not write intake config: ${err.message}`);
964
954
  }
965
955
 
966
956
  // Ask about documentation system
@@ -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,