@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.
- package/README.md +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +15 -1
- package/dist/install.js.map +1 -1
- package/dist/internal/agents.d.ts +29 -0
- package/dist/internal/agents.d.ts.map +1 -0
- package/dist/internal/agents.js +83 -0
- package/dist/internal/agents.js.map +1 -0
- package/extensions/enact-context/hooks/hooks.json +20 -0
- package/extensions/enact-core/.agents/plugin.json +39 -0
- package/extensions/enact-core/hooks/hooks.json +14 -0
- package/extensions/enact-factory/.agents/plugin.json +9 -3
- package/extensions/enact-factory/agents/architect.toml +30 -0
- package/extensions/enact-factory/agents/code-reviewer.toml +29 -0
- package/extensions/enact-factory/agents/critic.toml +35 -0
- package/extensions/enact-factory/agents/executor.toml +23 -0
- package/extensions/enact-factory/agents/explore.toml +22 -0
- package/extensions/enact-factory/agents/planner.toml +23 -0
- package/extensions/enact-factory/agents/verifier.toml +29 -0
- package/extensions/enact-factory/skills/ai-slop-cleaner/SKILL.md +52 -0
- package/extensions/enact-factory/skills/azdo-ci-strategy/SKILL.md +262 -0
- package/extensions/enact-factory/skills/azdo-ci-strategy/references/build-failures.md +60 -0
- package/extensions/enact-factory/skills/azdo-ci-strategy/references/cli-reference.md +87 -0
- package/extensions/enact-factory/skills/azdo-ci-strategy/references/policies-and-pipelines.md +132 -0
- package/extensions/enact-factory/skills/azdo-ci-strategy/references/troubleshooting.md +53 -0
- package/extensions/enact-factory/skills/deep-interview/SKILL.md +72 -0
- package/extensions/enact-factory/skills/drive-loop/SKILL.md +259 -0
- package/extensions/enact-factory/skills/drive-loop/references/contract-schema.md +107 -0
- package/extensions/enact-factory/skills/hyperplan/SKILL.md +51 -0
- package/extensions/enact-factory/skills/looplan/SKILL.md +103 -0
- package/extensions/enact-factory/skills/plan/SKILL.md +71 -0
- package/extensions/enact-factory/skills/remove-deadcode/SKILL.md +41 -0
- package/extensions/enact-factory/skills/research/SKILL.md +73 -0
- package/extensions/enact-factory/skills/review/SKILL.md +48 -0
- package/extensions/enact-factory/skills/security-research/SKILL.md +54 -0
- package/extensions/enact-factory/skills/tdd/SKILL.md +56 -0
- package/extensions/enact-factory/skills/trace/SKILL.md +37 -0
- package/extensions/enact-factory/skills/ultraqa/SKILL.md +79 -0
- package/extensions/enact-factory/skills/work-with-workitem/SKILL.md +51 -0
- package/extensions/enact-factory/skills/workitem-triage/SKILL.md +15 -0
- package/extensions/enact-loop/.agents/plugin.json +46 -0
- package/extensions/enact-loop/.mcp.json +1 -0
- package/extensions/enact-loop/hooks/hooks.json +27 -0
- package/extensions/enact-loop/skills/enact-loop/SKILL.md +327 -0
- package/extensions/enact-operator/.agents/plugin.json +0 -1
- package/extensions/enact-operator/hooks/hooks.json +0 -35
- package/extensions/enact-wiki/skills/wiki/SKILL.md +42 -0
- package/extensions/plugin-dev/.agents/plugin.json +4 -6
- package/extensions/plugin-dev/agents/plugin-validator.md +1 -1
- package/extensions/plugin-dev/skills/agent-development/SKILL.md +7 -7
- package/extensions/plugin-dev/{commands/create-plugin.md → skills/create-plugin/SKILL.md} +44 -37
- package/extensions/plugin-dev/skills/plugin-dev-guide/SKILL.md +13 -14
- package/extensions/plugin-dev/skills/skill-development/SKILL.md +0 -2
- package/extensions/plugin-dev/{commands/start.md → skills/start/SKILL.md} +7 -6
- package/package.json +11 -6
- package/scripts/check-hooks.mjs +174 -0
- package/scripts/check-principles.mjs +101 -0
- package/scripts/enact-extensions.mjs +87 -3
- package/scripts/lib/run-validate.mjs +36 -2
- package/scripts/lib/ups-router.mjs +432 -0
- package/spec/enact.json +4 -0
- package/spec/enact.md +5 -2
- package/extensions/cmux/.agents/plugin.json +0 -37
- package/extensions/cmux/skills/cmux/SKILL.md +0 -82
- package/extensions/cmux/skills/cmux/agents/openai.yaml +0 -4
- package/extensions/cmux/skills/cmux/references/handles-and-identify.md +0 -35
- package/extensions/cmux/skills/cmux/references/panes-surfaces.md +0 -37
- package/extensions/cmux/skills/cmux/references/trigger-flash-and-health.md +0 -23
- package/extensions/cmux/skills/cmux/references/windows-workspaces.md +0 -31
- package/extensions/cmux/skills/cmux-vm-monitor/SKILL.md +0 -122
- package/extensions/cmux/skills/cmux-vm-monitor/agents/openai.yaml +0 -4
- package/extensions/cmux/skills/cmux-vm-monitor/references/cmux-commands.md +0 -66
- package/extensions/cmux/skills/cmux-vm-monitor/scripts/codex_vm_monitor.sh +0 -45
- package/extensions/cmux/skills/cmux-workspace/SKILL.md +0 -93
- package/extensions/devops/.agents/plugin.json +0 -36
- package/extensions/devops/skills/azure-devops-cli/SKILL.md +0 -431
- package/extensions/devops/skills/azure-devops-cli/agents/openai.yaml +0 -4
- package/extensions/devops/skills/ci-pipeline-strategy/SKILL.md +0 -217
- package/extensions/devops/skills/ci-pipeline-strategy/agents/openai.yaml +0 -4
- package/extensions/enact-factory/hooks/user-prompt-submit.mjs +0 -67
- package/extensions/enact-operator/commands/doctor.md +0 -39
- package/extensions/enact-operator/commands/setup.md +0 -51
- package/extensions/plugin-dev/.mcp.json +0 -3
- package/extensions/plugin-dev/commands/_archive/create-marketplace.md +0 -427
- package/extensions/plugin-dev/commands/_archive/plugin-dev-guide.md +0 -12
- package/extensions/plugin-dev/hooks/hooks.json +0 -3
- package/extensions/plugin-dev/skills/command-development/SKILL.md +0 -763
- package/extensions/plugin-dev/skills/command-development/examples/plugin-commands.md +0 -612
- package/extensions/plugin-dev/skills/command-development/examples/simple-commands.md +0 -527
- package/extensions/plugin-dev/skills/command-development/references/advanced-workflows.md +0 -762
- package/extensions/plugin-dev/skills/command-development/references/documentation-patterns.md +0 -769
- package/extensions/plugin-dev/skills/command-development/references/frontmatter-reference.md +0 -508
- package/extensions/plugin-dev/skills/command-development/references/interactive-commands.md +0 -966
- package/extensions/plugin-dev/skills/command-development/references/marketplace-considerations.md +0 -943
- package/extensions/plugin-dev/skills/command-development/references/plugin-features-reference.md +0 -637
- package/extensions/plugin-dev/skills/command-development/references/plugin-integration.md +0 -191
- package/extensions/plugin-dev/skills/command-development/references/skill-tool.md +0 -447
- package/extensions/plugin-dev/skills/command-development/references/testing-strategies.md +0 -723
- package/extensions/plugin-dev/skills/command-development/scripts/check-frontmatter.sh +0 -234
- package/extensions/plugin-dev/skills/command-development/scripts/validate-command.sh +0 -160
- /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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
}
|