@jaimevalasek/aioson 1.29.1 → 1.30.0

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.
Files changed (107) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +7 -5
  3. package/docs/en/5-reference/cli-reference.md +40 -10
  4. package/docs/pt/4-agentes/pm.md +1 -1
  5. package/docs/pt/5-referencia/autopilot-handoff.md +4 -4
  6. package/docs/pt/5-referencia/comandos-cli.md +5 -3
  7. package/docs/pt/5-referencia/fluxo-artefatos.md +1 -1
  8. package/docs/pt/5-referencia/memoria-e-contexto.md +2 -2
  9. package/docs/pt/_arquivo/monitor-de-contexto.md +2 -2
  10. package/package.json +4 -2
  11. package/src/cli.js +67 -24
  12. package/src/commands/ac-test-audit.js +45 -0
  13. package/src/commands/artifact-validate.js +62 -50
  14. package/src/commands/classify.js +73 -2
  15. package/src/commands/context-brief.js +59 -0
  16. package/src/commands/context-guard.js +88 -0
  17. package/src/commands/context-monitor.js +1 -1
  18. package/src/commands/context-search.js +101 -52
  19. package/src/commands/context-select.js +11 -2
  20. package/src/commands/feature-archive.js +21 -12
  21. package/src/commands/feature-current.js +82 -0
  22. package/src/commands/gate-check.js +32 -15
  23. package/src/commands/harness-check.js +17 -1
  24. package/src/commands/hooks-install.js +169 -26
  25. package/src/commands/hygiene-scan.js +423 -0
  26. package/src/commands/rules-lint.js +11 -3
  27. package/src/commands/sdd-benchmark.js +134 -0
  28. package/src/commands/spec-analyze.js +6 -4
  29. package/src/commands/store-system.js +329 -49
  30. package/src/constants.js +8 -3
  31. package/src/context-brief.js +585 -0
  32. package/src/context-guard.js +209 -0
  33. package/src/context-search.js +796 -96
  34. package/src/context-selector.js +802 -444
  35. package/src/handoff-contract.js +14 -6
  36. package/src/harness/contract-schema.js +1 -1
  37. package/src/i18n/messages/en.js +12 -5
  38. package/src/i18n/messages/es.js +11 -4
  39. package/src/i18n/messages/fr.js +11 -4
  40. package/src/i18n/messages/pt-BR.js +12 -5
  41. package/src/lib/ac-test-audit.js +194 -0
  42. package/src/preflight-engine.js +10 -6
  43. package/src/squad/state-manager.js +1 -1
  44. package/template/.aioson/agents/analyst.md +41 -17
  45. package/template/.aioson/agents/architect.md +4 -2
  46. package/template/.aioson/agents/briefing-refiner.md +15 -2
  47. package/template/.aioson/agents/briefing.md +12 -8
  48. package/template/.aioson/agents/committer.md +1 -1
  49. package/template/.aioson/agents/copywriter.md +20 -9
  50. package/template/.aioson/agents/design-hybrid-forge.md +9 -5
  51. package/template/.aioson/agents/dev.md +22 -25
  52. package/template/.aioson/agents/deyvin.md +126 -124
  53. package/template/.aioson/agents/discover.md +3 -1
  54. package/template/.aioson/agents/discovery-design-doc.md +11 -2
  55. package/template/.aioson/agents/forge-run.md +3 -0
  56. package/template/.aioson/agents/genome.md +9 -5
  57. package/template/.aioson/agents/neo.md +30 -24
  58. package/template/.aioson/agents/orache.md +10 -6
  59. package/template/.aioson/agents/orchestrator.md +4 -2
  60. package/template/.aioson/agents/pentester.md +22 -12
  61. package/template/.aioson/agents/pm.md +5 -3
  62. package/template/.aioson/agents/product.md +25 -18
  63. package/template/.aioson/agents/profiler-enricher.md +10 -6
  64. package/template/.aioson/agents/profiler-forge.md +10 -6
  65. package/template/.aioson/agents/profiler-researcher.md +10 -6
  66. package/template/.aioson/agents/qa.md +21 -19
  67. package/template/.aioson/agents/scope-check.md +9 -3
  68. package/template/.aioson/agents/sheldon.md +22 -8
  69. package/template/.aioson/agents/site-forge.md +2 -0
  70. package/template/.aioson/agents/squad.md +4 -2
  71. package/template/.aioson/agents/tester.md +19 -15
  72. package/template/.aioson/agents/ux-ui.md +16 -8
  73. package/template/.aioson/config.md +4 -3
  74. package/template/.aioson/design-docs/agent-loading-contract.md +3 -3
  75. package/template/.aioson/docs/autopilot-handoff.md +3 -3
  76. package/template/.aioson/docs/dev/simple-plan-lane.md +73 -27
  77. package/template/.aioson/docs/dev/stack-conventions.md +1 -1
  78. package/template/.aioson/docs/deyvin/continuity-recovery.md +1 -1
  79. package/template/.aioson/docs/deyvin/runtime-handoffs.md +3 -3
  80. package/template/.aioson/docs/feature-expansion-taxonomy.md +53 -0
  81. package/template/.aioson/docs/handoff-persistence.md +14 -12
  82. package/template/.aioson/docs/product/conversation-playbook.md +1 -1
  83. package/template/.aioson/docs/sheldon/enrichment-paths.md +44 -1
  84. package/template/.aioson/docs/sheldon/harness-contract.md +23 -21
  85. package/template/.aioson/docs/tester/coverage-quality.md +1 -1
  86. package/template/.aioson/docs/ux-ui/design-execution.md +9 -7
  87. package/template/.aioson/rules/README.md +35 -17
  88. package/template/.aioson/rules/agent-structural-contract.md +165 -160
  89. package/template/.aioson/rules/aioson-context-boundary.md +5 -4
  90. package/template/.aioson/rules/canonical-path-contract.md +5 -4
  91. package/template/.aioson/rules/data-format-convention.md +5 -4
  92. package/template/.aioson/rules/disk-first-artifacts.md +2 -2
  93. package/template/.aioson/rules/implementation-structure-and-data-access.md +50 -0
  94. package/template/.aioson/rules/security-baseline.md +4 -3
  95. package/template/.aioson/rules/simple-plan-lane.md +18 -6
  96. package/template/.aioson/rules/source-code-language-convention.md +34 -0
  97. package/template/.aioson/skills/process/aioson-spec-driven/references/artifact-map.md +24 -23
  98. package/template/.aioson/skills/process/aioson-spec-driven/references/classification-map.md +4 -0
  99. package/template/.aioson/skills/process/aioson-spec-driven/references/dev.md +2 -2
  100. package/template/.aioson/skills/process/aioson-spec-driven/references/qa.md +1 -1
  101. package/template/.aioson/skills/process/briefing-expansion-scout/SKILL.md +72 -0
  102. package/template/.aioson/skills/process/product-scope-expansion/SKILL.md +74 -0
  103. package/template/.aioson/skills/process/sheldon-expansion-audit/SKILL.md +67 -0
  104. package/template/.aioson/skills/static/context-budget-guide.md +1 -1
  105. package/template/.aioson/skills/static/multi-agent-patterns.md +5 -4
  106. package/template/AGENTS.md +36 -19
  107. package/template/CLAUDE.md +9 -5
@@ -1,444 +1,802 @@
1
- 'use strict';
2
-
3
- const fs = require('node:fs/promises');
4
- const path = require('node:path');
5
- const {
6
- parseFrontmatter,
7
- parseAgentList,
8
- appliesToAgent,
9
- readFileSafe,
10
- readProjectPulse,
11
- readDevState
12
- } = require('./preflight-engine');
13
-
14
- const VALID_MODES = new Set(['planning', 'executing']);
15
-
16
- const SURFACES = [
17
- { key: 'rules', dir: path.join('.aioson', 'rules'), recursive: false, defaultTier: 'trigger' },
18
- { key: 'docs', dir: path.join('.aioson', 'docs'), recursive: true, defaultTier: 'trigger' },
19
- { key: 'design_governance', dir: path.join('.aioson', 'design-docs'), recursive: false, defaultTier: 'trigger' },
20
- { key: 'context', dir: path.join('.aioson', 'context'), recursive: false, defaultTier: 'trigger' },
21
- { key: 'bootstrap', dir: path.join('.aioson', 'context', 'bootstrap'), recursive: false, defaultTier: 'trigger' },
22
- { key: 'feature_dossier', dir: path.join('.aioson', 'context', 'features'), recursive: true, defaultTier: 'trigger' }
23
- ];
24
-
25
- const FOUNDATION_CONTEXT_BASENAMES = new Set([
26
- 'project.context.md',
27
- 'project-pulse.md',
28
- 'dev-state.md',
29
- 'memory-index.md'
30
- ]);
31
-
32
- const FOUNDATION_ACTIVATION_PATHS = new Set([
33
- '.aioson/context/project.context.md',
34
- '.aioson/context/project-pulse.md'
35
- ]);
36
-
37
- const FOUNDATION_ACTIVATION_AGENTS = [
38
- 'briefing',
39
- 'product',
40
- 'sheldon',
41
- 'analyst',
42
- 'architect',
43
- 'ux-ui',
44
- 'pm',
45
- 'qa',
46
- 'orchestrator',
47
- 'scope-check',
48
- 'discovery-design-doc'
49
- ];
50
-
51
- const ACTIVATION_ONLY_CONTEXT_PATHS_BY_AGENT = new Map([
52
- [
53
- 'deyvin',
54
- new Set([...FOUNDATION_ACTIVATION_PATHS, '.aioson/context/dev-state.md'])
55
- ],
56
- ...FOUNDATION_ACTIVATION_AGENTS.map((agent) => [agent, FOUNDATION_ACTIVATION_PATHS])
57
- ]);
58
-
59
- const UNIVERSAL_ALWAYS_CONTEXT_BASENAMES = new Set([
60
- 'project.context.md',
61
- 'project-pulse.md'
62
- ]);
63
-
64
- const AGENT_ALWAYS_CONTEXT_BASENAMES = new Map([
65
- ['dev', new Set(['dev-state.md', 'memory-index.md'])],
66
- ['deyvin', new Set(['dev-state.md', 'memory-index.md'])]
67
- ]);
68
-
69
- function normalizeSlashes(value) {
70
- return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
71
- }
72
-
73
- function normalizeToken(value) {
74
- return String(value || '')
75
- .normalize('NFD')
76
- .replace(/[\u0300-\u036f]/g, '')
77
- .toLowerCase()
78
- .replace(/[`*_]/g, '')
79
- .replace(/[^a-z0-9/-]+/g, ' ')
80
- .trim();
81
- }
82
-
83
- function normalizeFeaturePointer(value) {
84
- const normalized = normalizeToken(value).replace(/\s+/g, '-');
85
- if (!normalized || normalized === 'none' || normalized === '-none-' || normalized === '-') return '';
86
- return normalized;
87
- }
88
-
89
- function isActivationOnlyTask(agent, mode, task) {
90
- if (!ACTIVATION_ONLY_CONTEXT_PATHS_BY_AGENT.has(agent) || mode !== 'planning') return false;
91
- const normalized = normalizeToken(task);
92
- if (!normalized) return true;
93
- return (
94
- normalized.includes('agent activation') ||
95
- normalized.includes('activation only') ||
96
- normalized.includes('without concrete task') ||
97
- normalized.includes('no concrete task')
98
- );
99
- }
100
-
101
- function parseListValue(value) {
102
- if (value === undefined || value === null) return [];
103
- const raw = String(value).trim();
104
- if (!raw || raw === '[]') return [];
105
- if (raw.startsWith('[') && raw.endsWith(']')) {
106
- return raw
107
- .slice(1, -1)
108
- .split(',')
109
- .map((item) => item.trim().replace(/^["']|["']$/g, ''))
110
- .filter(Boolean);
111
- }
112
- return raw
113
- .split(',')
114
- .map((item) => item.trim().replace(/^["']|["']$/g, ''))
115
- .filter(Boolean);
116
- }
117
-
118
- function modeFromOptions(mode) {
119
- const normalized = normalizeToken(mode || 'planning');
120
- return VALID_MODES.has(normalized) ? normalized : 'planning';
121
- }
122
-
123
- function escapeRegex(value) {
124
- return String(value).replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
125
- }
126
-
127
- function globToRegex(glob) {
128
- const normalized = normalizeSlashes(glob);
129
- let out = '^';
130
- for (let i = 0; i < normalized.length; i += 1) {
131
- const char = normalized[i];
132
- const next = normalized[i + 1];
133
- if (char === '*' && next === '*') {
134
- out += '.*';
135
- i += 1;
136
- } else if (char === '*') {
137
- out += '[^/]*';
138
- } else {
139
- out += escapeRegex(char);
140
- }
141
- }
142
- out += '$';
143
- return new RegExp(out);
144
- }
145
-
146
- function pathMatchesPattern(filePath, pattern) {
147
- const file = normalizeSlashes(filePath);
148
- const normalizedPattern = normalizeSlashes(pattern);
149
- if (!file || !normalizedPattern) return false;
150
- if (normalizedPattern.endsWith('/**')) {
151
- const prefix = normalizedPattern.slice(0, -3);
152
- return file === prefix || file.startsWith(`${prefix}/`);
153
- }
154
- if (!normalizedPattern.includes('*')) {
155
- return file === normalizedPattern || file.startsWith(`${normalizedPattern}/`);
156
- }
157
- return globToRegex(normalizedPattern).test(file);
158
- }
159
-
160
- function splitOptionList(value) {
161
- if (Array.isArray(value)) return value.map(String).filter(Boolean);
162
- return String(value || '')
163
- .split(',')
164
- .map((item) => item.trim())
165
- .filter(Boolean);
166
- }
167
-
168
- async function walkMarkdown(rootDir, relDir, recursive) {
169
- const absDir = path.join(rootDir, relDir);
170
- const out = [];
171
- let entries;
172
- try {
173
- entries = await fs.readdir(absDir, { withFileTypes: true });
174
- } catch {
175
- return out;
176
- }
177
-
178
- for (const entry of entries) {
179
- if (entry.name.startsWith('.')) continue;
180
- const childRel = path.join(relDir, entry.name);
181
- if (entry.isDirectory()) {
182
- if (recursive) out.push(...await walkMarkdown(rootDir, childRel, recursive));
183
- continue;
184
- }
185
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
186
- if (entry.name.toLowerCase() === 'readme.md') continue;
187
- out.push(normalizeSlashes(childRel));
188
- }
189
- return out.sort();
190
- }
191
-
192
- function inferContextMetadata(relPath, fm) {
193
- const base = path.basename(relPath);
194
- const slugMatch = base.match(/^(prd|requirements|spec|design-doc|readiness|implementation-plan|ui-spec|scope-check)-(.+)\.md$/);
195
- const tags = [];
196
- let featureSlug = fm.feature_slug || fm.feature || '';
197
- let loadTier = fm.load_tier || 'trigger';
198
-
199
- if (FOUNDATION_CONTEXT_BASENAMES.has(base)) {
200
- if (UNIVERSAL_ALWAYS_CONTEXT_BASENAMES.has(base)) {
201
- loadTier = fm.load_tier || 'always';
202
- }
203
- tags.push('foundation');
204
- }
205
- if (slugMatch) {
206
- tags.push(slugMatch[1], 'feature');
207
- if (!featureSlug) featureSlug = slugMatch[2];
208
- }
209
- if (base === 'discovery.md') tags.push('discovery', 'project-memory', 'entities', 'business-rules');
210
- if (base === 'architecture.md') tags.push('architecture', 'technical-design', 'module-boundary');
211
- if (base === 'ui-spec.md') tags.push('ui-spec', 'ui', 'ux', 'frontend', 'visual-design');
212
- if (base === 'scope-check.md') tags.push('scope-check', 'alignment', 'pre-dev');
213
- if (relPath.includes('/bootstrap/')) tags.push('bootstrap');
214
- if (relPath.includes('/features/') && base === 'dossier.md') {
215
- tags.push('feature', 'dossier');
216
- if (!featureSlug) {
217
- const parts = relPath.split('/');
218
- const index = parts.indexOf('features');
219
- if (index !== -1) featureSlug = parts[index + 1] || '';
220
- }
221
- }
222
-
223
- return { tags, featureSlug, loadTier };
224
- }
225
-
226
- async function collectCandidates(targetDir) {
227
- const candidates = [];
228
-
229
- for (const surface of SURFACES) {
230
- const relPaths = await walkMarkdown(targetDir, surface.dir, surface.recursive);
231
- for (const relPath of relPaths) {
232
- if (surface.key === 'feature_dossier' && !relPath.endsWith('/dossier.md')) continue;
233
- const absPath = path.join(targetDir, relPath);
234
- const content = await readFileSafe(absPath);
235
- if (!content) continue;
236
- const stat = await fs.stat(absPath).catch(() => null);
237
- const fm = parseFrontmatter(content);
238
- const inferred = inferContextMetadata(relPath, fm);
239
- const description = fm.description || fm.name || path.basename(relPath, '.md');
240
- candidates.push({
241
- path: relPath,
242
- surface: surface.key,
243
- size: stat ? stat.size : content.length,
244
- frontmatter: fm,
245
- description,
246
- agents: parseAgentList(fm.agents),
247
- modes: parseListValue(fm.modes),
248
- taskTypes: parseListValue(fm.task_types || fm.taskTypes),
249
- triggers: parseListValue(fm.triggers),
250
- pathPatterns: parseListValue(fm.paths || fm.globs),
251
- scope: fm.scope || '',
252
- featureSlug: fm.feature_slug || fm.feature || inferred.featureSlug || '',
253
- tags: [...new Set([...parseListValue(fm.tags), ...inferred.tags])],
254
- loadTier: fm.load_tier || inferred.loadTier || surface.defaultTier
255
- });
256
- }
257
- }
258
-
259
- return candidates;
260
- }
261
-
262
- function keywordMatches(haystack, needles) {
263
- const normalizedHaystack = normalizeToken(haystack);
264
- const haystackWords = new Set(normalizedHaystack.split(/\s+/).flatMap(wordVariants));
265
- return needles.filter((needle) => {
266
- const normalizedNeedle = normalizeToken(needle);
267
- if (!normalizedNeedle) return false;
268
- if (normalizedHaystack.includes(normalizedNeedle)) return true;
269
- const words = normalizedNeedle.split(/\s+/).filter((word) => word.length >= 4);
270
- if (words.length === 0) return false;
271
- const hits = words.filter((word) => wordVariants(word).some((variant) => haystackWords.has(variant))).length;
272
- return hits >= Math.min(2, words.length);
273
- });
274
- }
275
-
276
- function wordVariants(word) {
277
- const raw = String(word || '').trim();
278
- if (!raw) return [];
279
- const variants = new Set([raw]);
280
- if (raw.endsWith('ing') && raw.length > 5) {
281
- const stem = raw.slice(0, -3);
282
- variants.add(stem);
283
- variants.add(`${stem}e`);
284
- }
285
- if (raw.endsWith('s') && raw.length > 4) variants.add(raw.slice(0, -1));
286
- return [...variants];
287
- }
288
-
289
- function scoreCandidate(candidate, context) {
290
- const reasons = [];
291
- let score = 0;
292
- let effectiveLoadTier = candidate.loadTier;
293
- const base = path.basename(candidate.path);
294
-
295
- if (!appliesToAgent(candidate.frontmatter, context.agent)) return null;
296
- if (context.activationOnly) {
297
- const allowedActivationPaths = ACTIVATION_ONLY_CONTEXT_PATHS_BY_AGENT.get(context.agent);
298
- if (!allowedActivationPaths || !allowedActivationPaths.has(candidate.path)) return null;
299
- }
300
-
301
- if (candidate.modes.length > 0 && !candidate.modes.map(normalizeToken).includes(context.mode)) {
302
- return null;
303
- }
304
- if (candidate.modes.length > 0) {
305
- score += 5;
306
- reasons.push(`mode:${context.mode}`);
307
- }
308
-
309
- const agentAlways = AGENT_ALWAYS_CONTEXT_BASENAMES.get(context.agent);
310
- if (agentAlways && agentAlways.has(base)) {
311
- effectiveLoadTier = 'always';
312
- score += 100;
313
- reasons.push('load_tier:always');
314
- } else if (candidate.loadTier === 'always') {
315
- score += 100;
316
- reasons.push('load_tier:always');
317
- }
318
-
319
- const matchedPaths = [];
320
- for (const requestedPath of context.paths) {
321
- for (const pattern of candidate.pathPatterns) {
322
- if (pathMatchesPattern(requestedPath, pattern)) matchedPaths.push(`${requestedPath}~${pattern}`);
323
- }
324
- }
325
- if (matchedPaths.length > 0) {
326
- score += 10;
327
- reasons.push(`paths:${matchedPaths.slice(0, 3).join(',')}`);
328
- }
329
-
330
- const activeFeature = context.feature || context.activeFeature || '';
331
- if (context.activationOnly && candidate.featureSlug && !context.feature) {
332
- return null;
333
- }
334
- if (candidate.featureSlug && activeFeature && candidate.featureSlug === activeFeature) {
335
- score += 45;
336
- reasons.push(`feature:${candidate.featureSlug}`);
337
- }
338
-
339
- if (candidate.featureSlug && context.lookup.includes(normalizeToken(candidate.featureSlug).replace(/-/g, ' '))) {
340
- score += 45;
341
- reasons.push(`feature-mentioned:${candidate.featureSlug}`);
342
- }
343
-
344
- const matchedTaskTypes = keywordMatches(context.lookup, candidate.taskTypes);
345
- if (matchedTaskTypes.length > 0) {
346
- score += 40;
347
- reasons.push(`task_types:${matchedTaskTypes.slice(0, 3).join(',')}`);
348
- }
349
-
350
- const matchedTriggers = keywordMatches(context.lookup, candidate.triggers);
351
- if (matchedTriggers.length > 0) {
352
- score += 40;
353
- reasons.push(`triggers:${matchedTriggers.slice(0, 3).join(',')}`);
354
- }
355
-
356
- const matchedTags = keywordMatches(context.lookup, candidate.tags);
357
- if (matchedTags.length > 0) {
358
- score += 20;
359
- reasons.push(`tags:${matchedTags.slice(0, 3).join(',')}`);
360
- }
361
-
362
- const descriptionHits = keywordMatches(context.lookup, [
363
- candidate.description,
364
- candidate.scope,
365
- path.basename(candidate.path, '.md').replace(/-/g, ' ')
366
- ]);
367
- if (descriptionHits.length > 0) {
368
- score += 20;
369
- reasons.push(`description:${descriptionHits.slice(0, 2).join(',')}`);
370
- }
371
-
372
- const threshold = effectiveLoadTier === 'justified' ? 50 : 30;
373
- if (score < threshold) return null;
374
-
375
- return {
376
- path: candidate.path,
377
- surface: candidate.surface,
378
- load_tier: effectiveLoadTier,
379
- size: candidate.size,
380
- score,
381
- reason: reasons.join('; ')
382
- };
383
- }
384
-
385
- async function selectContext(targetDir, options = {}) {
386
- const agent = normalizeToken(options.agent || 'dev');
387
- const mode = modeFromOptions(options.mode);
388
- const task = String(options.task || options.goal || '').trim();
389
- const paths = splitOptionList(options.paths || options.path).map(normalizeSlashes);
390
- const feature = normalizeFeaturePointer(options.feature || options.slug || '');
391
- const activationOnly = isActivationOnlyTask(agent, mode, task);
392
-
393
- const pulse = await readProjectPulse(targetDir);
394
- const devState = await readDevState(targetDir);
395
- const activeFeature = normalizeFeaturePointer(
396
- feature || pulse.active_feature || devState.active_feature || ''
397
- );
398
-
399
- const lookup = normalizeToken([
400
- agent,
401
- mode,
402
- task,
403
- paths.join(' '),
404
- activeFeature
405
- ].filter(Boolean).join(' '));
406
-
407
- const candidates = await collectCandidates(targetDir);
408
- const selected = [];
409
- for (const candidate of candidates) {
410
- const scored = scoreCandidate(candidate, {
411
- agent,
412
- mode,
413
- task,
414
- paths,
415
- feature,
416
- activeFeature,
417
- lookup,
418
- activationOnly
419
- });
420
- if (scored) selected.push(scored);
421
- }
422
-
423
- selected.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
424
-
425
- return {
426
- ok: true,
427
- agent,
428
- mode,
429
- task,
430
- paths,
431
- feature: feature || null,
432
- active_feature: activeFeature || null,
433
- activation_only: activationOnly,
434
- selected
435
- };
436
- }
437
-
438
- module.exports = {
439
- selectContext,
440
- collectCandidates,
441
- parseListValue,
442
- pathMatchesPattern,
443
- isActivationOnlyTask
444
- };
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const path = require('node:path');
5
+ const Database = require('better-sqlite3');
6
+ const {
7
+ parseFrontmatter,
8
+ parseAgentList,
9
+ appliesToAgent,
10
+ readFileSafe,
11
+ readProjectPulse,
12
+ readDevState
13
+ } = require('./preflight-engine');
14
+ const { openRuntimeDb } = require('./runtime-store');
15
+ const { searchProjectLearnings } = require('./learning-loop-fts5');
16
+
17
+ const VALID_MODES = new Set(['planning', 'executing']);
18
+ const SEMANTIC_MAX_TERMS = 24;
19
+ const SEMANTIC_RESULT_LIMIT = 80;
20
+ const MEMORY_RESULT_LIMIT = 5;
21
+
22
+ const SHORT_SEMANTIC_TERMS = new Set(['api', 'sql', 'orm', 'php', 'mvc', 'dto', 'ui', 'ux']);
23
+ const KNOWN_FRAMEWORK_TERMS = new Set([
24
+ 'adonis', 'angular', 'astro', 'django', 'express', 'fastapi', 'flask', 'hono',
25
+ 'laravel', 'next', 'node', 'nuxt', 'phoenix', 'rails', 'react', 'remix',
26
+ 'svelte', 'symfony', 'vue'
27
+ ]);
28
+
29
+ const SEMANTIC_STOP_WORDS = new Set([
30
+ 'a', 'an', 'and', 'as', 'by', 'com', 'como', 'da', 'das', 'de', 'do', 'dos',
31
+ 'e', 'em', 'for', 'from', 'in', 'into', 'no', 'nos', 'o', 'os', 'of', 'on',
32
+ 'ou', 'para', 'por', 'que', 'the', 'to', 'um', 'uma', 'with',
33
+ 'agent', 'agente', 'agents', 'aioson', 'dev', 'deyvin', 'architect',
34
+ 'feature', 'funcionalidade', 'task', 'tarefa', 'work', 'trabalho',
35
+ 'create', 'criar', 'fazer', 'implementar', 'implement', 'implementation',
36
+ 'nova', 'novo', 'new', 'ajuste', 'change', 'update',
37
+ 'evitar', 'exposto', 'http',
38
+ 'boundary', 'code', 'codigo', 'código', 'component', 'developer', 'model',
39
+ 'module', 'script', 'source', 'test'
40
+ ]);
41
+
42
+ const SEMANTIC_SYNONYMS = new Map([
43
+ ['ingles', ['english']],
44
+ ['english', ['ingles']],
45
+ ['fonte', []],
46
+ ['padrao', ['pattern', 'convention']],
47
+ ['padroes', ['patterns', 'conventions']],
48
+ ['pattern', ['convention']],
49
+ ['patterns', ['conventions']],
50
+ ['pasta', ['folder', 'directory']],
51
+ ['pastas', ['folders', 'directories']],
52
+ ['folder', ['directory']],
53
+ ['folders', ['directories']],
54
+ ['componentizacao', ['componentization']],
55
+ ['componentizando', ['componentization']],
56
+ ['componentizar', ['componentization']],
57
+ ['separacao', ['separation', 'boundary']],
58
+ ['separar', ['separation', 'boundary']],
59
+ ['manutencao', ['maintainability']],
60
+ ['manutenivel', ['maintainable']],
61
+ ['consulta', ['query', 'queries']],
62
+ ['consultas', ['query', 'queries']],
63
+ ['query', ['queries']],
64
+ ['queries', ['query']],
65
+ ['banco', ['database', 'data']],
66
+ ['dados', ['data', 'database']],
67
+ ['frameworks', ['framework']],
68
+ ['framework', ['convention']],
69
+ ['laravel', ['eloquent', 'artisan']],
70
+ ['php', ['laravel']],
71
+ ['controller', ['controllers']],
72
+ ['controllers', ['controller']],
73
+ ['service', ['services']],
74
+ ['services', ['service']],
75
+ ['repository', ['repositories']],
76
+ ['repositories', ['repository']],
77
+ ['migration', ['migrations']],
78
+ ['migrations', ['migration']],
79
+ ['eloquent', ['laravel']],
80
+ ['raw', ['sql']],
81
+ ['sql', ['query', 'database']]
82
+ ]);
83
+
84
+ const SURFACES = [
85
+ { key: 'rules', dir: path.join('.aioson', 'rules'), recursive: false, defaultTier: 'trigger' },
86
+ { key: 'docs', dir: path.join('.aioson', 'docs'), recursive: true, defaultTier: 'trigger' },
87
+ { key: 'design_governance', dir: path.join('.aioson', 'design-docs'), recursive: false, defaultTier: 'trigger' },
88
+ { key: 'context', dir: path.join('.aioson', 'context'), recursive: false, defaultTier: 'trigger' },
89
+ { key: 'bootstrap', dir: path.join('.aioson', 'context', 'bootstrap'), recursive: false, defaultTier: 'trigger' },
90
+ { key: 'feature_dossier', dir: path.join('.aioson', 'context', 'features'), recursive: true, defaultTier: 'trigger' }
91
+ ];
92
+
93
+ const FOUNDATION_CONTEXT_BASENAMES = new Set([
94
+ 'project.context.md',
95
+ 'project-pulse.md',
96
+ 'dev-state.md',
97
+ 'memory-index.md'
98
+ ]);
99
+
100
+ const FOUNDATION_ACTIVATION_PATHS = new Set([
101
+ '.aioson/context/project.context.md',
102
+ '.aioson/context/project-pulse.md'
103
+ ]);
104
+
105
+ const FOUNDATION_ACTIVATION_AGENTS = [
106
+ 'briefing',
107
+ 'product',
108
+ 'sheldon',
109
+ 'analyst',
110
+ 'architect',
111
+ 'ux-ui',
112
+ 'pm',
113
+ 'qa',
114
+ 'orchestrator',
115
+ 'scope-check',
116
+ 'discovery-design-doc'
117
+ ];
118
+
119
+ const ACTIVATION_ONLY_CONTEXT_PATHS_BY_AGENT = new Map([
120
+ [
121
+ 'deyvin',
122
+ new Set([...FOUNDATION_ACTIVATION_PATHS, '.aioson/context/dev-state.md'])
123
+ ],
124
+ ...FOUNDATION_ACTIVATION_AGENTS.map((agent) => [agent, FOUNDATION_ACTIVATION_PATHS])
125
+ ]);
126
+
127
+ const UNIVERSAL_ALWAYS_CONTEXT_BASENAMES = new Set([
128
+ 'project.context.md',
129
+ 'project-pulse.md'
130
+ ]);
131
+
132
+ const AGENT_ALWAYS_CONTEXT_BASENAMES = new Map([
133
+ ['dev', new Set(['dev-state.md', 'memory-index.md'])],
134
+ ['deyvin', new Set(['dev-state.md', 'memory-index.md'])]
135
+ ]);
136
+
137
+ function normalizeSlashes(value) {
138
+ return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
139
+ }
140
+
141
+ function normalizeToken(value) {
142
+ return String(value || '')
143
+ .normalize('NFD')
144
+ .replace(/[\u0300-\u036f]/g, '')
145
+ .toLowerCase()
146
+ .replace(/[`*_]/g, '')
147
+ .replace(/[^a-z0-9/-]+/g, ' ')
148
+ .trim();
149
+ }
150
+
151
+ function normalizeFeaturePointer(value) {
152
+ const normalized = normalizeToken(value).replace(/\s+/g, '-');
153
+ if (!normalized || normalized === 'none' || normalized === '-none-' || normalized === '-') return '';
154
+ return normalized;
155
+ }
156
+
157
+ function isActivationOnlyTask(agent, mode, task) {
158
+ if (!ACTIVATION_ONLY_CONTEXT_PATHS_BY_AGENT.has(agent) || mode !== 'planning') return false;
159
+ const normalized = normalizeToken(task);
160
+ if (!normalized) return true;
161
+ return (
162
+ normalized.includes('agent activation') ||
163
+ normalized.includes('activation only') ||
164
+ normalized.includes('without concrete task') ||
165
+ normalized.includes('no concrete task')
166
+ );
167
+ }
168
+
169
+ function parseListValue(value) {
170
+ if (value === undefined || value === null) return [];
171
+ const raw = String(value).trim();
172
+ if (!raw || raw === '[]') return [];
173
+ if (raw.startsWith('[') && raw.endsWith(']')) {
174
+ return raw
175
+ .slice(1, -1)
176
+ .split(',')
177
+ .map((item) => item.trim().replace(/^["']|["']$/g, ''))
178
+ .filter(Boolean);
179
+ }
180
+ return raw
181
+ .split(',')
182
+ .map((item) => item.trim().replace(/^["']|["']$/g, ''))
183
+ .filter(Boolean);
184
+ }
185
+
186
+ function semanticSearchEnabled(options) {
187
+ const raw = options.semantic;
188
+ if (raw === false) return false;
189
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'false') return false;
190
+ if (options.noSemantic === true || options['no-semantic'] === true) return false;
191
+ return true;
192
+ }
193
+
194
+ function modeFromOptions(mode) {
195
+ const normalized = normalizeToken(mode || 'planning');
196
+ return VALID_MODES.has(normalized) ? normalized : 'planning';
197
+ }
198
+
199
+ function escapeRegex(value) {
200
+ return String(value).replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
201
+ }
202
+
203
+ function globToRegex(glob) {
204
+ const normalized = normalizeSlashes(glob);
205
+ let out = '^';
206
+ for (let i = 0; i < normalized.length; i += 1) {
207
+ const char = normalized[i];
208
+ const next = normalized[i + 1];
209
+ if (char === '*' && next === '*') {
210
+ out += '.*';
211
+ i += 1;
212
+ } else if (char === '*') {
213
+ out += '[^/]*';
214
+ } else {
215
+ out += escapeRegex(char);
216
+ }
217
+ }
218
+ out += '$';
219
+ return new RegExp(out);
220
+ }
221
+
222
+ function pathMatchesPattern(filePath, pattern) {
223
+ const file = normalizeSlashes(filePath);
224
+ const normalizedPattern = normalizeSlashes(pattern);
225
+ if (!file || !normalizedPattern) return false;
226
+ if (normalizedPattern.endsWith('/**')) {
227
+ const prefix = normalizedPattern.slice(0, -3);
228
+ return file === prefix || file.startsWith(`${prefix}/`);
229
+ }
230
+ if (!normalizedPattern.includes('*')) {
231
+ return file === normalizedPattern || file.startsWith(`${normalizedPattern}/`);
232
+ }
233
+ return globToRegex(normalizedPattern).test(file);
234
+ }
235
+
236
+ function splitOptionList(value) {
237
+ if (Array.isArray(value)) return value.map(String).filter(Boolean);
238
+ return String(value || '')
239
+ .split(',')
240
+ .map((item) => item.trim())
241
+ .filter(Boolean);
242
+ }
243
+
244
+ async function walkMarkdown(rootDir, relDir, recursive) {
245
+ const absDir = path.join(rootDir, relDir);
246
+ const out = [];
247
+ let entries;
248
+ try {
249
+ entries = await fs.readdir(absDir, { withFileTypes: true });
250
+ } catch {
251
+ return out;
252
+ }
253
+
254
+ for (const entry of entries) {
255
+ if (entry.name.startsWith('.')) continue;
256
+ const childRel = path.join(relDir, entry.name);
257
+ if (entry.isDirectory()) {
258
+ if (recursive) out.push(...await walkMarkdown(rootDir, childRel, recursive));
259
+ continue;
260
+ }
261
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
262
+ if (entry.name.toLowerCase() === 'readme.md') continue;
263
+ out.push(normalizeSlashes(childRel));
264
+ }
265
+ return out.sort();
266
+ }
267
+
268
+ function inferContextMetadata(relPath, fm) {
269
+ const base = path.basename(relPath);
270
+ const slugMatch = base.match(/^(prd|requirements|spec|design-doc|readiness|implementation-plan|ui-spec|scope-check)-(.+)\.md$/);
271
+ const tags = [];
272
+ let featureSlug = fm.feature_slug || fm.feature || '';
273
+ let loadTier = fm.load_tier || 'trigger';
274
+
275
+ if (FOUNDATION_CONTEXT_BASENAMES.has(base)) {
276
+ if (UNIVERSAL_ALWAYS_CONTEXT_BASENAMES.has(base)) {
277
+ loadTier = fm.load_tier || 'always';
278
+ }
279
+ tags.push('foundation');
280
+ }
281
+ if (slugMatch) {
282
+ tags.push(slugMatch[1], 'feature');
283
+ if (!featureSlug) featureSlug = slugMatch[2];
284
+ }
285
+ if (base === 'discovery.md') tags.push('discovery', 'project-memory', 'entities', 'business-rules');
286
+ if (base === 'architecture.md') tags.push('architecture', 'technical-design', 'module-boundary');
287
+ if (base === 'ui-spec.md') tags.push('ui-spec', 'ui', 'ux', 'frontend', 'visual-design');
288
+ if (base === 'scope-check.md') tags.push('scope-check', 'alignment', 'pre-dev');
289
+ if (relPath.includes('/bootstrap/')) tags.push('bootstrap');
290
+ if (relPath.includes('/features/') && base === 'dossier.md') {
291
+ tags.push('feature', 'dossier');
292
+ if (!featureSlug) {
293
+ const parts = relPath.split('/');
294
+ const index = parts.indexOf('features');
295
+ if (index !== -1) featureSlug = parts[index + 1] || '';
296
+ }
297
+ }
298
+
299
+ return { tags, featureSlug, loadTier };
300
+ }
301
+
302
+ async function collectCandidates(targetDir) {
303
+ const candidates = [];
304
+
305
+ for (const surface of SURFACES) {
306
+ const relPaths = await walkMarkdown(targetDir, surface.dir, surface.recursive);
307
+ for (const relPath of relPaths) {
308
+ if (surface.key === 'feature_dossier' && !relPath.endsWith('/dossier.md')) continue;
309
+ const absPath = path.join(targetDir, relPath);
310
+ const content = await readFileSafe(absPath);
311
+ if (!content) continue;
312
+ const stat = await fs.stat(absPath).catch(() => null);
313
+ const fm = parseFrontmatter(content);
314
+ const inferred = inferContextMetadata(relPath, fm);
315
+ const description = fm.description || fm.name || path.basename(relPath, '.md');
316
+ candidates.push({
317
+ path: relPath,
318
+ surface: surface.key,
319
+ size: stat ? stat.size : content.length,
320
+ frontmatter: fm,
321
+ description,
322
+ agents: parseAgentList(fm.agents),
323
+ modes: parseListValue(fm.modes),
324
+ taskTypes: parseListValue(fm.task_types || fm.taskTypes),
325
+ triggers: parseListValue(fm.triggers),
326
+ aliases: parseListValue(fm.aliases || fm.alias),
327
+ entities: parseListValue(fm.entities || fm.entity),
328
+ retrievalIntents: parseListValue(fm.retrieval_intents || fm.intents || fm.intent),
329
+ pathPatterns: parseListValue(fm.paths || fm.globs),
330
+ scope: fm.scope || '',
331
+ featureSlug: fm.feature_slug || fm.feature || inferred.featureSlug || '',
332
+ tags: [...new Set([...parseListValue(fm.tags), ...inferred.tags])],
333
+ loadTier: fm.load_tier || inferred.loadTier || surface.defaultTier,
334
+ searchText: content.slice(0, 100_000)
335
+ });
336
+ }
337
+ }
338
+
339
+ return candidates;
340
+ }
341
+
342
+ function normalizeForSemantic(value) {
343
+ return normalizeToken(value).replace(/[/-]+/g, ' ');
344
+ }
345
+
346
+ function addSemanticTerm(out, term) {
347
+ const normalized = normalizeForSemantic(term).trim();
348
+ if (!normalized) return;
349
+ for (const part of normalized.split(/\s+/)) {
350
+ if (!part) continue;
351
+ if (SEMANTIC_STOP_WORDS.has(part)) continue;
352
+ if (part.length < 4 && !SHORT_SEMANTIC_TERMS.has(part)) continue;
353
+ out.add(part);
354
+ if (part.endsWith('s') && part.length > 4) out.add(part.slice(0, -1));
355
+ const synonyms = SEMANTIC_SYNONYMS.get(part) || [];
356
+ for (const synonym of synonyms) out.add(synonym);
357
+ }
358
+ }
359
+
360
+ function projectSemanticTerms(candidates) {
361
+ const project = candidates.find((candidate) => candidate.path === '.aioson/context/project.context.md');
362
+ if (!project) return [];
363
+ const fm = project.frontmatter || {};
364
+ return [
365
+ fm.framework
366
+ ].filter(Boolean);
367
+ }
368
+
369
+ function buildSemanticTerms({ task, paths, feature, activeFeature }, candidates) {
370
+ const terms = new Set();
371
+ const rawValues = [
372
+ task,
373
+ paths.join(' '),
374
+ feature,
375
+ activeFeature
376
+ ].filter(Boolean);
377
+
378
+ for (const raw of rawValues) addSemanticTerm(terms, raw);
379
+ const taskAlreadyNamesFramework = [...terms].some((term) => KNOWN_FRAMEWORK_TERMS.has(term));
380
+ if (!taskAlreadyNamesFramework) {
381
+ for (const raw of projectSemanticTerms(candidates)) addSemanticTerm(terms, raw);
382
+ }
383
+ return [...terms].slice(0, SEMANTIC_MAX_TERMS);
384
+ }
385
+
386
+ function buildFtsQuery(terms) {
387
+ return terms
388
+ .map((term) => `"${String(term).replace(/"/g, '').trim()}"`)
389
+ .filter((term) => term !== '""')
390
+ .join(' OR ');
391
+ }
392
+
393
+ function semanticBaseScore(candidate) {
394
+ if (candidate.surface === 'rules') return 26;
395
+ if (candidate.surface === 'design_governance') return 24;
396
+ if (candidate.surface === 'docs') return 22;
397
+ if (candidate.surface === 'bootstrap') return 24;
398
+ if (candidate.surface === 'feature_dossier') return 18;
399
+ return 16;
400
+ }
401
+
402
+ function semanticMinimumTerms(candidate) {
403
+ if (candidate.surface === 'docs') return 4;
404
+ if (candidate.surface === 'bootstrap') return 3;
405
+ if (candidate.surface === 'context') return 3;
406
+ return 3;
407
+ }
408
+
409
+ function semanticCandidateAllowed(candidate) {
410
+ const base = path.basename(candidate.path);
411
+ if (candidate.loadTier === 'always') return false;
412
+ if (candidate.surface === 'bootstrap') return base !== 'current-state-archive.md';
413
+ if (candidate.surface === 'context') {
414
+ if (candidate.featureSlug) return true;
415
+ return base === 'design-doc.md' || base === 'readiness.md';
416
+ }
417
+ return ['rules', 'design_governance', 'docs', 'feature_dossier'].includes(candidate.surface);
418
+ }
419
+
420
+ function matchSemanticTerms(candidate, terms) {
421
+ const haystack = normalizeForSemantic([
422
+ candidate.path,
423
+ candidate.description,
424
+ candidate.scope,
425
+ candidate.tags.join(' '),
426
+ candidate.searchText
427
+ ].join(' '));
428
+ return terms.filter((term) => haystack.includes(term));
429
+ }
430
+
431
+ function buildLexicalSemanticMatches(candidates, terms) {
432
+ const matches = new Map();
433
+ if (terms.length === 0) return matches;
434
+
435
+ for (const candidate of candidates) {
436
+ if (!semanticCandidateAllowed(candidate)) continue;
437
+ const matched = matchSemanticTerms(candidate, terms);
438
+ if (matched.length === 0) continue;
439
+ const score = Math.min(50, semanticBaseScore(candidate) + matched.length * 7);
440
+ matches.set(candidate.path, {
441
+ score,
442
+ terms: matched.slice(0, 6),
443
+ reason: `semantic:${matched.slice(0, 6).join(',')}`
444
+ });
445
+ }
446
+
447
+ return matches;
448
+ }
449
+
450
+ function buildSemanticMatches(candidates, terms) {
451
+ if (terms.length === 0) return new Map();
452
+ const query = buildFtsQuery(terms);
453
+ if (!query) return new Map();
454
+
455
+ let db;
456
+ try {
457
+ db = new Database(':memory:');
458
+ db.exec(`
459
+ CREATE VIRTUAL TABLE candidates USING fts5(
460
+ candidate_id UNINDEXED,
461
+ path,
462
+ title,
463
+ body,
464
+ tokenize = "unicode61 remove_diacritics 2"
465
+ );
466
+ `);
467
+ const insert = db.prepare('INSERT INTO candidates (candidate_id, path, title, body) VALUES (?, ?, ?, ?)');
468
+ const insertMany = db.transaction((items) => {
469
+ for (let i = 0; i < items.length; i += 1) {
470
+ const candidate = items[i];
471
+ insert.run(
472
+ i,
473
+ candidate.path,
474
+ candidate.description || path.basename(candidate.path),
475
+ [
476
+ candidate.path,
477
+ candidate.description,
478
+ candidate.scope,
479
+ candidate.tags.join(' '),
480
+ candidate.searchText
481
+ ].join('\n')
482
+ );
483
+ }
484
+ });
485
+ insertMany(candidates);
486
+
487
+ const rows = db.prepare(`
488
+ SELECT candidate_id
489
+ FROM candidates
490
+ WHERE candidates MATCH ?
491
+ ORDER BY rank
492
+ LIMIT ?
493
+ `).all(query, SEMANTIC_RESULT_LIMIT);
494
+
495
+ const matches = new Map();
496
+ for (const row of rows) {
497
+ const candidate = candidates[Number(row.candidate_id)];
498
+ if (!candidate) continue;
499
+ if (!semanticCandidateAllowed(candidate)) continue;
500
+ const matched = matchSemanticTerms(candidate, terms);
501
+ if (matched.length === 0) continue;
502
+ const score = Math.min(50, semanticBaseScore(candidate) + matched.length * 7);
503
+ matches.set(candidate.path, {
504
+ score,
505
+ terms: matched.slice(0, 6),
506
+ reason: `semantic:${matched.slice(0, 6).join(',')}`
507
+ });
508
+ }
509
+ return matches;
510
+ } catch {
511
+ return buildLexicalSemanticMatches(candidates, terms);
512
+ } finally {
513
+ if (db) db.close();
514
+ }
515
+ }
516
+
517
+ async function collectMemoryMatches(targetDir, terms) {
518
+ if (terms.length === 0) return [];
519
+ const query = terms.slice(0, 8).join(' ');
520
+ if (!query) return [];
521
+
522
+ let handle = null;
523
+ try {
524
+ handle = await openRuntimeDb(targetDir, { mustExist: true });
525
+ if (!handle || !handle.db) return [];
526
+ const outcome = searchProjectLearnings(handle.db, {
527
+ query,
528
+ limit: MEMORY_RESULT_LIMIT,
529
+ surface: 'all',
530
+ includeArchived: false
531
+ });
532
+ if (!outcome.ok) return [];
533
+ return outcome.results.map((result) => ({
534
+ surface: 'memory',
535
+ target_type: result.target_type,
536
+ target_id: result.target_id,
537
+ feature_slug: result.feature_slug || '',
538
+ status: result.status,
539
+ score: result.score,
540
+ snippet: result.snippet || '',
541
+ reason: `memory_fts:${query}`
542
+ }));
543
+ } catch {
544
+ return [];
545
+ } finally {
546
+ if (handle && handle.db) handle.db.close();
547
+ }
548
+ }
549
+
550
+ function keywordMatches(haystack, needles) {
551
+ const normalizedHaystack = normalizeToken(haystack);
552
+ const haystackWords = new Set(normalizedHaystack.split(/\s+/).flatMap(wordVariants));
553
+ return needles.filter((needle) => {
554
+ const normalizedNeedle = normalizeToken(needle);
555
+ if (!normalizedNeedle) return false;
556
+ const needleTokens = normalizedNeedle.split(/\s+/).filter(Boolean);
557
+ // Short single-token needles (e.g. alias "ui", entity "api") must match on a
558
+ // word boundary. A bare substring check false-fires inside "build"/"require"/
559
+ // "rapid", which pollutes selection scoring and — via the entities/aliases
560
+ // salience gate — makes context:guard inject unrelated rules.
561
+ if (needleTokens.length === 1 && normalizedNeedle.length <= 3) {
562
+ return wordVariants(normalizedNeedle).some((variant) => haystackWords.has(variant));
563
+ }
564
+ if (normalizedHaystack.includes(normalizedNeedle)) return true;
565
+ const words = needleTokens.filter((word) => word.length >= 4);
566
+ if (words.length === 0) return false;
567
+ const hits = words.filter((word) => wordVariants(word).some((variant) => haystackWords.has(variant))).length;
568
+ return hits >= Math.min(2, words.length);
569
+ });
570
+ }
571
+
572
+ function wordVariants(word) {
573
+ const raw = String(word || '').trim();
574
+ if (!raw) return [];
575
+ const variants = new Set([raw]);
576
+ if (raw.endsWith('ing') && raw.length > 5) {
577
+ const stem = raw.slice(0, -3);
578
+ variants.add(stem);
579
+ variants.add(`${stem}e`);
580
+ }
581
+ if (raw.endsWith('s') && raw.length > 4) variants.add(raw.slice(0, -1));
582
+ return [...variants];
583
+ }
584
+
585
+ function scoreCandidate(candidate, context) {
586
+ const reasons = [];
587
+ let score = 0;
588
+ let effectiveLoadTier = candidate.loadTier;
589
+ const base = path.basename(candidate.path);
590
+
591
+ if (!appliesToAgent(candidate.frontmatter, context.agent)) return null;
592
+ if (context.activationOnly) {
593
+ const allowedActivationPaths = ACTIVATION_ONLY_CONTEXT_PATHS_BY_AGENT.get(context.agent);
594
+ if (!allowedActivationPaths || !allowedActivationPaths.has(candidate.path)) return null;
595
+ }
596
+
597
+ if (candidate.modes.length > 0 && !candidate.modes.map(normalizeToken).includes(context.mode)) {
598
+ return null;
599
+ }
600
+ if (candidate.modes.length > 0) {
601
+ score += 5;
602
+ reasons.push(`mode:${context.mode}`);
603
+ }
604
+
605
+ const agentAlways = AGENT_ALWAYS_CONTEXT_BASENAMES.get(context.agent);
606
+ if (agentAlways && agentAlways.has(base)) {
607
+ effectiveLoadTier = 'always';
608
+ score += 100;
609
+ reasons.push('load_tier:always');
610
+ } else if (candidate.loadTier === 'always') {
611
+ score += 100;
612
+ reasons.push('load_tier:always');
613
+ }
614
+
615
+ const matchedPaths = [];
616
+ for (const requestedPath of context.paths) {
617
+ for (const pattern of candidate.pathPatterns) {
618
+ if (pathMatchesPattern(requestedPath, pattern)) matchedPaths.push(`${requestedPath}~${pattern}`);
619
+ }
620
+ }
621
+ const directPathMatch = context.paths.some((requestedPath) => {
622
+ const normalized = normalizeSlashes(requestedPath);
623
+ return normalized === candidate.path || pathMatchesPattern(candidate.path, normalized);
624
+ });
625
+ if (matchedPaths.length > 0) {
626
+ score += 10;
627
+ reasons.push(`paths:${matchedPaths.slice(0, 3).join(',')}`);
628
+ }
629
+ if (directPathMatch) {
630
+ score += 10;
631
+ reasons.push('paths:direct');
632
+ }
633
+
634
+ const activeFeature = context.feature || context.activeFeature || '';
635
+ const featureMentioned = candidate.featureSlug
636
+ && context.lookup.includes(normalizeToken(candidate.featureSlug).replace(/-/g, ' '));
637
+ if (context.activationOnly && candidate.featureSlug && !context.feature) {
638
+ return null;
639
+ }
640
+ if (candidate.featureSlug && candidate.featureSlug !== activeFeature && !featureMentioned && !directPathMatch) {
641
+ return null;
642
+ }
643
+ if (candidate.featureSlug && activeFeature && candidate.featureSlug === activeFeature) {
644
+ score += 45;
645
+ reasons.push(`feature:${candidate.featureSlug}`);
646
+ }
647
+
648
+ if (featureMentioned) {
649
+ score += 45;
650
+ reasons.push(`feature-mentioned:${candidate.featureSlug}`);
651
+ }
652
+
653
+ const matchedTaskTypes = keywordMatches(context.lookup, candidate.taskTypes);
654
+ if (matchedTaskTypes.length > 0) {
655
+ score += 40;
656
+ reasons.push(`task_types:${matchedTaskTypes.slice(0, 3).join(',')}`);
657
+ }
658
+
659
+ const matchedTriggers = keywordMatches(context.lookup, candidate.triggers);
660
+ if (matchedTriggers.length > 0) {
661
+ score += 40;
662
+ reasons.push(`triggers:${matchedTriggers.slice(0, 3).join(',')}`);
663
+ }
664
+
665
+ const matchedAliases = keywordMatches(context.lookup, candidate.aliases);
666
+ if (matchedAliases.length > 0) {
667
+ score += 35;
668
+ reasons.push(`aliases:${matchedAliases.slice(0, 3).join(',')}`);
669
+ }
670
+
671
+ const matchedEntities = keywordMatches(context.lookup, candidate.entities);
672
+ if (matchedEntities.length > 0) {
673
+ score += 30;
674
+ reasons.push(`entities:${matchedEntities.slice(0, 3).join(',')}`);
675
+ }
676
+
677
+ const matchedRetrievalIntents = keywordMatches(context.lookup, candidate.retrievalIntents);
678
+ if (matchedRetrievalIntents.length > 0) {
679
+ score += 25;
680
+ reasons.push(`retrieval_intents:${matchedRetrievalIntents.slice(0, 3).join(',')}`);
681
+ }
682
+
683
+ const matchedTags = keywordMatches(context.lookup, candidate.tags);
684
+ if (matchedTags.length > 0) {
685
+ score += 20;
686
+ reasons.push(`tags:${matchedTags.slice(0, 3).join(',')}`);
687
+ }
688
+
689
+ const descriptionHits = keywordMatches(context.lookup, [
690
+ candidate.description,
691
+ candidate.scope,
692
+ path.basename(candidate.path, '.md').replace(/-/g, ' ')
693
+ ]);
694
+ if (descriptionHits.length > 0) {
695
+ score += 20;
696
+ reasons.push(`description:${descriptionHits.slice(0, 2).join(',')}`);
697
+ }
698
+
699
+ const semanticHit = context.semanticMatches && context.semanticMatches.get(candidate.path);
700
+ const featureRouted = Boolean(
701
+ (candidate.featureSlug && activeFeature && candidate.featureSlug === activeFeature) || featureMentioned
702
+ );
703
+ const hardRoutingHit = matchedPaths.length > 0
704
+ || directPathMatch
705
+ || matchedTaskTypes.length > 0
706
+ || matchedTriggers.length > 0
707
+ || matchedAliases.length > 0
708
+ || matchedEntities.length > 0
709
+ || matchedRetrievalIntents.length > 0
710
+ || featureRouted;
711
+ const weakJustifiedSemanticHit = candidate.loadTier === 'justified' && semanticHit && semanticHit.terms.length < 3;
712
+ const weakPureSemanticHit = semanticHit && !hardRoutingHit && semanticHit.terms.length < semanticMinimumTerms(candidate);
713
+ if (semanticHit && effectiveLoadTier !== 'always' && !weakJustifiedSemanticHit && !weakPureSemanticHit) {
714
+ score += semanticHit.score;
715
+ reasons.push(semanticHit.reason);
716
+ }
717
+
718
+ const threshold = effectiveLoadTier === 'justified' ? 50 : 30;
719
+ if (score < threshold) return null;
720
+
721
+ return {
722
+ path: candidate.path,
723
+ surface: candidate.surface,
724
+ load_tier: effectiveLoadTier,
725
+ size: candidate.size,
726
+ score,
727
+ reason: reasons.join('; ')
728
+ };
729
+ }
730
+
731
+ async function selectContext(targetDir, options = {}) {
732
+ const agent = normalizeToken(options.agent || 'dev');
733
+ const mode = modeFromOptions(options.mode);
734
+ const task = String(options.task || options.goal || '').trim();
735
+ const paths = splitOptionList(options.paths || options.path).map(normalizeSlashes);
736
+ const feature = normalizeFeaturePointer(options.feature || options.slug || '');
737
+ const activationOnly = isActivationOnlyTask(agent, mode, task);
738
+
739
+ const pulse = await readProjectPulse(targetDir);
740
+ const devState = await readDevState(targetDir);
741
+ const activeFeature = normalizeFeaturePointer(
742
+ feature || pulse.active_feature || devState.active_feature || ''
743
+ );
744
+
745
+ const lookup = normalizeToken([
746
+ task,
747
+ paths.join(' '),
748
+ activeFeature
749
+ ].filter(Boolean).join(' '));
750
+
751
+ const candidates = await collectCandidates(targetDir);
752
+ const semanticEnabled = semanticSearchEnabled(options) && !activationOnly;
753
+ const semanticTerms = semanticEnabled
754
+ ? buildSemanticTerms({ task, paths, feature, activeFeature }, candidates)
755
+ : [];
756
+ const semanticMatches = semanticEnabled
757
+ ? buildSemanticMatches(candidates, semanticTerms)
758
+ : new Map();
759
+ const selected = [];
760
+ for (const candidate of candidates) {
761
+ const scored = scoreCandidate(candidate, {
762
+ agent,
763
+ mode,
764
+ task,
765
+ paths,
766
+ feature,
767
+ activeFeature,
768
+ lookup,
769
+ activationOnly,
770
+ semanticMatches
771
+ });
772
+ if (scored) selected.push(scored);
773
+ }
774
+
775
+ selected.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
776
+ const memory = semanticEnabled ? await collectMemoryMatches(targetDir, semanticTerms) : [];
777
+
778
+ return {
779
+ ok: true,
780
+ agent,
781
+ mode,
782
+ task,
783
+ paths,
784
+ feature: feature || null,
785
+ active_feature: activeFeature || null,
786
+ activation_only: activationOnly,
787
+ semantic: {
788
+ enabled: semanticEnabled,
789
+ terms: semanticTerms
790
+ },
791
+ memory,
792
+ selected
793
+ };
794
+ }
795
+
796
+ module.exports = {
797
+ selectContext,
798
+ collectCandidates,
799
+ parseListValue,
800
+ pathMatchesPattern,
801
+ isActivationOnlyTask
802
+ };