@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.
- package/README.md +24 -18
- package/agents/contracts.json +617 -2
- package/agents/contracts.schema.json +83 -0
- package/agents/prompts/cx-accessibility.md +5 -3
- package/agents/prompts/cx-ai-engineer.md +9 -7
- package/agents/prompts/cx-architect.md +8 -6
- package/agents/prompts/cx-business-strategist.md +8 -6
- package/agents/prompts/cx-data-analyst.md +9 -7
- package/agents/prompts/cx-data-engineer.md +5 -3
- package/agents/prompts/cx-debugger.md +8 -6
- package/agents/prompts/cx-designer.md +7 -5
- package/agents/prompts/cx-devil-advocate.md +6 -4
- package/agents/prompts/cx-docs-keeper.md +7 -5
- package/agents/prompts/cx-engineer.md +7 -5
- package/agents/prompts/cx-evaluator.md +4 -2
- package/agents/prompts/cx-explorer.md +10 -8
- package/agents/prompts/cx-legal-compliance.md +4 -2
- package/agents/prompts/cx-operations.md +7 -5
- package/agents/prompts/cx-orchestrator.md +12 -10
- package/agents/prompts/cx-platform-engineer.md +7 -5
- package/agents/prompts/cx-product-manager.md +6 -4
- package/agents/prompts/cx-qa.md +10 -8
- package/agents/prompts/cx-rd-lead.md +11 -9
- package/agents/prompts/cx-release-manager.md +7 -5
- package/agents/prompts/cx-researcher.md +24 -22
- package/agents/prompts/cx-reviewer.md +9 -7
- package/agents/prompts/cx-security.md +12 -10
- package/agents/prompts/cx-sre.md +9 -7
- package/agents/prompts/cx-test-automation.md +5 -3
- package/agents/prompts/cx-trace-reviewer.md +10 -8
- package/agents/prompts/cx-ux-researcher.md +5 -3
- package/agents/registry.json +8 -3
- package/bin/construct +444 -53
- package/commands/build/feature.md +4 -4
- package/commands/build/fix.md +8 -8
- package/commands/design/access.md +3 -3
- package/commands/design/flow.md +3 -3
- package/commands/design/ui.md +4 -4
- package/commands/measure/experiment.md +5 -5
- package/commands/measure/metrics.md +3 -3
- package/commands/measure/results.md +4 -4
- package/commands/plan/api.md +3 -3
- package/commands/plan/challenge.md +3 -3
- package/commands/plan/decide.md +3 -3
- package/commands/plan/feature.md +7 -7
- package/commands/plan/requirements.md +3 -3
- package/commands/remember/context.md +5 -5
- package/commands/remember/handoff.md +3 -3
- package/commands/remember/runbook.md +3 -3
- package/commands/review/code.md +8 -8
- package/commands/review/quality.md +4 -4
- package/commands/review/security.md +3 -3
- package/commands/ship/ready.md +3 -3
- package/commands/ship/release.md +3 -3
- package/commands/ship/status.md +4 -4
- package/commands/understand/docs.md +3 -3
- package/commands/understand/this.md +4 -4
- package/commands/understand/why.md +8 -8
- package/commands/work/clean.md +14 -14
- package/commands/work/drive.md +10 -10
- package/commands/work/optimize-prompts.md +9 -9
- package/commands/work/parallel-review.md +8 -8
- package/db/schema/006_graph.sql +24 -0
- package/examples/provider-plugin/README.md +7 -7
- package/examples/seed-observations/README.md +6 -6
- package/examples/seed-observations/anti-patterns.md +14 -14
- package/examples/seed-observations/decisions.md +4 -4
- package/examples/seed-observations/patterns.md +14 -14
- package/lib/auto-docs.mjs +13 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +2 -2
- 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/flavors/loader.mjs +1 -1
- 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/graph.mjs +213 -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 +189 -1
- package/lib/mcp/tools/profile.mjs +270 -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/platforms/claude/CLAUDE.md +6 -6
- package/rules/common/agents.md +2 -2
- package/rules/common/beads-hygiene.md +11 -11
- package/rules/common/code-review.md +1 -1
- package/rules/common/coding-style.md +1 -1
- package/rules/common/comments.md +8 -8
- package/rules/common/commit-approval.md +4 -4
- package/rules/common/cx-agent-routing.md +2 -2
- package/rules/common/cx-skill-routing.md +2 -2
- package/rules/common/development-workflow.md +3 -2
- package/rules/common/doc-ownership.md +2 -2
- package/rules/common/efficiency.md +3 -3
- package/rules/common/framing.md +1 -1
- package/rules/common/git-workflow.md +1 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/patterns.md +1 -1
- package/rules/common/performance.md +1 -1
- package/rules/common/release-gates.md +7 -7
- package/rules/common/research.md +4 -4
- package/rules/common/review-before-change.md +58 -0
- package/rules/common/security.md +1 -1
- package/rules/common/skill-composition.md +8 -8
- package/rules/common/testing.md +1 -1
- package/rules/golang/coding-style.md +2 -2
- package/rules/golang/hooks.md +1 -1
- package/rules/golang/patterns.md +1 -1
- package/rules/golang/security.md +1 -1
- package/rules/golang/testing.md +1 -1
- package/rules/python/coding-style.md +1 -1
- package/rules/python/hooks.md +1 -1
- package/rules/python/patterns.md +1 -1
- package/rules/python/security.md +1 -1
- package/rules/python/testing.md +1 -1
- package/rules/swift/coding-style.md +3 -3
- package/rules/swift/hooks.md +2 -2
- package/rules/swift/patterns.md +2 -2
- package/rules/swift/security.md +4 -4
- package/rules/swift/testing.md +2 -2
- package/rules/typescript/coding-style.md +1 -1
- package/rules/typescript/hooks.md +1 -1
- package/rules/typescript/patterns.md +1 -1
- package/rules/typescript/security.md +1 -1
- package/rules/typescript/testing.md +1 -1
- package/rules/web/coding-style.md +1 -1
- package/rules/web/design-quality.md +1 -1
- package/rules/web/hooks.md +1 -1
- package/rules/web/patterns.md +1 -1
- package/rules/web/performance.md +1 -1
- package/rules/web/security.md +1 -1
- package/rules/web/testing.md +1 -1
- package/scripts/sync-agents.mjs +45 -14
- package/skills/ai/agent-dev.md +1 -1
- package/skills/ai/llm-security.md +1 -1
- package/skills/ai/ml-ops.md +6 -6
- package/skills/ai/orchestration-workflow.md +1 -1
- package/skills/ai/prompt-and-eval.md +1 -1
- package/skills/ai/prompt-optimizer.md +13 -13
- package/skills/ai/rag-system.md +1 -1
- package/skills/architecture/api-design.md +1 -1
- package/skills/architecture/caching.md +1 -1
- package/skills/architecture/cloud-native.md +1 -1
- package/skills/architecture/message-queue.md +1 -1
- package/skills/architecture/security-arch.md +1 -1
- package/skills/compliance/ai-disclosure.md +1 -1
- package/skills/compliance/data-privacy.md +1 -1
- package/skills/compliance/license-audit.md +2 -2
- package/skills/compliance/regulatory-review.md +1 -1
- package/skills/development/cpp.md +1 -1
- package/skills/development/go.md +1 -1
- package/skills/development/java.md +1 -1
- package/skills/development/kotlin.md +9 -9
- package/skills/development/mobile-crossplatform.md +13 -13
- package/skills/development/python.md +1 -1
- package/skills/development/rust.md +1 -1
- package/skills/development/shell.md +1 -1
- package/skills/development/swift.md +6 -6
- package/skills/development/typescript.md +1 -1
- package/skills/devops/ci-cd.md +5 -5
- package/skills/devops/containerization.md +9 -9
- package/skills/devops/cost-optimization.md +1 -1
- package/skills/devops/data-engineering.md +2 -2
- package/skills/devops/database.md +1 -1
- package/skills/devops/dependency-management.md +3 -3
- package/skills/devops/devsecops.md +1 -1
- package/skills/devops/git-workflow.md +1 -1
- package/skills/devops/incident-response.md +18 -18
- package/skills/devops/monorepo.md +5 -5
- package/skills/devops/observability.md +1 -1
- package/skills/devops/performance.md +1 -1
- package/skills/devops/testing.md +1 -1
- package/skills/docs/adr-workflow.md +2 -2
- package/skills/docs/backlog-proposal-workflow.md +1 -1
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/document-ingest-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +1 -1
- package/skills/docs/init-docs.md +15 -15
- package/skills/docs/init-project.md +1 -1
- package/skills/docs/prd-workflow.md +3 -3
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +1 -1
- package/skills/docs/product-signal-workflow.md +9 -9
- package/skills/docs/research-workflow.md +10 -10
- package/skills/docs/runbook-workflow.md +2 -2
- package/skills/docs/strategy-workflow.md +3 -3
- package/skills/exploration/repo-map.md +11 -11
- package/skills/frameworks/django.md +15 -15
- package/skills/frameworks/nextjs.md +16 -16
- package/skills/frameworks/react.md +12 -12
- package/skills/frameworks/spring-boot.md +12 -12
- package/skills/frontend-design/accessibility.md +6 -6
- package/skills/frontend-design/component-patterns.md +1 -1
- package/skills/frontend-design/engineering.md +1 -1
- package/skills/frontend-design/state-management.md +1 -1
- package/skills/frontend-design/ui-aesthetics.md +1 -1
- package/skills/frontend-design/ux-principles.md +1 -1
- package/skills/operating/orchestration-reference.md +27 -27
- package/skills/quality-gates/review-work.md +3 -3
- package/skills/quality-gates/verify-change.md +1 -1
- package/skills/quality-gates/verify-module.md +1 -1
- package/skills/quality-gates/verify-quality.md +1 -1
- package/skills/quality-gates/verify-security.md +1 -1
- package/skills/routing.md +14 -14
- package/skills/security/blue-team.md +1 -1
- package/skills/security/code-audit.md +1 -1
- package/skills/security/pentest.md +1 -1
- package/skills/security/red-team.md +1 -1
- package/skills/security/threat-intel.md +1 -1
- package/skills/security/vuln-research.md +1 -1
- package/skills/utility/clean-code.md +2 -2
- package/templates/docs/changelog-entry.md +1 -1
- package/templates/docs/construct_guide.md +18 -18
- package/templates/docs/meta-prd.md +16 -16
- package/templates/docs/one-pager.md +1 -1
- package/templates/docs/prd-business.md +1 -1
- package/templates/docs/prd-platform.md +1 -1
- package/templates/docs/prd.md +17 -17
- package/templates/docs/research-brief.md +8 -8
- package/templates/docs/rfc.md +1 -1
- package/templates/docs/skill-artifact.md +1 -1
- package/templates/docs/strategy.md +1 -1
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
119
|
+
ensureCxDir(rootDir);
|
|
119
120
|
writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
|
|
120
121
|
return next;
|
|
121
122
|
}
|
package/lib/intake/prepare.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|