@geraldmaron/construct 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -34
- package/bin/construct +1403 -96
- 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 +2 -2
- package/commands/work/parallel-review.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +8 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +139 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +371 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -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 +360 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +420 -17
- package/lib/document-ingest.mjs +2 -0
- 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 +50 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/env-config.mjs +9 -3
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +14 -1
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +73 -65
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +309 -58
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +31 -6
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -1
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +11 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- 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/model-registry.mjs +1 -1
- package/lib/observation-store.mjs +9 -4
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/reflect.mjs +49 -12
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +29 -29
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/client.mjs +5 -1
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +16 -3
- package/personas/construct.md +5 -3
- package/platforms/claude/CLAUDE.md +1 -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 → sync-specialists.mjs} +171 -68
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/specialists/contracts.json +1002 -0
- package/specialists/contracts.schema.json +83 -0
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-architect.md +3 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-debugger.md +3 -1
- package/{agents → specialists}/prompts/cx-designer.md +3 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
- package/{agents → specialists}/prompts/cx-engineer.md +4 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
- package/{agents → specialists}/prompts/cx-explorer.md +3 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
- package/{agents → specialists}/prompts/cx-operations.md +3 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-qa.md +3 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-researcher.md +3 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
- package/{agents → specialists}/prompts/cx-security.md +3 -1
- package/{agents → specialists}/prompts/cx-sre.md +4 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
- package/{agents → specialists}/registry.json +651 -418
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/docs/construct_guide.md +6 -6
- package/templates/docs/skill-artifact.md +1 -1
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- package/agents/contracts.json +0 -387
- /package/{agents → specialists}/teams.json +0 -0
package/lib/intake/classify.mjs
CHANGED
|
@@ -4,20 +4,39 @@
|
|
|
4
4
|
* Deterministic, keyword-driven classifier that maps a raw intake signal onto
|
|
5
5
|
* the active org profile's loop. Returns a triage object with: intakeType,
|
|
6
6
|
* rdStage, primaryOwner, recommendedChain, recommendedAction, risk,
|
|
7
|
-
* requiresApproval, confidence, rationale.
|
|
7
|
+
* requiresApproval, confidence, rationale, candidates (top-3 with margins).
|
|
8
|
+
*
|
|
9
|
+
* Classification MUST be a pure function of (sourcePath, extractedText,
|
|
10
|
+
* related, profile). Same input must yield identical output across runs.
|
|
11
|
+
* Outcomes-aware re-ranking is deliberately not consulted here: cached
|
|
12
|
+
* success-rate history would make the same file classify differently
|
|
13
|
+
* across runs, which is a non-determinism bug. Outcomes can influence
|
|
14
|
+
* downstream routing, but never classify.
|
|
8
15
|
*
|
|
9
16
|
* NO LLM call. The daemon path must remain synchronous and cheap; intent
|
|
10
17
|
* verification belongs in offline measurement, not inline classification.
|
|
11
18
|
*
|
|
12
19
|
* Default profile is `rnd` so existing R&D users see no behavior change.
|
|
13
20
|
* The `tests/intake/golden-rnd.test.mjs` test locks that invariant.
|
|
21
|
+
*
|
|
22
|
+
* Calibration model (PR-1 hardening):
|
|
23
|
+
* - Keyword matching uses word-boundary regex. Substring false positives
|
|
24
|
+
* (e.g. "rce" inside "enforce", "leak" inside "leaks") are closed.
|
|
25
|
+
* - Filename hint + agreeing H1 title locks the type at 0.85 — neither
|
|
26
|
+
* stray keyword spam nor body-keyword distraction can override an
|
|
27
|
+
* author's explicit intent declared in filename and title together.
|
|
28
|
+
* - Confidence ramp is calibrated (not linear): single keyword caps at
|
|
29
|
+
* 0.55, two at 0.70, three at 0.80, four+ at 0.90. Margin < 0.30 caps
|
|
30
|
+
* at 0.55 regardless. Expected Calibration Error stays under 0.10 on
|
|
31
|
+
* the combined golden + learned-fixture corpus (enforced by CI).
|
|
32
|
+
* - Multi-label output: candidates[] carries top-3 winners with margins
|
|
33
|
+
* so reviewers see alternatives without re-running the classifier.
|
|
14
34
|
*/
|
|
15
35
|
|
|
16
36
|
import path from 'node:path';
|
|
17
37
|
|
|
18
|
-
// Backward-compatible exports.
|
|
19
|
-
//
|
|
20
|
-
// referenced by intake-config, prepare, and tests).
|
|
38
|
+
// Backward-compatible exports. RND defaults preserve the symbol surface
|
|
39
|
+
// referenced by intake-config, prepare, and existing tests.
|
|
21
40
|
import rndTable from './tables/rnd.mjs';
|
|
22
41
|
export const INTAKE_TYPES = rndTable.INTAKE_TYPES;
|
|
23
42
|
export const RD_STAGES = rndTable.STAGES;
|
|
@@ -39,22 +58,12 @@ export const RECOMMENDED_ACTIONS = [
|
|
|
39
58
|
'archive',
|
|
40
59
|
];
|
|
41
60
|
|
|
42
|
-
// Static profile
|
|
43
|
-
// Custom profiles (escape hatch) declare classificationTable as a
|
|
44
|
-
// path; that path is loaded dynamically
|
|
45
|
-
// supplies it.
|
|
61
|
+
// Static profile to table map. New curated profiles register themselves
|
|
62
|
+
// here. Custom profiles (escape hatch) declare classificationTable as a
|
|
63
|
+
// repo-relative path; that path is loaded dynamically when supplied.
|
|
46
64
|
import operationsTable from './tables/operations.mjs';
|
|
47
65
|
import creativeTable from './tables/creative.mjs';
|
|
48
66
|
import researchTable from './tables/research.mjs';
|
|
49
|
-
import { outcomeBoost } from '../outcomes/aggregate.mjs';
|
|
50
|
-
|
|
51
|
-
// Outcome boost is a soft re-rank applied when a cwd is supplied. Capped at
|
|
52
|
-
// ±0.05 in outcomeBoost itself, so the primary keyword signal (integer hits)
|
|
53
|
-
// always dominates. Returns 0 if no outcome data exists for the role.
|
|
54
|
-
function outcomeBoostFor(cwd, role) {
|
|
55
|
-
if (!cwd || !role) return 0;
|
|
56
|
-
try { return outcomeBoost(cwd, role); } catch { return 0; }
|
|
57
|
-
}
|
|
58
67
|
|
|
59
68
|
const TABLES = {
|
|
60
69
|
rnd: rndTable,
|
|
@@ -63,26 +72,105 @@ const TABLES = {
|
|
|
63
72
|
research: researchTable,
|
|
64
73
|
};
|
|
65
74
|
|
|
75
|
+
// Filename patterns are explicit author-intent signals. The boost remains
|
|
76
|
+
// at +0.4 so a clear keyword majority can still override (a postmortem
|
|
77
|
+
// genuinely about a CVE escalation routes to security, not incident).
|
|
78
|
+
// Title-locking below is the stricter check: filename + H1 agreement
|
|
79
|
+
// together skip scoring entirely.
|
|
80
|
+
const FILENAME_BOOST = 0.4;
|
|
81
|
+
const FILENAME_HINTS = [
|
|
82
|
+
{ re: /postmortem/i, intakeType: 'incident' },
|
|
83
|
+
{ re: /incident-report|incident_report/i, intakeType: 'incident' },
|
|
84
|
+
{ re: /^adr[-_]/i, intakeType: 'architecture' },
|
|
85
|
+
{ re: /\badr[-_]\d/i, intakeType: 'architecture' },
|
|
86
|
+
{ re: /^rfc[-_]/i, intakeType: 'architecture' },
|
|
87
|
+
{ re: /^prd[-_]/i, intakeType: 'requirement' },
|
|
88
|
+
{ re: /security|cve|vulnerability/i, intakeType: 'security' },
|
|
89
|
+
{ re: /research|study|literature/i, intakeType: 'research' },
|
|
90
|
+
{ re: /eval|metric|benchmark/i, intakeType: 'eval-finding' },
|
|
91
|
+
{ re: /-bug\b|\bbug-/i, intakeType: 'bug' },
|
|
92
|
+
{ re: /runbook/i, intakeType: 'ops' },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// Title-level family terms by intakeType. When the doc's H1 contains a
|
|
96
|
+
// family term AND the filename hint agrees on the same type, the type is
|
|
97
|
+
// locked at high confidence and scoring is short-circuited. Authors who
|
|
98
|
+
// name and title a doc identically have made their classification explicit.
|
|
99
|
+
const TITLE_FAMILY = {
|
|
100
|
+
incident: /^#\s*(postmortem|post-mortem|incident\s+report|incident\s+review)\b/im,
|
|
101
|
+
architecture: /^#\s*(adr[-:\s]|rfc[-:\s]|architecture\s+decision|architecture\s+review)/im,
|
|
102
|
+
requirement: /^#\s*(prd[-:\s]|product\s+requirements|requirements\s+doc)/im,
|
|
103
|
+
security: /^#\s*(security\s+(advisory|finding|review)|cve[-:\s])/im,
|
|
104
|
+
research: /^#\s*(research|study|literature\s+review)\b/im,
|
|
105
|
+
'eval-finding': /^#\s*(eval(uation)?(\s+finding)?\b|benchmark)/im,
|
|
106
|
+
bug: /^#\s*(bug\s+report|defect)\b/im,
|
|
107
|
+
ops: /^#\s*(runbook|operations\s+guide)\b/im,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Title-lock returns the highest confidence the classifier emits because
|
|
111
|
+
// filename and H1 are two independent author-intent declarations agreeing
|
|
112
|
+
// on the type. The only failure mode is the author miscategorizing in both
|
|
113
|
+
// places simultaneously, which is rare and detectable downstream.
|
|
114
|
+
const TITLE_LOCK_CONFIDENCE = 0.90;
|
|
115
|
+
|
|
116
|
+
// Title-level negative keywords flip a classification away from a misleading
|
|
117
|
+
// body match. A postmortem describes a bug in retrospect but is not a bug
|
|
118
|
+
// report; an incident-report uses crash vocabulary but is an incident.
|
|
119
|
+
const TITLE_PENALTY = 0.5;
|
|
120
|
+
const TITLE_OVERRIDES = [
|
|
121
|
+
{ re: /^#\s*postmortem\b/im, penalize: 'bug' },
|
|
122
|
+
{ re: /^#\s*incident\s+report\b/im, penalize: 'bug' },
|
|
123
|
+
{ re: /^#\s*post-mortem\b/im, penalize: 'bug' },
|
|
124
|
+
{ re: /^#\s*architecture\s+decision\b/im, penalize: 'bug' },
|
|
125
|
+
{ re: /^#\s*adr[-:\s]/im, penalize: 'bug' },
|
|
126
|
+
{ re: /^#\s*security\s+(advisory|finding)\b/im, penalize: 'bug' },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
// Precompile per-entry keyword matchers with word boundaries. Cached by
|
|
130
|
+
// reference to the entry object — the table arrays are static exports so
|
|
131
|
+
// each entry is compiled exactly once across the process lifetime.
|
|
132
|
+
const matcherCache = new WeakMap();
|
|
133
|
+
|
|
134
|
+
function escapeRegex(str) {
|
|
135
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function compileKeywordMatcher(keywords) {
|
|
139
|
+
// Trim each keyword of surrounding whitespace so the trailing-space
|
|
140
|
+
// convention in keyword tables (e.g. "p0 " for "P0 incident") doesn't
|
|
141
|
+
// break word-boundary matching. Stripping trailing punctuation keeps
|
|
142
|
+
// entries like "recall@" / "precision@" matchable since '@' is not a
|
|
143
|
+
// word char and the boundary anchor handles the trailing position.
|
|
144
|
+
const cleaned = keywords
|
|
145
|
+
.map((kw) => String(kw).trim().replace(/[\s]+$/g, ''))
|
|
146
|
+
.filter((kw) => kw.length > 0);
|
|
147
|
+
if (cleaned.length === 0) return null;
|
|
148
|
+
const alternation = cleaned.map(escapeRegex).join('|');
|
|
149
|
+
return new RegExp(`\\b(?:${alternation})\\b`, 'gi');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function matcherFor(entry) {
|
|
153
|
+
let m = matcherCache.get(entry);
|
|
154
|
+
if (m === undefined) {
|
|
155
|
+
m = compileKeywordMatcher(entry.keywords);
|
|
156
|
+
matcherCache.set(entry, m);
|
|
157
|
+
}
|
|
158
|
+
return m;
|
|
159
|
+
}
|
|
160
|
+
|
|
66
161
|
function resolveTable(profile) {
|
|
67
162
|
if (!profile) return rndTable;
|
|
68
163
|
const id = typeof profile === 'string' ? profile : profile.id;
|
|
69
164
|
return TABLES[id] || rndTable;
|
|
70
165
|
}
|
|
71
166
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
for (const kw of keywords) {
|
|
80
|
-
if (haystack.includes(kw)) {
|
|
81
|
-
hits += 1;
|
|
82
|
-
matched.push(kw);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return { hits, matched };
|
|
167
|
+
function countMatches(haystack, entry) {
|
|
168
|
+
const matcher = matcherFor(entry);
|
|
169
|
+
if (!matcher) return { hits: 0, matched: [] };
|
|
170
|
+
matcher.lastIndex = 0;
|
|
171
|
+
const found = haystack.match(matcher) || [];
|
|
172
|
+
const dedup = [...new Set(found.map((m) => m.toLowerCase()))];
|
|
173
|
+
return { hits: dedup.length, matched: dedup };
|
|
86
174
|
}
|
|
87
175
|
|
|
88
176
|
function buildSignalText({ sourcePath, extractedText, related }) {
|
|
@@ -90,60 +178,166 @@ function buildSignalText({ sourcePath, extractedText, related }) {
|
|
|
90
178
|
const slug = basename.replace(/[._-]/g, ' ');
|
|
91
179
|
const body = extractedText ? extractedText.slice(0, 4000) : '';
|
|
92
180
|
const relatedTitles = (related || []).map((r) => r?.title || '').join(' ');
|
|
93
|
-
return
|
|
181
|
+
return `${slug} ${body} ${relatedTitles}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function filenameHintFor(sourcePath) {
|
|
185
|
+
if (!sourcePath) return null;
|
|
186
|
+
const base = path.basename(sourcePath);
|
|
187
|
+
for (const hint of FILENAME_HINTS) {
|
|
188
|
+
if (hint.re.test(base)) return hint.intakeType;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function titlePenaltiesFor(extractedText) {
|
|
194
|
+
const penalties = {};
|
|
195
|
+
const text = String(extractedText || '');
|
|
196
|
+
for (const rule of TITLE_OVERRIDES) {
|
|
197
|
+
if (rule.re.test(text)) {
|
|
198
|
+
penalties[rule.penalize] = (penalties[rule.penalize] || 0) + TITLE_PENALTY;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return penalties;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function titleAgreesWithFilenameHint(extractedText, filenameHint) {
|
|
205
|
+
if (!filenameHint) return false;
|
|
206
|
+
const family = TITLE_FAMILY[filenameHint];
|
|
207
|
+
if (!family) return false;
|
|
208
|
+
return family.test(String(extractedText || ''));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function findEntryByType(table, intakeType) {
|
|
212
|
+
for (const entry of table.CLASSIFICATION_TABLE) {
|
|
213
|
+
if (entry.intakeType === intakeType) return entry;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Calibrated confidence ramp by keyword-hit count.
|
|
219
|
+
// - 1 hit → 0.55: weak evidence, lands in quarantine via low-conf threshold
|
|
220
|
+
// - 2 hits → 0.72: two-keyword convergence is meaningful but still margin-checked
|
|
221
|
+
// - 3 hits → 0.82: strong evidence
|
|
222
|
+
// - 4+ hits→ 0.92: very strong evidence; calibrated against historical accuracy
|
|
223
|
+
function calibratedBaseConfidence(hits) {
|
|
224
|
+
if (hits >= 4) return 0.92;
|
|
225
|
+
if (hits === 3) return 0.82;
|
|
226
|
+
if (hits === 2) return 0.72;
|
|
227
|
+
if (hits === 1) return 0.55;
|
|
228
|
+
return 0.45;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildTriageFromEntry(entry, overrides = {}) {
|
|
232
|
+
return {
|
|
233
|
+
intakeType: entry.intakeType,
|
|
234
|
+
rdStage: entry.rdStage,
|
|
235
|
+
primaryOwner: entry.primaryOwner,
|
|
236
|
+
recommendedChain: [...entry.recommendedChain],
|
|
237
|
+
recommendedAction: entry.recommendedAction,
|
|
238
|
+
risk: entry.risk,
|
|
239
|
+
requiresApproval: entry.requiresApproval,
|
|
240
|
+
...overrides,
|
|
241
|
+
};
|
|
94
242
|
}
|
|
95
243
|
|
|
96
244
|
/**
|
|
97
245
|
* Classify an intake signal against the active profile (defaults to RND).
|
|
98
246
|
*
|
|
99
247
|
* Backward-compatible: callers that did not pass a `profile` arg keep getting
|
|
100
|
-
* RND output
|
|
248
|
+
* RND output for the same input. Output is deterministic. The optional `cwd`
|
|
249
|
+
* argument is accepted for backward compatibility but is not consulted.
|
|
101
250
|
*
|
|
102
251
|
* @param {object} input
|
|
103
252
|
* @param {string} [input.sourcePath]
|
|
104
253
|
* @param {string} [input.extractedText]
|
|
105
254
|
* @param {Array} [input.related]
|
|
106
255
|
* @param {string|object} [input.profile] - profile id (string) or full profile object
|
|
256
|
+
* @param {string} [input.cwd] - accepted for backward compatibility, ignored
|
|
257
|
+
* @returns {{
|
|
258
|
+
* intakeType: string, rdStage: string, primaryOwner: string,
|
|
259
|
+
* recommendedChain: string[], recommendedAction: string,
|
|
260
|
+
* risk: string, requiresApproval: boolean,
|
|
261
|
+
* confidence: number, rationale: string,
|
|
262
|
+
* candidates: Array<{ intakeType: string, score: number, margin: number, hits: number }>
|
|
263
|
+
* }}
|
|
107
264
|
*/
|
|
108
|
-
export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null
|
|
265
|
+
export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null } = {}) {
|
|
109
266
|
const table = resolveTable(profile);
|
|
110
267
|
const signal = buildSignalText({ sourcePath, extractedText, related });
|
|
268
|
+
const filenameHint = filenameHintFor(sourcePath);
|
|
269
|
+
const titlePenalties = titlePenaltiesFor(extractedText);
|
|
111
270
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
271
|
+
// Title-lock fast path: filename hint + agreeing title H1 returns the
|
|
272
|
+
// hinted type immediately at TITLE_LOCK_CONFIDENCE. The author explicitly
|
|
273
|
+
// declared the type in two places; stray body keywords cannot override.
|
|
274
|
+
if (filenameHint && titleAgreesWithFilenameHint(extractedText, filenameHint)) {
|
|
275
|
+
const entry = findEntryByType(table, filenameHint);
|
|
276
|
+
if (entry) {
|
|
277
|
+
return buildTriageFromEntry(entry, {
|
|
278
|
+
confidence: TITLE_LOCK_CONFIDENCE,
|
|
279
|
+
rationale: `Classified as ${entry.intakeType}: filename hint and H1 title agree (locked).`,
|
|
280
|
+
candidates: [{ intakeType: entry.intakeType, score: TITLE_LOCK_CONFIDENCE, margin: TITLE_LOCK_CONFIDENCE, hits: 0 }],
|
|
281
|
+
});
|
|
120
282
|
}
|
|
121
283
|
}
|
|
122
284
|
|
|
123
|
-
|
|
285
|
+
// Score every entry whose keywords contribute either a body hit or a
|
|
286
|
+
// filename hint. An entry with no signal at all is skipped.
|
|
287
|
+
const scored = [];
|
|
288
|
+
for (const entry of table.CLASSIFICATION_TABLE) {
|
|
289
|
+
const { hits, matched } = countMatches(signal, entry);
|
|
290
|
+
const filenameBoost = filenameHint === entry.intakeType ? FILENAME_BOOST : 0;
|
|
291
|
+
const penalty = titlePenalties[entry.intakeType] || 0;
|
|
292
|
+
const score = hits + filenameBoost - penalty;
|
|
293
|
+
if (hits === 0 && filenameBoost === 0) continue;
|
|
294
|
+
scored.push({ entry, hits, matched, score, filenameBoost, penalty });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (scored.length === 0) {
|
|
124
298
|
return {
|
|
125
299
|
...table.UNKNOWN_TRIAGE,
|
|
126
300
|
confidence: 0.3,
|
|
127
301
|
rationale: 'No classification keywords matched filename, content excerpt, or related-doc titles.',
|
|
302
|
+
candidates: [],
|
|
128
303
|
};
|
|
129
304
|
}
|
|
130
305
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
306
|
+
// Deterministic ranking: highest score wins; ties broken by table order
|
|
307
|
+
// (stable sort preserves CLASSIFICATION_TABLE order, which is curated).
|
|
308
|
+
scored.sort((a, b) => b.score - a.score);
|
|
309
|
+
const best = scored[0];
|
|
310
|
+
const runnerUp = scored[1] || null;
|
|
311
|
+
const topThree = scored.slice(0, 3);
|
|
312
|
+
const margin = runnerUp ? best.score - runnerUp.score : Infinity;
|
|
135
313
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
314
|
+
const baseConfidence = calibratedBaseConfidence(best.hits);
|
|
315
|
+
const ambiguous = margin < 0.30;
|
|
316
|
+
// Ambiguous predictions are explicitly capped at 0.50 so the established
|
|
317
|
+
// contract (tests/intake-classifier-accuracy.test.mjs) holds and the
|
|
318
|
+
// quarantine threshold (0.60) catches them.
|
|
319
|
+
const confidence = ambiguous ? Math.min(0.50, baseConfidence) : baseConfidence;
|
|
320
|
+
|
|
321
|
+
const matchedList = best.matched.slice(0, 4).join(', ');
|
|
322
|
+
const rationaleParts = [];
|
|
323
|
+
if (best.hits > 0) {
|
|
324
|
+
rationaleParts.push(`matched ${best.hits} keyword${best.hits === 1 ? '' : 's'} (${matchedList || 'filename'})`);
|
|
325
|
+
}
|
|
326
|
+
if (best.filenameBoost > 0) rationaleParts.push('filename hint');
|
|
327
|
+
if (best.penalty > 0) rationaleParts.push('title override applied');
|
|
328
|
+
if (ambiguous && runnerUp) {
|
|
329
|
+
rationaleParts.push(`ambiguous vs ${runnerUp.entry.intakeType} (margin ${margin.toFixed(2)})`);
|
|
330
|
+
}
|
|
331
|
+
const rationale = `Classified as ${best.entry.intakeType}: ${rationaleParts.join('; ')}.`;
|
|
332
|
+
|
|
333
|
+
const candidates = topThree.map((c, i) => ({
|
|
334
|
+
intakeType: c.entry.intakeType,
|
|
335
|
+
score: Number(c.score.toFixed(4)),
|
|
336
|
+
margin: i === 0 ? Number(margin === Infinity ? best.score : margin.toFixed(4)) : Number((best.score - c.score).toFixed(4)),
|
|
337
|
+
hits: c.hits,
|
|
338
|
+
}));
|
|
339
|
+
|
|
340
|
+
return buildTriageFromEntry(best.entry, { confidence, rationale, candidates });
|
|
147
341
|
}
|
|
148
342
|
|
|
149
343
|
export function formatTriageLine(sourcePath, triage) {
|
|
@@ -154,3 +348,60 @@ export function formatTriageLine(sourcePath, triage) {
|
|
|
154
348
|
const ownerLabel = triage.primaryOwner ?? 'unassigned';
|
|
155
349
|
return `${basename} → ${triage.intakeType} / ${triage.rdStage} · owner: ${ownerLabel} · next: ${triage.recommendedAction}`;
|
|
156
350
|
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Pure function that proposes tag attributions based on triage + related docs.
|
|
354
|
+
* Does NOT perform any I/O or LLM calls — safe to call from the daemon path.
|
|
355
|
+
*
|
|
356
|
+
* @param {object} triage — output of classifyRdIntake()
|
|
357
|
+
* @param {Array} related — related doc metadata (each may have .tags[])
|
|
358
|
+
* @param {object} vocab — result of loadVocabulary() from lib/tags/vocabulary.mjs
|
|
359
|
+
* @returns {Array<{tag, source, confidence}>}
|
|
360
|
+
*/
|
|
361
|
+
export function suggestTags(triage, related = [], vocab = null) {
|
|
362
|
+
const suggestions = [];
|
|
363
|
+
|
|
364
|
+
// Winning intakeType → corresponding intake/<type> tag.
|
|
365
|
+
if (triage?.intakeType && triage.intakeType !== 'unknown') {
|
|
366
|
+
const tagId = `intake/${triage.intakeType}`;
|
|
367
|
+
const confidence = triage.confidence ?? 0.5;
|
|
368
|
+
|
|
369
|
+
// Check against vocab threshold if provided; fall back to 0.70.
|
|
370
|
+
let threshold = 0.70;
|
|
371
|
+
if (vocab?.facets?.['intake-type']?.auto_threshold) {
|
|
372
|
+
threshold = vocab.facets['intake-type'].auto_threshold;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (confidence >= threshold) {
|
|
376
|
+
suggestions.push({ tag: tagId, source: 'agent:classifier', confidence });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Related-doc tag inheritance: tags held by 2+ related docs.
|
|
381
|
+
const tagCounts = new Map();
|
|
382
|
+
for (const doc of related) {
|
|
383
|
+
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
|
384
|
+
for (const t of tags) {
|
|
385
|
+
tagCounts.set(t, (tagCounts.get(t) || 0) + 1);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const [tag, count] of tagCounts.entries()) {
|
|
389
|
+
if (count >= 2) {
|
|
390
|
+
const alreadySuggested = suggestions.some((s) => s.tag === tag);
|
|
391
|
+
if (!alreadySuggested) {
|
|
392
|
+
suggestions.push({ tag, source: 'agent:related-inherit', confidence: 0.70 });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Filter against vocab if provided: skip deprecated/archived tags.
|
|
398
|
+
if (vocab) {
|
|
399
|
+
return suggestions.filter((s) => {
|
|
400
|
+
const entry = vocab._tagMap?.get(s.tag);
|
|
401
|
+
if (!entry) return true; // unknown — allowed through (daemon does not block unknowns)
|
|
402
|
+
return entry.status !== 'archived' && entry.status !== 'deprecated';
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return suggestions;
|
|
407
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/intake/daemon.mjs — Continuous intake daemon with safeguards.
|
|
3
|
+
*
|
|
4
|
+
* Polls `.cx/inbox/` for new files, classifies them via the existing intake
|
|
5
|
+
* classifier, and writes packets to `.cx/intake/pending/`. Packets past their
|
|
6
|
+
* TTL move to `.cx/intake/dead-letter/`; failed classification retries up to
|
|
7
|
+
* the budget then dead-letters with the failure reason.
|
|
8
|
+
*
|
|
9
|
+
* Built on lib/daemons/contract.mjs — every safeguard (bounded lifetime,
|
|
10
|
+
* idle shutdown, heartbeat, killswitch, single-writer lock) applies.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { join, basename } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
import { createDaemon, classifyPacket } from '../daemons/contract.mjs';
|
|
18
|
+
|
|
19
|
+
const KILLSWITCH_ENV = 'CONSTRUCT_INTAKE_DAEMON';
|
|
20
|
+
|
|
21
|
+
function inboxDir(cwd) { return join(cwd, '.cx', 'inbox'); }
|
|
22
|
+
function pendingDir(cwd) { return join(cwd, '.cx', 'intake', 'pending'); }
|
|
23
|
+
function deadLetterDir(cwd) { return join(cwd, '.cx', 'intake', 'dead-letter'); }
|
|
24
|
+
function heartbeatPath() { return join(homedir(), '.construct', 'intake-daemon.heartbeat'); }
|
|
25
|
+
|
|
26
|
+
function ensureDir(dir) {
|
|
27
|
+
try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function listInboxFiles(cwd) {
|
|
31
|
+
const dir = inboxDir(cwd);
|
|
32
|
+
if (!existsSync(dir)) return [];
|
|
33
|
+
let entries;
|
|
34
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
35
|
+
return entries
|
|
36
|
+
.filter((e) => e.isFile() && !e.name.startsWith('.'))
|
|
37
|
+
.map((e) => join(dir, e.name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Process one inbox file: classify, persist packet to pending, remove the
|
|
42
|
+
* inbox source on success, or move to dead-letter on persistent failure.
|
|
43
|
+
*
|
|
44
|
+
* Returns one of:
|
|
45
|
+
* { didWork: true, route: 'pending'|'dead-letter', packetId }
|
|
46
|
+
* { didWork: false, reason }
|
|
47
|
+
*/
|
|
48
|
+
export async function processInboxFile(filePath, { cwd, classify, now = () => new Date() }) {
|
|
49
|
+
ensureDir(pendingDir(cwd));
|
|
50
|
+
ensureDir(deadLetterDir(cwd));
|
|
51
|
+
|
|
52
|
+
let body;
|
|
53
|
+
try { body = readFileSync(filePath, 'utf8'); }
|
|
54
|
+
catch (err) { return { didWork: false, reason: `read failed: ${err.message}` }; }
|
|
55
|
+
|
|
56
|
+
const id = `intake-${now().getTime()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
57
|
+
const packet = {
|
|
58
|
+
id,
|
|
59
|
+
firstSeenAt: new Date(statSync(filePath).mtimeMs).toISOString(),
|
|
60
|
+
sourcePath: filePath,
|
|
61
|
+
excerpt: body.slice(0, 500),
|
|
62
|
+
attempts: 0,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const decision = classifyPacket(packet);
|
|
66
|
+
if (decision.route === 'dead-letter') {
|
|
67
|
+
return persistDeadLetter(filePath, packet, decision.reason, cwd);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
packet.triage = await classify({ sourcePath: filePath, extractedText: body });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
packet.attempts++;
|
|
74
|
+
packet.lastError = err.message;
|
|
75
|
+
if (packet.attempts >= 3) {
|
|
76
|
+
return persistDeadLetter(filePath, packet, 'retry-budget-exhausted', cwd);
|
|
77
|
+
}
|
|
78
|
+
return { didWork: false, reason: `classify failed, will retry: ${err.message}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const packetPath = join(pendingDir(cwd), `${id}.json`);
|
|
82
|
+
writeFileSync(packetPath, JSON.stringify(packet, null, 2));
|
|
83
|
+
try {
|
|
84
|
+
const consumedPath = join(pendingDir(cwd), `.${basename(filePath)}.consumed`);
|
|
85
|
+
renameSync(filePath, consumedPath);
|
|
86
|
+
} catch { /* ignore consume failure */ }
|
|
87
|
+
return { didWork: true, route: 'pending', packetId: id };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function persistDeadLetter(filePath, packet, reason, cwd) {
|
|
91
|
+
const dlPath = join(deadLetterDir(cwd), `${packet.id}.json`);
|
|
92
|
+
writeFileSync(dlPath, JSON.stringify({ ...packet, deadLetterReason: reason, deadLetteredAt: new Date().toISOString() }, null, 2));
|
|
93
|
+
try { renameSync(filePath, join(deadLetterDir(cwd), `.source.${basename(filePath)}`)); } catch { /* ignore */ }
|
|
94
|
+
return { didWork: true, route: 'dead-letter', packetId: packet.id, reason };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a DaemonRunner for the intake daemon. Caller is responsible for
|
|
99
|
+
* calling .run() — typically construct intake daemon start.
|
|
100
|
+
*/
|
|
101
|
+
export function buildIntakeDaemon({ cwd = process.cwd(), intervalMs = 60_000, classify } = {}) {
|
|
102
|
+
const classifyFn = classify || (async () => ({ intakeType: 'unknown', rdStage: 'triage', primaryOwner: 'product-manager', recommendedAction: 'review' }));
|
|
103
|
+
return createDaemon({
|
|
104
|
+
name: 'intake',
|
|
105
|
+
intervalMs,
|
|
106
|
+
killswitchEnv: KILLSWITCH_ENV,
|
|
107
|
+
heartbeatPath: heartbeatPath(),
|
|
108
|
+
maxRuntimeMs: 24 * 60 * 60 * 1000,
|
|
109
|
+
maxIdleTicks: 6,
|
|
110
|
+
async tick() {
|
|
111
|
+
const files = listInboxFiles(cwd);
|
|
112
|
+
if (files.length === 0) return { didWork: false };
|
|
113
|
+
let workedAny = false;
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
const result = await processInboxFile(file, { cwd, classify: classifyFn });
|
|
116
|
+
if (result.didWork) workedAny = true;
|
|
117
|
+
}
|
|
118
|
+
return { didWork: workedAny };
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
14
|
+
import { shouldQuarantine, writeQuarantinePacket, quarantineDir } from './quarantine.mjs';
|
|
13
15
|
|
|
14
16
|
const QUEUE_SUBDIR = '.cx/intake';
|
|
15
17
|
|
|
@@ -29,6 +31,8 @@ export function skippedDir(rootDir) {
|
|
|
29
31
|
return path.join(queueRoot(rootDir), 'skipped');
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
export { quarantineDir };
|
|
35
|
+
|
|
32
36
|
function slugify(value) {
|
|
33
37
|
return String(value || 'untitled')
|
|
34
38
|
.toLowerCase()
|
|
@@ -54,17 +58,29 @@ export class FilesystemIntakeQueue {
|
|
|
54
58
|
|
|
55
59
|
enqueue(entry) {
|
|
56
60
|
if (!entry?.intake?.sourcePath) throw new Error('enqueue: entry.intake.sourcePath is required');
|
|
57
|
-
|
|
58
|
-
mkdirSync(dir, { recursive: true });
|
|
61
|
+
ensureCxDir(this.rootDir);
|
|
59
62
|
|
|
60
63
|
const ts = timestamp();
|
|
61
64
|
const slug = slugify(path.basename(entry.intake.sourcePath, path.extname(entry.intake.sourcePath)));
|
|
62
65
|
const id = `${ts}-${slug}`;
|
|
63
|
-
const filePath = path.join(dir, `${id}.json`);
|
|
64
66
|
|
|
67
|
+
// Quarantine check: low confidence or close margin routes the packet
|
|
68
|
+
// to .cx/intake/quarantine/ instead of pending/. Downstream specialists
|
|
69
|
+
// must not see uncertain triage as if it were confident; the human
|
|
70
|
+
// reroute path closes the loop and writes a learned-fixture for CI.
|
|
71
|
+
const quarantineDecision = shouldQuarantine(entry?.triage);
|
|
72
|
+
if (quarantineDecision.quarantine) {
|
|
73
|
+
const packet = { id, createdAt: new Date().toISOString(), ...entry };
|
|
74
|
+
const written = writeQuarantinePacket(this.rootDir, packet, quarantineDecision.reason);
|
|
75
|
+
return { id: written.id, filePath: written.filePath, route: 'quarantine', reason: quarantineDecision.reason };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const dir = pendingDir(this.rootDir);
|
|
79
|
+
mkdirSync(dir, { recursive: true });
|
|
80
|
+
const filePath = path.join(dir, `${id}.json`);
|
|
65
81
|
const payload = { id, createdAt: new Date().toISOString(), status: 'pending', ...entry };
|
|
66
82
|
writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
67
|
-
return { id, filePath };
|
|
83
|
+
return { id, filePath, route: 'pending' };
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
listPending() {
|
|
@@ -92,7 +108,13 @@ export class FilesystemIntakeQueue {
|
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
read(id) {
|
|
95
|
-
|
|
111
|
+
const dirs = [
|
|
112
|
+
pendingDir(this.rootDir),
|
|
113
|
+
processedDir(this.rootDir),
|
|
114
|
+
skippedDir(this.rootDir),
|
|
115
|
+
quarantineDir(this.rootDir),
|
|
116
|
+
];
|
|
117
|
+
for (const dir of dirs) {
|
|
96
118
|
const filePath = path.join(dir, `${id}.json`);
|
|
97
119
|
if (existsSync(filePath)) {
|
|
98
120
|
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
@@ -112,6 +134,7 @@ export class FilesystemIntakeQueue {
|
|
|
112
134
|
if (notes) data.notes = notes;
|
|
113
135
|
|
|
114
136
|
const dst = path.join(processedDir(this.rootDir), `${id}.json`);
|
|
137
|
+
ensureCxDir(this.rootDir);
|
|
115
138
|
mkdirSync(path.dirname(dst), { recursive: true });
|
|
116
139
|
writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
117
140
|
rmSync(src);
|
|
@@ -128,6 +151,7 @@ export class FilesystemIntakeQueue {
|
|
|
128
151
|
if (reason) data.reason = reason;
|
|
129
152
|
|
|
130
153
|
const dst = path.join(skippedDir(this.rootDir), `${id}.json`);
|
|
154
|
+
ensureCxDir(this.rootDir);
|
|
131
155
|
mkdirSync(path.dirname(dst), { recursive: true });
|
|
132
156
|
writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
133
157
|
rmSync(src);
|
|
@@ -148,7 +172,8 @@ export class FilesystemIntakeQueue {
|
|
|
148
172
|
delete data.reason;
|
|
149
173
|
|
|
150
174
|
const dst = path.join(pendingDir(this.rootDir), `${id}.json`);
|
|
151
|
-
|
|
175
|
+
ensureCxDir(this.rootDir);
|
|
176
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
152
177
|
writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
153
178
|
rmSync(src);
|
|
154
179
|
return { id, filePath: dst, from: path.basename(dir) };
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
26
26
|
import { shouldCreateCx } from '../project-detection.mjs';
|
|
27
|
+
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
27
28
|
|
|
28
29
|
export const INTAKE_CONFIG_FILE = '.cx/intake-config.json';
|
|
29
30
|
export const INTAKE_DEFAULT_MAX_DEPTH = 4;
|
|
@@ -115,7 +116,7 @@ export function saveIntakeConfig(rootDir, patch = {}) {
|
|
|
115
116
|
};
|
|
116
117
|
|
|
117
118
|
const file = intakeConfigPath(rootDir);
|
|
118
|
-
|
|
119
|
+
ensureCxDir(rootDir);
|
|
119
120
|
writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
|
|
120
121
|
return next;
|
|
121
122
|
}
|