opencode-wait-a-minute 0.0.0-stage → 1.1.0-rc.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/LICENSE +21 -0
- package/README.md +318 -2
- package/index.js +1836 -0
- package/package.json +70 -4
- package/skills/codebase-design/SKILL.md +114 -0
- package/skills/design-contract/SKILL.md +27 -0
- package/skills/design-critique/SKILL.md +19 -0
- package/skills/design-system/SKILL.md +17 -0
- package/skills/design-system/references/image-analysis.md +14 -0
- package/skills/design-system/references/tokens.md +32 -0
- package/skills/design-system/references/ui-rules.md +14 -0
- package/skills/diagnosing-bugs/SKILL.md +138 -0
- package/skills/frontend-design/SKILL.md +36 -0
- package/skills/handoff/SKILL.md +16 -0
- package/skills/improve-codebase-architecture/SKILL.md +71 -0
- package/skills/linter-negative/SKILL.md +74 -0
- package/skills/no-slop-ui/SKILL.md +26 -0
- package/skills/prototype/SKILL.md +26 -0
- package/skills/registry.json +44087 -0
- package/skills/tastemaker/SKILL.md +8 -0
- package/skills/tastemaker/references/tastemaker.md +14 -0
- package/skills/tdd/SKILL.md +38 -0
- package/skills/visual-review/SKILL.md +103 -0
- package/skills/visual-review/references/review-gates.md +42 -0
- package/skills/visual-review/references/surface-archetypes.md +37 -0
- package/skills/wizard/SKILL.md +44 -0
- package/skills/writing-for-agents/SKILL.md +81 -0
- package/src/cognition/cognition-store.js +251 -0
- package/src/cognition/cognitive-state.js +126 -0
- package/src/cognition/hypothesis-manager.js +113 -0
- package/src/cognition/observation-engine.js +112 -0
- package/src/cognition/observation-provenance.js +268 -0
- package/src/context/active-context-boundary.js +68 -0
- package/src/context/assembly.js +494 -0
- package/src/context/assessment-rules.js +55 -0
- package/src/context/capsule-staleness.js +267 -0
- package/src/context/capsules.js +2 -0
- package/src/context/compare-array.js +98 -0
- package/src/context/compare-object.js +89 -0
- package/src/context/compare-values.js +42 -0
- package/src/context/context-assembly-contract.js +71 -0
- package/src/context/context-budget-manager.js +232 -0
- package/src/context/context-builder.js +49 -0
- package/src/context/context-cache.js +55 -0
- package/src/context/context-capture.js +243 -0
- package/src/context/context-classification.js +199 -0
- package/src/context/context-compaction.js +179 -0
- package/src/context/context-decision-audit.js +267 -0
- package/src/context/context-demotion.js +173 -0
- package/src/context/context-dictionary.js +106 -0
- package/src/context/context-drift-detection.js +225 -0
- package/src/context/context-evaluation.js +374 -0
- package/src/context/context-event-ingress.js +100 -0
- package/src/context/context-event-normalization.js +224 -0
- package/src/context/context-feedback-loop.js +216 -0
- package/src/context/context-freshness.js +21 -0
- package/src/context/context-graph-builder.js +71 -0
- package/src/context/context-graph.js +451 -0
- package/src/context/context-history-relevance.js +321 -0
- package/src/context/context-interception.js +145 -0
- package/src/context/context-invalidation-events.js +154 -0
- package/src/context/context-lifecycle.js +170 -0
- package/src/context/context-loop-prevention.js +347 -0
- package/src/context/context-manager.js +340 -0
- package/src/context/context-memory-layers.js +265 -0
- package/src/context/context-minimality.js +166 -0
- package/src/context/context-normalization.js +130 -0
- package/src/context/context-optimization-metrics.js +434 -0
- package/src/context/context-output-policy.js +135 -0
- package/src/context/context-pack-authority.js +157 -0
- package/src/context/context-promotion.js +172 -0
- package/src/context/context-query-contract.js +101 -0
- package/src/context/context-ranking.js +164 -0
- package/src/context/context-rate-distortion.js +261 -0
- package/src/context/context-recovery.js +209 -0
- package/src/context/context-retrieval-integration.js +60 -0
- package/src/context/context-retrieval.js +93 -0
- package/src/context/context-router.js +457 -0
- package/src/context/context-routing-evaluation.js +331 -0
- package/src/context/context-routing-observability.js +296 -0
- package/src/context/context-routing-shadow.js +237 -0
- package/src/context/context-scope.js +181 -0
- package/src/context/context-snapshot.js +226 -0
- package/src/context/context-source-registry.js +95 -0
- package/src/context/context-sufficiency-gate.js +120 -0
- package/src/context/context-sufficiency-oracle.js +167 -0
- package/src/context/context-telemetry.js +176 -0
- package/src/context/context.js +633 -0
- package/src/context/dependency-closure.js +246 -0
- package/src/context/page-fault-tracker.js +275 -0
- package/src/context/prompt-engine.js +74 -0
- package/src/context/runtime-context-graph.js +260 -0
- package/src/context/selection.js +2 -0
- package/src/context/session.js +2 -0
- package/src/context/sufficiency-contract.js +300 -0
- package/src/context/tokenizer.js +17 -0
- package/src/context-benchmark-router.mjs +125 -0
- package/src/evidence/evidence-engine.js +161 -0
- package/src/evidence/evidence-factory.js +3 -0
- package/src/evidence/evidence-freshness.js +463 -0
- package/src/evidence/evidence-gap.js +76 -0
- package/src/evidence/evidence-lineage.js +525 -0
- package/src/evidence/evidence.js +136 -0
- package/src/evidence/freshness-validation.js +187 -0
- package/src/evidence/lineage.js +4 -0
- package/src/execution/action-evaluation.js +179 -0
- package/src/execution/circuit-breaker.js +70 -0
- package/src/execution/claim-interception.js +63 -0
- package/src/execution/execution-engine.js +258 -0
- package/src/execution/execution-intent.js +69 -0
- package/src/execution/execution-state.js +89 -0
- package/src/execution/formatting.js +60 -0
- package/src/execution/l1-sanitize.js +16 -0
- package/src/execution/l2-resource-guards.js +19 -0
- package/src/execution/l3-invariant-checker.js +5 -0
- package/src/execution/l4-containment.js +6 -0
- package/src/execution/runtime-guards.js +106 -0
- package/src/execution/strategy-identity.js +162 -0
- package/src/execution/tool-interceptor.js +7 -0
- package/src/execution/tool-result-classifier.js +63 -0
- package/src/integration/canary-deploy.js +143 -0
- package/src/integration/collector.js +59 -0
- package/src/integration/error-recovery.js +6 -0
- package/src/integration/event-bus.js +66 -0
- package/src/integration/health-checker.js +57 -0
- package/src/integration/logger.js +39 -0
- package/src/integration/message-handler.js +611 -0
- package/src/integration/opencode-db.js +153 -0
- package/src/integration/orchestration.js +419 -0
- package/src/integration/part-provenance.js +65 -0
- package/src/integration/rate-limiter.js +55 -0
- package/src/integration/release-gate.js +336 -0
- package/src/integration/rollback-manager.js +164 -0
- package/src/integration/router-adapter.js +269 -0
- package/src/integration/telemetry.js +45 -0
- package/src/integration/update-checker.js +10 -0
- package/src/persistence/event-sourcing.js +75 -0
- package/src/persistence/memory.js +501 -0
- package/src/persistence/migration-runner.js +16 -0
- package/src/persistence/persistence-manager.js +39 -0
- package/src/persistence/transaction-log.js +157 -0
- package/src/policy/action-capabilities.js +116 -0
- package/src/policy/action-policy.js +4 -0
- package/src/policy/assessment-engine.js +104 -0
- package/src/policy/assumption-tracking.js +66 -0
- package/src/policy/budget-enforcer.js +7 -0
- package/src/policy/config-validator.js +102 -0
- package/src/policy/execution-assessment.js +142 -0
- package/src/policy/failure-classification.js +5 -0
- package/src/policy/feature-flags.js +65 -0
- package/src/policy/governance-enforcement.js +118 -0
- package/src/policy/locale.js +15 -0
- package/src/policy/multi-signer.js +151 -0
- package/src/policy/policy-state-machine.js +97 -0
- package/src/policy/ponytail-constraint.js +38 -0
- package/src/policy/project-inspector.js +93 -0
- package/src/policy/query-builder.js +14 -0
- package/src/policy/request-classifier.js +79 -0
- package/src/policy/risk-engine.js +266 -0
- package/src/policy/scope-enforcement.js +140 -0
- package/src/policy/secret-scanner.js +68 -0
- package/src/policy/skill-routing.js +104 -0
- package/src/policy/strategy-capabilities.js +211 -0
- package/src/policy/strategy.js +6 -0
- package/src/policy/uncertainty.js +117 -0
- package/src/shared/container.js +44 -0
- package/src/shared/decision-types.js +24 -0
- package/src/shared/simple-test.js +160 -0
- package/src/shared/wam-events.js +52 -0
- package/src/skills/engine.js +1705 -0
- package/src/skills/skill-registry.js +19 -0
- package/src/skills/skill-routing.js +340 -0
- package/src/state/lifecycle-manager.js +113 -0
- package/src/state/operational-state.js +198 -0
- package/src/state/projections.js +47 -0
- package/src/state/replay-engine.js +5 -0
- package/src/state/requirement-ownership.js +20 -0
- package/src/state/requirement-state.js +152 -0
- package/src/state/run-event-identity.js +172 -0
- package/src/state/state-engine.js +19 -0
- package/src/state/state-machine.js +119 -0
- package/src/state/state-schema.js +318 -0
- package/src/state/state-store.js +335 -0
- package/src/state/state-transition.js +84 -0
- package/src/state/task-dependencies.js +287 -0
- package/src/state/task-execution.js +217 -0
- package/src/state/task-lifecycle.js +59 -0
- package/src/state/task-runs.js +361 -0
- package/src/state/task-state-file.js +86 -0
- package/src/state/task-state.js +41 -0
- package/src/state/task-status.js +54 -0
- package/src/state/task-store.js +48 -0
- package/src/state/wam-state.js +148 -0
- package/src/verification/completion-gate-simple.js +8 -0
- package/src/verification/completion-gate.js +183 -0
- package/src/verification/contract.js +9 -0
- package/src/verification/contradiction-resolution.js +255 -0
- package/src/verification/diagnostic-engine.js +659 -0
- package/src/verification/false-completion-prevention.js +100 -0
- package/src/verification/hook-validator.js +6 -0
- package/src/verification/requirements.js +6 -0
- package/src/verification/verification-context.js +162 -0
- package/src/verification/verification-lifecycle.js +66 -0
- package/src/verification/verification-model.js +89 -0
- package/src/verification/verification-persistence.js +129 -0
- package/src/verification/verification-policy.js +127 -0
- package/src/verification/verification.js +415 -0
|
@@ -0,0 +1,1705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait a Minute — Pre-Flight Cognitive Analysis Engine
|
|
3
|
+
*
|
|
4
|
+
* Analiza peticiones de usuario antes de la resolución de skills y la ejecución del agente.
|
|
5
|
+
* Clasifica la tarea, inspecciona el proyecto, detecta supuestos y selecciona skills.
|
|
6
|
+
*/
|
|
7
|
+
// Debug removed — no hardcoded paths in runtime package.
|
|
8
|
+
|
|
9
|
+
const fs = await import("node:fs");
|
|
10
|
+
const path = await import("node:path");
|
|
11
|
+
const os = await import("node:os");
|
|
12
|
+
import { logger } from "../integration/logger.js";
|
|
13
|
+
const tmpdir = os.tmpdir();
|
|
14
|
+
|
|
15
|
+
// Pre-flight modules (facade)
|
|
16
|
+
import { classifyRequest } from "../policy/request-classifier.js";
|
|
17
|
+
import { detectStack } from "../policy/project-inspector.js";
|
|
18
|
+
import { classifyUncertainty, buildUncertainties, buildAssumptions } from "../policy/uncertainty.js";
|
|
19
|
+
import { discoverSkills } from "../policy/skill-routing.js";
|
|
20
|
+
|
|
21
|
+
// Re-export for backward compatibility
|
|
22
|
+
export { classifyRequest, detectStack, classifyUncertainty, buildUncertainties, buildAssumptions, discoverSkills };
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
// -- Caveman compression (terse, para headroom de contexto) ----------------
|
|
26
|
+
|
|
27
|
+
const FILLERS = /\b(a|an|the|just|really|basically|actually|simply|please|por favor)\b/gi;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Comprime texto a estilo caveman: sin artículos, sin relleno, frases cortas.
|
|
31
|
+
* Sirve para inyecciones y resúmenes que deben ocupar el menor contexto posible.
|
|
32
|
+
*/
|
|
33
|
+
export function cavemanify(text = "") {
|
|
34
|
+
return (text || "")
|
|
35
|
+
.replace(FILLERS, "")
|
|
36
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
37
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Estimación de tokens (chars/4 — proxy barato, sin tokenizer). */
|
|
42
|
+
export function estimateTokens(text = "") {
|
|
43
|
+
return Math.ceil((text || "").length / 4);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
// -- Task Deduplication --
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Finds an existing active task with the same summary to prevent infinite loops.
|
|
51
|
+
* Returns the taskId if found, null otherwise.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} summary - The task summary to search for
|
|
54
|
+
* @param {string} root - The workspace root
|
|
55
|
+
* @returns {string|null} - Existing taskId or null
|
|
56
|
+
*/
|
|
57
|
+
export function findDuplicateTask(summary, root) {
|
|
58
|
+
if (!summary || summary.length < 10) return null;
|
|
59
|
+
const tasksDir = path.join(root || process.cwd(), ".wam", "tasks");
|
|
60
|
+
try {
|
|
61
|
+
const entries = fs.readdirSync(tasksDir, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (!entry.isDirectory()) continue;
|
|
64
|
+
const stateFile = path.join(tasksDir, entry.name, "state.yaml");
|
|
65
|
+
try {
|
|
66
|
+
const raw = fs.readFileSync(stateFile, "utf-8");
|
|
67
|
+
const state = JSON.parse(raw);
|
|
68
|
+
if (state?.phase === "DONE" || state?.phase === "VERIFIED") continue;
|
|
69
|
+
const taskSummary = state?.lastAction || state?.contract?.objective || "";
|
|
70
|
+
if (taskSummary && normalizeText(taskSummary) === normalizeText(summary)) {
|
|
71
|
+
return entry.name;
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeText(text = "") {
|
|
80
|
+
return (text || "").toLowerCase().replace(/[^a-z0-9áéíóúñü\s]/g, "").replace(/\s+/g, " ").trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// -- Task State Management --
|
|
84
|
+
|
|
85
|
+
import { ContextDecisionTracer } from "../context/context-decision-audit.js";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Archives a completed task session by moving its directory to history.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} taskId - The ID of the task to archive.
|
|
91
|
+
* @param {string} root - The workspace root.
|
|
92
|
+
*/
|
|
93
|
+
export async function archiveTaskSession(taskId, root) {
|
|
94
|
+
const fs = await import("node:fs/promises");
|
|
95
|
+
const path = await import("node:path");
|
|
96
|
+
|
|
97
|
+
const taskDir = path.join(root, ".wam", "tasks", taskId);
|
|
98
|
+
const date = new Date().toISOString().split("T")[0];
|
|
99
|
+
const historyDir = path.join(root, ".wam", "history", date);
|
|
100
|
+
const destDir = path.join(historyDir, taskId);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await fs.mkdir(historyDir, { recursive: true });
|
|
104
|
+
await fs.rename(taskDir, destDir);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err.code !== "ENOENT") {
|
|
107
|
+
console.error(`[WAM] Failed to archive task ${taskId}:`, err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Decision Gate — helper para subagentes.
|
|
114
|
+
*
|
|
115
|
+
* Invocado cuando una bifurcación arquitectónica (nuevo servicio, modelo de datos,
|
|
116
|
+
* cambio de arquitectura) es detectada. Delegado obligatorio: documentar alternativas
|
|
117
|
+
* y evidencia técnica ANTES de implementar.
|
|
118
|
+
*
|
|
119
|
+
* @param {Object} args
|
|
120
|
+
* @param {string} args.taskId - ID de tarea (default: "default").
|
|
121
|
+
* @param {string} args.projectRoot - Raíz del proyecto (default: cwd).
|
|
122
|
+
* @param {string} args.decisionPoint - Qué se está decidiendo.
|
|
123
|
+
* @param {string} args.rationale - Por qué esta ruta.
|
|
124
|
+
* @param {Array} args.alternatives - Alternativas descartadas [{name, pros, cons, whyRejected}].
|
|
125
|
+
* @param {Array} args.evidence - Evidencia técnica (links, benchmarks, docs, file:line).
|
|
126
|
+
* @param {Object} [args.technicalMetrics] - Métricas opcionales (latencia, costo, complejidad).
|
|
127
|
+
* @returns {{ok: boolean, entry: object|null, errors: string[]}}
|
|
128
|
+
*/
|
|
129
|
+
export function logArchitecturalDecision({
|
|
130
|
+
taskId = "default",
|
|
131
|
+
projectRoot,
|
|
132
|
+
decisionPoint,
|
|
133
|
+
rationale,
|
|
134
|
+
alternatives = [],
|
|
135
|
+
evidence = [],
|
|
136
|
+
technicalMetrics = {},
|
|
137
|
+
} = {}) {
|
|
138
|
+
const errors = [];
|
|
139
|
+
if (!decisionPoint || typeof decisionPoint !== "string") errors.push("decisionPoint requerido");
|
|
140
|
+
if (!rationale || typeof rationale !== "string") errors.push("rationale requerido");
|
|
141
|
+
if (!Array.isArray(alternatives) || alternatives.length === 0) {
|
|
142
|
+
errors.push("alternatives[] requerido (≥1 alternativa descartada)");
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(evidence) || evidence.length === 0) {
|
|
145
|
+
errors.push("evidence[] requerido (≥1 evidencia técnica)");
|
|
146
|
+
}
|
|
147
|
+
if (errors.length > 0) {
|
|
148
|
+
logger.warn("DecisionGate", `Bloqueado: ${errors.join("; ")}`);
|
|
149
|
+
return { ok: false, entry: null, errors };
|
|
150
|
+
}
|
|
151
|
+
const tracer = new ContextDecisionTracer(taskId, projectRoot || process.cwd());
|
|
152
|
+
tracer.logTechnicalDecision(decisionPoint, rationale, alternatives, evidence, technicalMetrics);
|
|
153
|
+
logger.info("DecisionGate", `Registrada bifurcación: ${decisionPoint}`);
|
|
154
|
+
return { ok: true, entry: { decisionPoint, rationale }, errors: [] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function persistTaskState(taskId, state, root) {
|
|
158
|
+
// Migrate legacy progress to requirements if needed
|
|
159
|
+
if (state.progress && !state.requirements) {
|
|
160
|
+
const total = state.progress.total || 0;
|
|
161
|
+
state.requirements = Array.from({length: total}, (_, i) => ({
|
|
162
|
+
id: `req-${i + 1}`,
|
|
163
|
+
title: `Requisito ${i + 1}`,
|
|
164
|
+
status: 'pending',
|
|
165
|
+
evidence: []
|
|
166
|
+
}));
|
|
167
|
+
delete state.progress;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const dir = path.join(root || process.cwd(), ".wam", "tasks", taskId);
|
|
171
|
+
if (!fileExists(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
172
|
+
fs.writeFileSync(path.join(dir, "state.yaml"), JSON.stringify(state, null, 2));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Recupera estado de tarea
|
|
177
|
+
*/
|
|
178
|
+
export function getTaskState(taskId, root) {
|
|
179
|
+
const file = path.join(root || process.cwd(), ".wam", "tasks", taskId, "state.yaml");
|
|
180
|
+
if (!fileExists(file)) return null;
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(readFileSafely(file));
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Lee un archivo si existe, retorna vacío en caso contrario
|
|
190
|
+
*/
|
|
191
|
+
function readFileSafely(filePath) {
|
|
192
|
+
try {
|
|
193
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
194
|
+
} catch {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Verifica si un archivo existe
|
|
201
|
+
*/
|
|
202
|
+
function fileExists(filePath) {
|
|
203
|
+
try {
|
|
204
|
+
fs.accessSync(filePath);
|
|
205
|
+
return true;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Obtiene el contenido de AGENTS.md si existe
|
|
213
|
+
*/
|
|
214
|
+
function getAgentsMd(projectPath) {
|
|
215
|
+
const agentsPath = path.join(projectPath, "AGENTS.md");
|
|
216
|
+
return readFileSafely(agentsPath);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Obtiene el package.json si existe
|
|
221
|
+
*/
|
|
222
|
+
function getPackageJson(projectPath) {
|
|
223
|
+
const pkgPath = path.join(projectPath, "package.json");
|
|
224
|
+
if (!fileExists(pkgPath)) return null;
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(readFileSafely(pkgPath));
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Obtiene dependencias relevantes del package.json
|
|
234
|
+
*/
|
|
235
|
+
function getDependencies(pkgJson) {
|
|
236
|
+
if (!pkgJson) return {};
|
|
237
|
+
return {
|
|
238
|
+
...(pkgJson.dependencies || {}),
|
|
239
|
+
...(pkgJson.devDependencies || {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Obtiene la configuración de OpenCode (agents.md equivalents)
|
|
247
|
+
*/
|
|
248
|
+
function getOpenCodeConfig(projectPath) {
|
|
249
|
+
// Check for .opencode directory
|
|
250
|
+
const opencodeDir = path.join(projectPath, ".opencode");
|
|
251
|
+
if (!fileExists(opencodeDir)) return null;
|
|
252
|
+
|
|
253
|
+
// Check for AGENTS.md
|
|
254
|
+
const agentsMd = readFileSafely(path.join(opencodeDir, "AGENTS.md"));
|
|
255
|
+
if (agentsMd.trim()) return { agentsMd };
|
|
256
|
+
|
|
257
|
+
// Check for opencode.jsonc
|
|
258
|
+
const configPath = path.join(opencodeDir, "opencode.jsonc");
|
|
259
|
+
if (fileExists(configPath)) {
|
|
260
|
+
try {
|
|
261
|
+
return { opencodeJsonc: readFileSafely(configPath) };
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Busca skills relevantes en las directories conocidas
|
|
270
|
+
*/
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// PERSISTENT POLICIES — reglas transversales del Policy Engine.
|
|
273
|
+
// Siempre aplicables. NO son skills; no requieren carga bajo demanda.
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
const PERSISTENT_POLICIES = {
|
|
277
|
+
scope: {
|
|
278
|
+
name: "Scope",
|
|
279
|
+
kind: "persistent",
|
|
280
|
+
rules: [
|
|
281
|
+
"Entregar únicamente lo pedido — sin expansión de alcance",
|
|
282
|
+
"No agregar features no solicitadas",
|
|
283
|
+
"No refactorizar código ajeno a la tarea",
|
|
284
|
+
"Cambiar la mínima superficie posible",
|
|
285
|
+
],
|
|
286
|
+
gates: ["scope-creep", "surface-change"],
|
|
287
|
+
},
|
|
288
|
+
verify: {
|
|
289
|
+
name: "Verify",
|
|
290
|
+
kind: "persistent",
|
|
291
|
+
rules: [
|
|
292
|
+
"Toda implementación debe tener resultado verificable",
|
|
293
|
+
"Ninguna tarea se marca DONE sin evidencia satisfecha",
|
|
294
|
+
"Ejecutar tests/lint/typecheck de los artefactos tocados",
|
|
295
|
+
"Verificar consumidores de los cambios, no solo el cambio",
|
|
296
|
+
],
|
|
297
|
+
gates: ["completion-gate", "evidence-required"],
|
|
298
|
+
},
|
|
299
|
+
simplify: {
|
|
300
|
+
name: "Simplify",
|
|
301
|
+
kind: "persistent",
|
|
302
|
+
rules: [
|
|
303
|
+
"YAGNI — no construir para necesidades futuras especulativas",
|
|
304
|
+
"Reusar código y abstracciones existentes",
|
|
305
|
+
"Preferir mecanismos nativos y stdlib cuando aplique",
|
|
306
|
+
"Implementación mínima y superficie de cambio mínima",
|
|
307
|
+
"Evitar dependencias innecesarias y generalización prematura",
|
|
308
|
+
"Abstracción solo cuando el requerimiento la exige ahora",
|
|
309
|
+
],
|
|
310
|
+
gates: ["speculative-abstraction", "unnecessary-deps"],
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Excepciones de Simplify: nunca eliminar restricciones esenciales.
|
|
315
|
+
const SIMPLIFY_PROTECTED = [
|
|
316
|
+
"security",
|
|
317
|
+
"seguridad",
|
|
318
|
+
"data integrity",
|
|
319
|
+
"integridad",
|
|
320
|
+
"explicit requirements",
|
|
321
|
+
"requisito",
|
|
322
|
+
"error handling",
|
|
323
|
+
"manejo de errores",
|
|
324
|
+
"trust-boundary",
|
|
325
|
+
"observability",
|
|
326
|
+
"observabilidad",
|
|
327
|
+
"required tests",
|
|
328
|
+
"tests requeridos",
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Evalúa las políticas persistentes contra la tarea.
|
|
333
|
+
* Aplica transversalmente en todo ciclo; devuelve qué políticas aplican y las gates activas.
|
|
334
|
+
*/
|
|
335
|
+
function evaluatePersistentPolicies(prompt, projectInfo, classification, mode) {
|
|
336
|
+
const lower = prompt.toLowerCase();
|
|
337
|
+
const applied = [];
|
|
338
|
+
|
|
339
|
+
// -- Decision Gate: detectar bifurcaciones arquitectónicas / modelo de datos --
|
|
340
|
+
// Bifurcaciones que exigen documentación obligatoria de alternativas + evidencia
|
|
341
|
+
// antes de proceder. Si hay match, se loguea vía tracer (si está disponible) y se
|
|
342
|
+
// marca como POLICY ACTIVE con gates adicionales para forzar cumplimiento.
|
|
343
|
+
const DECISION_GATES = [
|
|
344
|
+
{
|
|
345
|
+
id: "new-service",
|
|
346
|
+
label: "Nuevo servicio / componente",
|
|
347
|
+
pattern: /\b(nuevo|nueva|create|new)\s+(servicio|service|microservicio|módulo|module|servicio\s+externo)\b/,
|
|
348
|
+
gates: ["service-design-evidence", "alternatives-documented"],
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "data-model",
|
|
352
|
+
label: "Cambio de modelo de datos / schema",
|
|
353
|
+
pattern: /\b(schema|migraci(ó|o)n|migration|modelo\s+de\s+datos|data\s+model|entidad\s+nueva|new\s+entity|nueva\s+tabla|new\s+table)\b/,
|
|
354
|
+
gates: ["schema-evolution-evidence", "backward-compat-check"],
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: "architecture",
|
|
358
|
+
label: "Cambio de arquitectura",
|
|
359
|
+
pattern: /\b(arquitectura|architecture|refactor\s+arquitect(ó|o)nico|migrar\s+a|migrate\s+to|reemplazar\s+stack|replace\s+stack|monorepo|monolith\s+to\s+microservices|event\s+driven|ddd|hexagonal)\b/,
|
|
360
|
+
gates: ["adr-required", "trade-off-analysis"],
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
const bifurcations = DECISION_GATES.filter((g) => g.pattern.test(lower));
|
|
365
|
+
if (bifurcations.length > 0) {
|
|
366
|
+
applied.push({
|
|
367
|
+
policy: "decision-gate",
|
|
368
|
+
status: "ACTIVE",
|
|
369
|
+
gates: bifurcations.flatMap((b) => b.gates),
|
|
370
|
+
restrictions: bifurcations.map(
|
|
371
|
+
(b) => `Bifurcación detectada (${b.label}): subagente DEBE invocar tracer.logTechnicalDecision(decisionPoint, rationale, alternatives[], evidence[], technicalMetrics{}) antes de implementar.`
|
|
372
|
+
),
|
|
373
|
+
bifurcations: bifurcations.map((b) => b.id),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
// Scope: siempre aplica salvo tareas triviales puras
|
|
379
|
+
if (classification.type !== "trivial") {
|
|
380
|
+
applied.push({
|
|
381
|
+
policy: "scope",
|
|
382
|
+
status: "ACTIVE",
|
|
383
|
+
gates: ["scope-creep", "surface-change"],
|
|
384
|
+
restrictions: PERSISTENT_POLICIES.scope.rules.slice(0, 2),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Verify: aplica a toda tarea con resultado material; gate de completitud
|
|
389
|
+
if (classification.type !== "trivial") {
|
|
390
|
+
applied.push({
|
|
391
|
+
policy: "verify",
|
|
392
|
+
status: "ACTIVE",
|
|
393
|
+
gates: ["completion-gate", "evidence-required"],
|
|
394
|
+
restrictions: PERSISTENT_POLICIES.verify.rules,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Simplify: aplica transversalmente (YAGNI/reuso/stdlib)
|
|
399
|
+
const simplifyRestrictions = [...PERSISTENT_POLICIES.simplify.rules];
|
|
400
|
+
|
|
401
|
+
// Detectar si la tarea toca zonas protegidas (no simplificar restricciones esenciales)
|
|
402
|
+
const protectedHits = SIMPLIFY_PROTECTED.filter((w) => lower.includes(w));
|
|
403
|
+
if (protectedHits.length > 0) {
|
|
404
|
+
simplifyRestrictions.unshift(
|
|
405
|
+
`Protegido por Simplify: NO eliminar ${protectedHits.join(", ")} — simplificar implementación, no la restricción`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
applied.push({
|
|
410
|
+
policy: "simplify",
|
|
411
|
+
status: "ACTIVE",
|
|
412
|
+
gates: ["speculative-abstraction", "unnecessary-deps", ...(protectedHits.length > 0 ? ["protected-restriction"] : [])],
|
|
413
|
+
restrictions: simplifyRestrictions.slice(0, 6),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
return { applied, templates: applied, priority: ["scope", "verify", "simplify"] };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// SKILL REGISTRY — catálogo metadata-first de skills autocontenido.
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
const SKILL_LIFECYCLE = ["DISCOVERED", "EVALUATING", "APPROVED", "AVAILABLE", "LOADED", "USED", "REJECTED", "DEPRECATED", "DISABLED"];
|
|
424
|
+
const APPROVED_STATUSES = ["APPROVED", "AVAILABLE", "LOADED"];
|
|
425
|
+
|
|
426
|
+
// Pesos de scoring configurables (spec §7)
|
|
427
|
+
const DEFAULT_SCORING_WEIGHTS = {
|
|
428
|
+
name: 5,
|
|
429
|
+
capability: 4,
|
|
430
|
+
keyword: 3,
|
|
431
|
+
description: 2,
|
|
432
|
+
domain: 1,
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Base skills (loadStrategy: "base") bypass scoring and are always selected
|
|
436
|
+
// First level of guarantee: they appear even if the prompt doesn’t match anything.
|
|
437
|
+
const BASE_SKILL_DEFAULTS = {
|
|
438
|
+
writing_for_agents: { "loadStrategy": "base" },
|
|
439
|
+
codebase_design: { "loadStrategy": "base" },
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Asegura la estructura del corpus local .wam/skills/
|
|
444
|
+
*/
|
|
445
|
+
function ensureSkillCorpus(baseDir) {
|
|
446
|
+
const root = path.join(baseDir || process.cwd(), ".wam", "skills");
|
|
447
|
+
try {
|
|
448
|
+
for (const sub of ["sources", "registry", "cache", "policies"]) {
|
|
449
|
+
const dir = path.join(root, sub);
|
|
450
|
+
if (!fileExists(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
451
|
+
}
|
|
452
|
+
return root;
|
|
453
|
+
} catch {
|
|
454
|
+
return null; // corpus no disponible (permisos/ruta inválida) — continuar sin persistencia
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Parseo de frontmatter YAML mínimo (name, description, keywords, compatibility).
|
|
460
|
+
* v1: regex simple, sin dependencias. No carga contenido post-frontmatter.
|
|
461
|
+
*/
|
|
462
|
+
function parseFrontmatter(skillMdContent) {
|
|
463
|
+
const meta = { name: null, description: "", keywords: [], compatibility: [] };
|
|
464
|
+
const match = skillMdContent.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
465
|
+
if (!match) return meta;
|
|
466
|
+
|
|
467
|
+
const block = match[1];
|
|
468
|
+
const nameMatch = block.match(/^name\s*:\s*(.+)$/im);
|
|
469
|
+
if (nameMatch) meta.name = nameMatch[1].trim();
|
|
470
|
+
|
|
471
|
+
const descMatch = block.match(/^description\s*:\s*(.+)$/im);
|
|
472
|
+
if (descMatch) meta.description = descMatch[1].trim();
|
|
473
|
+
|
|
474
|
+
const kwMatch = block.match(/keywords\s*:\s*([\s\S]*?)(?:\n\w|\n---|$)/i);
|
|
475
|
+
if (kwMatch) {
|
|
476
|
+
const kwLines = kwMatch[1]
|
|
477
|
+
.split("\n")
|
|
478
|
+
.map((l) => l.trim().replace(/^-\s*/, "").replace(/^["']|["']$/g, ""))
|
|
479
|
+
.filter(Boolean);
|
|
480
|
+
meta.keywords = kwLines;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Khasky usa metadata.tags — soportar ambos formatos
|
|
484
|
+
if (meta.keywords.length === 0) {
|
|
485
|
+
const tagsMatch = block.match(/tags\s*:\s*(\[[^\]]*\]|[\s\S]*?)(?:\n\w|\n---|$)/i);
|
|
486
|
+
if (tagsMatch) {
|
|
487
|
+
const tags = tagsMatch[1].trim();
|
|
488
|
+
if (tags.startsWith("[")) {
|
|
489
|
+
meta.keywords = tags
|
|
490
|
+
.replace(/^\[|\]$/g, "")
|
|
491
|
+
.split(",")
|
|
492
|
+
.map((t) => t.trim().replace(/^["']|["']$/g, ""))
|
|
493
|
+
.filter(Boolean);
|
|
494
|
+
} else {
|
|
495
|
+
meta.keywords = tags
|
|
496
|
+
.split("\n")
|
|
497
|
+
.map((l) => l.trim().replace(/^-\s*/, "").replace(/^["']|["']$/g, ""))
|
|
498
|
+
.filter(Boolean);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const compatMatch = block.match(/compatibility\s*:\s*(.+)$/im);
|
|
504
|
+
if (compatMatch) {
|
|
505
|
+
meta.compatibility = compatMatch[1]
|
|
506
|
+
.split(/[,\s]+/)
|
|
507
|
+
.map((s) => s.trim().toLowerCase())
|
|
508
|
+
.filter(Boolean);
|
|
509
|
+
}
|
|
510
|
+
return meta;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Genera keywords deterministas desde name + description.
|
|
515
|
+
* Las keywords derivadas quedan marcadas (spec §9).
|
|
516
|
+
*/
|
|
517
|
+
function deriveKeywords(name, description) {
|
|
518
|
+
const words = new Set();
|
|
519
|
+
const source = `${name || ""} ${description || ""}`.toLowerCase();
|
|
520
|
+
for (const w of source.split(/[^a-z0-9]+/)) {
|
|
521
|
+
if (w.length >= 3 && !["the", "and", "for", "with", "skill", "skills", "this"].includes(w)) {
|
|
522
|
+
words.add(w);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return [...words].slice(0, 12);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Lee el commit actual de un repo local.
|
|
530
|
+
*/
|
|
531
|
+
function getRepoCommit(repoDir) {
|
|
532
|
+
const head = path.join(repoDir, ".git", "HEAD");
|
|
533
|
+
if (!fileExists(head)) return null;
|
|
534
|
+
const ref = readFileSafely(head).trim().replace(/^ref:\s*/, "");
|
|
535
|
+
const refPath = path.join(repoDir, ".git", ref);
|
|
536
|
+
const sha = fileExists(refPath) ? readFileSafely(refPath).trim() : null;
|
|
537
|
+
return sha || ref;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Escanea un source clonado buscando SKILL.md, normaliza metadata, status DISCOVERED.
|
|
542
|
+
* No carga contenido post-frontmatter (spec §5).
|
|
543
|
+
*/
|
|
544
|
+
/**
|
|
545
|
+
* Escanea un source existente en el corpus local buscando SKILL.md.
|
|
546
|
+
* NO realiza operaciones de red.
|
|
547
|
+
*/
|
|
548
|
+
function scanSourceSkills(source, sourceDir) {
|
|
549
|
+
if (!fileExists(sourceDir)) return [];
|
|
550
|
+
const found = [];
|
|
551
|
+
const walk = (dir) => {
|
|
552
|
+
try {
|
|
553
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
554
|
+
for (const entry of entries) {
|
|
555
|
+
const full = path.join(dir, entry.name);
|
|
556
|
+
if (entry.isDirectory()) {
|
|
557
|
+
if (![".git", "node_modules"].includes(entry.name)) walk(full);
|
|
558
|
+
} else if (entry.name === "SKILL.md") {
|
|
559
|
+
const content = readFileSafely(full);
|
|
560
|
+
const fm = parseFrontmatter(content);
|
|
561
|
+
const relPath = path.relative(sourceDir, full).replace(/\\/g, "/");
|
|
562
|
+
const skillName =
|
|
563
|
+
fm.name?.toLowerCase().replace(/[^a-z0-9]+/g, "-") ||
|
|
564
|
+
path.basename(path.dirname(full)).toLowerCase();
|
|
565
|
+
const keywords = fm.keywords.length > 0 ? fm.keywords : deriveKeywords(fm.name, fm.description);
|
|
566
|
+
found.push({
|
|
567
|
+
id: `${source.id}-${skillName}`,
|
|
568
|
+
name: fm.name || skillName,
|
|
569
|
+
description: fm.description || "",
|
|
570
|
+
keywords,
|
|
571
|
+
keywordsDerived: fm.keywords.length === 0,
|
|
572
|
+
capabilities: [],
|
|
573
|
+
domain: [],
|
|
574
|
+
compatibility: fm.compatibility.length > 0 ? fm.compatibility : ["opencode"],
|
|
575
|
+
risk: "low",
|
|
576
|
+
source: {
|
|
577
|
+
id: source.id,
|
|
578
|
+
repository: source.repository,
|
|
579
|
+
path: relPath,
|
|
580
|
+
},
|
|
581
|
+
status: "DISCOVERED",
|
|
582
|
+
trust: source.trust,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
} catch {}
|
|
587
|
+
};
|
|
588
|
+
walk(sourceDir);
|
|
589
|
+
return found;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Carga el catálogo embebido del plugin (skills/registry.json).
|
|
594
|
+
* El corpus es distribuido con WAM — sin red en runtime.
|
|
595
|
+
*/
|
|
596
|
+
function loadBundledRegistry() {
|
|
597
|
+
try {
|
|
598
|
+
const regFile = path.join(import.meta.dirname, "..", "..", "skills", "registry.json");
|
|
599
|
+
if (!fileExists(regFile)) return {};
|
|
600
|
+
const entries = JSON.parse(readFileSafely(regFile));
|
|
601
|
+
const reg = {};
|
|
602
|
+
for (const s of Array.isArray(entries) ? entries : Object.values(entries)) {
|
|
603
|
+
if (!s?.id) continue;
|
|
604
|
+
if (!s.name || s.name.trim().length < 2) continue; // filtrar ruido (nombres de 1 char)
|
|
605
|
+
const desc = (s.description || "").replace(/^["']|["']$/g, "");
|
|
606
|
+
if (desc.length < 20) continue; // filtrar skills sin contenido significativo
|
|
607
|
+
reg[s.id] = { ...s, description: desc };
|
|
608
|
+
}
|
|
609
|
+
return reg;
|
|
610
|
+
} catch {
|
|
611
|
+
return {};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Construye registry unificado: skills locales instaladas (APPROVED)
|
|
617
|
+
* + catálogo embebido distribuido con el plugin (skills/registry.json).
|
|
618
|
+
* No realiza operaciones de red.
|
|
619
|
+
*/
|
|
620
|
+
export function buildRegistry(availableSkills, baseDir) {
|
|
621
|
+
const registry = buildSkillRegistry(availableSkills);
|
|
622
|
+
const bundled = loadBundledRegistry();
|
|
623
|
+
|
|
624
|
+
for (const [id, skill] of Object.entries(bundled)) {
|
|
625
|
+
if (registry[id]) continue; // skills locales ganan
|
|
626
|
+
registry[id] = { ...skill, status: "APPROVED" };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const sources = [];
|
|
630
|
+
for (const [, s] of Object.entries(bundled)) {
|
|
631
|
+
const repo = s.source?.repository || "bundled-local";
|
|
632
|
+
if (!sources.includes(repo)) sources.push(repo);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return { registry, corpusRoot: null, sources, registryFile: null };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Persiste el registry en skills.json (spec §2). No incluye contenido, solo metadata.
|
|
640
|
+
*/
|
|
641
|
+
function persistRegistry(registryFile, registry) {
|
|
642
|
+
fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Score de relevancia ponderado (spec §7) — pesos configurables.
|
|
647
|
+
*/
|
|
648
|
+
function scoreSkill(skill, promptTokens, weights) {
|
|
649
|
+
const w = { ...DEFAULT_SCORING_WEIGHTS, ...(weights || {}) };
|
|
650
|
+
const lowerPrompt = promptTokens.toLowerCase();
|
|
651
|
+
const name = (skill.name || "").toLowerCase();
|
|
652
|
+
const desc = (skill.description || "").toLowerCase();
|
|
653
|
+
let score = 0;
|
|
654
|
+
|
|
655
|
+
if (name && lowerPrompt.includes(name)) score += w.name;
|
|
656
|
+
for (const c of skill.capabilities || []) {
|
|
657
|
+
if (lowerPrompt.includes(c.toLowerCase())) score += w.capability;
|
|
658
|
+
}
|
|
659
|
+
for (const k of skill.keywords || []) {
|
|
660
|
+
if (lowerPrompt.includes(k.toLowerCase())) score += w.keyword;
|
|
661
|
+
}
|
|
662
|
+
if (desc && lowerPrompt.includes(desc.slice(0, 20))) score += w.description;
|
|
663
|
+
for (const d of skill.domain || []) {
|
|
664
|
+
if (lowerPrompt.includes(d.toLowerCase())) score += w.domain;
|
|
665
|
+
}
|
|
666
|
+
return score;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Detección de duplicados por similitud de nombre (spec §19).
|
|
671
|
+
* Clúster: canonical + alternatives. Nunca elimina automáticamente.
|
|
672
|
+
*/
|
|
673
|
+
function dedupRegistry(registry) {
|
|
674
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
675
|
+
const clusters = [];
|
|
676
|
+
const seen = new Set();
|
|
677
|
+
|
|
678
|
+
for (const [id, skill] of Object.entries(registry)) {
|
|
679
|
+
const base = norm(skill.name || id).replace(/-(skill|skills)$/, "");
|
|
680
|
+
const baseWords = base.split("-");
|
|
681
|
+
const key = baseWords.slice(0, 2).join("-");
|
|
682
|
+
if (!seen.has(key)) {
|
|
683
|
+
seen.add(key);
|
|
684
|
+
clusters.push({ canonical: id, alternatives: [] });
|
|
685
|
+
} else {
|
|
686
|
+
const cluster = clusters.find((c) => norm(registry[c.canonical].name).startsWith(key) || key.startsWith(norm(registry[c.canonical].name).slice(0, 6)));
|
|
687
|
+
if (cluster) cluster.alternatives.push(id);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return clusters.filter((c) => c.alternatives.length > 0);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Registra uso de skill para auditabilidad (spec §24).
|
|
695
|
+
*/
|
|
696
|
+
function recordSkillUsage(usage, baseDir) {
|
|
697
|
+
const logFile = path.join(baseDir || process.cwd(), ".wam", "skills", "registry", "usage.log");
|
|
698
|
+
const entry = { at: new Date().toISOString(), ...usage };
|
|
699
|
+
let log = [];
|
|
700
|
+
if (fileExists(logFile)) {
|
|
701
|
+
try {
|
|
702
|
+
log = JSON.parse(readFileSafely(logFile));
|
|
703
|
+
} catch {}
|
|
704
|
+
}
|
|
705
|
+
log.push(entry);
|
|
706
|
+
if (log.length > 200) log = log.slice(-200);
|
|
707
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
708
|
+
fs.writeFileSync(logFile, JSON.stringify(log, null, 2));
|
|
709
|
+
return entry;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Routing con scoring ponderado + persistencia. Reemplaza routeSkills() en analyze.
|
|
714
|
+
*/
|
|
715
|
+
export function routeSkillsV2(prompt, projectInfo, registry, mode, options = {}) {
|
|
716
|
+
prompt = String(prompt ?? "");
|
|
717
|
+
logger.info("pipeline-routing", `Starting routeSkillsV2. Mode: ${mode}, Prompt: ${prompt.substring(0, 50)}...`);
|
|
718
|
+
const tracer = new ContextDecisionTracer(options.taskId || "default", options.projectRoot || process.cwd());
|
|
719
|
+
|
|
720
|
+
// Log concept extraction
|
|
721
|
+
tracer.logConceptExtraction(prompt, { source: "user" });
|
|
722
|
+
|
|
723
|
+
// Log candidate retrieval
|
|
724
|
+
tracer.logCandidateRetrieval(prompt, Object.keys(registry).length, { source: "registry" });
|
|
725
|
+
|
|
726
|
+
// Continue with existing routing logic...
|
|
727
|
+
const rigor = options.rigor || (mode === "FAST" ? "MINIMAL" : mode === "STRICT" ? "RIGOROUS" : "STANDARD");
|
|
728
|
+
const lower = prompt.toLowerCase();
|
|
729
|
+
const candidates = [];
|
|
730
|
+
const rejected = [];
|
|
731
|
+
const baseSkills = []; // always-selected skills (loadStrategy: "base")
|
|
732
|
+
|
|
733
|
+
for (const [id, skill] of Object.entries(registry)) {
|
|
734
|
+
if (!APPROVED_STATUSES.includes(skill.status)) {
|
|
735
|
+
rejected.push({ name: skill.name || id, reason: `estado ${skill.status} — requiere approval` });
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Base skills bypass scoring: always included, even if no prompt match
|
|
740
|
+
if (skill.loadStrategy === "base") {
|
|
741
|
+
baseSkills.push({
|
|
742
|
+
id,
|
|
743
|
+
name: skill.name || id,
|
|
744
|
+
score: 0,
|
|
745
|
+
matchingKeywords: [],
|
|
746
|
+
matchingCapabilities: [],
|
|
747
|
+
risk: skill.risk,
|
|
748
|
+
source: skill.source?.id || "local",
|
|
749
|
+
base: true,
|
|
750
|
+
});
|
|
751
|
+
tracer.logSelectionDecision(id, 0, {
|
|
752
|
+
reason: `Skill base (loadStrategy: "base") — siempre seleccionada`
|
|
753
|
+
});
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const score = scoreSkill(skill, lower, options.weights);
|
|
758
|
+
if (score > 0) {
|
|
759
|
+
candidates.push({
|
|
760
|
+
id,
|
|
761
|
+
name: skill.name || id,
|
|
762
|
+
score,
|
|
763
|
+
matchingKeywords: (skill.keywords || []).filter((k) => lower.includes(k.toLowerCase())).slice(0, 4),
|
|
764
|
+
matchingCapabilities: (skill.capabilities || []).filter((c) => lower.includes(c.toLowerCase())),
|
|
765
|
+
risk: skill.risk,
|
|
766
|
+
source: skill.source?.id || "local",
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
772
|
+
const limit = rigor === "MINIMAL" ? 0 : rigor === "RIGOROUS" ? 5 : 3;
|
|
773
|
+
// Base skills always selected; on-demand candidates fill remaining slots
|
|
774
|
+
const selected = [...baseSkills, ...candidates.slice(0, limit)];
|
|
775
|
+
const overflow = candidates.slice(Math.max(0, limit - baseSkills.length));
|
|
776
|
+
|
|
777
|
+
// Log selection decisions
|
|
778
|
+
selected.forEach(skill => {
|
|
779
|
+
tracer.logSelectionDecision(skill.id, skill.score, {
|
|
780
|
+
reason: skill.base
|
|
781
|
+
? `Skill base (loadStrategy: "base") — siempre seleccionada`
|
|
782
|
+
: `Seleccionado por score ${skill.score} y keywords: ${skill.matchingKeywords.join(', ')}`
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
overflow.forEach(skill => {
|
|
787
|
+
tracer.logSelectionDecision(skill.id, skill.score, {
|
|
788
|
+
reason: `Rechazado por score bajo (${skill.score})`
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
rejected.forEach(skill => {
|
|
793
|
+
tracer.logSelectionDecision(skill.id, 0, {
|
|
794
|
+
reason: skill.reason
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
return {
|
|
799
|
+
candidates,
|
|
800
|
+
selected: selected.map((c) => ({
|
|
801
|
+
id: c.id,
|
|
802
|
+
name: c.name,
|
|
803
|
+
relevance: c.score,
|
|
804
|
+
reason: c.base
|
|
805
|
+
? `Skill base (loadStrategy: "base")`
|
|
806
|
+
: `score ${c.score} (${c.matchingCapabilities.join(",") || c.matchingKeywords.join(",")})`,
|
|
807
|
+
capabilities: c.matchingCapabilities,
|
|
808
|
+
keywords: c.matchingKeywords,
|
|
809
|
+
risk: c.risk,
|
|
810
|
+
source: c.source,
|
|
811
|
+
hasContent: !!(registry[c.id]?.content || "").trim(),
|
|
812
|
+
loaded: !!(registry[c.id]?.content || "").trim(),
|
|
813
|
+
base: !!c.base,
|
|
814
|
+
})),
|
|
815
|
+
rejected: rejected.map((r) => r.name),
|
|
816
|
+
exceeded: overflow.map((c) => c.name),
|
|
817
|
+
counts: { selected: selected.length, limit, total: candidates.length, base: baseSkills.length },
|
|
818
|
+
explain: () =>
|
|
819
|
+
selected.map((c) => ` ${c.name}: score ${c.score} | caps: ${c.matchingCapabilities.join(",") || "-"} | kw: ${c.matchingKeywords.join(",") || "-"} | risk: ${c.risk} | src: ${c.source}${c.base ? " | BASE" : ""}`).join("\n"),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Descubre skills en el registro (dirs locales), extrae metadata sin cargar contenido.
|
|
825
|
+
* Registry: id, name, source, capabilities, triggers, risk, compatibility, status.
|
|
826
|
+
*/
|
|
827
|
+
/**
|
|
828
|
+
* Lee el contenido de una skill local desde su SKILL.md y remueve el
|
|
829
|
+
* frontmatter YAML. Se embebe en el registry en build-time para que la
|
|
830
|
+
* inyeccion N3 (assembleContext) y la carga on-demand operen igual que con
|
|
831
|
+
* el catalogo bundleado (que ya trae content embebido).
|
|
832
|
+
*/
|
|
833
|
+
function readSkillContent(skillPath) {
|
|
834
|
+
if (!skillPath) return "";
|
|
835
|
+
try {
|
|
836
|
+
const raw = fs.readFileSync(skillPath, "utf-8");
|
|
837
|
+
return raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
|
|
838
|
+
} catch {
|
|
839
|
+
return "";
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function buildSkillRegistry(availableSkills) {
|
|
844
|
+
const registry = {};
|
|
845
|
+
const builtinCapabilities = {
|
|
846
|
+
"nestjs-developer": { capabilities: ["backend", "nestjs", "api"], triggers: ["nestjs", "nest", "api", "endpoint", "guard", "module"], risk: "medium" },
|
|
847
|
+
"agent-context-engineering": { capabilities: ["context", "prompt", "agent"], triggers: ["contexto", "context", "prompt", "system prompt"], risk: "low" },
|
|
848
|
+
"angular-developer": { capabilities: ["frontend", "angular", "component"], triggers: ["angular", "componente", "frontend"], risk: "medium" },
|
|
849
|
+
"angular-new-app": { capabilities: ["frontend", "angular", "scaffold"], triggers: ["angular", "app", "nuevo proyecto"], risk: "low" },
|
|
850
|
+
"anti-reimplementation": { capabilities: ["design", "reuse"], triggers: ["reimplementar", "reescribir", "desde cero"], risk: "medium" },
|
|
851
|
+
"architectural-governance": { capabilities: ["architecture", "coherence"], triggers: ["arquitectura", "architecture", "drift"], risk: "high" },
|
|
852
|
+
"backend-integrity": { capabilities: ["backend", "transactions", "data"], triggers: ["base de datos", "transaccion", "db", "source of truth"], risk: "high" },
|
|
853
|
+
"frontend-boundary-protection": { capabilities: ["frontend", "contract"], triggers: ["frontend", "componente", "interfaz"], risk: "medium" },
|
|
854
|
+
"efficient-coding": { capabilities: ["implementation", "efficiency"], triggers: ["implementar", "codigo", "refactor"], risk: "low" },
|
|
855
|
+
"graphify": { capabilities: ["analysis", "knowledge-graph"], triggers: ["graph", "grafo", "arquitectura", "analisis"], risk: "low" },
|
|
856
|
+
"repository-semantic-map": { capabilities: ["analysis", "onboarding"], triggers: ["onboarding", "mapear", "explorar"], risk: "low" },
|
|
857
|
+
"senior-refactor-reviewer": { capabilities: ["refactor", "review"], triggers: ["refactor", "review", "mantenibilidad"], risk: "medium" },
|
|
858
|
+
"shadcn": { capabilities: ["frontend", "ui"], triggers: ["shadcn", "ui", "component"], risk: "low" },
|
|
859
|
+
"ui-ux-design-pro": { capabilities: ["frontend", "ui", "design"], triggers: ["ui", "ux", "diseno", "design", "dashboard"], risk: "low" },
|
|
860
|
+
"fixing-accessibility": { capabilities: ["frontend", "a11y", "accessibility"], triggers: ["accessibilidad", "a11y", "aria"], risk: "low" },
|
|
861
|
+
"fixing-metadata": { capabilities: ["frontend", "seo", "meta"], triggers: ["seo", "meta", "og", "cannonical"], risk: "low" },
|
|
862
|
+
"fixing-motion-performance": { capabilities: ["frontend", "performance", "animation"], triggers: ["animacion", "animation", "rendimiento"], risk: "low" },
|
|
863
|
+
"baseline-ui": { capabilities: ["frontend", "ui"], triggers: ["ui", "css", "tailwind"], risk: "low" },
|
|
864
|
+
"bailian-cli": { capabilities: ["ai", "bailian", "alibaba"], triggers: ["bailian", "百炼", "dashscope"], risk: "low" },
|
|
865
|
+
"bailian-finetune": { capabilities: ["ai", "bailian", "finetune"], triggers: ["finetune", "fine-tune", "bailian"], risk: "low" },
|
|
866
|
+
"bailian-gen": { capabilities: ["ai", "bailian", "generation"], triggers: ["generar", "imagen", "video", "tts", "bailian"], risk: "low" },
|
|
867
|
+
"bailian-managed-agent": { capabilities: ["ai", "bailian", "agent"], triggers: ["agent", "bailian", "managed-agent"], risk: "low" },
|
|
868
|
+
"bailian-protocol": { capabilities: ["ai", "bailian", "protocol"], triggers: ["bailian", "protocol"], risk: "low" },
|
|
869
|
+
// mattpocock/skills — MIT licensed, vendored into skills/
|
|
870
|
+
"writing-for-agents": { capabilities: ["context", "prompt", "agent"], triggers: ["escribir skill", "editar skill", "AGENTS.md", "CLAUDE.md", "writing for agents"], risk: "low", loadStrategy: "base" },
|
|
871
|
+
"codebase-design": { capabilities: ["architecture", "deep-module"], triggers: ["deep module", "arquitectura de modulo", "seam", "testable", "interface"], risk: "low", loadStrategy: "base" },
|
|
872
|
+
"diagnosing-bugs": { capabilities: ["debug", "diagnosis"], triggers: ["debug", "diagnose", "broken", "throwing", "failing", "slow", "regression"], risk: "high" },
|
|
873
|
+
"tdd": { capabilities: ["test", "tdd"], triggers: ["test", "TDD", "test-first", "red-green-refactor", "integration test"], risk: "low" },
|
|
874
|
+
"handoff": { capabilities: ["context", "handoff"], triggers: ["handoff", "hand-off", "continuar despues", "next agent", "pasar la pelota"], risk: "low" },
|
|
875
|
+
"wizard": { capabilities: ["setup", "infra", "credentials"], triggers: ["setup", "configurar", "credenciales", "one-shot", "migracion", "cutover"], risk: "medium" },
|
|
876
|
+
"prototype": { capabilities: ["design", "ui", "state"], triggers: ["prototype", "sanity-check", "state model", "validar disenio"], risk: "medium" },
|
|
877
|
+
"improve-codebase-architecture": { capabilities: ["architecture", "deepening"], triggers: ["deepening", "arquitectura", "scan", "friction", "AI-navigable"], risk: "medium" },
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
for (const [name, info] of Object.entries(availableSkills || {})) {
|
|
881
|
+
const base = builtinCapabilities[name] || {
|
|
882
|
+
capabilities: [],
|
|
883
|
+
triggers: [],
|
|
884
|
+
risk: "low",
|
|
885
|
+
};
|
|
886
|
+
// Extension contract: an entry MAY carry explicit routing metadata under
|
|
887
|
+
// `metadata`. Injected metadata wins over builtins so a new capability can
|
|
888
|
+
// be registered without editing this module. Entries without `metadata`
|
|
889
|
+
// behave exactly as before.
|
|
890
|
+
const ext = info?.metadata || {};
|
|
891
|
+
const baseLoad = base.loadStrategy ?? "ondemand";
|
|
892
|
+
const meta = {
|
|
893
|
+
capabilities: ext.capabilities ?? base.capabilities,
|
|
894
|
+
triggers: ext.triggers ?? base.triggers,
|
|
895
|
+
risk: ext.risk ?? base.risk,
|
|
896
|
+
loadStrategy: ext.loadStrategy ?? baseLoad,
|
|
897
|
+
};
|
|
898
|
+
// Skills locales ya presentes -> marcadas APPROVED (confianza local)
|
|
899
|
+
registry[name] = {
|
|
900
|
+
id: name,
|
|
901
|
+
name,
|
|
902
|
+
source: { kind: "local", path: info?.path, ref: "installed" },
|
|
903
|
+
capabilities: meta.capabilities,
|
|
904
|
+
triggers: meta.triggers,
|
|
905
|
+
risk: meta.risk,
|
|
906
|
+
loadStrategy: meta.loadStrategy,
|
|
907
|
+
compatibility: { opencode: true },
|
|
908
|
+
status: "APPROVED",
|
|
909
|
+
cache: false,
|
|
910
|
+
content: readSkillContent(info?.path),
|
|
911
|
+
...(ext.keywords ? { keywords: ext.keywords } : {}),
|
|
912
|
+
...(ext.domain ? { domain: ext.domain } : {}),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return registry;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Evalúa una skill bajo demanda (heurística para v1).
|
|
921
|
+
* Determinístico: status es APPROVED/REJECTED según criterios de calidad.
|
|
922
|
+
*/
|
|
923
|
+
function evaluateSkill(skillId, registry) {
|
|
924
|
+
const skill = registry[skillId];
|
|
925
|
+
if (!skill) return { skillId, status: "REJECTED", reason: "No registrada" };
|
|
926
|
+
if (!skill.compatibility?.opencode && skill.source?.kind !== "local") {
|
|
927
|
+
return { skillId, status: "REJECTED", reason: "Incompatible con OpenCode" };
|
|
928
|
+
}
|
|
929
|
+
if (skill.risk === "high" && skill.status !== "APPROVED") {
|
|
930
|
+
return { skillId, status: "REJECTED", reason: "Skill de alto riesgo no aprobada" };
|
|
931
|
+
}
|
|
932
|
+
return { skillId, status: "APPROVED", reason: "Aprobada por registry" };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Carga una skill bajo demanda (content-load, sólo tras selección).
|
|
937
|
+
* El contenido real viaja embebido en el catálogo (build-time); sin red en runtime.
|
|
938
|
+
*/
|
|
939
|
+
export function loadSkillOnDemand(skillId, registry, baseDir) {
|
|
940
|
+
logger.info("plugin-load", `Starting loadSkillOnDemand for skill: ${skillId}`);
|
|
941
|
+
const tracer = new ContextDecisionTracer(`load-${skillId}`, baseDir || process.cwd());
|
|
942
|
+
tracer.logConceptExtraction(`Carga de skill ${skillId}`, { source: "demand" });
|
|
943
|
+
|
|
944
|
+
const skill = registry[skillId];
|
|
945
|
+
if (!skill) {
|
|
946
|
+
tracer.logSelectionDecision(skillId, 0, { reason: "No registrada" });
|
|
947
|
+
return { loaded: false, reason: "No registrada" };
|
|
948
|
+
}
|
|
949
|
+
if (!APPROVED_STATUSES.includes(skill.status)) {
|
|
950
|
+
tracer.logSelectionDecision(skillId, 0, { reason: `No aprobada (${skill.status})` });
|
|
951
|
+
return { loaded: false, reason: `No aprobada (${skill.status})` };
|
|
952
|
+
}
|
|
953
|
+
if (!skill.content || !skill.content.trim()) {
|
|
954
|
+
tracer.logSelectionDecision(skillId, 0, { reason: `Sin contenido embebido (${skillId}) — catálogo metadata-only` });
|
|
955
|
+
return { loaded: false, skillId, reason: `Sin contenido embebido (${skillId}) — catálogo metadata-only` };
|
|
956
|
+
}
|
|
957
|
+
const root = baseDir || process.cwd();
|
|
958
|
+
const dir = path.join(root, ".wam", "skills", "bundled", skillId);
|
|
959
|
+
const skillFile = path.join(dir, "SKILL.md");
|
|
960
|
+
if (!fileExists(skillFile)) {
|
|
961
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
962
|
+
fs.writeFileSync(skillFile, skill.content);
|
|
963
|
+
}
|
|
964
|
+
tracer.logSelectionDecision(skillId, 1, { reason: "Cargada exitosamente" });
|
|
965
|
+
return {
|
|
966
|
+
loaded: true,
|
|
967
|
+
skillId,
|
|
968
|
+
contentPath: skillFile,
|
|
969
|
+
instructions: "Skill materializada para el runtime nativo de OpenCode. Cargar SKILL.md desde contentPath si el agente ejecuta esta skill.",
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Inspecciona el proyecto para Known/Inferred/Assumed/Unknown
|
|
977
|
+
*/
|
|
978
|
+
async function inspectProject(projectPath) {
|
|
979
|
+
const known = [];
|
|
980
|
+
const inferred = [];
|
|
981
|
+
const assumed = [];
|
|
982
|
+
const unknown = [];
|
|
983
|
+
|
|
984
|
+
// Read AGENTS.md
|
|
985
|
+
const agentsMd = getAgentsMd(projectPath);
|
|
986
|
+
if (agentsMd.trim()) {
|
|
987
|
+
known.push("AGENTS.md presente en el proyecto");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Read package.json
|
|
991
|
+
const pkgJson = getPackageJson(projectPath);
|
|
992
|
+
if (pkgJson) {
|
|
993
|
+
known.push("package.json detectado");
|
|
994
|
+
known.push(`Dependencias: ${Object.keys(pkgJson.dependencies || {}).join(", ") || "ninguna"}`);
|
|
995
|
+
|
|
996
|
+
// Detect framework
|
|
997
|
+
const deps = { ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) };
|
|
998
|
+
if (deps["@nestjs/*"] || deps.nestjs) inferred.push("NestJS framework detectado");
|
|
999
|
+
if (deps["@angular/core"] || deps.angular) inferred.push("Angular framework detectado");
|
|
1000
|
+
if (deps.express) inferred.push("Express.js detectado");
|
|
1001
|
+
if (deps["@nestjs/core"]) inferred.push("NestJS core detectado");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Detect OpenSpec specs
|
|
1005
|
+
const openspecDir = path.join(projectPath, "openspec");
|
|
1006
|
+
if (fileExists(openspecDir)) {
|
|
1007
|
+
known.push("Directorio openspec presente");
|
|
1008
|
+
// List specs
|
|
1009
|
+
try {
|
|
1010
|
+
const specFiles = fs.readdirSync(openspecDir).filter(f => f.endsWith(".md"));
|
|
1011
|
+
if (specFiles.length > 0) {
|
|
1012
|
+
known.push(`Specs OpenSpec: ${specFiles.join(", ")}`);
|
|
1013
|
+
}
|
|
1014
|
+
} catch {}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Detect skills in project
|
|
1018
|
+
const skillCandidates = discoverSkills();
|
|
1019
|
+
if (Object.keys(skillCandidates).length > 0) {
|
|
1020
|
+
known.push(`Skills disponibles: ${Object.keys(skillCandidates).join(", ")}`);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Detect stack
|
|
1024
|
+
const stackInfo = detectStack(projectPath);
|
|
1025
|
+
if (stackInfo.stack !== "unknown") {
|
|
1026
|
+
known.push(`Stack tecnológico: ${stackInfo.stack}`);
|
|
1027
|
+
stackInfo.languages.forEach(lang => inferred.push(`Idioma: ${lang}`));
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// If we have minimal info, add generic unknowns
|
|
1031
|
+
if (known.length < 3) {
|
|
1032
|
+
unknown.push("Contexto del proyecto limitado - inspección recomendada");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
return { known, inferred, assumed, unknown };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Audita supuestos detectados en la petición
|
|
1040
|
+
*/
|
|
1041
|
+
function auditAssumptions(prompt, projectInfo) {
|
|
1042
|
+
const assumptions = [];
|
|
1043
|
+
|
|
1044
|
+
// Common assumptions based on prompt language
|
|
1045
|
+
const lower = prompt.toLowerCase();
|
|
1046
|
+
|
|
1047
|
+
// If user mentions "add X" without context
|
|
1048
|
+
if (/agregar|add/.test(lower) && !/ya tiene|already has/.test(lower)) {
|
|
1049
|
+
assumptions.push("El usuario asume que se necesita agregar nueva funcionalidad");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// If mentions performance
|
|
1053
|
+
if (/mejorar.*rendimiento|performance/.test(lower)) {
|
|
1054
|
+
assumptions.push("El usuario asume que la mejora de rendimiento es la prioridad");
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// If mentions authentication
|
|
1058
|
+
if (/autentic(?:ión|ar|o|a)|oauth|jwt/.test(lower)) {
|
|
1059
|
+
assumptions.push("El usuario asume que el cambio es sobre autenticación");
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// If mentions cache/redis
|
|
1063
|
+
if (/(cache|redis)/.test(lower)) {
|
|
1064
|
+
assumptions.push("El usuario asume que se necesita cache/redis");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Project-context assumptions
|
|
1068
|
+
if (projectInfo) {
|
|
1069
|
+
const { known, inferred } = projectInfo;
|
|
1070
|
+
|
|
1071
|
+
// If NestJS detected but user says "add auth"
|
|
1072
|
+
if (inferred.some(i => i.includes("NestJS")) && /auth/.test(lower)) {
|
|
1073
|
+
assumptions.push("NestJS auth module existente - ¿reemplazar o extender?");
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// If Angular detected
|
|
1077
|
+
if (inferred.some(i => i.includes("Angular")) && /service/.test(lower)) {
|
|
1078
|
+
assumptions.push("Angular service - ¿nueva o reemplazo?");
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return assumptions;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Descubre skills relevantes para la tarea
|
|
1087
|
+
*/
|
|
1088
|
+
function discoverSkillsForTask(prompt, projectInfo, availableSkills) {
|
|
1089
|
+
const lower = prompt.toLowerCase();
|
|
1090
|
+
const candidates = [];
|
|
1091
|
+
const selected = [];
|
|
1092
|
+
const rejected = [];
|
|
1093
|
+
|
|
1094
|
+
// Evaluate each available skill
|
|
1095
|
+
for (const [name, skillInfo] of Object.entries(availableSkills)) {
|
|
1096
|
+
const description = (skillInfo.description || "").toLowerCase();
|
|
1097
|
+
const triggers = (skillInfo.triggers || []).map(t => t.toLowerCase());
|
|
1098
|
+
|
|
1099
|
+
let relevanceScore = 0;
|
|
1100
|
+
let isRedundant = false;
|
|
1101
|
+
|
|
1102
|
+
// Check triggers match
|
|
1103
|
+
const triggerMatch = triggers.some(t => lower.includes(t));
|
|
1104
|
+
if (triggerMatch) relevanceScore += 20;
|
|
1105
|
+
|
|
1106
|
+
// Check description keywords
|
|
1107
|
+
const keywordCounts = {
|
|
1108
|
+
implementation: ["implement", "crear", "agregar", "add", "nuevo"],
|
|
1109
|
+
refactoring: ["refactor", "mejorar", "restructur"],
|
|
1110
|
+
debugging: ["bug", "error", "fallo", "debug"],
|
|
1111
|
+
security: ["seguridad", "security", "vulnerabil", "auth", "oauth"],
|
|
1112
|
+
testing: ["test", "pruebas", "jest", "mocha"],
|
|
1113
|
+
architecture: ["arquitectura", "architecture", "diseño", "design"],
|
|
1114
|
+
performance: ["rendimiento", "performance", "optimizar", "cache"],
|
|
1115
|
+
migration: ["migra", "migrate", "upgrade"],
|
|
1116
|
+
documentation: ["doc", "documenta", "readme"],
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
for (const [category, keywords] of Object.entries(keywordCounts)) {
|
|
1120
|
+
const matches = keywords.filter(k => description.includes(k) || lower.includes(k));
|
|
1121
|
+
if (matches.length > 0) {
|
|
1122
|
+
relevanceScore += matches.length * 10;
|
|
1123
|
+
// Check if this category is already selected
|
|
1124
|
+
if (selected.some(s => s.category === category)) {
|
|
1125
|
+
isRedundant = true;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Check project-awareness
|
|
1131
|
+
if (projectInfo) {
|
|
1132
|
+
const { inferred } = projectInfo;
|
|
1133
|
+
// Project-specific skill selection
|
|
1134
|
+
if (inferred.some(i => i.includes("NestJS")) && name === "nestjs-best-practices") {
|
|
1135
|
+
relevanceScore += 30;
|
|
1136
|
+
}
|
|
1137
|
+
if (inferred.some(i => i.includes("Angular")) && name === "angular-developer") {
|
|
1138
|
+
relevanceScore += 30;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Deduplication: reject if too redundant
|
|
1143
|
+
if (isRedundant && relevanceScore < 40) {
|
|
1144
|
+
relevanceScore -= 20;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Only include skills with meaningful relevance
|
|
1148
|
+
if (relevanceScore >= 30) {
|
|
1149
|
+
candidates.push({
|
|
1150
|
+
name,
|
|
1151
|
+
relevance: relevanceScore,
|
|
1152
|
+
description: skillInfo.description,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Sort by relevance
|
|
1158
|
+
candidates.sort((a, b) => b.relevance - a.relevance);
|
|
1159
|
+
|
|
1160
|
+
// Select top 3-5, reject the rest
|
|
1161
|
+
const maxSelect = 5;
|
|
1162
|
+
for (let i = 0; i < Math.min(candidates.length, maxSelect); i++) {
|
|
1163
|
+
selected.push(candidates[i].name);
|
|
1164
|
+
}
|
|
1165
|
+
for (let i = maxSelect; i < candidates.length; i++) {
|
|
1166
|
+
rejected.push(candidates[i].name);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// If no candidates have enough relevance, select based on task type
|
|
1170
|
+
if (selected.length === 0) {
|
|
1171
|
+
const type = classifyRequest(prompt);
|
|
1172
|
+
const fallbackSkills = {
|
|
1173
|
+
question: ["frontend-boundary-protection", "efficient-coding"],
|
|
1174
|
+
research: ["graphify", "nestjs-best-practices"],
|
|
1175
|
+
debugging: ["nestjs-best-practices", "efficient-coding"],
|
|
1176
|
+
"bug-fix": ["nestjs-best-practices", "efficient-coding"],
|
|
1177
|
+
implementation: ["nestjs-best-practices", "efficient-coding"],
|
|
1178
|
+
refactoring: ["nestjs-best-practices", "efficient-coding"],
|
|
1179
|
+
architecture: ["architectural-governance", "nestjs-best-practices"],
|
|
1180
|
+
security: ["nestjs-best-practices", "efficient-coding"],
|
|
1181
|
+
performance: ["nestjs-best-practices", "efficient-coding"],
|
|
1182
|
+
testing: ["nestjs-best-practices", "efficient-coding"],
|
|
1183
|
+
migration: ["nestjs-best-practices", "efficient-coding"],
|
|
1184
|
+
infrastructure: ["nestjs-best-practices", "efficient-coding"],
|
|
1185
|
+
documentation: ["nestjs-best-practices", "efficient-coding"],
|
|
1186
|
+
investigation: ["graphify", "nestjs-best-practices"],
|
|
1187
|
+
planning: ["nestjs-best-practices", "efficient-coding"],
|
|
1188
|
+
"destructive-operation": ["nestjs-best-practices", "efficient-coding"],
|
|
1189
|
+
}[type.type] || ["efficient-coding"];
|
|
1190
|
+
|
|
1191
|
+
for (const skill of fallbackSkills) {
|
|
1192
|
+
if (availableSkills[skill]) selected.push(skill);
|
|
1193
|
+
else rejected.push(skill);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return { candidates, selected, rejected };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Determina el modo de operación (FAST/NORMAL/STRICT)
|
|
1202
|
+
*/
|
|
1203
|
+
function determineMode(classification, projectInfo, riskLevel) {
|
|
1204
|
+
// FAST: trivial tasks
|
|
1205
|
+
if (classification.type === "trivial") return { mode: "FAST", reason: "Tarea trivial y no ambigua" };
|
|
1206
|
+
|
|
1207
|
+
// STRICT: high risk or architectural
|
|
1208
|
+
if (riskLevel === "high" || classification.type === "architectural") return { mode: "STRICT", reason: "Alto riesgo o decisión arquitectónica" };
|
|
1209
|
+
|
|
1210
|
+
// Check for STRICT triggers in prompt
|
|
1211
|
+
const lower = (classification.prompt || "").toLowerCase();
|
|
1212
|
+
const strictTriggers = ["migra", "security", "arquitectura", "producción", "destructivo"];
|
|
1213
|
+
if (strictTriggers.some(t => lower.includes(t))) {
|
|
1214
|
+
return { mode: "STRICT", reason: "Triggers STRICT detectados en la petición" };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Default: NORMAL
|
|
1218
|
+
return { mode: "NORMAL", reason: "Modo por defecto - análisis ligero" };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Main analysis function - entry point for the plugin
|
|
1223
|
+
*/
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Detecta incertidumbres DECISION_CRITICAL expresadas en el prompt mismo,
|
|
1227
|
+
* SOLO cuando hay una decisión explícita de material (migrar-o-eliminar,
|
|
1228
|
+
* alcance de eliminación). Una migración normal es la tarea misma, no una
|
|
1229
|
+
* incertidumbre bloqueante. Rule-based, determinista.
|
|
1230
|
+
*/
|
|
1231
|
+
export function extractPromptCriticalUncertainties(prompt = "") {
|
|
1232
|
+
const text = (prompt || "").trim();
|
|
1233
|
+
if (!text) return [];
|
|
1234
|
+
const found = [];
|
|
1235
|
+
const lower = text.toLowerCase();
|
|
1236
|
+
|
|
1237
|
+
if (/(migra|migrate|migr)[\s\S]*(o|or)[\s\S]*(elim|borr|dele|descart|discard)/i.test(lower)) {
|
|
1238
|
+
found.push({
|
|
1239
|
+
question: "¿Deben migrarse o eliminarse los datos existentes?",
|
|
1240
|
+
source: text.slice(0, 140),
|
|
1241
|
+
options: ["Migrar", "Eliminar"],
|
|
1242
|
+
});
|
|
1243
|
+
} else if (/elim|borr[ao]|delet/i.test(lower)) {
|
|
1244
|
+
found.push({
|
|
1245
|
+
question: "¿Alcance de la eliminación (hard delete / soft delete / anonymizar)?",
|
|
1246
|
+
source: text.slice(0, 140),
|
|
1247
|
+
options: ["hard delete", "soft delete", "anonymizar"],
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
return found;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const ASSUMPTION_IMPACT =
|
|
1254
|
+
/elim|delet|borr|actualiz|update|schema|esquema|migraci|endpoint|api|arquitectur|estructura|m[oó]dulo|seguridad|auth|token|compatib|breaking|alcance|scope|destructiv|data.?loss|aceptaci[oó]n|criterios/i;
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Clasifica una asunción (rule-based): DECISION_CRITICAL si toca impacto
|
|
1258
|
+
* material (data mutation, API, arquitectura, seguridad, compat, alcance,
|
|
1259
|
+
* destructivo, aceptación); si no, NON_BLOCKING.
|
|
1260
|
+
*/
|
|
1261
|
+
export function classifyAssumption(statement = "") {
|
|
1262
|
+
return ASSUMPTION_IMPACT.test(statement) ? "DECISION_CRITICAL" : "NON_BLOCKING";
|
|
1263
|
+
}/**
|
|
1264
|
+
* Convierte asunciones textuales del análisis en objetos de estado
|
|
1265
|
+
* {id, statement, classification, status} (spec assumption-gate R1).
|
|
1266
|
+
*/
|
|
1267
|
+
/**
|
|
1268
|
+
* DECISION_CRITICAL/blocking, y las mirroriza a contract.unknowns (blocking,
|
|
1269
|
+
* con assumptionId) para reutilizar la fase ASKING (spec R2/R3).
|
|
1270
|
+
*/
|
|
1271
|
+
export function escalateAssumptions(state, taskText = "") {
|
|
1272
|
+
const contract = state?.contract || {};
|
|
1273
|
+
const escalated = [];
|
|
1274
|
+
const assumptions = contract.assumptions || [];
|
|
1275
|
+
let unknowns = contract.unknowns || [];
|
|
1276
|
+
let changed = false;
|
|
1277
|
+
|
|
1278
|
+
for (const a of assumptions) {
|
|
1279
|
+
if (a.status === "active" && a.classification === "NON_BLOCKING") {
|
|
1280
|
+
const taskImpact = !!taskText && ASSUMPTION_IMPACT.test(taskText);
|
|
1281
|
+
const genericAssumption = /funcionalidad|agregar nueva|nueva funcionalidad|add support|add.*support/i.test(a.statement);
|
|
1282
|
+
const cls =
|
|
1283
|
+
classifyAssumption(a.statement) === "DECISION_CRITICAL" ||
|
|
1284
|
+
(taskImpact && genericAssumption)
|
|
1285
|
+
? "DECISION_CRITICAL"
|
|
1286
|
+
: "NON_BLOCKING";
|
|
1287
|
+
if (cls === "DECISION_CRITICAL") {
|
|
1288
|
+
a.classification = cls;
|
|
1289
|
+
a.status = "blocking";
|
|
1290
|
+
const existing = unknowns.find((u) => u.status === "blocking" && !u.assumptionId);
|
|
1291
|
+
if (existing) {
|
|
1292
|
+
existing.assumptionId = a.id; // reuso: la pregunta existente cubre esta asunción (sin duplicar)
|
|
1293
|
+
} else if (!unknowns.some((u) => u.assumptionId === a.id)) {
|
|
1294
|
+
unknowns = [
|
|
1295
|
+
...unknowns,
|
|
1296
|
+
{
|
|
1297
|
+
id: `U${unknowns.length + 1}`,
|
|
1298
|
+
kind: "ASSUMED",
|
|
1299
|
+
question: `¿${a.statement}?`,
|
|
1300
|
+
status: "blocking",
|
|
1301
|
+
assumptionId: a.id,
|
|
1302
|
+
},
|
|
1303
|
+
];
|
|
1304
|
+
}
|
|
1305
|
+
escalated.push(a);
|
|
1306
|
+
changed = true;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
if (changed) {
|
|
1312
|
+
contract.assumptions = assumptions;
|
|
1313
|
+
contract.unknowns = unknowns;
|
|
1314
|
+
state.contract = contract;
|
|
1315
|
+
}
|
|
1316
|
+
return { escalated, changed };
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// ---------------------------------------------------------------------------
|
|
1320
|
+
// Topic Scope Classifier — Determinista, sin LLM
|
|
1321
|
+
// ---------------------------------------------------------------------------
|
|
1322
|
+
// Clasifica la relación entre un nuevo mensaje y el contract activo en 3 estados:
|
|
1323
|
+
// "continuation" — mismo tema, el agente debe continuar la tarea actual
|
|
1324
|
+
// "extension" — tema relacionado, se puede agregar al contract existente
|
|
1325
|
+
// "new-topic" — tema diferente, requiere nuevo contract / backlog
|
|
1326
|
+
|
|
1327
|
+
function tokenize(text = "") {
|
|
1328
|
+
return new Set(
|
|
1329
|
+
(text || "")
|
|
1330
|
+
.toLowerCase()
|
|
1331
|
+
.replace(/[^a-z0-9áéíóúñü\s]/g, " ")
|
|
1332
|
+
.split(/\s+/)
|
|
1333
|
+
.filter((w) => w.length > 2)
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function tokenOverlap(a, b) {
|
|
1338
|
+
const aSet = typeof a === "Set" ? a : tokenize(a);
|
|
1339
|
+
const bSet = typeof b === "Set" ? b : tokenize(b);
|
|
1340
|
+
if (!aSet.size || !bSet.size) return 0;
|
|
1341
|
+
let hit = 0;
|
|
1342
|
+
for (const t of bSet) if (aSet.has(t)) hit++;
|
|
1343
|
+
return hit / Math.max(aSet.size, 1);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const DOMAIN_KEYWORDS = {
|
|
1347
|
+
frontend: /\b(frontend|fe|front-?end|ui|button|componente|vista|pantalla|react|angular|css|tailwind|html|form|input|modal)\b/i,
|
|
1348
|
+
backend: /\b(backend|back-?end|api|server|endpoint|base de datos|db|database|nest|express|postgres|mongo|route|middleware)\b/i,
|
|
1349
|
+
scraper: /\b(scrap|crawl|parser|puppeteer|playwright|selenium|fetch|http client)\b/i,
|
|
1350
|
+
tests: /\b(test|e2e|spec|coverage|unitario|prueba|mock|stub|fixture)\b/i,
|
|
1351
|
+
security: /\b(security|seguridad|auth|oauth|token|jwt|credencial|encrypt|hash)\b/i,
|
|
1352
|
+
infra: /\b(infra|deploy|docker|ci|cd|pipeline|aws|gcp|azure|kubernetes|k8s)\b/i,
|
|
1353
|
+
data: /\b(data|datos|migration|migración|schema|modelo|query|sql|orm)\b/i,
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
function detectDomain(text = "") {
|
|
1357
|
+
const domains = [];
|
|
1358
|
+
for (const [domain, pattern] of Object.entries(DOMAIN_KEYWORDS)) {
|
|
1359
|
+
if (pattern.test(text)) domains.push(domain);
|
|
1360
|
+
}
|
|
1361
|
+
return domains;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Clasifica la relación entre un nuevo prompt y el contract activo.
|
|
1366
|
+
*
|
|
1367
|
+
* @param {string} prompt - El mensaje nuevo del usuario
|
|
1368
|
+
* @param {object} contract - El contract actual (requirements, status, etc.)
|
|
1369
|
+
* @returns {"continuation"|"extension"|"new-topic"}
|
|
1370
|
+
*/
|
|
1371
|
+
export function topicScope(prompt, contract) {
|
|
1372
|
+
if (!contract?.requirements?.length) return "new-topic";
|
|
1373
|
+
|
|
1374
|
+
const promptTokens = tokenize(prompt);
|
|
1375
|
+
const contractText = contract.requirements.map((r) => r.title || r).join(" ");
|
|
1376
|
+
const contractTokens = tokenize(contractText);
|
|
1377
|
+
|
|
1378
|
+
// 1. Token overlap — qué tan relacionado está el prompt con los reqs actuales
|
|
1379
|
+
const overlap = tokenOverlap(promptTokens, contractTokens);
|
|
1380
|
+
|
|
1381
|
+
// 2. Domain overlap — comparten mismo dominio técnico?
|
|
1382
|
+
const promptDomains = detectDomain(prompt);
|
|
1383
|
+
const contractDomains = detectDomain(contractText);
|
|
1384
|
+
const domainOverlap = promptDomains.some((d) => contractDomains.includes(d));
|
|
1385
|
+
|
|
1386
|
+
// 3. Continuation signals — el usuario dice explícitamente que continúa
|
|
1387
|
+
const CONTINUATION_RE = /\b(continuar|sigue|siguiente|proximo|próximo|seguir|avanzar|completar|terminar|finalizar|resuelto|listo|hecho|done)\b/i;
|
|
1388
|
+
const isContinuationClaim = CONTINUATION_RE.test(prompt);
|
|
1389
|
+
|
|
1390
|
+
// 4. Decision: continuation > extension > new-topic
|
|
1391
|
+
if (isContinuationClaim && overlap > 0.1) return "continuation";
|
|
1392
|
+
if (overlap > 0.3 || (domainOverlap && overlap > 0.15)) return "extension";
|
|
1393
|
+
return "new-topic";
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Agrega un requerimiento al backlog (fuera de alcance del contract activo).
|
|
1398
|
+
* El backlog se persiste en state.backlog y NUNCA se inyecta en contexto activo.
|
|
1399
|
+
*/
|
|
1400
|
+
export function addToBacklog(state, title, source = "user") {
|
|
1401
|
+
if (!state.backlog) state.backlog = [];
|
|
1402
|
+
const id = `backlog-${Date.now()}`;
|
|
1403
|
+
state.backlog.push({
|
|
1404
|
+
id,
|
|
1405
|
+
title: (title || "").slice(0, 200),
|
|
1406
|
+
addedAt: new Date().toISOString(),
|
|
1407
|
+
source,
|
|
1408
|
+
status: "pending",
|
|
1409
|
+
});
|
|
1410
|
+
return id;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Obtiene el backlog formateado para inyección en contexto.
|
|
1415
|
+
* Solo se muestra bajo demanda (/wam backlog), nunca en el pack N0-N3.
|
|
1416
|
+
*/
|
|
1417
|
+
export function formatBacklog(state) {
|
|
1418
|
+
const pending = (state.backlog || []).filter((b) => b.status === "pending");
|
|
1419
|
+
if (!pending.length) return null;
|
|
1420
|
+
const lines = [`[wam backlog] ${pending.length} requerimiento(s) fuera de alcance:`];
|
|
1421
|
+
for (const b of pending.slice(0, 10)) {
|
|
1422
|
+
lines.push(` ${b.id}: ${b.title}`);
|
|
1423
|
+
}
|
|
1424
|
+
if (pending.length > 10) lines.push(` ...(+${pending.length - 10} más)`);
|
|
1425
|
+
return lines.join("\n");
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Synthesiza un Completion Contract específico a partir del prompt (rule-based,
|
|
1430
|
+
* sin LLM): descompone cláusulas accionables y deriva requisitos/verificación.
|
|
1431
|
+
* Fallback: genéricos si no se puede extraer nada.
|
|
1432
|
+
*/
|
|
1433
|
+
/**
|
|
1434
|
+
* Máximo de requisitos individuales que synthesizeContract generará antes de
|
|
1435
|
+
* consolidar. Más allá de este umbral, los excedentes se fusionan en un
|
|
1436
|
+
* req de alto nivel para evitar contratos ingobernables (54 reqs, 12M tokens).
|
|
1437
|
+
*/
|
|
1438
|
+
const MAX_CONTRACT_REQUIREMENTS = 10;
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Patrones de cláusulas NO accionables — preguntas, URLs, narrativa pura,
|
|
1442
|
+
* fragmentos de spec que no representan una tarea concreta.
|
|
1443
|
+
*/
|
|
1444
|
+
const NON_ACTIONABLE_PATTERNS = [
|
|
1445
|
+
/^\?/, // empieza con ?
|
|
1446
|
+
/\?$/, // termina con ?
|
|
1447
|
+
/^https?:\/\//, // URLs
|
|
1448
|
+
/^https?:/,
|
|
1449
|
+
/^\//, // paths /rutas
|
|
1450
|
+
/^\{/, // objetos JSON
|
|
1451
|
+
/^\[/, // arrays JSON
|
|
1452
|
+
/^".*":\s/, // líneas de spec JSON ("field": value)
|
|
1453
|
+
/^\|/, // tablas markdown
|
|
1454
|
+
/^---/, // separadores
|
|
1455
|
+
/^#+/, // headers markdown
|
|
1456
|
+
/^```/, // code fences
|
|
1457
|
+
/^ejemplo/i, // "Ejemplo: ..."
|
|
1458
|
+
/^ejemplo:/i,
|
|
1459
|
+
/^ver tamb/i, // "Ver también..."
|
|
1460
|
+
/^nota:/i, // "Nota: ..."
|
|
1461
|
+
/^nota /i,
|
|
1462
|
+
/^decisión/i, // "Decisión arquitectónica..."
|
|
1463
|
+
/^decisión/i,
|
|
1464
|
+
/^nombre que/i, // "Nombre que usaría..."
|
|
1465
|
+
/^se propondría/i, // "Yo separaría..."
|
|
1466
|
+
/^yo separaría/i,
|
|
1467
|
+
/^mantendría/i,
|
|
1468
|
+
/^independiente de/i,
|
|
1469
|
+
];
|
|
1470
|
+
|
|
1471
|
+
export function synthesizeContract(prompt = "", mode = "NORMAL", uncertainties = []) {
|
|
1472
|
+
const requirements = [];
|
|
1473
|
+
const verification = [];
|
|
1474
|
+
const lower = (prompt || "").toLowerCase();
|
|
1475
|
+
|
|
1476
|
+
// --- Gate 1: prompts tipo documento/spec largo (>2000 chars) → alto nivel ---
|
|
1477
|
+
// Evita explosión de cláusulas por comas. Un spec de 50 párrafos no son
|
|
1478
|
+
// 50 requisitos — es 1-3 bloques de trabajo accionables.
|
|
1479
|
+
if ((prompt || "").length > 2000) {
|
|
1480
|
+
const isSpec = /\b(spec|specification|propuesta|propuesto|objetivo|alcance|requisitos?|criterios?|edge cases?|api|endpoint|flujo principal)\b/i.test(lower);
|
|
1481
|
+
if (isSpec) {
|
|
1482
|
+
requirements.push("Implementar funcionalidad según la especificación");
|
|
1483
|
+
if (/test|prueba|suite/i.test(lower)) {
|
|
1484
|
+
requirements.push("Agregar tests para el cambio");
|
|
1485
|
+
verification.push("Ejecutar suite de tests relevante");
|
|
1486
|
+
}
|
|
1487
|
+
if (/security|seguridad|auth|oauth|token/i.test(lower)) {
|
|
1488
|
+
requirements.push("Auditar seguridad del cambio");
|
|
1489
|
+
verification.push("Revisar superficie de autenticación/secretos");
|
|
1490
|
+
}
|
|
1491
|
+
if (!verification.length) verification.push("Evidencia de satisfacción del contrato");
|
|
1492
|
+
const unknowns = (uncertainties || [])
|
|
1493
|
+
.filter((u) => u.classification === "DECISION_CRITICAL")
|
|
1494
|
+
.map((u) => ({ id: u.id, question: u.question, classification: u.classification, status: "blocking", options: u.options || [] }));
|
|
1495
|
+
return { requirements, constraints: [], verification, unknowns, status: "PROPOSED", rigor: mode };
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// --- Gate 2: splitting tradicional con filtros ---
|
|
1500
|
+
const clauses = (prompt || "")
|
|
1501
|
+
.split(/[;,]|\by\b|\b\+\b|\band\b|\bplus\b/i)
|
|
1502
|
+
.map((c) => c.trim())
|
|
1503
|
+
.filter((c) => c.length > 4);
|
|
1504
|
+
const clean = (c) =>
|
|
1505
|
+
c
|
|
1506
|
+
.replace(/^(agrega|agregar|implementa|implementar|crea|crear|haz|hacer|refactoriza|refactorizar|migra|migrar|configura|configurar|habilita|habilitar|elimina|eliminar|arregla|arreglar|fix|fixear)\s+/i, "")
|
|
1507
|
+
.replace(/[.!¿?]$/, "")
|
|
1508
|
+
.trim();
|
|
1509
|
+
const verbFor = (clause) =>
|
|
1510
|
+
/^(elimina|eliminar|quita|quitar)/i.test(clause)
|
|
1511
|
+
? "Eliminar"
|
|
1512
|
+
: /^(configura|configurar|habilita|habilitar)/i.test(clause)
|
|
1513
|
+
? "Configurar"
|
|
1514
|
+
: /^(migra|migrar)/i.test(clause)
|
|
1515
|
+
? "Migrar"
|
|
1516
|
+
: "Implementar";
|
|
1517
|
+
|
|
1518
|
+
for (const clause of clauses) {
|
|
1519
|
+
if (requirements.length >= MAX_CONTRACT_REQUIREMENTS) break;
|
|
1520
|
+
const cleaned = clean(clause);
|
|
1521
|
+
if (!cleaned || cleaned.length < 3) continue;
|
|
1522
|
+
// Filtrar cláusulas no accionables
|
|
1523
|
+
if (NON_ACTIONABLE_PATTERNS.some((p) => p.test(cleaned))) continue;
|
|
1524
|
+
// Filtrar cláusulas que son predominantemente URLs
|
|
1525
|
+
if (/https?:\/\/\S+/.test(cleaned) && cleaned.replace(/https?:\/\/\S+/g, "").trim().length < 10) continue;
|
|
1526
|
+
const cap = cleaned[0].toUpperCase() + cleaned.slice(1);
|
|
1527
|
+
requirements.push(`${verbFor(clause)}: ${cap}`);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Keyword-derived requirements (con cap)
|
|
1531
|
+
if (/test|prueba|suite/i.test(lower) && requirements.length < MAX_CONTRACT_REQUIREMENTS) {
|
|
1532
|
+
requirements.push("Agregar tests para el cambio");
|
|
1533
|
+
verification.push("Ejecutar suite de tests relevante");
|
|
1534
|
+
}
|
|
1535
|
+
if (/migra|migrate|migración|refresh.?token/i.test(lower) && requirements.length < MAX_CONTRACT_REQUIREMENTS) {
|
|
1536
|
+
requirements.push("Invalidar/rotar credenciales anteriores");
|
|
1537
|
+
verification.push("Verificar integridad de datos post-migración");
|
|
1538
|
+
}
|
|
1539
|
+
if (/security|seguridad|auth|oauth|token/i.test(lower) && requirements.length < MAX_CONTRACT_REQUIREMENTS) {
|
|
1540
|
+
requirements.push("Auditar seguridad del cambio");
|
|
1541
|
+
verification.push("Revisar superficie de autenticación/secretos");
|
|
1542
|
+
}
|
|
1543
|
+
if (/cache|redis|rendimiento|performance/i.test(lower)) {
|
|
1544
|
+
verification.push("Medir impacto de rendimiento");
|
|
1545
|
+
}
|
|
1546
|
+
if (!verification.length) verification.push("Evidencia de satisfacción del contrato");
|
|
1547
|
+
|
|
1548
|
+
if (!requirements.length) {
|
|
1549
|
+
requirements.push("Tarea completada según intención", "Resultados verificables");
|
|
1550
|
+
}
|
|
1551
|
+
const unknowns = (uncertainties || [])
|
|
1552
|
+
.filter((u) => u.classification === "DECISION_CRITICAL")
|
|
1553
|
+
.map((u) => ({ id: u.id, question: u.question, classification: u.classification, status: "blocking", options: u.options || [] }));
|
|
1554
|
+
return { requirements, constraints: [], verification, unknowns, status: "PROPOSED", rigor: mode };
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
export async function analyze(options = {}) {
|
|
1558
|
+
const {
|
|
1559
|
+
prompt = "",
|
|
1560
|
+
projectPath,
|
|
1561
|
+
config,
|
|
1562
|
+
tierCaps,
|
|
1563
|
+
activePreset,
|
|
1564
|
+
activeMode,
|
|
1565
|
+
} = options;
|
|
1566
|
+
|
|
1567
|
+
// Step 1: Classify the request
|
|
1568
|
+
const classification = classifyRequest(prompt);
|
|
1569
|
+
|
|
1570
|
+
// Step 2: Inspect the project
|
|
1571
|
+
const projectInfo = await inspectProject(projectPath || process.cwd());
|
|
1572
|
+
|
|
1573
|
+
// Step 3: Audit assumptions + clasificar incertidumbre (decision-critical)
|
|
1574
|
+
const assumptions = auditAssumptions(prompt, projectInfo);
|
|
1575
|
+
const uncertainties = buildUncertainties(assumptions, projectInfo.unknown);
|
|
1576
|
+
const promptCriticals = extractPromptCriticalUncertainties(prompt);
|
|
1577
|
+
for (const pc of promptCriticals) {
|
|
1578
|
+
if (!uncertainties.some((u) => u.question === pc.question)) {
|
|
1579
|
+
uncertainties.push({
|
|
1580
|
+
id: `U${uncertainties.length + 1}`,
|
|
1581
|
+
question: pc.question,
|
|
1582
|
+
kind: "UNKNOWN",
|
|
1583
|
+
classification: "DECISION_CRITICAL",
|
|
1584
|
+
status: "active",
|
|
1585
|
+
options: pc.options || [],
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// Step 5: Select skills (registry unificado: local APPROVED + catálogo embebido)
|
|
1591
|
+
const availableSkills = discoverSkills();
|
|
1592
|
+
const baseDir = projectPath || process.cwd();
|
|
1593
|
+
const { registry: skillRegistry, corpusRoot, sources: usedSources, registryFile } =
|
|
1594
|
+
buildRegistry(availableSkills, baseDir);
|
|
1595
|
+
|
|
1596
|
+
// Step 6: Determine mode and contract
|
|
1597
|
+
const riskLevel = assumptions.some(a => /alto riesgo|high risk|peligroso|destructivo/.test(a)) ? "high" : "medium";
|
|
1598
|
+
const modeInfo = determineMode(classification, projectInfo, riskLevel);
|
|
1599
|
+
|
|
1600
|
+
// Step 6.5: Persistent policies — transversal, siempre evaluadas
|
|
1601
|
+
const persistentPolicies = evaluatePersistentPolicies(prompt, projectInfo, classification, modeInfo.mode);
|
|
1602
|
+
|
|
1603
|
+
// Step 5.5: Skill routing v2 — scoring ponderado + persistencia registry
|
|
1604
|
+
const rigor = modeInfo.mode === "FAST" ? "MINIMAL" : modeInfo.mode === "STRICT" ? "RIGOROUS" : "STANDARD";
|
|
1605
|
+
const skillSelection = routeSkillsV2(prompt, projectInfo, skillRegistry, rigor, { rigor, weights: config?.scoringWeights, projectRoot: baseDir });
|
|
1606
|
+
try { persistRegistry(registryFile, skillRegistry); } catch {}
|
|
1607
|
+
|
|
1608
|
+
// Explicalidad: qué skills seleccionadas y por qué (spec §23)
|
|
1609
|
+
const selectionExplain = skillSelection.explain();
|
|
1610
|
+
const auditEntries = [];
|
|
1611
|
+
try {
|
|
1612
|
+
for (const s of skillSelection.selected) {
|
|
1613
|
+
auditEntries.push(
|
|
1614
|
+
recordSkillUsage(
|
|
1615
|
+
{ skill_id: s.name, source: s.source, selected_because: s.reason, loaded: s.loaded ?? false },
|
|
1616
|
+
baseDir
|
|
1617
|
+
)
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
} catch {}
|
|
1621
|
+
|
|
1622
|
+
// Completion Contract proposal — específico del prompt (rule-based)
|
|
1623
|
+
const completionContract = synthesizeContract(prompt, modeInfo.mode, uncertainties);
|
|
1624
|
+
|
|
1625
|
+
// Step 7: Build the output
|
|
1626
|
+
const result = {
|
|
1627
|
+
intent: {
|
|
1628
|
+
classification: classification.type,
|
|
1629
|
+
ambiguity: classification.ambiguity,
|
|
1630
|
+
confidence: classification.confidence,
|
|
1631
|
+
},
|
|
1632
|
+
completionContract,
|
|
1633
|
+
project: {
|
|
1634
|
+
detected_stack: projectInfo.detected_stack || "unknown",
|
|
1635
|
+
architecture: projectInfo.known?.includes("openspec") ? "openspec project" : "unknown",
|
|
1636
|
+
relevant_files: [
|
|
1637
|
+
...(projectInfo.known?.includes("AGENTS.md") ? ["AGENTS.md"] : []),
|
|
1638
|
+
...(projectInfo.known?.includes("package.json") ? ["package.json"] : []),
|
|
1639
|
+
],
|
|
1640
|
+
},
|
|
1641
|
+
known: projectInfo.known,
|
|
1642
|
+
inferred: projectInfo.inferred,
|
|
1643
|
+
assumed: assumptions,
|
|
1644
|
+
assumptions: buildAssumptions(assumptions),
|
|
1645
|
+
unknown: projectInfo.unknown,
|
|
1646
|
+
uncertainties,
|
|
1647
|
+
|
|
1648
|
+
questions: [],
|
|
1649
|
+
|
|
1650
|
+
// Persistent policies: siempre aplicadas (transversal)
|
|
1651
|
+
persistentPolicies: persistentPolicies.applied.map((p) => ({
|
|
1652
|
+
policy: p.policy,
|
|
1653
|
+
status: p.status,
|
|
1654
|
+
gates: p.gates,
|
|
1655
|
+
restrictions: p.restrictions.slice(0, 3),
|
|
1656
|
+
})),
|
|
1657
|
+
|
|
1658
|
+
// Skill Registry: metadata-first routing, on-demand
|
|
1659
|
+
skillRegistry: {
|
|
1660
|
+
total: Object.keys(skillRegistry).length,
|
|
1661
|
+
approved: Object.values(skillRegistry).filter((s) => APPROVED_STATUSES.includes(s.status)).length,
|
|
1662
|
+
discovered: Object.values(skillRegistry).filter((s) => s.status === "DISCOVERED").length,
|
|
1663
|
+
rejected: Object.values(skillRegistry).filter((s) => !APPROVED_STATUSES.includes(s.status) && s.status !== "DISCOVERED").length,
|
|
1664
|
+
sources: usedSources.map((s) => (typeof s === "string" ? s : s.id)),
|
|
1665
|
+
corpus: corpusRoot,
|
|
1666
|
+
},
|
|
1667
|
+
skills: {
|
|
1668
|
+
candidates: skillSelection.candidates.map((c) => ({
|
|
1669
|
+
name: c.name,
|
|
1670
|
+
relevance: c.score,
|
|
1671
|
+
capabilities: c.matchingCapabilities,
|
|
1672
|
+
keywords: c.matchingKeywords,
|
|
1673
|
+
risk: c.risk,
|
|
1674
|
+
source: c.source,
|
|
1675
|
+
})),
|
|
1676
|
+
selected: skillSelection.selected,
|
|
1677
|
+
rejected: skillSelection.rejected,
|
|
1678
|
+
exceeded: skillSelection.exceeded,
|
|
1679
|
+
limit: skillSelection.counts.limit,
|
|
1680
|
+
explanation: selectionExplain,
|
|
1681
|
+
},
|
|
1682
|
+
|
|
1683
|
+
risk: riskLevel,
|
|
1684
|
+
complexity: classification.type === "trivial" ? "trivial" : "medium",
|
|
1685
|
+
ambiguity: classification.ambiguity,
|
|
1686
|
+
strategy: modeInfo.mode,
|
|
1687
|
+
|
|
1688
|
+
ready: modeInfo.mode === "FAST" || (modeInfo.mode === "NORMAL" && classification.type !== "architectural" && riskLevel !== "high"),
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
// Add mode-specific info
|
|
1692
|
+
if (modeInfo.mode === "STRICT") {
|
|
1693
|
+
result.strict = true;
|
|
1694
|
+
result.advice = "Análisis profundo requerido antes de continuar. Verificar supuestos y decisiones arquitectónicas.";
|
|
1695
|
+
} else if (modeInfo.mode === "FAST") {
|
|
1696
|
+
result.fast = true;
|
|
1697
|
+
result.advice = "Tarea trivial - proceder directamente sin entrevista";
|
|
1698
|
+
} else {
|
|
1699
|
+
result.strict = false;
|
|
1700
|
+
result.fast = false;
|
|
1701
|
+
result.advice = "Análisis normal - inspección contextual y preguntas solo si necesarias";
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
return result;
|
|
1705
|
+
}
|