tackle-harness 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +259 -0
- package/README.md +261 -0
- package/bin/tackle.js +150 -0
- package/package.json +29 -0
- package/plugins/contracts/plugin-interface.js +244 -0
- package/plugins/core/hook-skill-gate/index.js +437 -0
- package/plugins/core/hook-skill-gate/plugin.json +12 -0
- package/plugins/core/provider-memory-store/index.js +403 -0
- package/plugins/core/provider-memory-store/plugin.json +9 -0
- package/plugins/core/provider-role-registry/index.js +477 -0
- package/plugins/core/provider-role-registry/plugin.json +9 -0
- package/plugins/core/provider-state-store/index.js +244 -0
- package/plugins/core/provider-state-store/plugin.json +9 -0
- package/plugins/core/skill-agent-dispatcher/plugin.json +13 -0
- package/plugins/core/skill-agent-dispatcher/skill.md +912 -0
- package/plugins/core/skill-batch-task-creator/plugin.json +13 -0
- package/plugins/core/skill-batch-task-creator/skill.md +616 -0
- package/plugins/core/skill-checklist/plugin.json +10 -0
- package/plugins/core/skill-checklist/skill.md +115 -0
- package/plugins/core/skill-completion-report/plugin.json +10 -0
- package/plugins/core/skill-completion-report/skill.md +331 -0
- package/plugins/core/skill-experience-logger/plugin.json +10 -0
- package/plugins/core/skill-experience-logger/skill.md +235 -0
- package/plugins/core/skill-human-checkpoint/plugin.json +10 -0
- package/plugins/core/skill-human-checkpoint/skill.md +194 -0
- package/plugins/core/skill-progress-tracker/plugin.json +10 -0
- package/plugins/core/skill-progress-tracker/skill.md +204 -0
- package/plugins/core/skill-role-manager/plugin.json +10 -0
- package/plugins/core/skill-role-manager/skill.md +252 -0
- package/plugins/core/skill-split-work-package/plugin.json +13 -0
- package/plugins/core/skill-split-work-package/skill.md +446 -0
- package/plugins/core/skill-task-creator/plugin.json +13 -0
- package/plugins/core/skill-task-creator/skill.md +744 -0
- package/plugins/core/skill-team-cleanup/plugin.json +10 -0
- package/plugins/core/skill-team-cleanup/skill.md +266 -0
- package/plugins/core/skill-workflow-orchestrator/plugin.json +13 -0
- package/plugins/core/skill-workflow-orchestrator/skill.md +274 -0
- package/plugins/core/validator-doc-sync/index.js +248 -0
- package/plugins/core/validator-doc-sync/plugin.json +9 -0
- package/plugins/core/validator-work-package/index.js +300 -0
- package/plugins/core/validator-work-package/plugin.json +9 -0
- package/plugins/plugin-registry.json +118 -0
- package/plugins/runtime/config-manager.js +306 -0
- package/plugins/runtime/event-bus.js +187 -0
- package/plugins/runtime/harness-build.js +1019 -0
- package/plugins/runtime/logger.js +174 -0
- package/plugins/runtime/plugin-loader.js +339 -0
- package/plugins/runtime/state-store.js +277 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tackle-harness",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Plugin-based AI Agent Harness framework for Claude Code",
|
|
5
|
+
"main": "plugins/runtime/harness-build.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tackle-harness": "bin/tackle.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"plugins/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "node bin/tackle.js build",
|
|
15
|
+
"validate": "node bin/tackle.js validate"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"agent",
|
|
21
|
+
"harness",
|
|
22
|
+
"plugin",
|
|
23
|
+
"workflow"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=14.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Interface - Plugin contract definitions for AI Agent Harness
|
|
3
|
+
*
|
|
4
|
+
* Defines the base interface and type markers for all plugin types:
|
|
5
|
+
* - ProviderPlugin: capability providers (role-registry, memory-store, etc.)
|
|
6
|
+
* - HookPlugin: lifecycle hooks (pre/post tool use)
|
|
7
|
+
* - ValidatorPlugin: output validators
|
|
8
|
+
* - SkillPlugin: executable skills
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Plugin lifecycle states
|
|
15
|
+
*/
|
|
16
|
+
const PluginState = {
|
|
17
|
+
DISCOVERED: 'discovered',
|
|
18
|
+
LOADED: 'loaded',
|
|
19
|
+
RESOLVED: 'resolved',
|
|
20
|
+
ACTIVATED: 'activated',
|
|
21
|
+
RUNNING: 'running',
|
|
22
|
+
DEACTIVATED: 'deactivated',
|
|
23
|
+
UNLOADED: 'unloaded',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Plugin type constants
|
|
28
|
+
*/
|
|
29
|
+
const PluginType = {
|
|
30
|
+
SKILL: 'skill',
|
|
31
|
+
HOOK: 'hook',
|
|
32
|
+
VALIDATOR: 'validator',
|
|
33
|
+
PROVIDER: 'provider',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Base Plugin class. All plugin implementations should extend this.
|
|
38
|
+
*
|
|
39
|
+
* Subclasses must set:
|
|
40
|
+
* - type {string} one of PluginType values
|
|
41
|
+
* - name {string} unique kebab-case identifier
|
|
42
|
+
* - version {string} semver version string
|
|
43
|
+
*
|
|
44
|
+
* Optional overrides:
|
|
45
|
+
* - description {string}
|
|
46
|
+
* - dependencies {{ plugins?: string[], providers?: string[] }}
|
|
47
|
+
* - onActivate(context) called during activation
|
|
48
|
+
* - onDeactivate() called during deactivation
|
|
49
|
+
*/
|
|
50
|
+
class Plugin {
|
|
51
|
+
constructor() {
|
|
52
|
+
/** @type {string} */
|
|
53
|
+
this.type = '';
|
|
54
|
+
/** @type {string} */
|
|
55
|
+
this.name = '';
|
|
56
|
+
/** @type {string} */
|
|
57
|
+
this.version = '0.0.0';
|
|
58
|
+
/** @type {string} */
|
|
59
|
+
this.description = '';
|
|
60
|
+
/** @type {{ plugins?: string[], providers?: string[] }} */
|
|
61
|
+
this.dependencies = {};
|
|
62
|
+
/** @type {string} current lifecycle state */
|
|
63
|
+
this.state = PluginState.DISCOVERED;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Called when the plugin is activated.
|
|
68
|
+
* @param {PluginContext} context - injected runtime context
|
|
69
|
+
*/
|
|
70
|
+
async onActivate(context) {
|
|
71
|
+
// default no-op
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Called when the plugin is deactivated.
|
|
76
|
+
*/
|
|
77
|
+
async onDeactivate() {
|
|
78
|
+
// default no-op
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* SkillPlugin - executable skill plugin
|
|
84
|
+
*
|
|
85
|
+
* Additional properties:
|
|
86
|
+
* - triggers {string[]} keywords that activate this skill
|
|
87
|
+
* - metadata.stage workflow stage
|
|
88
|
+
* - metadata.requiresPlanMode
|
|
89
|
+
* - metadata.gatedByHuman
|
|
90
|
+
* - metadata.gatedByCode
|
|
91
|
+
*/
|
|
92
|
+
class SkillPlugin extends Plugin {
|
|
93
|
+
constructor() {
|
|
94
|
+
super();
|
|
95
|
+
this.type = PluginType.SKILL;
|
|
96
|
+
/** @type {string[]} */
|
|
97
|
+
this.triggers = [];
|
|
98
|
+
/** @type {object} */
|
|
99
|
+
this.metadata = {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Execute the skill.
|
|
104
|
+
* @param {PluginContext} context
|
|
105
|
+
* @param {object} args - skill-specific arguments
|
|
106
|
+
* @returns {Promise<object>} skill result
|
|
107
|
+
*/
|
|
108
|
+
async execute(context, args) {
|
|
109
|
+
throw new Error('SkillPlugin.execute() must be implemented by subclass');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* HookPlugin - lifecycle hook plugin
|
|
115
|
+
*
|
|
116
|
+
* Additional properties:
|
|
117
|
+
* - trigger.event {'PreToolUse' | 'PostToolUse'}
|
|
118
|
+
* - trigger.tools {string[]} (optional) tool filter
|
|
119
|
+
* - trigger.skills {string[]} (optional) skill filter
|
|
120
|
+
* - priority {number} execution priority (lower = earlier)
|
|
121
|
+
*/
|
|
122
|
+
class HookPlugin extends Plugin {
|
|
123
|
+
constructor() {
|
|
124
|
+
super();
|
|
125
|
+
this.type = PluginType.HOOK;
|
|
126
|
+
/** @type {{ event: string, tools?: string[], skills?: string[] }} */
|
|
127
|
+
this.trigger = { event: '', tools: [], skills: [] };
|
|
128
|
+
/** @type {number} */
|
|
129
|
+
this.priority = 100;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle a hook invocation.
|
|
134
|
+
* @param {object} context - hook context
|
|
135
|
+
* @returns {Promise<{ allowed: boolean, reason?: string, stateChanges?: object[] }>}
|
|
136
|
+
*/
|
|
137
|
+
async handle(context) {
|
|
138
|
+
throw new Error('HookPlugin.handle() must be implemented by subclass');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* ValidatorPlugin - output validation plugin
|
|
144
|
+
*
|
|
145
|
+
* Additional properties:
|
|
146
|
+
* - targets {string[]} skill names this validator checks
|
|
147
|
+
* - blocking {boolean} whether failure stops the workflow
|
|
148
|
+
*/
|
|
149
|
+
class ValidatorPlugin extends Plugin {
|
|
150
|
+
constructor() {
|
|
151
|
+
super();
|
|
152
|
+
this.type = PluginType.VALIDATOR;
|
|
153
|
+
/** @type {string[]} */
|
|
154
|
+
this.targets = [];
|
|
155
|
+
/** @type {boolean} */
|
|
156
|
+
this.blocking = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run validation.
|
|
161
|
+
* @param {object} context - validation context
|
|
162
|
+
* @returns {Promise<{ passed: boolean, errors: object[], warnings: object[] }>}
|
|
163
|
+
*/
|
|
164
|
+
async validate(context) {
|
|
165
|
+
throw new Error('ValidatorPlugin.validate() must be implemented by subclass');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* ProviderPlugin - capability provider plugin
|
|
171
|
+
*
|
|
172
|
+
* Additional properties:
|
|
173
|
+
* - provides {string} capability identifier
|
|
174
|
+
*/
|
|
175
|
+
class ProviderPlugin extends Plugin {
|
|
176
|
+
constructor() {
|
|
177
|
+
super();
|
|
178
|
+
this.type = PluginType.PROVIDER;
|
|
179
|
+
/** @type {string} */
|
|
180
|
+
this.provides = '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create the provider instance.
|
|
185
|
+
* @param {PluginContext} context
|
|
186
|
+
* @returns {Promise<object>} provider instance
|
|
187
|
+
*/
|
|
188
|
+
async factory(context) {
|
|
189
|
+
throw new Error('ProviderPlugin.factory() must be implemented by subclass');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* PluginContext - injected into every plugin on activation.
|
|
195
|
+
*/
|
|
196
|
+
class PluginContext {
|
|
197
|
+
/**
|
|
198
|
+
* @param {string} pluginName
|
|
199
|
+
* @param {object} runtime - { eventBus, stateStore, logger, configManager }
|
|
200
|
+
*/
|
|
201
|
+
constructor(pluginName, runtime) {
|
|
202
|
+
this.pluginName = pluginName;
|
|
203
|
+
this.eventBus = runtime.eventBus;
|
|
204
|
+
this.stateStore = runtime.stateStore;
|
|
205
|
+
this.logger = runtime.logger;
|
|
206
|
+
this.config = runtime.configManager;
|
|
207
|
+
this._runtime = runtime;
|
|
208
|
+
this._providerCache = new Map();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Lazily query a provider by name.
|
|
213
|
+
* @param {string} name - provider identifier
|
|
214
|
+
* @returns {Promise<object>}
|
|
215
|
+
*/
|
|
216
|
+
async getProvider(name) {
|
|
217
|
+
if (this._providerCache.has(name)) {
|
|
218
|
+
return this._providerCache.get(name);
|
|
219
|
+
}
|
|
220
|
+
const provider = await this._runtime.getProvider(name);
|
|
221
|
+
this._providerCache.set(name, provider);
|
|
222
|
+
return provider;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get another loaded plugin by name.
|
|
227
|
+
* @param {string} name
|
|
228
|
+
* @returns {Plugin|undefined}
|
|
229
|
+
*/
|
|
230
|
+
getPlugin(name) {
|
|
231
|
+
return this._runtime.loadedPlugins.get(name);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
PluginState,
|
|
237
|
+
PluginType,
|
|
238
|
+
Plugin,
|
|
239
|
+
SkillPlugin,
|
|
240
|
+
HookPlugin,
|
|
241
|
+
ValidatorPlugin,
|
|
242
|
+
ProviderPlugin,
|
|
243
|
+
PluginContext,
|
|
244
|
+
};
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook: Skill Gate (Unified)
|
|
3
|
+
*
|
|
4
|
+
* Merges three hook behaviors into one plugin:
|
|
5
|
+
* 1. PreToolUse(Edit|Write) - block file edits when state is in a blocked state (e.g. "waiting")
|
|
6
|
+
* 2. PostToolUse(Skill) - set state to "waiting" after a gated skill completes
|
|
7
|
+
* 3. Dynamic gated skill query - read gated skills from plugin-registry.json metadata
|
|
8
|
+
*
|
|
9
|
+
* Usage (CLI):
|
|
10
|
+
* node plugins/core/hook-skill-gate/index.js --pre-tool (invoked by PreToolUse hook)
|
|
11
|
+
* node plugins/core/hook-skill-gate/index.js --post-skill (invoked by PostToolUse hook)
|
|
12
|
+
*
|
|
13
|
+
* Usage (Programmatic):
|
|
14
|
+
* const SkillGateHook = require('./index.js');
|
|
15
|
+
* const hook = new SkillGateHook();
|
|
16
|
+
* await hook.onActivate(context);
|
|
17
|
+
* const result = await hook.handle({ event: 'PreToolUse', tool: 'Edit', ... });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
var fs = require('fs');
|
|
23
|
+
var path = require('path');
|
|
24
|
+
var { HookPlugin } = require('../../contracts/plugin-interface');
|
|
25
|
+
var { StateStore, FileSystemAdapter } = require('../../runtime/state-store');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default configuration values.
|
|
29
|
+
*/
|
|
30
|
+
var DEFAULT_CONFIG = {
|
|
31
|
+
gatedSkills: [],
|
|
32
|
+
blockedStates: ['waiting'],
|
|
33
|
+
stateKey: 'harness.state',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the project root directory.
|
|
38
|
+
* Walks up from a starting directory to find task.md or .claude/.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} [startDir] - directory to start from (default: process.cwd())
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function resolveProjectRoot(startDir) {
|
|
44
|
+
var dir = startDir || process.cwd();
|
|
45
|
+
for (var i = 0; i < 10; i++) {
|
|
46
|
+
if (fs.existsSync(path.join(dir, 'task.md'))) return dir;
|
|
47
|
+
if (fs.existsSync(path.join(dir, '.claude'))) return dir;
|
|
48
|
+
var parent = path.dirname(dir);
|
|
49
|
+
if (parent === dir) break;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
return process.cwd();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read plugin-registry.json and return skill names that are marked as gated.
|
|
57
|
+
*
|
|
58
|
+
* A skill is considered "gated" if:
|
|
59
|
+
* - Its plugin.json has metadata.gatedByCode === true, OR
|
|
60
|
+
* - It appears in the hook-skill-gate config.gatedSkills list
|
|
61
|
+
*
|
|
62
|
+
* @param {string} projectRoot
|
|
63
|
+
* @param {object} hookConfig - the hook-skill-gate config section
|
|
64
|
+
* @returns {string[]} array of gated skill names
|
|
65
|
+
*/
|
|
66
|
+
function discoverGatedSkills(projectRoot, hookConfig) {
|
|
67
|
+
var registryPath = path.join(projectRoot, 'plugins', 'plugin-registry.json');
|
|
68
|
+
var gatedFromMetadata = [];
|
|
69
|
+
var gatedFromConfig = (hookConfig && hookConfig.gatedSkills) || [];
|
|
70
|
+
|
|
71
|
+
// 1. Read registry and scan each plugin for metadata.gatedByCode
|
|
72
|
+
try {
|
|
73
|
+
var content = fs.readFileSync(registryPath, 'utf-8');
|
|
74
|
+
var registry = JSON.parse(content);
|
|
75
|
+
var plugins = registry.plugins || [];
|
|
76
|
+
|
|
77
|
+
for (var i = 0; i < plugins.length; i++) {
|
|
78
|
+
var entry = plugins[i];
|
|
79
|
+
if (!entry.source) continue;
|
|
80
|
+
|
|
81
|
+
// Attempt to read the plugin's plugin.json for metadata
|
|
82
|
+
var pluginJsonPath = path.join(
|
|
83
|
+
projectRoot,
|
|
84
|
+
'plugins',
|
|
85
|
+
entry.source,
|
|
86
|
+
'plugin.json'
|
|
87
|
+
);
|
|
88
|
+
try {
|
|
89
|
+
var pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf-8'));
|
|
90
|
+
if (
|
|
91
|
+
pluginJson.metadata &&
|
|
92
|
+
(pluginJson.metadata.gatedByCode === true ||
|
|
93
|
+
pluginJson.metadata.gatedByHuman === true)
|
|
94
|
+
) {
|
|
95
|
+
gatedFromMetadata.push(pluginJson.name || entry.name);
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// plugin.json may not exist or be unreadable - skip
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// Registry may not exist - continue with config-only list
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Merge and deduplicate
|
|
106
|
+
var all = gatedFromMetadata.concat(gatedFromConfig);
|
|
107
|
+
var seen = {};
|
|
108
|
+
var result = [];
|
|
109
|
+
for (var j = 0; j < all.length; j++) {
|
|
110
|
+
if (!seen[all[j]]) {
|
|
111
|
+
seen[all[j]] = true;
|
|
112
|
+
result.push(all[j]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* SkillGateHook - Unified skill gate hook plugin.
|
|
121
|
+
*
|
|
122
|
+
* Extends HookPlugin from the plugin interface contract.
|
|
123
|
+
*/
|
|
124
|
+
class SkillGateHook extends HookPlugin {
|
|
125
|
+
constructor() {
|
|
126
|
+
super();
|
|
127
|
+
this.name = 'hook-skill-gate';
|
|
128
|
+
this.version = '1.0.0';
|
|
129
|
+
this.description = '统一技能门控 Hook';
|
|
130
|
+
this.dependencies = { providers: ['provider:state-store'] };
|
|
131
|
+
this.trigger = {
|
|
132
|
+
event: 'PreToolUse',
|
|
133
|
+
tools: ['Edit', 'Write'],
|
|
134
|
+
skills: [],
|
|
135
|
+
};
|
|
136
|
+
this.priority = 10;
|
|
137
|
+
|
|
138
|
+
/** @type {StateStore|null} */
|
|
139
|
+
this._store = null;
|
|
140
|
+
/** @type {object} */
|
|
141
|
+
this._config = Object.assign({}, DEFAULT_CONFIG);
|
|
142
|
+
/** @type {string} */
|
|
143
|
+
this._projectRoot = '';
|
|
144
|
+
/** @type {string[]|null} cached gated skills */
|
|
145
|
+
this._gatedSkillsCache = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Called during plugin activation.
|
|
150
|
+
* Initializes state store and reads config.
|
|
151
|
+
*
|
|
152
|
+
* @param {PluginContext} context
|
|
153
|
+
*/
|
|
154
|
+
async onActivate(context) {
|
|
155
|
+
this._projectRoot = resolveProjectRoot();
|
|
156
|
+
var stateFilePath = path.join(this._projectRoot, '.claude-state');
|
|
157
|
+
this._store = new StateStore({ filePath: stateFilePath });
|
|
158
|
+
|
|
159
|
+
// Merge context config if available
|
|
160
|
+
if (context && context.config) {
|
|
161
|
+
var pluginConfig = context.config.getPluginConfig
|
|
162
|
+
? context.config.getPluginConfig(this.name)
|
|
163
|
+
: null;
|
|
164
|
+
if (pluginConfig) {
|
|
165
|
+
for (var key in pluginConfig) {
|
|
166
|
+
if (pluginConfig.hasOwnProperty(key)) {
|
|
167
|
+
this._config[key] = pluginConfig[key];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Initial discovery of gated skills
|
|
174
|
+
this._gatedSkillsCache = discoverGatedSkills(this._projectRoot, this._config);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Main hook handler.
|
|
179
|
+
* Dispatches to the appropriate sub-handler based on the event type.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} context - hook context
|
|
182
|
+
* @param {string} context.event - 'PreToolUse' or 'PostToolUse'
|
|
183
|
+
* @param {string} [context.tool] - tool name (for PreToolUse)
|
|
184
|
+
* @param {string} [context.skill] - skill name (for PostToolUse)
|
|
185
|
+
* @returns {Promise<{ allowed: boolean, reason?: string, stateChanges?: object[] }>}
|
|
186
|
+
*/
|
|
187
|
+
async handle(context) {
|
|
188
|
+
if (!context || !context.event) {
|
|
189
|
+
return { allowed: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (context.event === 'PreToolUse') {
|
|
193
|
+
return this._handlePreToolUse(context);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (context.event === 'PostToolUse') {
|
|
197
|
+
return this._handlePostToolUse(context);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { allowed: true };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* PreToolUse handler - block Edit/Write when state is in a blocked state.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} context
|
|
207
|
+
* @param {string} context.tool - the tool being used ('Edit', 'Write', etc.)
|
|
208
|
+
* @returns {Promise<{ allowed: boolean, reason?: string }>}
|
|
209
|
+
*/
|
|
210
|
+
async _handlePreToolUse(context) {
|
|
211
|
+
var tool = context.tool || '';
|
|
212
|
+
var restrictedTools = ['Edit', 'Write'];
|
|
213
|
+
|
|
214
|
+
// Only gate Edit and Write operations
|
|
215
|
+
var isRestricted = false;
|
|
216
|
+
for (var i = 0; i < restrictedTools.length; i++) {
|
|
217
|
+
if (tool === restrictedTools[i]) {
|
|
218
|
+
isRestricted = true;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!isRestricted) {
|
|
224
|
+
return { allowed: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Read current state from store
|
|
228
|
+
var currentState = await this._getState();
|
|
229
|
+
var blockedStates = this._config.blockedStates || ['waiting'];
|
|
230
|
+
|
|
231
|
+
for (var j = 0; j < blockedStates.length; j++) {
|
|
232
|
+
if (currentState === blockedStates[j]) {
|
|
233
|
+
return {
|
|
234
|
+
allowed: false,
|
|
235
|
+
reason:
|
|
236
|
+
'Blocked: current state is "' +
|
|
237
|
+
currentState +
|
|
238
|
+
'". ' +
|
|
239
|
+
'Edit/Write operations are not allowed while waiting for human confirmation.',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { allowed: true };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* PostToolUse handler - after a gated skill executes, set state to "waiting".
|
|
249
|
+
*
|
|
250
|
+
* @param {object} context
|
|
251
|
+
* @param {string} context.tool - tool name (should be 'Skill')
|
|
252
|
+
* @param {string} [context.skill] - the skill that was executed
|
|
253
|
+
* @param {string} [context.skillName] - alternative field for skill name
|
|
254
|
+
* @returns {Promise<{ allowed: boolean, handled: boolean, newState?: string, stateChanges?: object[] }>}
|
|
255
|
+
*/
|
|
256
|
+
async _handlePostToolUse(context) {
|
|
257
|
+
var tool = context.tool || '';
|
|
258
|
+
if (tool !== 'Skill') {
|
|
259
|
+
return { allowed: true, handled: false };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Get the skill name from context
|
|
263
|
+
var skillName = context.skill || context.skillName || '';
|
|
264
|
+
if (!skillName) {
|
|
265
|
+
return { allowed: true, handled: false };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Refresh gated skills list (dynamic query)
|
|
269
|
+
var gatedSkills = this._getGatedSkills();
|
|
270
|
+
|
|
271
|
+
// Check if the skill that just ran is gated
|
|
272
|
+
var isGated = false;
|
|
273
|
+
for (var i = 0; i < gatedSkills.length; i++) {
|
|
274
|
+
if (gatedSkills[i] === skillName) {
|
|
275
|
+
isGated = true;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!isGated) {
|
|
281
|
+
return { allowed: true, handled: false };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Set state to "waiting" to pause for human confirmation
|
|
285
|
+
var waitingState = 'waiting';
|
|
286
|
+
await this._setState(waitingState);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
allowed: true,
|
|
290
|
+
handled: true,
|
|
291
|
+
newState: waitingState,
|
|
292
|
+
stateChanges: [
|
|
293
|
+
{
|
|
294
|
+
key: this._config.stateKey,
|
|
295
|
+
oldValue: await this._getState(),
|
|
296
|
+
newValue: waitingState,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get the current list of gated skills.
|
|
304
|
+
* Refreshes from registry on each call for dynamic discovery.
|
|
305
|
+
*
|
|
306
|
+
* @returns {string[]}
|
|
307
|
+
*/
|
|
308
|
+
_getGatedSkills() {
|
|
309
|
+
this._gatedSkillsCache = discoverGatedSkills(
|
|
310
|
+
this._projectRoot,
|
|
311
|
+
this._config
|
|
312
|
+
);
|
|
313
|
+
return this._gatedSkillsCache;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Read the current state from the store.
|
|
318
|
+
* @returns {Promise<string|undefined>}
|
|
319
|
+
*/
|
|
320
|
+
async _getState() {
|
|
321
|
+
if (!this._store) return undefined;
|
|
322
|
+
return await this._store.get(this._config.stateKey);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Write a new state to the store.
|
|
327
|
+
* @param {string} value
|
|
328
|
+
* @returns {Promise<void>}
|
|
329
|
+
*/
|
|
330
|
+
async _setState(value) {
|
|
331
|
+
if (!this._store) return;
|
|
332
|
+
await this._store.set(this._config.stateKey, value);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- CLI Entry Point ---
|
|
337
|
+
//
|
|
338
|
+
// When invoked via `node index.js --pre-tool` or `node index.js --post-skill`,
|
|
339
|
+
// reads stdin for the hook context JSON and outputs a JSON result.
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read all data from stdin and parse as JSON.
|
|
343
|
+
* @param {function} callback - callback(error, data)
|
|
344
|
+
*/
|
|
345
|
+
function readStdin(callback) {
|
|
346
|
+
var chunks = [];
|
|
347
|
+
process.stdin.setEncoding('utf-8');
|
|
348
|
+
process.stdin.on('data', function (chunk) {
|
|
349
|
+
chunks.push(chunk);
|
|
350
|
+
});
|
|
351
|
+
process.stdin.on('end', function () {
|
|
352
|
+
var raw = chunks.join('');
|
|
353
|
+
if (!raw.trim()) {
|
|
354
|
+
return callback(null, {});
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
var parsed = JSON.parse(raw);
|
|
358
|
+
callback(null, parsed);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
callback(e);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
process.stdin.on('error', function (err) {
|
|
364
|
+
callback(err);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Main CLI function.
|
|
370
|
+
*/
|
|
371
|
+
function main() {
|
|
372
|
+
var args = process.argv.slice(2);
|
|
373
|
+
|
|
374
|
+
if (args.length === 0) {
|
|
375
|
+
console.error(
|
|
376
|
+
'Usage: node index.js --pre-tool | --post-skill\n' +
|
|
377
|
+
' --pre-tool Run PreToolUse check (reads context from stdin)\n' +
|
|
378
|
+
' --post-skill Run PostToolUse handler (reads context from stdin)'
|
|
379
|
+
);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
var mode = args[0];
|
|
384
|
+
if (mode !== '--pre-tool' && mode !== '--post-skill') {
|
|
385
|
+
console.error('Unknown mode: ' + mode);
|
|
386
|
+
console.error('Valid modes: --pre-tool, --post-skill');
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
readStdin(function (err, context) {
|
|
391
|
+
if (err) {
|
|
392
|
+
console.error('Failed to read stdin: ' + err.message);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
var hook = new SkillGateHook();
|
|
397
|
+
|
|
398
|
+
// Activate the hook (initialize state store, etc.)
|
|
399
|
+
hook
|
|
400
|
+
.onActivate(null)
|
|
401
|
+
.then(function () {
|
|
402
|
+
// Set the event type based on CLI mode
|
|
403
|
+
if (mode === '--pre-tool') {
|
|
404
|
+
context.event = 'PreToolUse';
|
|
405
|
+
} else if (mode === '--post-skill') {
|
|
406
|
+
context.event = 'PostToolUse';
|
|
407
|
+
// Normalize: if tool is not set, default to 'Skill' for post-skill mode
|
|
408
|
+
if (!context.tool) {
|
|
409
|
+
context.tool = 'Skill';
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return hook.handle(context);
|
|
414
|
+
})
|
|
415
|
+
.then(function (result) {
|
|
416
|
+
// Output the result as JSON
|
|
417
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
418
|
+
|
|
419
|
+
// Exit with code 0 if allowed, 2 if blocked (Claude Code hook convention)
|
|
420
|
+
if (result.allowed === false) {
|
|
421
|
+
process.exit(2);
|
|
422
|
+
}
|
|
423
|
+
process.exit(0);
|
|
424
|
+
})
|
|
425
|
+
.catch(function (error) {
|
|
426
|
+
console.error('Hook execution failed: ' + error.message);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Run CLI if executed directly (not required as a module)
|
|
433
|
+
if (require.main === module) {
|
|
434
|
+
main();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
module.exports = SkillGateHook;
|