@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.
- package/README.md +19 -17
- package/agents/contracts.json +617 -2
- package/agents/contracts.schema.json +83 -0
- package/agents/prompts/cx-accessibility.md +2 -0
- package/agents/prompts/cx-ai-engineer.md +2 -0
- package/agents/prompts/cx-architect.md +2 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +2 -0
- package/agents/prompts/cx-data-engineer.md +2 -0
- package/agents/prompts/cx-debugger.md +2 -0
- package/agents/prompts/cx-designer.md +2 -0
- package/agents/prompts/cx-devil-advocate.md +2 -0
- package/agents/prompts/cx-docs-keeper.md +2 -0
- package/agents/prompts/cx-engineer.md +2 -0
- package/agents/prompts/cx-evaluator.md +2 -0
- package/agents/prompts/cx-explorer.md +2 -0
- package/agents/prompts/cx-legal-compliance.md +2 -0
- package/agents/prompts/cx-operations.md +2 -0
- package/agents/prompts/cx-orchestrator.md +2 -0
- package/agents/prompts/cx-platform-engineer.md +2 -0
- package/agents/prompts/cx-product-manager.md +2 -0
- package/agents/prompts/cx-qa.md +2 -0
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-release-manager.md +2 -0
- package/agents/prompts/cx-researcher.md +2 -0
- package/agents/prompts/cx-reviewer.md +2 -0
- package/agents/prompts/cx-security.md +2 -0
- package/agents/prompts/cx-sre.md +2 -0
- package/agents/prompts/cx-test-automation.md +2 -0
- package/agents/prompts/cx-trace-reviewer.md +2 -0
- package/agents/prompts/cx-ux-researcher.md +2 -0
- package/agents/registry.json +8 -3
- package/bin/construct +209 -32
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +1 -1
- package/commands/work/parallel-review.md +1 -1
- package/lib/auto-docs.mjs +7 -7
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/comment-lint.mjs +134 -0
- package/lib/contracts/validate.mjs +323 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +310 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +211 -1
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +2 -0
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/mcp-audit.mjs +2 -1
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/init-unified.mjs +55 -65
- package/lib/intake/classify.mjs +108 -24
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +6 -1
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/prepare.mjs +0 -1
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +2 -0
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/observation-store.mjs +9 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/reflect.mjs +49 -12
- package/lib/server/index.mjs +22 -28
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/telemetry/client.mjs +5 -1
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/package.json +4 -1
- package/personas/construct.md +3 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/sync-agents.mjs +45 -14
- package/templates/docs/construct_guide.md +6 -6
- 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
|
-
//
|
|
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
|
|
package/lib/init-unified.mjs
CHANGED
|
@@ -615,87 +615,76 @@ function discoverProjectDirs(targetPath) {
|
|
|
615
615
|
return dirs.sort();
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
673
|
-
console.log('
|
|
674
|
-
console.log('
|
|
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: '
|
|
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
|
|
686
|
-
console.log('.cx/inbox/ and docs/intake/
|
|
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 —
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
package/lib/intake/classify.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
const
|
|
117
|
-
const score = hits +
|
|
118
|
-
if (
|
|
119
|
-
|
|
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 (
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
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,
|