@minhpnq1807/contextos 0.5.9 → 0.5.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.11
4
+
5
+ - Adds Antigravity workflow discovery roots under `.gemini/workflows`, `.gemini/antigravity/workflows`, and `.gemini/antigravity-cli/workflows`.
6
+
7
+ ## 0.5.10
8
+
9
+ - Adds workflow discovery for `.claude/workflows/`, `.codex/workflows/`, `~/.claude/workflows/`, and `~/.codex/workflows/`.
10
+ - Adds `ctx sync --workflows` to parse markdown workflow headings, agent chains, and warm workflow embeddings.
11
+ - Injects prompt-relevant workflow hints into ContextOS prompt context and shows them in `ctx debug`.
12
+
3
13
  ## 0.5.9
4
14
 
5
15
  - Formats `ctx report`, `ctx evidence`, `ctx stats`, and `ctx benchmark` with sectioned terminal tables for easier scanning and analysis.
package/README.md CHANGED
@@ -214,6 +214,31 @@ ctx sync --skills --agents codex,claude,agy
214
214
 
215
215
  After this, `ctx debug -- "task"` and prompt hooks can suggest skills from `~/.config/skillshare/skills/` plus agent-specific skill folders.
216
216
 
217
+ ## Workflow Discovery
218
+
219
+ ContextOS can also index Claude/Codex workflow markdown files and suggest the right workflow for the current task:
220
+
221
+ ```bash
222
+ ctx sync --workflows
223
+ ```
224
+
225
+ It scans project workflows first, then global workflows:
226
+
227
+ ```text
228
+ .claude/workflows/
229
+ .codex/workflows/
230
+ .gemini/workflows/
231
+ .gemini/antigravity/workflows/
232
+ .gemini/antigravity-cli/workflows/
233
+ ~/.claude/workflows/
234
+ ~/.codex/workflows/
235
+ ~/.gemini/workflows/
236
+ ~/.gemini/antigravity/workflows/
237
+ ~/.gemini/antigravity-cli/workflows/
238
+ ```
239
+
240
+ Workflow files do not need YAML frontmatter. ContextOS reads the top `#` heading, section headings, and referenced agent names such as `planner`, `tester`, `code-reviewer`, and `docs-manager`, then warms semantic embeddings. Prompt hooks inject a `Suggested workflow for this task` section only when a workflow is relevant enough.
241
+
217
242
  ## Modes
218
243
 
219
244
  Injection mode is the default:
@@ -359,7 +384,8 @@ This warning comes from a transitive dependency in the local embedding/WASM stac
359
384
  | `ctx sync --skills --agents <list>` | Syncs skills only for selected agents. | You want to target a subset such as `codex,claude` or `codex,claude,agy`. | Runs `skillshare sync --agents <list>` with `agy` normalized to `antigravity`, then refreshes skill embeddings. |
360
385
  | `ctx sync --skills --dry-run` | Previews skillshare sync. | You want to inspect behavior before changing skill directories. | Runs `skillshare sync --dry-run` and skips embedding rebuild. |
361
386
  | `ctx sync --skills --no-collect` | Skips collecting existing agent skills into skillshare. | You already manage `~/.config/skillshare/skills` and only want to push it out. | Initializes/syncs skillshare without running `skillshare backup` or `skillshare collect --all`. |
362
- | `ctx embeddings warm -- "task"` | Prepares local semantic embedding caches. | First install, CI smoke checks, or after changing AGENTS.md/project files/skills. | Loads/downloads `Xenova/all-MiniLM-L6-v2` and writes rule, file-path, and skill vectors to `~/.ctx/contextos/embeddings.db`. |
387
+ | `ctx sync --workflows` | Indexes agent workflow markdown files for prompt-time workflow suggestions. | You use `.claude/workflows/` or `.codex/workflows/` and want ContextOS to suggest the best workflow chain for each task. | Scans project/global workflow folders, parses headings and agent chain mentions, warms workflow embeddings, and makes `ctx debug`/prompt hooks show relevant workflow hints. |
388
+ | `ctx embeddings warm -- "task"` | Prepares local semantic embedding caches. | First install, CI smoke checks, or after changing AGENTS.md/project files/skills/workflows. | Loads/downloads `Xenova/all-MiniLM-L6-v2` and writes rule, file-path, skill, and workflow vectors to `~/.ctx/contextos/embeddings.db`. |
363
389
  | `ctx ruler -- <args>` | Forwards args to the installed `ruler` CLI. | You need native Ruler commands such as `init`, `apply`, or `revert`. | Preserves Ruler stdout/stderr and exit status. |
364
390
  | `ctx skillshare -- <args>` | Forwards args to the installed `skillshare` CLI. | You need native skillshare commands such as `status`, `target list`, `doctor`, `push`, or `pull`. | Preserves skillshare stdout/stderr and exit status. |
365
391
  | `ctx --version` | Prints the installed ContextOS CLI version. | You want to confirm which npm version is being executed. | Prints the version from package metadata. |
package/bin/ctx.js CHANGED
@@ -28,6 +28,7 @@ import { syncSkills } from "../plugins/ctx/lib/skillshare-sync.js";
28
28
  import { scanSkills, warmSkillEmbeddings } from "../plugins/ctx/lib/skill-discoverer.js";
29
29
  import { parsePassthroughArgs, runPassthrough } from "../plugins/ctx/lib/passthrough.js";
30
30
  import { parseAgentList, parseSetupArgs, setupSummaryLines } from "../plugins/ctx/lib/setup-wizard.js";
31
+ import { syncWorkflows, warmWorkflowEmbeddings } from "../plugins/ctx/lib/workflow-discoverer.js";
31
32
 
32
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
34
  const rootDir = path.resolve(__dirname, "..");
@@ -61,6 +62,7 @@ Usage:
61
62
  ctx sync --rules --dry-run
62
63
  ctx sync --rules --no-import-codex-mcp
63
64
  ctx sync --skills
65
+ ctx sync --workflows
64
66
  ctx sync --skills --dry-run
65
67
  ctx sync --skills --no-collect
66
68
  ctx sync --skills --agents codex,claude,antigravity
@@ -186,6 +188,7 @@ async function install({ copy = false, inject = true, agent = "codex" } = {}) {
186
188
  console.log(`Embedding vectors cache: ${warmResult.cachePath}`);
187
189
  console.log(`File path embeddings warmed: ${warmResult.fileCount || 0}`);
188
190
  console.log(`Skill embeddings warmed: ${warmResult.skillCount || 0}`);
191
+ console.log(`Workflow embeddings warmed: ${warmResult.workflowCount || 0}`);
189
192
  console.log(`Prompt context injection: ${inject ? "enabled" : "quiet logging only"}`);
190
193
  console.log("Restart Claude Code if it was already running, then submit a task to trigger ContextOS.");
191
194
  return;
@@ -209,6 +212,7 @@ async function install({ copy = false, inject = true, agent = "codex" } = {}) {
209
212
  console.log(`Embedding vectors cache: ${warmResult.cachePath}`);
210
213
  console.log(`File path embeddings warmed: ${warmResult.fileCount || 0}`);
211
214
  console.log(`Skill embeddings warmed: ${warmResult.skillCount || 0}`);
215
+ console.log(`Workflow embeddings warmed: ${warmResult.workflowCount || 0}`);
212
216
  console.log(`Prompt context injection: ${inject ? "enabled" : "quiet logging only"}`);
213
217
  console.log("Restart Antigravity or agy if it was already running, then submit a task to trigger ContextOS.");
214
218
  return;
@@ -247,6 +251,7 @@ async function install({ copy = false, inject = true, agent = "codex" } = {}) {
247
251
  console.log(`Embedding vectors cache: ${warmResult.cachePath}`);
248
252
  console.log(`File path embeddings warmed: ${warmResult.fileCount || 0}`);
249
253
  console.log(`Skill embeddings warmed: ${warmResult.skillCount || 0}`);
254
+ console.log(`Workflow embeddings warmed: ${warmResult.workflowCount || 0}`);
250
255
  console.log(`Prompt context injection: ${inject ? "enabled" : "quiet logging only"}`);
251
256
  console.log("Restart Codex if it was already running, then submit a task to trigger ContextOS.");
252
257
  } catch (error) {
@@ -282,7 +287,12 @@ async function warmInstallEmbeddings() {
282
287
  dataDir,
283
288
  allowRemote: !modelReady
284
289
  });
285
- return { ...result, modelAlreadyCached: modelReady, fileCount: fileResult.count, skillCount: skillResult.count };
290
+ const workflowResult = await warmWorkflowEmbeddings({
291
+ cwd: process.cwd(),
292
+ dataDir,
293
+ allowRemote: !modelReady
294
+ });
295
+ return { ...result, modelAlreadyCached: modelReady, fileCount: fileResult.count, skillCount: skillResult.count, workflowCount: workflowResult.count };
286
296
  }
287
297
 
288
298
  function tryRunCodex(args) {
@@ -340,7 +350,8 @@ async function debug(task) {
340
350
  const rules = scored.scoredRules;
341
351
  const relevantFiles = scored.suggestedFiles.slice(0, 3);
342
352
  const suggestedSkills = (scored.suggestedSkills || []).slice(0, 3);
343
- const scheduled = scheduleContext({ rules, relevantFiles, suggestedSkills });
353
+ const suggestedWorkflows = (scored.suggestedWorkflows || []).slice(0, 2);
354
+ const scheduled = scheduleContext({ rules, relevantFiles, suggestedSkills, suggestedWorkflows });
344
355
 
345
356
  console.log("ContextOS debug");
346
357
  console.log(`cwd: ${cwd}`);
@@ -372,6 +383,15 @@ async function debug(task) {
372
383
  }
373
384
  if (!suggestedSkills.length) console.log("(none)");
374
385
  console.log("");
386
+ console.log("Suggested workflows:");
387
+ for (const workflow of suggestedWorkflows) {
388
+ const score = Number(workflow.score || 0).toFixed(2);
389
+ const chain = workflow.chain?.length ? ` chain:${workflow.chain.join(" -> ")}` : "";
390
+ const location = workflow.relativePath || workflow.path ? ` path:${workflow.relativePath || workflow.path}` : "";
391
+ console.log(`${score} ${workflow.title || workflow.name}${chain}${location}`);
392
+ }
393
+ if (!suggestedWorkflows.length) console.log("(none)");
394
+ console.log("");
375
395
  console.log("Final additionalContext:");
376
396
  console.log(scheduled.additionalContext || "(empty)");
377
397
  }
@@ -397,9 +417,15 @@ async function warmEmbeddings(task) {
397
417
  dataDir: contextOSDataDir(),
398
418
  allowRemote: true
399
419
  });
420
+ const workflowResult = await warmWorkflowEmbeddings({
421
+ cwd,
422
+ dataDir: contextOSDataDir(),
423
+ allowRemote: true
424
+ });
400
425
  console.log(`Warmed ${result.count} embeddings`);
401
426
  console.log(`Warmed ${fileResult.count} file path embeddings`);
402
427
  console.log(`Warmed ${skillResult.count} skill embeddings`);
428
+ console.log(`Warmed ${workflowResult.count} workflow embeddings`);
403
429
  console.log(`Cache: ${result.cachePath}`);
404
430
  }
405
431
 
@@ -541,7 +567,13 @@ try {
541
567
  if (!task.trim()) throw new Error('Usage: ctx benchmark -- "task"');
542
568
  console.log(formatBenchmark(benchmarkWorkspace({ cwd: process.cwd(), task })));
543
569
  } else if (command === "sync") {
544
- if (args.includes("--skills")) {
570
+ if (args.includes("--workflows")) {
571
+ await syncWorkflows({
572
+ cwd: process.cwd(),
573
+ dataDir: contextOSDataDir(),
574
+ allowRemote: !isModelCacheReady(contextOSDataDir())
575
+ });
576
+ } else if (args.includes("--skills")) {
545
577
  await syncSkills({
546
578
  cwd: process.cwd(),
547
579
  args: args.slice(1),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "description": "Task-aware AGENTS.md context injection and compliance reporting for Codex, Claude Code, and Antigravity.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,8 @@ export async function handlePromptPayload(
35
35
  const scoredRules = scored.scoredRules || [];
36
36
  const relevantFiles = (scored.suggestedFiles || []).slice(0, 3);
37
37
  const suggestedSkills = (scored.suggestedSkills || []).slice(0, 3);
38
- const scheduled = scheduleContext({ rules: scoredRules, relevantFiles, suggestedSkills });
38
+ const suggestedWorkflows = (scored.suggestedWorkflows || []).slice(0, 2);
39
+ const scheduled = scheduleContext({ rules: scoredRules, relevantFiles, suggestedSkills, suggestedWorkflows });
39
40
 
40
41
  const runtime = {
41
42
  at: now.toISOString(),
@@ -48,11 +49,13 @@ export async function handlePromptPayload(
48
49
  },
49
50
  relevantFiles,
50
51
  suggestedSkills,
52
+ suggestedWorkflows,
51
53
  telemetry: {
52
54
  ...(scored.telemetry || {}),
53
55
  rulesInjected: (scheduled.highRules?.length || 0) + (scheduled.midRules?.length || 0),
54
56
  filesSuggested: relevantFiles.length,
55
- skillsSuggested: suggestedSkills.length
57
+ skillsSuggested: suggestedSkills.length,
58
+ workflowsSuggested: suggestedWorkflows.length
56
59
  },
57
60
  scheduled,
58
61
  injected: injectContext,
@@ -1,6 +1,6 @@
1
1
  const MAX_CONTEXT_CHARS = 4000;
2
2
 
3
- export function scheduleContext({ rules = [], relevantFiles = [], suggestedSkills = [], maxChars = MAX_CONTEXT_CHARS } = {}) {
3
+ export function scheduleContext({ rules = [], relevantFiles = [], suggestedSkills = [], suggestedWorkflows = [], maxChars = MAX_CONTEXT_CHARS } = {}) {
4
4
  const high = rules.filter((rule) => rule.score >= 0.5);
5
5
  const mid = rules.filter((rule) => rule.score >= 0.1 && rule.score < 0.5);
6
6
  const dropped = rules.filter((rule) => rule.score < 0.1);
@@ -15,6 +15,9 @@ export function scheduleContext({ rules = [], relevantFiles = [], suggestedSkill
15
15
  if (suggestedSkills.length) {
16
16
  sections.push(section("Skills to activate for this task", suggestedSkills.map(formatSkill)));
17
17
  }
18
+ if (suggestedWorkflows.length) {
19
+ sections.push(section("Suggested workflow for this task", suggestedWorkflows.map(formatWorkflow)));
20
+ }
18
21
  if (mid.length) {
19
22
  sections.push(section("Additional relevant rules", mid.slice(0, 8).map(formatRule)));
20
23
  }
@@ -29,6 +32,7 @@ export function scheduleContext({ rules = [], relevantFiles = [], suggestedSkill
29
32
  droppedRules: dropped,
30
33
  relevantFiles,
31
34
  suggestedSkills,
35
+ suggestedWorkflows,
32
36
  additionalContext
33
37
  };
34
38
  }
@@ -49,6 +53,15 @@ function formatSkill(skill) {
49
53
  return `- ${skill.name}${description}${location}`;
50
54
  }
51
55
 
56
+ function formatWorkflow(workflow) {
57
+ const name = workflow.title || workflow.name;
58
+ const hint = workflow.hint ? `: ${workflow.hint}` : "";
59
+ const chain = workflow.chain?.length ? `\n chain: ${workflow.chain.join(" -> ")}` : "";
60
+ const location = workflow.relativePath || workflow.path;
61
+ const source = location ? `\n see: ${location}` : "";
62
+ return `- ${name}${hint}${chain}${source}`;
63
+ }
64
+
52
65
  function trimToLimit(value, maxChars) {
53
66
  if (value.length <= maxChars) return value;
54
67
  return `${value.slice(0, Math.max(0, maxChars - 80)).trimEnd()}\n\n[ContextOS truncated context to ${maxChars} chars]`;
@@ -4,6 +4,7 @@ import { readAgentsChain } from "./reader.js";
4
4
  import { filterActionableRules, parseRules, scoreRules, findRelevantFiles } from "./analyzer.js";
5
5
  import { enhanceRuleScoresWithEmbeddings } from "./embedding-scorer.js";
6
6
  import { scanSkills, suggestSkills } from "./skill-discoverer.js";
7
+ import { scanWorkflows, suggestWorkflows } from "./workflow-discoverer.js";
7
8
 
8
9
  export async function scoreContext({
9
10
  cwd = process.cwd(),
@@ -12,7 +13,9 @@ export async function scoreContext({
12
13
  dataDir,
13
14
  maxFiles = 5,
14
15
  maxSkills = 3,
16
+ maxWorkflows = 2,
15
17
  skills = null,
18
+ workflows = null,
16
19
  embeddingTimeoutMs = 5000,
17
20
  fileEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || 80)
18
21
  } = {}) {
@@ -46,6 +49,13 @@ export async function scoreContext({
46
49
  dataDir,
47
50
  limit: maxSkills
48
51
  });
52
+ const workflowCatalog = Array.isArray(workflows) ? workflows : scanWorkflows({ cwd });
53
+ const suggestedWorkflows = await suggestWorkflows({
54
+ prompt,
55
+ workflows: workflowCatalog,
56
+ dataDir,
57
+ limit: maxWorkflows
58
+ });
49
59
 
50
60
  return {
51
61
  cwd,
@@ -53,6 +63,7 @@ export async function scoreContext({
53
63
  scoredRules,
54
64
  suggestedFiles,
55
65
  suggestedSkills,
66
+ suggestedWorkflows,
56
67
  telemetry: {
57
68
  elapsedMs: Date.now() - started,
58
69
  modelStatus: embedding.status,
@@ -64,6 +75,8 @@ export async function scoreContext({
64
75
  filesSuggested: suggestedFiles.length,
65
76
  skillsScanned: skillCatalog.length,
66
77
  skillsSuggested: suggestedSkills.length,
78
+ workflowsScanned: workflowCatalog.length,
79
+ workflowsSuggested: suggestedWorkflows.length,
67
80
  sources: merged.sources.map((source) => path.relative(cwd, source))
68
81
  }
69
82
  };
@@ -8,6 +8,9 @@ const DEFAULT_LIMIT = 3;
8
8
  const DEFAULT_MAX_SKILLS = 2000;
9
9
  const DEFAULT_EMBEDDING_CANDIDATES = 120;
10
10
  const DEFAULT_SEMANTIC_CATALOG_LIMIT = 300;
11
+ const SCAN_CACHE_TTL_MS = 5 * 60 * 1000;
12
+
13
+ const scanCache = new Map();
11
14
 
12
15
  export function skillSearchRoots({ cwd = process.cwd(), home = os.homedir() } = {}) {
13
16
  return [
@@ -60,6 +63,12 @@ function firstParagraph(body) {
60
63
  }
61
64
 
62
65
  export function scanSkills({ cwd = process.cwd(), roots = skillSearchRoots({ cwd }), maxSkills = DEFAULT_MAX_SKILLS } = {}) {
66
+ const cacheKey = `${path.resolve(cwd)}\0${maxSkills}\0${roots.map((root) => path.resolve(root)).join("\0")}`;
67
+ const cached = scanCache.get(cacheKey);
68
+ if (cached && Date.now() - cached.createdAt < SCAN_CACHE_TTL_MS) {
69
+ return cached.skills;
70
+ }
71
+
63
72
  const skills = [];
64
73
  const seen = new Set();
65
74
  for (const root of roots) {
@@ -87,6 +96,7 @@ export function scanSkills({ cwd = process.cwd(), roots = skillSearchRoots({ cwd
87
96
  });
88
97
  }
89
98
  }
99
+ scanCache.set(cacheKey, { createdAt: Date.now(), skills });
90
100
  return skills;
91
101
  }
92
102
 
@@ -0,0 +1,317 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { enhanceRuleScoresWithEmbeddings, warmRuleEmbeddings } from "./embedding-scorer.js";
6
+
7
+ const DEFAULT_LIMIT = 2;
8
+ const MIN_WORKFLOW_BYTES = 100;
9
+ const MAX_DESCRIPTION_CHARS = 500;
10
+ const DEFAULT_EMBEDDING_CANDIDATES = 40;
11
+ const KNOWN_AGENT_NAMES = new Set([
12
+ "planner",
13
+ "tester",
14
+ "code-reviewer",
15
+ "docs-manager",
16
+ "debugger",
17
+ "researcher",
18
+ "project-manager",
19
+ "mcp-manager",
20
+ "database-admin",
21
+ "ui-ux-designer",
22
+ "copywriter",
23
+ "scout",
24
+ "scout-external",
25
+ "journal-writer",
26
+ "git-manager",
27
+ "brainstormer"
28
+ ]);
29
+
30
+ export function workflowSearchRoots({ cwd = process.cwd(), home = os.homedir() } = {}) {
31
+ return [
32
+ path.join(cwd, ".claude", "workflows"),
33
+ path.join(cwd, ".codex", "workflows"),
34
+ path.join(cwd, ".gemini", "workflows"),
35
+ path.join(cwd, ".gemini", "antigravity", "workflows"),
36
+ path.join(cwd, ".gemini", "antigravity-cli", "workflows"),
37
+ path.join(home, ".claude", "workflows"),
38
+ path.join(home, ".codex", "workflows"),
39
+ path.join(home, ".gemini", "workflows"),
40
+ path.join(home, ".gemini", "antigravity", "workflows"),
41
+ path.join(home, ".gemini", "antigravity-cli", "workflows")
42
+ ];
43
+ }
44
+
45
+ export function scanWorkflows({ cwd = process.cwd(), roots = workflowSearchRoots({ cwd }) } = {}) {
46
+ const workflows = [];
47
+ const seen = new Set();
48
+ const seenNames = new Set();
49
+ for (const root of roots) {
50
+ let entries = [];
51
+ try {
52
+ entries = fs.readdirSync(root, { withFileTypes: true });
53
+ } catch {
54
+ continue;
55
+ }
56
+ for (const entry of entries) {
57
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
58
+ const filePath = path.join(root, entry.name);
59
+ const realPath = safeRealpath(filePath) || filePath;
60
+ if (seen.has(realPath)) continue;
61
+ seen.add(realPath);
62
+ const workflow = parseWorkflowFile(filePath, { cwd, root });
63
+ if (workflow?.name && seenNames.has(workflow.name)) continue;
64
+ if (workflow?.name) seenNames.add(workflow.name);
65
+ if (workflow) workflows.push(workflow);
66
+ }
67
+ }
68
+ return workflows;
69
+ }
70
+
71
+ export function parseWorkflowFile(filePath, { cwd = process.cwd(), root = path.dirname(filePath) } = {}) {
72
+ let stat;
73
+ let content;
74
+ try {
75
+ stat = fs.statSync(filePath);
76
+ if (stat.size < MIN_WORKFLOW_BYTES) return null;
77
+ content = fs.readFileSync(filePath, "utf8");
78
+ } catch {
79
+ return null;
80
+ }
81
+
82
+ const body = stripFrontmatter(content);
83
+ const title = extractTitle(body) || titleFromFile(filePath);
84
+ const sectionTitles = extractSectionTitles(body);
85
+ const chain = extractAgentChain(body);
86
+ const description = buildWorkflowDescription({ title, sectionTitles, chain, body });
87
+ if (!description) return null;
88
+
89
+ return {
90
+ name: path.basename(filePath, ".md"),
91
+ title,
92
+ description,
93
+ chain,
94
+ path: filePath,
95
+ relativePath: path.relative(cwd, filePath),
96
+ root,
97
+ scope: isInsidePath(filePath, cwd) ? "project" : "global",
98
+ mtime: Math.round(stat.mtimeMs)
99
+ };
100
+ }
101
+
102
+ export async function suggestWorkflows({
103
+ prompt = "",
104
+ workflows = [],
105
+ dataDir,
106
+ limit = DEFAULT_LIMIT,
107
+ timeoutMs = Number(process.env.CONTEXTOS_WORKFLOW_EMBEDDING_TIMEOUT_MS || process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || 800)
108
+ } = {}) {
109
+ if (!String(prompt || "").trim() || !workflows.length) return [];
110
+ const base = scoreWorkflowsByKeyword({ prompt, workflows });
111
+ const embeddingCandidates = selectWorkflowEmbeddingCandidates(base);
112
+ if (!embeddingCandidates.length) return [];
113
+
114
+ const embedding = await enhanceRuleScoresWithEmbeddings(embeddingCandidates, prompt, {
115
+ dataDir,
116
+ sources: embeddingCandidates.map((workflow) => workflow.path).filter(Boolean),
117
+ timeoutMs,
118
+ allowRemote: false
119
+ });
120
+
121
+ return finalizeWorkflowScores(embedding.rules, limit);
122
+ }
123
+
124
+ function selectWorkflowEmbeddingCandidates(workflows) {
125
+ return workflows
126
+ .filter((workflow) => Number(workflow.keywordScore || 0) > 0)
127
+ .sort((a, b) => Number(b.keywordScore || 0) - Number(a.keywordScore || 0) || a.name.localeCompare(b.name))
128
+ .slice(0, DEFAULT_EMBEDDING_CANDIDATES);
129
+ }
130
+
131
+ export async function warmWorkflowEmbeddings({
132
+ cwd = process.cwd(),
133
+ dataDir,
134
+ allowRemote = true,
135
+ workflows = scanWorkflows({ cwd })
136
+ } = {}) {
137
+ if (!dataDir || !workflows.length) return { count: 0, cachePath: null };
138
+ return warmRuleEmbeddings({
139
+ rules: workflows.map((workflow) => ({ content: workflowEmbeddingText(workflow) })),
140
+ task: "workflow discovery semantic retrieval feature implementation documentation testing",
141
+ dataDir,
142
+ sources: workflows.map((workflow) => workflow.path).filter(Boolean),
143
+ allowRemote
144
+ });
145
+ }
146
+
147
+ export async function syncWorkflows({
148
+ cwd = process.cwd(),
149
+ dataDir,
150
+ allowRemote = true,
151
+ logger = console.log
152
+ } = {}) {
153
+ const workflows = scanWorkflows({ cwd });
154
+ logger("ContextOS workflow index");
155
+ logger(`Found workflows: ${workflows.length}`);
156
+ if (workflows.length) {
157
+ for (const workflow of workflows) {
158
+ logger(`- ${workflow.relativePath || workflow.path} (${workflow.chain.join(" -> ") || "no chain"})`);
159
+ }
160
+ }
161
+ const result = await warmWorkflowEmbeddings({ cwd, dataDir, allowRemote, workflows });
162
+ logger(`Indexed workflows: ${workflows.length}`);
163
+ if (result.cachePath) logger(`Cache: ${result.cachePath}`);
164
+ return { workflows, embeddings: result };
165
+ }
166
+
167
+ function stripFrontmatter(content) {
168
+ return String(content || "").replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)/, "");
169
+ }
170
+
171
+ function extractTitle(content) {
172
+ const match = String(content || "").match(/^#\s+(.+?)\s*$/m);
173
+ return match ? match[1].trim() : "";
174
+ }
175
+
176
+ function extractSectionTitles(content) {
177
+ return [...String(content || "").matchAll(/^#{3,4}\s+(.+?)\s*$/gm)]
178
+ .map((match) => match[1].replace(/^\d+\.\s*/, "").trim())
179
+ .filter(Boolean);
180
+ }
181
+
182
+ function extractAgentChain(content) {
183
+ const names = [];
184
+ const add = (value) => {
185
+ const normalized = normalizeAgentName(value);
186
+ if (!normalized || !KNOWN_AGENT_NAMES.has(normalized) || names.includes(normalized)) return;
187
+ names.push(normalized);
188
+ };
189
+
190
+ for (const match of String(content || "").matchAll(/`([a-z][a-z0-9-]*(?:-agent)?)`/gi)) {
191
+ add(match[1]);
192
+ }
193
+ for (const name of KNOWN_AGENT_NAMES) {
194
+ if (new RegExp(`\\b${escapeRegExp(name)}\\b`, "i").test(content)) add(name);
195
+ }
196
+ return names;
197
+ }
198
+
199
+ function buildWorkflowDescription({ title, sectionTitles, chain, body }) {
200
+ const parts = [
201
+ title,
202
+ sectionTitles.join(", "),
203
+ chain.length ? `delegates to ${chain.join(", ")} agents` : "",
204
+ firstParagraph(body)
205
+ ].filter(Boolean);
206
+ return parts.join(" — ").replace(/\s+/g, " ").trim().slice(0, MAX_DESCRIPTION_CHARS);
207
+ }
208
+
209
+ function firstParagraph(body) {
210
+ return String(body || "")
211
+ .split(/\n\s*\n/)
212
+ .filter((part) => !/^\s*#/.test(part))
213
+ .map((part) => part.replace(/^#+\s*/gm, "").replace(/[*_`#>-]/g, "").replace(/\s+/g, " ").trim())
214
+ .find(Boolean) || "";
215
+ }
216
+
217
+ function titleFromFile(filePath) {
218
+ return path.basename(filePath, ".md").split(/[-_]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
219
+ }
220
+
221
+ function scoreWorkflowsByKeyword({ prompt, workflows }) {
222
+ const normalizedPrompt = normalize(prompt);
223
+ const promptTokens = new Set(normalizedPrompt.split(/\s+/).filter(Boolean));
224
+ return workflows.map((workflow, index) => {
225
+ const content = workflowEmbeddingText(workflow);
226
+ const workflowTokens = new Set(normalize(content).split(/\s+/).filter(Boolean));
227
+ const matches = [...workflowTokens].filter((token) => promptTokens.has(token) && token.length > 2);
228
+ const nameHit = normalizedPrompt.includes(normalize(workflow.name)) || normalizedPrompt.includes(normalize(workflow.title));
229
+ const chainHit = workflow.chain.some((agent) => normalizedPrompt.includes(normalize(agent)));
230
+ const actionBonus = actionIntentBonus(normalizedPrompt, workflow);
231
+ const scopeBonus = workflow.scope === "project" ? 0.08 : 0;
232
+ const score = Math.min(1, (matches.length ? 0.22 + matches.length * 0.08 : 0) + (nameHit ? 0.22 : 0) + (chainHit ? 0.16 : 0) + actionBonus + scopeBonus);
233
+ return {
234
+ id: `workflow-${index + 1}`,
235
+ ...workflow,
236
+ content,
237
+ score,
238
+ keywordScore: score,
239
+ reasons: [
240
+ ...(matches.length ? [`keyword:${matches.slice(0, 5).join(",")}`] : []),
241
+ ...(nameHit ? ["name-match"] : []),
242
+ ...(chainHit ? ["chain-match"] : []),
243
+ ...(actionBonus ? ["workflow-intent"] : [])
244
+ ],
245
+ originalOrder: index
246
+ };
247
+ });
248
+ }
249
+
250
+ function actionIntentBonus(normalizedPrompt, workflow) {
251
+ const name = normalize(`${workflow.name} ${workflow.title} ${workflow.description}`);
252
+ const implementationIntent = /\b(implement|feature|build|create|fix|debug|test|ci|failing)\b/.test(normalizedPrompt);
253
+ const docsIntent = /\b(doc|docs|documentation|readme|changelog|roadmap)\b/.test(normalizedPrompt);
254
+ const orchestrationIntent = /\b(parallel|sequential|chain|delegate|agent|subagent|orchestrat)\b/.test(normalizedPrompt);
255
+ if (implementationIntent && /\b(primary|implementation|testing|debugging|quality)\b/.test(name)) return 0.35;
256
+ if (docsIntent && /\b(documentation|docs|changelog|roadmap)\b/.test(name)) return 0.35;
257
+ if (orchestrationIntent && /\b(orchestration|parallel|sequential|chaining)\b/.test(name)) return 0.35;
258
+ return 0;
259
+ }
260
+
261
+ function finalizeWorkflowScores(workflows, limit) {
262
+ return workflows
263
+ .map((workflow) => ({
264
+ name: workflow.name,
265
+ title: workflow.title,
266
+ description: workflow.description,
267
+ chain: workflow.chain || [],
268
+ path: workflow.path,
269
+ relativePath: workflow.relativePath,
270
+ scope: workflow.scope,
271
+ keywordScore: workflow.keywordScore,
272
+ score: Math.min(1, Number(workflow.score || 0)),
273
+ embeddingScore: workflow.embeddingScore,
274
+ reasons: workflow.reasons || [],
275
+ hint: workflowHint(workflow)
276
+ }))
277
+ .filter((workflow) => Number(workflow.score || 0) >= 0.4 || Number(workflow.embeddingScore || 0) >= 0.62)
278
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
279
+ .slice(0, limit);
280
+ }
281
+
282
+ function workflowHint(workflow) {
283
+ const text = normalize(`${workflow.name} ${workflow.title} ${workflow.description}`);
284
+ if (text.includes("documentation")) return "use when documentation, changelog, or roadmap updates are needed";
285
+ if (text.includes("orchestration")) return "use when chaining or parallelizing agents";
286
+ if (text.includes("development rules")) return "use for coding conventions and pre-commit discipline";
287
+ return "use for feature implementation, testing, review, and debugging";
288
+ }
289
+
290
+ function workflowEmbeddingText(workflow) {
291
+ return `${workflow.title || workflow.name} ${workflow.description || ""} ${(workflow.chain || []).join(" ")}`;
292
+ }
293
+
294
+ function normalize(value) {
295
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
296
+ }
297
+
298
+ function normalizeAgentName(value) {
299
+ return String(value || "").toLowerCase().replace(/-agent$/, "");
300
+ }
301
+
302
+ function escapeRegExp(value) {
303
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
304
+ }
305
+
306
+ function safeRealpath(filePath) {
307
+ try {
308
+ return fs.realpathSync(filePath);
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ function isInsidePath(filePath, parentPath) {
315
+ const relative = path.relative(path.resolve(parentPath), path.resolve(filePath));
316
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
317
+ }
@@ -18,16 +18,25 @@ export function createContextOSMcpServer({ dataDir }) {
18
18
  openFiles: z.array(z.string()).optional(),
19
19
  maxFiles: z.number().int().positive().max(20).optional(),
20
20
  maxSkills: z.number().int().positive().max(10).optional(),
21
+ maxWorkflows: z.number().int().positive().max(10).optional(),
21
22
  skills: z.array(z.object({
22
23
  name: z.string(),
23
24
  description: z.string(),
24
25
  path: z.string().optional()
26
+ })).optional(),
27
+ workflows: z.array(z.object({
28
+ name: z.string(),
29
+ title: z.string().optional(),
30
+ description: z.string(),
31
+ chain: z.array(z.string()).optional(),
32
+ path: z.string().optional()
25
33
  })).optional()
26
34
  },
27
35
  outputSchema: {
28
36
  scoredRules: z.array(z.any()),
29
37
  suggestedFiles: z.array(z.any()),
30
38
  suggestedSkills: z.array(z.any()),
39
+ suggestedWorkflows: z.array(z.any()),
31
40
  telemetry: z.record(z.string(), z.any())
32
41
  }
33
42
  }, async (args) => {
@@ -38,7 +47,9 @@ export function createContextOSMcpServer({ dataDir }) {
38
47
  dataDir,
39
48
  maxFiles: args.maxFiles || 5,
40
49
  maxSkills: args.maxSkills || 3,
41
- skills: args.skills
50
+ maxWorkflows: args.maxWorkflows || 2,
51
+ skills: args.skills,
52
+ workflows: args.workflows
42
53
  });
43
54
  return {
44
55
  content: [
@@ -51,6 +62,7 @@ export function createContextOSMcpServer({ dataDir }) {
51
62
  scoredRules: result.scoredRules,
52
63
  suggestedFiles: result.suggestedFiles,
53
64
  suggestedSkills: result.suggestedSkills,
65
+ suggestedWorkflows: result.suggestedWorkflows,
54
66
  telemetry: result.telemetry
55
67
  }
56
68
  };
@@ -70,7 +70,9 @@ async function handleBridgeRequest(socket, raw) {
70
70
  dataDir,
71
71
  maxFiles: payload.maxFiles || 5,
72
72
  maxSkills: payload.maxSkills || 3,
73
- skills: payload.skills
73
+ maxWorkflows: payload.maxWorkflows || 2,
74
+ skills: payload.skills,
75
+ workflows: payload.workflows
74
76
  });
75
77
  socket.end(JSON.stringify(result));
76
78
  } catch (error) {
@@ -79,6 +81,7 @@ async function handleBridgeRequest(socket, raw) {
79
81
  scoredRules: [],
80
82
  suggestedFiles: [],
81
83
  suggestedSkills: [],
84
+ suggestedWorkflows: [],
82
85
  telemetry: { elapsedMs: 0, modelStatus: "error" }
83
86
  }));
84
87
  }