@geraldmaron/construct 1.0.7 → 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 +20 -17
- package/bin/construct +1199 -69
- package/commands/work/optimize-prompts.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 +2 -2
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +5 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +65 -17
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/doctor/watchers/consistency.mjs +62 -12
- package/lib/document-extract.mjs +221 -28
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/inbox.mjs +48 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/env-config.mjs +9 -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/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +12 -0
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/registry-sync.mjs +2 -2
- 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 +18 -0
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +222 -55
- package/lib/intake/filesystem-queue.mjs +25 -5
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -0
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/mcp/server.mjs +9 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +1 -1
- package/lib/migrations/index.mjs +1 -1
- package/lib/model-registry.mjs +1 -1
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- 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/prompt-metadata.mjs +4 -4
- 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/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 +7 -1
- 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/{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/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/workflows/instantiate.mjs +320 -0
- package/package.json +13 -3
- package/personas/construct.md +2 -2
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/no-fabrication.md +1 -1
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
- 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/{agents → specialists}/contracts.schema.json +1 -1
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-architect.md +1 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-debugger.md +1 -1
- package/{agents → specialists}/prompts/cx-designer.md +1 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
- package/{agents → specialists}/prompts/cx-engineer.md +2 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
- package/{agents → specialists}/prompts/cx-explorer.md +1 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
- package/{agents → specialists}/prompts/cx-operations.md +1 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-qa.md +1 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-researcher.md +1 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
- package/{agents → specialists}/prompts/cx-security.md +1 -1
- package/{agents → specialists}/prompts/cx-sre.md +2 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
- package/{agents → specialists}/registry.json +651 -423
- package/{agents → specialists}/role-manifests.json +8 -8
- 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 → specialists}/contracts.json +0 -0
- /package/{agents → specialists}/teams.json +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/hygiene/scan.mjs — Lightweight doc hygiene candidate scanner.
|
|
3
|
+
*
|
|
4
|
+
* Selects up to N (default 25) documents for hygiene review each run using:
|
|
5
|
+
* 1. lifecycle/approved tag + last_verified_at older than 30 days
|
|
6
|
+
* 2. lifecycle/draft tag + last_verified_at older than 7 days
|
|
7
|
+
* 3. Any document without a last_verified_at field
|
|
8
|
+
*
|
|
9
|
+
* Sorted oldest-first. Results are beads-item candidates — actual creation
|
|
10
|
+
* is handled by the scheduler job handler.
|
|
11
|
+
*
|
|
12
|
+
* Wired into the `doc-hygiene-scan` scheduler job (lib/scheduler/index.mjs).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const APPROVED_STALE_DAYS = 30;
|
|
19
|
+
const DRAFT_STALE_DAYS = 7;
|
|
20
|
+
const DEFAULT_BATCH = 25;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read YAML frontmatter from a markdown file. Returns {} on failure.
|
|
24
|
+
*/
|
|
25
|
+
function readFrontmatter(filePath) {
|
|
26
|
+
let text = '';
|
|
27
|
+
try { text = fs.readFileSync(filePath, 'utf8'); } catch { return {}; }
|
|
28
|
+
const m = text.match(/^---\n([\s\S]*?)\n---/);
|
|
29
|
+
if (!m) return {};
|
|
30
|
+
const fm = {};
|
|
31
|
+
for (const line of m[1].split('\n')) {
|
|
32
|
+
const kv = line.match(/^(\w[\w_-]*):\s*(.+)$/);
|
|
33
|
+
if (kv) fm[kv[1].replace(/-/g, '_')] = kv[2].trim().replace(/^["']|["']$/g, '');
|
|
34
|
+
}
|
|
35
|
+
return fm;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Walk a directory and return all .md / .mdx files.
|
|
40
|
+
*/
|
|
41
|
+
function walkMarkdown(dir, results = []) {
|
|
42
|
+
let entries;
|
|
43
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
const p = path.join(dir, e.name);
|
|
46
|
+
if (e.isDirectory()) walkMarkdown(p, results);
|
|
47
|
+
else if (e.name.endsWith('.md') || e.name.endsWith('.mdx')) results.push(p);
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Return the N oldest hygiene candidates from the given scopes.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {string} opts.cwd — project root
|
|
57
|
+
* @param {string[]} [opts.scopes] — directories to scan relative to cwd
|
|
58
|
+
* @param {number} [opts.limit] — max candidates (default 25)
|
|
59
|
+
* @returns {Array<{path, rel, verdict, lastVerifiedAt, ageDays, tags}>}
|
|
60
|
+
*/
|
|
61
|
+
export function findHygieneCandidates({ cwd = process.cwd(), scopes = ['docs/prd', 'docs/adr', 'docs/rfc', '.cx/knowledge'], limit = DEFAULT_BATCH } = {}) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const candidates = [];
|
|
64
|
+
|
|
65
|
+
for (const scope of scopes) {
|
|
66
|
+
const dir = path.join(cwd, scope);
|
|
67
|
+
if (!fs.existsSync(dir)) continue;
|
|
68
|
+
for (const filePath of walkMarkdown(dir)) {
|
|
69
|
+
const fm = readFrontmatter(filePath);
|
|
70
|
+
// Handle both inline YAML arrays `[a, b]` and comma-separated strings.
|
|
71
|
+
const rawTags = fm.tags ? String(fm.tags).replace(/^\s*\[|\]\s*$/g, '') : '';
|
|
72
|
+
const tags = rawTags ? rawTags.split(',').map((t) => t.trim().replace(/^["']|["']$/g, '')) : [];
|
|
73
|
+
const lva = fm.last_verified_at || null;
|
|
74
|
+
const ageDays = lva ? Math.floor((now - new Date(lva).getTime()) / 86400000) : null;
|
|
75
|
+
|
|
76
|
+
const lifecycle = tags.find((t) => t.startsWith('lifecycle/')) || null;
|
|
77
|
+
|
|
78
|
+
let include = false;
|
|
79
|
+
let reason = '';
|
|
80
|
+
|
|
81
|
+
if (!lva) {
|
|
82
|
+
include = true;
|
|
83
|
+
reason = 'no last_verified_at';
|
|
84
|
+
} else if (lifecycle === 'lifecycle/approved' && ageDays > APPROVED_STALE_DAYS) {
|
|
85
|
+
include = true;
|
|
86
|
+
reason = `approved and stale (${ageDays}d > ${APPROVED_STALE_DAYS}d)`;
|
|
87
|
+
} else if (lifecycle === 'lifecycle/draft' && ageDays > DRAFT_STALE_DAYS) {
|
|
88
|
+
include = true;
|
|
89
|
+
reason = `draft and stale (${ageDays}d > ${DRAFT_STALE_DAYS}d)`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (include) {
|
|
93
|
+
candidates.push({
|
|
94
|
+
path: filePath,
|
|
95
|
+
rel: path.relative(cwd, filePath),
|
|
96
|
+
reason,
|
|
97
|
+
lastVerifiedAt: lva,
|
|
98
|
+
ageDays,
|
|
99
|
+
tags,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Sort: no last_verified_at first (oldest-first by path), then by age descending.
|
|
106
|
+
candidates.sort((a, b) => {
|
|
107
|
+
if (!a.lastVerifiedAt && !b.lastVerifiedAt) return a.rel.localeCompare(b.rel);
|
|
108
|
+
if (!a.lastVerifiedAt) return -1;
|
|
109
|
+
if (!b.lastVerifiedAt) return 1;
|
|
110
|
+
return (b.ageDays || 0) - (a.ageDays || 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return candidates.slice(0, limit);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Stamp last_verified_at into a document's frontmatter.
|
|
118
|
+
* If no frontmatter exists, prepends one.
|
|
119
|
+
*/
|
|
120
|
+
export function stampVerified(filePath, { date = new Date().toISOString().slice(0, 10) } = {}) {
|
|
121
|
+
let text = '';
|
|
122
|
+
try { text = fs.readFileSync(filePath, 'utf8'); } catch {
|
|
123
|
+
throw new Error(`stampVerified: cannot read ${filePath}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fmMatch = text.match(/^(---\n)([\s\S]*?)(\n---)/);
|
|
127
|
+
if (fmMatch) {
|
|
128
|
+
const fmBody = fmMatch[2];
|
|
129
|
+
if (fmBody.includes('last_verified_at:')) {
|
|
130
|
+
// Update existing field.
|
|
131
|
+
const updated = fmBody.replace(/^last_verified_at:.+$/m, `last_verified_at: ${date}`);
|
|
132
|
+
fs.writeFileSync(filePath, `${fmMatch[1]}${updated}${fmMatch[3]}${text.slice(fmMatch[0].length)}`);
|
|
133
|
+
} else {
|
|
134
|
+
// Append field inside frontmatter.
|
|
135
|
+
fs.writeFileSync(filePath, `${fmMatch[1]}${fmBody}\nlast_verified_at: ${date}${fmMatch[3]}${text.slice(fmMatch[0].length)}`);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// No frontmatter — prepend one.
|
|
139
|
+
fs.writeFileSync(filePath, `---\nlast_verified_at: ${date}\n---\n${text}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
package/lib/init-unified.mjs
CHANGED
|
@@ -891,6 +891,24 @@ async function main() {
|
|
|
891
891
|
if (!claudePre && fs.existsSync(path.join(target, '.claude', 'settings.json'))) {
|
|
892
892
|
created.push('.claude/ (agents + settings)');
|
|
893
893
|
}
|
|
894
|
+
|
|
895
|
+
// Also sync home-level adapters for all 6 host surfaces (Copilot, OpenCode,
|
|
896
|
+
// Codex, VS Code, Cursor, plus Claude home) so the user's environment is
|
|
897
|
+
// fully wired after init. Project-local Claude adapters were written above.
|
|
898
|
+
const syncScript = path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
|
|
899
|
+
if (fs.existsSync(syncScript)) {
|
|
900
|
+
const { spawnSync } = await import('node:child_process');
|
|
901
|
+
const homeResult = spawnSync(process.execPath, [syncScript], {
|
|
902
|
+
cwd: target,
|
|
903
|
+
stdio: 'pipe',
|
|
904
|
+
env: { ...process.env, CONSTRUCT_PROJECT_ROOT: target },
|
|
905
|
+
timeout: 120000,
|
|
906
|
+
});
|
|
907
|
+
if (homeResult.status !== 0) {
|
|
908
|
+
const errMsg = homeResult.stderr.toString().substring(0, 200);
|
|
909
|
+
console.warn(`⚠️ Home adapter sync had issues (exit ${homeResult.status}): ${errMsg}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
894
912
|
} catch (err) {
|
|
895
913
|
console.warn(`⚠️ Adapter staging failed: ${err.message}`);
|
|
896
914
|
}
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Same work as `bin/construct-postinstall.mjs` but callable from both the
|
|
5
5
|
* npm postinstall hook and `construct init`. Stages `.construct/{run.mjs,
|
|
6
|
-
* bootstrap.sh, bootstrap.ps1, version}` and runs `sync-
|
|
6
|
+
* bootstrap.sh, bootstrap.ps1, version}` and runs `sync-specialists.mjs --project`
|
|
7
7
|
* to materialise `.claude/agents/` and `.claude/settings.json` from the
|
|
8
8
|
* bundled registry. Idempotent — re-runs overwrite the launcher files but
|
|
9
9
|
* leave existing `.construct/version` and project state untouched beyond
|
|
10
|
-
* what `sync-
|
|
10
|
+
* what `sync-specialists.mjs --project` writes itself.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { spawnSync } from 'node:child_process';
|
|
@@ -20,13 +20,13 @@ export function stageProjectAdapters({ projectRoot, packageRoot, pkgVersion, log
|
|
|
20
20
|
const emit = typeof log === 'function' ? log : () => {};
|
|
21
21
|
|
|
22
22
|
const templateDir = path.join(packageRoot, 'templates', 'distribution');
|
|
23
|
-
const syncScript = path.join(packageRoot, 'scripts', 'sync-
|
|
23
|
+
const syncScript = path.join(packageRoot, 'scripts', 'sync-specialists.mjs');
|
|
24
24
|
|
|
25
25
|
ensureProjectLauncher({ projectRoot, templateDir, pkgVersion });
|
|
26
26
|
emit(`staged .construct/ launcher in ${projectRoot}`);
|
|
27
27
|
|
|
28
28
|
if (!existsSync(syncScript)) {
|
|
29
|
-
emit(`sync-
|
|
29
|
+
emit(`sync-specialists.mjs not found at ${syncScript}; skipping adapter sync`);
|
|
30
30
|
return { staged: true, synced: false };
|
|
31
31
|
}
|
|
32
32
|
|
package/lib/intake/classify.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
8
|
*
|
|
9
9
|
* Classification MUST be a pure function of (sourcePath, extractedText,
|
|
10
10
|
* related, profile). Same input must yield identical output across runs.
|
|
@@ -18,13 +18,25 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Default profile is `rnd` so existing R&D users see no behavior change.
|
|
20
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.
|
|
21
34
|
*/
|
|
22
35
|
|
|
23
36
|
import path from 'node:path';
|
|
24
37
|
|
|
25
|
-
// Backward-compatible exports.
|
|
26
|
-
//
|
|
27
|
-
// 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.
|
|
28
40
|
import rndTable from './tables/rnd.mjs';
|
|
29
41
|
export const INTAKE_TYPES = rndTable.INTAKE_TYPES;
|
|
30
42
|
export const RD_STAGES = rndTable.STAGES;
|
|
@@ -46,10 +58,9 @@ export const RECOMMENDED_ACTIONS = [
|
|
|
46
58
|
'archive',
|
|
47
59
|
];
|
|
48
60
|
|
|
49
|
-
// Static profile to table map. New curated profiles register themselves
|
|
50
|
-
// Custom profiles (escape hatch) declare classificationTable as a
|
|
51
|
-
// path; that path is loaded dynamically
|
|
52
|
-
// 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.
|
|
53
64
|
import operationsTable from './tables/operations.mjs';
|
|
54
65
|
import creativeTable from './tables/creative.mjs';
|
|
55
66
|
import researchTable from './tables/research.mjs';
|
|
@@ -61,9 +72,11 @@ const TABLES = {
|
|
|
61
72
|
research: researchTable,
|
|
62
73
|
};
|
|
63
74
|
|
|
64
|
-
// Filename patterns are explicit intent signals.
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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.
|
|
67
80
|
const FILENAME_BOOST = 0.4;
|
|
68
81
|
const FILENAME_HINTS = [
|
|
69
82
|
{ re: /postmortem/i, intakeType: 'incident' },
|
|
@@ -79,11 +92,30 @@ const FILENAME_HINTS = [
|
|
|
79
92
|
{ re: /runbook/i, intakeType: 'ops' },
|
|
80
93
|
];
|
|
81
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
|
+
|
|
82
116
|
// Title-level negative keywords flip a classification away from a misleading
|
|
83
117
|
// body match. A postmortem describes a bug in retrospect but is not a bug
|
|
84
118
|
// 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
119
|
const TITLE_PENALTY = 0.5;
|
|
88
120
|
const TITLE_OVERRIDES = [
|
|
89
121
|
{ re: /^#\s*postmortem\b/im, penalize: 'bug' },
|
|
@@ -94,26 +126,51 @@ const TITLE_OVERRIDES = [
|
|
|
94
126
|
{ re: /^#\s*security\s+(advisory|finding)\b/im, penalize: 'bug' },
|
|
95
127
|
];
|
|
96
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
|
+
|
|
97
161
|
function resolveTable(profile) {
|
|
98
162
|
if (!profile) return rndTable;
|
|
99
163
|
const id = typeof profile === 'string' ? profile : profile.id;
|
|
100
164
|
return TABLES[id] || rndTable;
|
|
101
165
|
}
|
|
102
166
|
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
for (const kw of keywords) {
|
|
111
|
-
if (haystack.includes(kw)) {
|
|
112
|
-
hits += 1;
|
|
113
|
-
matched.push(kw);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
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 };
|
|
117
174
|
}
|
|
118
175
|
|
|
119
176
|
function buildSignalText({ sourcePath, extractedText, related }) {
|
|
@@ -121,11 +178,9 @@ function buildSignalText({ sourcePath, extractedText, related }) {
|
|
|
121
178
|
const slug = basename.replace(/[._-]/g, ' ');
|
|
122
179
|
const body = extractedText ? extractedText.slice(0, 4000) : '';
|
|
123
180
|
const relatedTitles = (related || []).map((r) => r?.title || '').join(' ');
|
|
124
|
-
return
|
|
181
|
+
return `${slug} ${body} ${relatedTitles}`;
|
|
125
182
|
}
|
|
126
183
|
|
|
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
184
|
function filenameHintFor(sourcePath) {
|
|
130
185
|
if (!sourcePath) return null;
|
|
131
186
|
const base = path.basename(sourcePath);
|
|
@@ -135,8 +190,6 @@ function filenameHintFor(sourcePath) {
|
|
|
135
190
|
return null;
|
|
136
191
|
}
|
|
137
192
|
|
|
138
|
-
// Detect title-level overrides on the raw extracted text (case-insensitive,
|
|
139
|
-
// multiline anchored to ^# so only true H1s match).
|
|
140
193
|
function titlePenaltiesFor(extractedText) {
|
|
141
194
|
const penalties = {};
|
|
142
195
|
const text = String(extractedText || '');
|
|
@@ -148,14 +201,52 @@ function titlePenaltiesFor(extractedText) {
|
|
|
148
201
|
return penalties;
|
|
149
202
|
}
|
|
150
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
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
151
244
|
/**
|
|
152
245
|
* Classify an intake signal against the active profile (defaults to RND).
|
|
153
246
|
*
|
|
154
247
|
* Backward-compatible: callers that did not pass a `profile` arg keep getting
|
|
155
248
|
* RND output for the same input. Output is deterministic. The optional `cwd`
|
|
156
|
-
* argument is accepted for backward compatibility
|
|
157
|
-
* not consulted; outcomes-aware re-ranking is intentionally absent from the
|
|
158
|
-
* classify path to preserve determinism.
|
|
249
|
+
* argument is accepted for backward compatibility but is not consulted.
|
|
159
250
|
*
|
|
160
251
|
* @param {object} input
|
|
161
252
|
* @param {string} [input.sourcePath]
|
|
@@ -163,6 +254,13 @@ function titlePenaltiesFor(extractedText) {
|
|
|
163
254
|
* @param {Array} [input.related]
|
|
164
255
|
* @param {string|object} [input.profile] - profile id (string) or full profile object
|
|
165
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
|
+
* }}
|
|
166
264
|
*/
|
|
167
265
|
export function classifyRdIntake({ sourcePath = '', extractedText = '', related = [], profile = null } = {}) {
|
|
168
266
|
const table = resolveTable(profile);
|
|
@@ -170,11 +268,25 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
|
|
|
170
268
|
const filenameHint = filenameHintFor(sourcePath);
|
|
171
269
|
const titlePenalties = titlePenaltiesFor(extractedText);
|
|
172
270
|
|
|
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
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
173
285
|
// Score every entry whose keywords contribute either a body hit or a
|
|
174
286
|
// filename hint. An entry with no signal at all is skipped.
|
|
175
287
|
const scored = [];
|
|
176
288
|
for (const entry of table.CLASSIFICATION_TABLE) {
|
|
177
|
-
const { hits, matched } = countMatches(signal, entry
|
|
289
|
+
const { hits, matched } = countMatches(signal, entry);
|
|
178
290
|
const filenameBoost = filenameHint === entry.intakeType ? FILENAME_BOOST : 0;
|
|
179
291
|
const penalty = titlePenalties[entry.intakeType] || 0;
|
|
180
292
|
const score = hits + filenameBoost - penalty;
|
|
@@ -187,6 +299,7 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
|
|
|
187
299
|
...table.UNKNOWN_TRIAGE,
|
|
188
300
|
confidence: 0.3,
|
|
189
301
|
rationale: 'No classification keywords matched filename, content excerpt, or related-doc titles.',
|
|
302
|
+
candidates: [],
|
|
190
303
|
};
|
|
191
304
|
}
|
|
192
305
|
|
|
@@ -195,14 +308,15 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
|
|
|
195
308
|
scored.sort((a, b) => b.score - a.score);
|
|
196
309
|
const best = scored[0];
|
|
197
310
|
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.
|
|
311
|
+
const topThree = scored.slice(0, 3);
|
|
202
312
|
const margin = runnerUp ? best.score - runnerUp.score : Infinity;
|
|
203
|
-
|
|
204
|
-
const baseConfidence =
|
|
205
|
-
const
|
|
313
|
+
|
|
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;
|
|
206
320
|
|
|
207
321
|
const matchedList = best.matched.slice(0, 4).join(', ');
|
|
208
322
|
const rationaleParts = [];
|
|
@@ -216,18 +330,14 @@ export function classifyRdIntake({ sourcePath = '', extractedText = '', related
|
|
|
216
330
|
}
|
|
217
331
|
const rationale = `Classified as ${best.entry.intakeType}: ${rationaleParts.join('; ')}.`;
|
|
218
332
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
requiresApproval: entry.requiresApproval,
|
|
228
|
-
confidence,
|
|
229
|
-
rationale,
|
|
230
|
-
};
|
|
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 });
|
|
231
341
|
}
|
|
232
342
|
|
|
233
343
|
export function formatTriageLine(sourcePath, triage) {
|
|
@@ -238,3 +348,60 @@ export function formatTriageLine(sourcePath, triage) {
|
|
|
238
348
|
const ownerLabel = triage.primaryOwner ?? 'unassigned';
|
|
239
349
|
return `${basename} → ${triage.intakeType} / ${triage.rdStage} · owner: ${ownerLabel} · next: ${triage.recommendedAction}`;
|
|
240
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
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
14
|
+
import { shouldQuarantine, writeQuarantinePacket, quarantineDir } from './quarantine.mjs';
|
|
14
15
|
|
|
15
16
|
const QUEUE_SUBDIR = '.cx/intake';
|
|
16
17
|
|
|
@@ -30,6 +31,8 @@ export function skippedDir(rootDir) {
|
|
|
30
31
|
return path.join(queueRoot(rootDir), 'skipped');
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
export { quarantineDir };
|
|
35
|
+
|
|
33
36
|
function slugify(value) {
|
|
34
37
|
return String(value || 'untitled')
|
|
35
38
|
.toLowerCase()
|
|
@@ -55,18 +58,29 @@ export class FilesystemIntakeQueue {
|
|
|
55
58
|
|
|
56
59
|
enqueue(entry) {
|
|
57
60
|
if (!entry?.intake?.sourcePath) throw new Error('enqueue: entry.intake.sourcePath is required');
|
|
58
|
-
const dir = pendingDir(this.rootDir);
|
|
59
61
|
ensureCxDir(this.rootDir);
|
|
60
|
-
mkdirSync(dir, { recursive: true });
|
|
61
62
|
|
|
62
63
|
const ts = timestamp();
|
|
63
64
|
const slug = slugify(path.basename(entry.intake.sourcePath, path.extname(entry.intake.sourcePath)));
|
|
64
65
|
const id = `${ts}-${slug}`;
|
|
65
|
-
const filePath = path.join(dir, `${id}.json`);
|
|
66
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`);
|
|
67
81
|
const payload = { id, createdAt: new Date().toISOString(), status: 'pending', ...entry };
|
|
68
82
|
writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
69
|
-
return { id, filePath };
|
|
83
|
+
return { id, filePath, route: 'pending' };
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
listPending() {
|
|
@@ -94,7 +108,13 @@ export class FilesystemIntakeQueue {
|
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
read(id) {
|
|
97
|
-
|
|
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) {
|
|
98
118
|
const filePath = path.join(dir, `${id}.json`);
|
|
99
119
|
if (existsSync(filePath)) {
|
|
100
120
|
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|