@amsterdamdatalabs/enact-extensions 0.1.5 → 0.1.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.
Files changed (105) hide show
  1. package/README.md +2 -2
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/install.d.ts.map +1 -1
  7. package/dist/install.js +15 -1
  8. package/dist/install.js.map +1 -1
  9. package/dist/internal/agents.d.ts +29 -0
  10. package/dist/internal/agents.d.ts.map +1 -0
  11. package/dist/internal/agents.js +83 -0
  12. package/dist/internal/agents.js.map +1 -0
  13. package/extensions/enact-context/hooks/hooks.json +20 -0
  14. package/extensions/enact-core/.agents/plugin.json +39 -0
  15. package/extensions/enact-core/hooks/hooks.json +14 -0
  16. package/extensions/enact-factory/.agents/plugin.json +9 -3
  17. package/extensions/enact-factory/agents/architect.toml +30 -0
  18. package/extensions/enact-factory/agents/code-reviewer.toml +29 -0
  19. package/extensions/enact-factory/agents/critic.toml +35 -0
  20. package/extensions/enact-factory/agents/executor.toml +23 -0
  21. package/extensions/enact-factory/agents/explore.toml +22 -0
  22. package/extensions/enact-factory/agents/planner.toml +23 -0
  23. package/extensions/enact-factory/agents/verifier.toml +29 -0
  24. package/extensions/enact-factory/skills/ai-slop-cleaner/SKILL.md +52 -0
  25. package/extensions/enact-factory/skills/azdo-ci-strategy/SKILL.md +262 -0
  26. package/extensions/enact-factory/skills/azdo-ci-strategy/references/build-failures.md +60 -0
  27. package/extensions/enact-factory/skills/azdo-ci-strategy/references/cli-reference.md +87 -0
  28. package/extensions/enact-factory/skills/azdo-ci-strategy/references/policies-and-pipelines.md +132 -0
  29. package/extensions/enact-factory/skills/azdo-ci-strategy/references/troubleshooting.md +53 -0
  30. package/extensions/enact-factory/skills/deep-interview/SKILL.md +72 -0
  31. package/extensions/enact-factory/skills/drive-loop/SKILL.md +259 -0
  32. package/extensions/enact-factory/skills/drive-loop/references/contract-schema.md +107 -0
  33. package/extensions/enact-factory/skills/hyperplan/SKILL.md +51 -0
  34. package/extensions/enact-factory/skills/looplan/SKILL.md +103 -0
  35. package/extensions/enact-factory/skills/plan/SKILL.md +71 -0
  36. package/extensions/enact-factory/skills/remove-deadcode/SKILL.md +41 -0
  37. package/extensions/enact-factory/skills/research/SKILL.md +73 -0
  38. package/extensions/enact-factory/skills/review/SKILL.md +48 -0
  39. package/extensions/enact-factory/skills/security-research/SKILL.md +54 -0
  40. package/extensions/enact-factory/skills/tdd/SKILL.md +56 -0
  41. package/extensions/enact-factory/skills/trace/SKILL.md +37 -0
  42. package/extensions/enact-factory/skills/ultraqa/SKILL.md +79 -0
  43. package/extensions/enact-factory/skills/work-with-workitem/SKILL.md +51 -0
  44. package/extensions/enact-factory/skills/workitem-triage/SKILL.md +15 -0
  45. package/extensions/enact-loop/.agents/plugin.json +46 -0
  46. package/extensions/enact-loop/.mcp.json +1 -0
  47. package/extensions/enact-loop/hooks/hooks.json +27 -0
  48. package/extensions/enact-loop/skills/enact-loop/SKILL.md +327 -0
  49. package/extensions/enact-operator/.agents/plugin.json +0 -1
  50. package/extensions/enact-operator/hooks/hooks.json +0 -35
  51. package/extensions/enact-wiki/skills/wiki/SKILL.md +42 -0
  52. package/extensions/plugin-dev/.agents/plugin.json +4 -6
  53. package/extensions/plugin-dev/agents/plugin-validator.md +1 -1
  54. package/extensions/plugin-dev/skills/agent-development/SKILL.md +7 -7
  55. package/extensions/plugin-dev/{commands/create-plugin.md → skills/create-plugin/SKILL.md} +44 -37
  56. package/extensions/plugin-dev/skills/plugin-dev-guide/SKILL.md +13 -14
  57. package/extensions/plugin-dev/skills/skill-development/SKILL.md +0 -2
  58. package/extensions/plugin-dev/{commands/start.md → skills/start/SKILL.md} +7 -6
  59. package/package.json +11 -6
  60. package/scripts/check-hooks.mjs +174 -0
  61. package/scripts/check-principles.mjs +101 -0
  62. package/scripts/enact-extensions.mjs +87 -3
  63. package/scripts/lib/run-validate.mjs +36 -2
  64. package/scripts/lib/ups-router.mjs +432 -0
  65. package/spec/enact.json +4 -0
  66. package/spec/enact.md +5 -2
  67. package/extensions/cmux/.agents/plugin.json +0 -37
  68. package/extensions/cmux/skills/cmux/SKILL.md +0 -82
  69. package/extensions/cmux/skills/cmux/agents/openai.yaml +0 -4
  70. package/extensions/cmux/skills/cmux/references/handles-and-identify.md +0 -35
  71. package/extensions/cmux/skills/cmux/references/panes-surfaces.md +0 -37
  72. package/extensions/cmux/skills/cmux/references/trigger-flash-and-health.md +0 -23
  73. package/extensions/cmux/skills/cmux/references/windows-workspaces.md +0 -31
  74. package/extensions/cmux/skills/cmux-vm-monitor/SKILL.md +0 -122
  75. package/extensions/cmux/skills/cmux-vm-monitor/agents/openai.yaml +0 -4
  76. package/extensions/cmux/skills/cmux-vm-monitor/references/cmux-commands.md +0 -66
  77. package/extensions/cmux/skills/cmux-vm-monitor/scripts/codex_vm_monitor.sh +0 -45
  78. package/extensions/cmux/skills/cmux-workspace/SKILL.md +0 -93
  79. package/extensions/devops/.agents/plugin.json +0 -36
  80. package/extensions/devops/skills/azure-devops-cli/SKILL.md +0 -431
  81. package/extensions/devops/skills/azure-devops-cli/agents/openai.yaml +0 -4
  82. package/extensions/devops/skills/ci-pipeline-strategy/SKILL.md +0 -217
  83. package/extensions/devops/skills/ci-pipeline-strategy/agents/openai.yaml +0 -4
  84. package/extensions/enact-factory/hooks/user-prompt-submit.mjs +0 -67
  85. package/extensions/enact-operator/commands/doctor.md +0 -39
  86. package/extensions/enact-operator/commands/setup.md +0 -51
  87. package/extensions/plugin-dev/.mcp.json +0 -3
  88. package/extensions/plugin-dev/commands/_archive/create-marketplace.md +0 -427
  89. package/extensions/plugin-dev/commands/_archive/plugin-dev-guide.md +0 -12
  90. package/extensions/plugin-dev/hooks/hooks.json +0 -3
  91. package/extensions/plugin-dev/skills/command-development/SKILL.md +0 -763
  92. package/extensions/plugin-dev/skills/command-development/examples/plugin-commands.md +0 -612
  93. package/extensions/plugin-dev/skills/command-development/examples/simple-commands.md +0 -527
  94. package/extensions/plugin-dev/skills/command-development/references/advanced-workflows.md +0 -762
  95. package/extensions/plugin-dev/skills/command-development/references/documentation-patterns.md +0 -769
  96. package/extensions/plugin-dev/skills/command-development/references/frontmatter-reference.md +0 -508
  97. package/extensions/plugin-dev/skills/command-development/references/interactive-commands.md +0 -966
  98. package/extensions/plugin-dev/skills/command-development/references/marketplace-considerations.md +0 -943
  99. package/extensions/plugin-dev/skills/command-development/references/plugin-features-reference.md +0 -637
  100. package/extensions/plugin-dev/skills/command-development/references/plugin-integration.md +0 -191
  101. package/extensions/plugin-dev/skills/command-development/references/skill-tool.md +0 -447
  102. package/extensions/plugin-dev/skills/command-development/references/testing-strategies.md +0 -723
  103. package/extensions/plugin-dev/skills/command-development/scripts/check-frontmatter.sh +0 -234
  104. package/extensions/plugin-dev/skills/command-development/scripts/validate-command.sh +0 -160
  105. /package/extensions/enact-operator/{skills/_variants.md → docs/skill-variants.md} +0 -0
@@ -799,11 +799,95 @@ if (command === "serve") {
799
799
  process.exitCode = 0; // signal intent; overridden to 1 on error inside IIFE
800
800
  }
801
801
 
802
+ // ---------------------------------------------------------------------------
803
+ // `hook` command — execute a hook handler by name, reading JSON from stdin.
804
+ //
805
+ // Currently supported sub-commands:
806
+ // enact-extensions hook user-prompt-submit
807
+ // Reads a UserPromptSubmit hook payload from stdin, extracts the prompt,
808
+ // builds the skill catalog from the bundled extensions dir, routes the
809
+ // prompt, and writes the hook response to stdout.
810
+ //
811
+ // Output contract (Claude Code hooks):
812
+ // Match: { continue: true, hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: <string> } }
813
+ // No match: { continue: true }
814
+ // Error: { continue: true } (never throws/crashes — degrade gracefully)
815
+ // ---------------------------------------------------------------------------
816
+ if (command === "hook") {
817
+ const subCommand = path; // positional[1]
818
+
819
+ if (subCommand !== "user-prompt-submit") {
820
+ process.stderr.write(
821
+ `[enact-extensions hook] Unknown sub-command: ${subCommand ?? "(none)"}. ` +
822
+ `Supported: user-prompt-submit\n`,
823
+ );
824
+ process.stdout.write(JSON.stringify({ continue: true }) + "\n");
825
+ process.exit(0);
826
+ }
827
+
828
+ // Async IIFE: stdin reading and dynamic import require async context.
829
+ (async () => {
830
+ // Read stdin to get the hook payload JSON.
831
+ let payload;
832
+ try {
833
+ const chunks = [];
834
+ for await (const chunk of process.stdin) chunks.push(chunk);
835
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
836
+ payload = raw ? JSON.parse(raw) : {};
837
+ } catch {
838
+ // Malformed or empty stdin — safe passthrough.
839
+ process.stdout.write(JSON.stringify({ continue: true }) + "\n");
840
+ process.exit(0);
841
+ }
842
+
843
+ try {
844
+ // Extract the user prompt from the hook payload.
845
+ // `prompt` is the canonical UserPromptSubmit field across surfaces:
846
+ // Claude Code (code.claude.com/docs/en/hooks) and Codex
847
+ // (developers.openai.com/codex/hooks) both send { prompt: string, ... };
848
+ // Cursor follows the same Claude-compatible schema. Single field, no fallback.
849
+ const prompt = payload && typeof payload.prompt === "string" ? payload.prompt : null;
850
+
851
+ if (!prompt) {
852
+ process.stdout.write(JSON.stringify({ continue: true }) + "\n");
853
+ process.exit(0);
854
+ }
855
+
856
+ // Resolve the extensions dir relative to the installed package root so
857
+ // the hook works whether invoked from the repo or the globally installed package.
858
+ const { buildSkillCatalog, routePrompt } = await import("./lib/ups-router.mjs");
859
+ const extensionsDir = join(PACKAGE_ROOT, "extensions");
860
+ const catalog = buildSkillCatalog(extensionsDir);
861
+
862
+ const { additionalContext } = routePrompt(prompt, catalog);
863
+
864
+ if (additionalContext) {
865
+ process.stdout.write(
866
+ JSON.stringify({
867
+ continue: true,
868
+ hookSpecificOutput: {
869
+ hookEventName: "UserPromptSubmit",
870
+ additionalContext,
871
+ },
872
+ }) + "\n",
873
+ );
874
+ } else {
875
+ process.stdout.write(JSON.stringify({ continue: true }) + "\n");
876
+ }
877
+ } catch {
878
+ // Any unexpected error — safe passthrough, never crash.
879
+ process.stdout.write(JSON.stringify({ continue: true }) + "\n");
880
+ }
881
+
882
+ process.exit(0);
883
+ })();
884
+ }
885
+
802
886
  // ---------------------------------------------------------------------------
803
887
  // Commands that require resolving a plugin root path.
804
- // (Only run when command !== "serve" serve handled above and must not fall through.)
888
+ // (Only run when command is not already handled above.)
805
889
  // ---------------------------------------------------------------------------
806
- if (command !== "serve") {
890
+ if (command !== "serve" && command !== "hook") {
807
891
 
808
892
  // Resolve the plugin root:
809
893
  // - If no path arg given → default to cwd (existing behaviour).
@@ -838,7 +922,7 @@ try {
838
922
 
839
923
  try {
840
924
  if (command === "validate") {
841
- const ok = runValidate(pluginRoot);
925
+ const ok = runValidate(pluginRoot, undefined, { pluginValidate: true });
842
926
  process.exit(ok ? 0 : 1);
843
927
  }
844
928
 
@@ -1,3 +1,6 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
1
4
  import {
2
5
  validatePluginBundleFromCanonical,
3
6
  checkPluginBundleComponentPathsFromCanonical,
@@ -10,8 +13,16 @@ import {
10
13
  * read or created.
11
14
  *
12
15
  * For backward-compatibility, `platforms` defaults to all four surfaces.
16
+ *
17
+ * If the plugin's `.agents/plugin.json` declares a `validate` field (a CLI
18
+ * command string) AND `options.pluginValidate` is true, that command is also
19
+ * executed in the plugin root. A non-zero exit code is treated as a validation
20
+ * failure. `pluginValidate` defaults to false so that library callers (tests,
21
+ * programmatic use) are not affected by environment-specific CLI availability.
22
+ * The CLI sets it to true.
13
23
  */
14
- export function runValidate(pluginRoot, platforms) {
24
+ export function runValidate(pluginRoot, platforms, options = {}) {
25
+ const { pluginValidate = false } = options;
15
26
  const report = validatePluginBundleFromCanonical(pluginRoot, platforms);
16
27
  const warnings = checkPluginBundleComponentPathsFromCanonical(pluginRoot);
17
28
  const hookErrors = checkHookEvents(pluginRoot);
@@ -35,5 +46,28 @@ export function runValidate(pluginRoot, platforms) {
35
46
  console.log(`[warn] ${warn}`);
36
47
  }
37
48
 
38
- return report.ok && warnings.length === 0 && hookErrors.length === 0;
49
+ // Run the plugin's declared validate command when present and requested.
50
+ let pluginValidateOk = true;
51
+ const manifestPath = join(pluginRoot, ".agents", "plugin.json");
52
+ if (pluginValidate && existsSync(manifestPath)) {
53
+ let manifest;
54
+ try {
55
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
56
+ } catch {
57
+ // malformed manifest — schema checks above will have already flagged it
58
+ }
59
+ const validateCmd = manifest?.validate;
60
+ if (typeof validateCmd === "string" && validateCmd.trim().length > 0) {
61
+ console.log(`[validate] running plugin validate: ${validateCmd}`);
62
+ try {
63
+ execSync(validateCmd, { cwd: pluginRoot, stdio: "inherit" });
64
+ console.log(`[ok] plugin validate: ${validateCmd}`);
65
+ } catch (err) {
66
+ console.log(`[FAIL] plugin validate: ${validateCmd} (exit ${err.status ?? 1})`);
67
+ pluginValidateOk = false;
68
+ }
69
+ }
70
+ }
71
+
72
+ return report.ok && warnings.length === 0 && hookErrors.length === 0 && pluginValidateOk;
39
73
  }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * ups-router.mjs — pure, testable module for the UserPromptSubmit keyword-router.
3
+ *
4
+ * Discovers plugin bundles and their skills, extracts trigger keywords from
5
+ * SKILL.md frontmatter, and routes a user prompt to matching skills.
6
+ *
7
+ * Two exports:
8
+ * buildSkillCatalog(extensionsDir) → CatalogEntry[]
9
+ * routePrompt(prompt, catalog) → RouteResult
10
+ *
11
+ * Both are deterministic and side-effect free (no network, no writes, no clock).
12
+ */
13
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types (JSDoc)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * @typedef {{ skill: string, plugin: string, keywords: string[], invoke: string }} CatalogEntry
22
+ * @typedef {{ skill: string, plugin: string, invoke: string, reason: string }} RouteMatch
23
+ * @typedef {{ matches: RouteMatch[], additionalContext: string|null }} RouteResult
24
+ */
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Stop-word list — short or trivial tokens that should never fire a match.
28
+ // The list is intentionally broad: we want precision over recall in the router
29
+ // so that only genuinely skill-relevant keywords generate matches.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const STOP_WORDS = new Set([
33
+ // articles, conjunctions, prepositions
34
+ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
35
+ "of", "with", "by", "from", "up", "as", "via", "per",
36
+ // pronouns / determiners
37
+ "it", "its", "this", "that", "these", "those", "i", "we", "you", "he",
38
+ "she", "they", "my", "our", "your", "their", "its", "all", "any", "both",
39
+ // copula / auxiliaries
40
+ "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
41
+ "do", "does", "did", "will", "would", "could", "should", "may", "might",
42
+ "can", "shall", "must",
43
+ // high-frequency verbs that are not skill identifiers
44
+ "get", "set", "put", "let", "run", "use", "add", "see", "say", "ask",
45
+ "go", "give", "make", "take", "want", "need", "know", "keep", "find",
46
+ "call", "read", "work", "look", "show", "used", "each", "also", "into",
47
+ "when", "then", "than", "just", "more", "most", "only", "such", "even",
48
+ "now", "new", "not", "no", "so", "if", "how", "what", "which", "where",
49
+ "who", "why", "over", "after", "before", "about", "like",
50
+ // generic nouns too broad to be useful keywords
51
+ "task", "work", "file", "data", "code", "tool", "type", "name", "list",
52
+ "item", "part", "case", "time", "step", "way", "mode", "form", "end",
53
+ "start", "path", "user", "output", "input", "result", "context", "note",
54
+ "example", "reason", "detail", "value", "field", "key", "state", "stage",
55
+ ]);
56
+
57
+ /**
58
+ * Return true when token should be excluded as a keyword.
59
+ * Tokens shorter than 3 characters, or pure stop-words, are excluded.
60
+ *
61
+ * @param {string} token
62
+ * @returns {boolean}
63
+ */
64
+ function isStopWord(token) {
65
+ if (token.length < 3) return true;
66
+ return STOP_WORDS.has(token.toLowerCase());
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Frontmatter parsing
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Extract the YAML frontmatter block from a Markdown file's text.
75
+ * Returns null when no frontmatter block is found.
76
+ *
77
+ * @param {string} text
78
+ * @returns {string|null}
79
+ */
80
+ function extractFrontmatterBlock(text) {
81
+ if (!text.startsWith("---")) return null;
82
+ const m = /^---\n([\s\S]*?)\n---/.exec(text);
83
+ return m ? m[1] : null;
84
+ }
85
+
86
+ /**
87
+ * Parse a specific scalar field from a raw YAML frontmatter block.
88
+ * Handles both single-line `field: "value"` and block-scalar `field: >\n val`.
89
+ *
90
+ * @param {string} block Raw frontmatter text (between the --- delimiters).
91
+ * @param {string} field Field name to extract.
92
+ * @returns {string|null}
93
+ */
94
+ function parseFrontmatterField(block, field) {
95
+ // Match single-line scalar: field: value (quoted or bare).
96
+ // After extracting the value, skip it when it is a block-scalar indicator
97
+ // (">", ">-", "|", "|-", etc.) so those fall through to the block-scalar branch.
98
+ const singleLine = new RegExp(`(?:^|\\n)${field}:\\s*(?:["']([^"'\\n]*)["']|(.+))`, "m");
99
+ const sl = singleLine.exec(block);
100
+ if (sl) {
101
+ const raw = (sl[1] ?? sl[2] ?? "").trim();
102
+ // Reject YAML block-scalar indicators so we fall through to the block branch.
103
+ if (raw && !/^[>|]-?$/.test(raw)) {
104
+ return raw || null;
105
+ }
106
+ }
107
+
108
+ // Match block scalar (folded or literal): field: >-\n line1\n line2
109
+ const blockScalar = new RegExp(`(?:^|\\n)${field}:\\s*[>|]-?\\n((?: [^\\n]*\\n?)+)`, "m");
110
+ const bs = blockScalar.exec(block);
111
+ if (bs) {
112
+ return bs[1]
113
+ .split("\n")
114
+ .map((l) => l.replace(/^ /, "").trim())
115
+ .filter(Boolean)
116
+ .join(" ")
117
+ .trim() || null;
118
+ }
119
+
120
+ return null;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Keyword extraction from a SKILL.md description
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Extract explicit trigger keywords from a skill description.
129
+ * Looks for quoted phrases after "Triggers on" (e.g. Triggers on "foo", "bar").
130
+ *
131
+ * @param {string} description
132
+ * @returns {string[]}
133
+ */
134
+ function extractExplicitTriggers(description) {
135
+ const triggerSection = /[Tt]riggers?\s+on\s+(.*)/;
136
+ const m = triggerSection.exec(description);
137
+ if (!m) return [];
138
+ const section = m[1];
139
+ // Extract all double-quoted tokens from the trigger section.
140
+ const quoted = [];
141
+ const re = /"([^"]+)"/g;
142
+ let hit;
143
+ while ((hit = re.exec(section)) !== null) {
144
+ const phrase = hit[1].trim().toLowerCase();
145
+ if (phrase && !isStopWord(phrase)) quoted.push(phrase);
146
+ }
147
+ return quoted;
148
+ }
149
+
150
+ /**
151
+ * Derive salient keywords from a skill's description text.
152
+ *
153
+ * Conservative: only extracts tokens that are plausibly skill identifiers:
154
+ * - Hyphenated compound tokens (e.g. "contract-runner", "test-driven")
155
+ * - $skill-name mentions (e.g. "$loop", "$tdd")
156
+ * - Quoted short phrases other than the "Triggers on" section
157
+ * - Unquoted tokens that are longer (>=6 chars) and not stop-words
158
+ *
159
+ * @param {string} description
160
+ * @returns {string[]}
161
+ */
162
+ function descriptionKeywords(description) {
163
+ const tokens = new Set();
164
+
165
+ // $skill mentions: "$foo", "$foo-bar"
166
+ const dollarRe = /\$([a-z][a-z0-9-]+)/gi;
167
+ let m;
168
+ while ((m = dollarRe.exec(description)) !== null) {
169
+ tokens.add(m[1].toLowerCase());
170
+ }
171
+
172
+ // Hyphenated compound tokens: at least two segments, each 2+ chars
173
+ const hyphenRe = /\b([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)\b/gi;
174
+ while ((m = hyphenRe.exec(description)) !== null) {
175
+ const tok = m[1].toLowerCase();
176
+ if (!isStopWord(tok)) tokens.add(tok);
177
+ }
178
+
179
+ // Longer bare words (>=6 chars) that don't look like common English prose.
180
+ const longWordRe = /\b([a-z]{6,})\b/gi;
181
+ while ((m = longWordRe.exec(description)) !== null) {
182
+ const tok = m[1].toLowerCase();
183
+ if (!isStopWord(tok)) tokens.add(tok);
184
+ }
185
+
186
+ return [...tokens];
187
+ }
188
+
189
+ /**
190
+ * Derive the full keyword set for a single skill.
191
+ * Priority: explicit "Triggers on" > skill name > salient description terms.
192
+ * Deduplicates and excludes stop-words.
193
+ *
194
+ * @param {string} skillName
195
+ * @param {string|null} description
196
+ * @returns {string[]}
197
+ */
198
+ function deriveKeywords(skillName, description) {
199
+ const seen = new Set();
200
+ const push = (kw) => {
201
+ const k = kw.toLowerCase().trim();
202
+ if (k && !seen.has(k) && !isStopWord(k)) { seen.add(k); }
203
+ };
204
+
205
+ // Always include the skill name itself (unless it is a stop-word).
206
+ if (skillName) push(skillName);
207
+
208
+ if (description) {
209
+ // Explicit triggers have highest priority.
210
+ for (const t of extractExplicitTriggers(description)) push(t);
211
+ // Salient terms from the description.
212
+ for (const t of descriptionKeywords(description)) push(t);
213
+ }
214
+
215
+ return [...seen];
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Directory helpers
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function isDir(p) {
223
+ try { return statSync(p).isDirectory(); } catch { return false; }
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // buildSkillCatalog
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Discover all plugin bundles under extensionsDir, read each bundle's
232
+ * skills/<name>/SKILL.md, and build a catalog of { skill, plugin, keywords, invoke }.
233
+ *
234
+ * - Skips bundles without a .agents/plugin.json.
235
+ * - Skips skill dirs without a SKILL.md.
236
+ * - Skips malformed manifests / SKILL.md files without throwing.
237
+ * - Invocation form: "$<skill>" (the canonical invocation for all surfaces).
238
+ *
239
+ * @param {string} extensionsDir Absolute path to the extensions/ directory.
240
+ * @returns {CatalogEntry[]}
241
+ */
242
+ export function buildSkillCatalog(extensionsDir) {
243
+ const dir = resolve(extensionsDir);
244
+ if (!existsSync(dir)) return [];
245
+
246
+ let bundleNames;
247
+ try {
248
+ bundleNames = readdirSync(dir);
249
+ } catch {
250
+ return [];
251
+ }
252
+
253
+ const catalog = [];
254
+
255
+ for (const bundleName of bundleNames) {
256
+ const bundleDir = join(dir, bundleName);
257
+ if (!isDir(bundleDir)) continue;
258
+
259
+ const manifestPath = join(bundleDir, ".agents", "plugin.json");
260
+ if (!existsSync(manifestPath)) continue;
261
+
262
+ let manifest;
263
+ try {
264
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
265
+ } catch {
266
+ // malformed manifest — skip silently
267
+ continue;
268
+ }
269
+
270
+ const pluginName = (typeof manifest.name === "string" ? manifest.name : null) ?? bundleName;
271
+
272
+ const skillsDir = join(bundleDir, "skills");
273
+ if (!isDir(skillsDir)) continue;
274
+
275
+ let skillNames;
276
+ try {
277
+ skillNames = readdirSync(skillsDir);
278
+ } catch {
279
+ continue;
280
+ }
281
+
282
+ for (const skillName of skillNames) {
283
+ const skillDir = join(skillsDir, skillName);
284
+ if (!isDir(skillDir)) continue;
285
+
286
+ const skillMdPath = join(skillDir, "SKILL.md");
287
+ if (!existsSync(skillMdPath)) continue;
288
+
289
+ let skillText;
290
+ try {
291
+ skillText = readFileSync(skillMdPath, "utf8");
292
+ } catch {
293
+ continue;
294
+ }
295
+
296
+ const fmBlock = extractFrontmatterBlock(skillText);
297
+
298
+ // `name` from frontmatter (fall back to directory name).
299
+ const fmName = fmBlock ? parseFrontmatterField(fmBlock, "name") : null;
300
+ const resolvedName = fmName ?? skillName;
301
+
302
+ // `description` from frontmatter — both "description" and "desc" keys.
303
+ let description = null;
304
+ if (fmBlock) {
305
+ description = parseFrontmatterField(fmBlock, "description") ?? parseFrontmatterField(fmBlock, "desc");
306
+ }
307
+
308
+ const keywords = deriveKeywords(resolvedName, description ?? "");
309
+ const invoke = `$${resolvedName}`;
310
+
311
+ catalog.push({ skill: resolvedName, plugin: pluginName, keywords, invoke });
312
+ }
313
+ }
314
+
315
+ return catalog;
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // routePrompt
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /**
323
+ * Route a user prompt against the skill catalog.
324
+ *
325
+ * Matching strategy:
326
+ * 1. Explicit mention — `/skill-name` or `$skill-name` in the prompt (strong).
327
+ * 2. Keyword match — any non-stop-word keyword appears as a word-boundary token.
328
+ *
329
+ * Returns all matched skills (de-duplicated by skill name).
330
+ * No match → additionalContext: null (passthrough).
331
+ *
332
+ * @param {string|null|undefined} prompt
333
+ * @param {CatalogEntry[]} catalog
334
+ * @returns {RouteResult}
335
+ */
336
+ export function routePrompt(prompt, catalog) {
337
+ if (!prompt || typeof prompt !== "string") {
338
+ return { matches: [], additionalContext: null };
339
+ }
340
+
341
+ const promptLower = prompt.toLowerCase();
342
+
343
+ // Normalise catalog: skip bogus entries.
344
+ const validCatalog = (Array.isArray(catalog) ? catalog : []).filter(
345
+ (e) => e && typeof e.skill === "string" && Array.isArray(e.keywords),
346
+ );
347
+
348
+ const matchMap = new Map(); // skill → RouteMatch
349
+
350
+ for (const entry of validCatalog) {
351
+ if (matchMap.has(entry.skill)) continue; // already matched
352
+
353
+ const { skill, plugin, keywords, invoke } = entry;
354
+
355
+ // 1. Explicit /skill or $skill mention.
356
+ const explicitPatterns = [`/${skill.toLowerCase()}`, `$${skill.toLowerCase()}`];
357
+ for (const pat of explicitPatterns) {
358
+ if (promptLower.includes(pat)) {
359
+ matchMap.set(skill, {
360
+ skill,
361
+ plugin,
362
+ invoke: invoke ?? `$${skill}`,
363
+ reason: `explicit mention of "${pat}"`,
364
+ });
365
+ break;
366
+ }
367
+ }
368
+ if (matchMap.has(skill)) continue;
369
+
370
+ // 2. Keyword word-boundary match.
371
+ for (const kw of keywords) {
372
+ if (!kw || isStopWord(kw)) continue;
373
+ // Word-boundary check: kw must appear as a whole token (not substring of another word).
374
+ // Use a simple regex with \b-equivalent: check that surrounding chars are non-word.
375
+ const kwLower = kw.toLowerCase();
376
+ // Build a word-boundary regex. Escape regex metacharacters in kw.
377
+ const escaped = kwLower.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
378
+ const re = new RegExp(`(?<![a-z0-9_-])${escaped}(?![a-z0-9_-])`, "i");
379
+ if (re.test(promptLower)) {
380
+ matchMap.set(skill, {
381
+ skill,
382
+ plugin,
383
+ invoke: invoke ?? `$${skill}`,
384
+ reason: `keyword match on "${kw}"`,
385
+ });
386
+ break;
387
+ }
388
+ }
389
+ }
390
+
391
+ const matches = [...matchMap.values()];
392
+
393
+ if (matches.length === 0) {
394
+ return { matches: [], additionalContext: null };
395
+ }
396
+
397
+ const additionalContext = buildAdditionalContext(matches);
398
+ return { matches, additionalContext };
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // additionalContext builder
403
+ // ---------------------------------------------------------------------------
404
+
405
+ /**
406
+ * Build a human-readable additionalContext string that points the agent at
407
+ * the matching skills and how to invoke them.
408
+ *
409
+ * @param {RouteMatch[]} matches
410
+ * @returns {string}
411
+ */
412
+ function buildAdditionalContext(matches) {
413
+ const lines = [
414
+ "<!-- enact-extensions: UserPromptSubmit keyword-router -->",
415
+ `The following skill${matches.length === 1 ? "" : "s"} appear${matches.length === 1 ? "s" : ""} relevant to the user's prompt:`,
416
+ "",
417
+ ];
418
+
419
+ for (const m of matches) {
420
+ lines.push(`- **${m.skill}** (plugin: ${m.plugin})`);
421
+ lines.push(` Invoke with: \`${m.invoke}\``);
422
+ lines.push(` Matched because: ${m.reason}`);
423
+ lines.push("");
424
+ }
425
+
426
+ lines.push(
427
+ "If the user's intent aligns with one of these skills, invoke it using the form shown above.",
428
+ "If unsure, proceed with the default behaviour and ignore this hint.",
429
+ );
430
+
431
+ return lines.join("\n");
432
+ }
package/spec/enact.json CHANGED
@@ -105,6 +105,10 @@
105
105
  "description": "Relative path to default settings applied when the plugin is enabled.",
106
106
  "default": "./settings.json"
107
107
  },
108
+ "validate": {
109
+ "type": "string",
110
+ "description": "Optional CLI command run by 'enact-extensions validate' after schema checks. The command is executed in the plugin root; a non-zero exit fails validation. Allows plugins to declare a self-check (e.g. 'enact-loop doctor')."
111
+ },
108
112
  "bin": {
109
113
  "type": "string",
110
114
  "description": "Relative path to executables added to the Bash PATH while the plugin is active.",
package/spec/enact.md CHANGED
@@ -9,7 +9,8 @@ Extend what Enact can do, for example:
9
9
 
10
10
  - Install the enact-context plugin to give agents deep code intelligence and semantic search.
11
11
  - Install the enact-wiki plugin to persist knowledge across sessions and projects.
12
- - Install the enact-operator plugin to unlock factory workflows, team orchestration, and operator oversight.
12
+ - Install the enact-loop plugin to run durable contract-verified work with independent judgment grading.
13
+ - Install the enact-operator plugin for the full operator surface (team orchestration, HUD, doctor, and oversight tools).
13
14
 
14
15
  A plugin can contain:
15
16
 
@@ -85,10 +86,12 @@ If you want to keep a plugin installed but turn it off, set its entry in
85
86
  `~/.enact/config.toml` to `enabled = false`, then restart the agent:
86
87
 
87
88
  ```toml
88
- [plugins."enact-operator@enact-curated"]
89
+ [plugins."enact-loop@enact-curated"]
89
90
  enabled = false
90
91
  ```
91
92
 
93
+ > **Note:** enact-operator is also available in the registry for teams that need the full operator surface (HUD, doctor, team orchestration, oversight). enact-loop is the recommended default for contract-verified loop work.
94
+
92
95
  ## Build your own plugin
93
96
 
94
97
  Plugins live in a directory containing an `.agents/` folder with a `plugin.json`
@@ -1,37 +0,0 @@
1
- {
2
- "name": "cmux",
3
- "version": "0.1.0",
4
- "description": "cmux terminal and VM orchestration skills: split panes, workspace control, and VM monitoring for agent-driven sessions.",
5
- "author": {
6
- "name": "Amsterdam Data Labs"
7
- },
8
- "license": "UNLICENSED",
9
- "keywords": [
10
- "cmux",
11
- "terminal",
12
- "vm",
13
- "workspace",
14
- "orchestration"
15
- ],
16
- "targets": [
17
- "claude",
18
- "codex",
19
- "cursor"
20
- ],
21
- "skills": "./skills/",
22
- "interface": {
23
- "displayName": "cmux",
24
- "shortDescription": "Terminal/VM orchestration: panes, workspaces, and VM monitoring.",
25
- "developerName": "Amsterdam Data Labs",
26
- "category": "Developer Tools",
27
- "capabilities": [
28
- "terminal control",
29
- "workspace management",
30
- "vm monitoring"
31
- ]
32
- },
33
- "factory": {
34
- "firstParty": true,
35
- "operatorScope": "global"
36
- }
37
- }