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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +259 -0
  3. package/README.md +261 -0
  4. package/bin/tackle.js +150 -0
  5. package/package.json +29 -0
  6. package/plugins/contracts/plugin-interface.js +244 -0
  7. package/plugins/core/hook-skill-gate/index.js +437 -0
  8. package/plugins/core/hook-skill-gate/plugin.json +12 -0
  9. package/plugins/core/provider-memory-store/index.js +403 -0
  10. package/plugins/core/provider-memory-store/plugin.json +9 -0
  11. package/plugins/core/provider-role-registry/index.js +477 -0
  12. package/plugins/core/provider-role-registry/plugin.json +9 -0
  13. package/plugins/core/provider-state-store/index.js +244 -0
  14. package/plugins/core/provider-state-store/plugin.json +9 -0
  15. package/plugins/core/skill-agent-dispatcher/plugin.json +13 -0
  16. package/plugins/core/skill-agent-dispatcher/skill.md +912 -0
  17. package/plugins/core/skill-batch-task-creator/plugin.json +13 -0
  18. package/plugins/core/skill-batch-task-creator/skill.md +616 -0
  19. package/plugins/core/skill-checklist/plugin.json +10 -0
  20. package/plugins/core/skill-checklist/skill.md +115 -0
  21. package/plugins/core/skill-completion-report/plugin.json +10 -0
  22. package/plugins/core/skill-completion-report/skill.md +331 -0
  23. package/plugins/core/skill-experience-logger/plugin.json +10 -0
  24. package/plugins/core/skill-experience-logger/skill.md +235 -0
  25. package/plugins/core/skill-human-checkpoint/plugin.json +10 -0
  26. package/plugins/core/skill-human-checkpoint/skill.md +194 -0
  27. package/plugins/core/skill-progress-tracker/plugin.json +10 -0
  28. package/plugins/core/skill-progress-tracker/skill.md +204 -0
  29. package/plugins/core/skill-role-manager/plugin.json +10 -0
  30. package/plugins/core/skill-role-manager/skill.md +252 -0
  31. package/plugins/core/skill-split-work-package/plugin.json +13 -0
  32. package/plugins/core/skill-split-work-package/skill.md +446 -0
  33. package/plugins/core/skill-task-creator/plugin.json +13 -0
  34. package/plugins/core/skill-task-creator/skill.md +744 -0
  35. package/plugins/core/skill-team-cleanup/plugin.json +10 -0
  36. package/plugins/core/skill-team-cleanup/skill.md +266 -0
  37. package/plugins/core/skill-workflow-orchestrator/plugin.json +13 -0
  38. package/plugins/core/skill-workflow-orchestrator/skill.md +274 -0
  39. package/plugins/core/validator-doc-sync/index.js +248 -0
  40. package/plugins/core/validator-doc-sync/plugin.json +9 -0
  41. package/plugins/core/validator-work-package/index.js +300 -0
  42. package/plugins/core/validator-work-package/plugin.json +9 -0
  43. package/plugins/plugin-registry.json +118 -0
  44. package/plugins/runtime/config-manager.js +306 -0
  45. package/plugins/runtime/event-bus.js +187 -0
  46. package/plugins/runtime/harness-build.js +1019 -0
  47. package/plugins/runtime/logger.js +174 -0
  48. package/plugins/runtime/plugin-loader.js +339 -0
  49. package/plugins/runtime/state-store.js +277 -0
@@ -0,0 +1,1019 @@
1
+ /**
2
+ * HarnessBuild - Plugin-to-native format builder for AI Agent Harness
3
+ *
4
+ * Reads plugins from plugin-registry.json, converts them to Claude Code
5
+ * native skill/hook format, and outputs to .claude/skills/ and .claude/hooks/.
6
+ *
7
+ * Usage:
8
+ * node plugins/runtime/harness-build.js # build all plugins
9
+ * node plugins/runtime/harness-build.js --validate # validate only
10
+ *
11
+ * Features:
12
+ * - Builds skill plugins -> .claude/skills/{name}/skill.md
13
+ * - Builds hook plugins -> .claude/hooks/{name}/index.js
14
+ * - Validates plugin.json required fields
15
+ * - Handles empty registries gracefully
16
+ * - Build report with plugin counts and output paths
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ var fs = require('fs');
22
+ var path = require('path');
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ var PLUGIN_REQUIRED_FIELDS = ['name', 'version', 'type', 'description'];
29
+ var VALID_PLUGIN_TYPES = ['skill', 'hook', 'validator', 'provider'];
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // HarnessBuild class
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * @param {object} [options]
37
+ * @param {string} [options.rootDir] - project root directory
38
+ * @param {string} [options.registryPath] - override path to plugin-registry.json
39
+ * @param {string} [options.pluginsDir] - override path to plugins/core/
40
+ * @param {string} [options.outputSkillsDir] - override output .claude/skills/
41
+ * @param {string} [options.outputHooksDir] - override output .claude/hooks/
42
+ */
43
+ function HarnessBuild(options) {
44
+ options = options || {};
45
+
46
+ this._rootDir = options.rootDir || process.cwd();
47
+ this._registryPath = options.registryPath || path.join(this._rootDir, 'plugins', 'plugin-registry.json');
48
+ this._pluginsDir = options.pluginsDir || path.join(this._rootDir, 'plugins', 'core');
49
+ this._outputSkillsDir = options.outputSkillsDir || path.join(this._rootDir, '.claude', 'skills');
50
+ this._outputHooksDir = options.outputHooksDir || path.join(this._rootDir, '.claude', 'hooks');
51
+
52
+ /** @type {object[]} validation errors collected during --validate */
53
+ this._validationErrors = [];
54
+ /** @type {object[]} validation warnings */
55
+ this._validationWarnings = [];
56
+ /** @type {object[]} build results */
57
+ this._buildResults = [];
58
+ /** @type {object|null} cached harness config */
59
+ this._harnessConfig = null;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Public API
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Run validation on all plugins listed in the registry.
68
+ * Checks plugin.json format, required fields, and companion files.
69
+ *
70
+ * @returns {{ valid: boolean, errors: object[], warnings: object[], summary: string }}
71
+ */
72
+ HarnessBuild.prototype.validate = function validate() {
73
+ this._validationErrors = [];
74
+ this._validationWarnings = [];
75
+
76
+ var registry = this._readRegistry();
77
+ var pluginEntries = this._getPluginEntries(registry);
78
+
79
+ if (pluginEntries.length === 0) {
80
+ return {
81
+ valid: true,
82
+ errors: [],
83
+ warnings: [],
84
+ summary: 'Registry is empty, nothing to validate.',
85
+ };
86
+ }
87
+
88
+ for (var i = 0; i < pluginEntries.length; i++) {
89
+ this._validatePlugin(pluginEntries[i]);
90
+ }
91
+
92
+ var valid = this._validationErrors.length === 0;
93
+ var summary = this._formatValidationSummary(pluginEntries.length);
94
+
95
+ return {
96
+ valid: valid,
97
+ errors: this._validationErrors,
98
+ warnings: this._validationWarnings,
99
+ summary: summary,
100
+ };
101
+ };
102
+
103
+ /**
104
+ * Build all plugins from the registry into Claude Code native format.
105
+ *
106
+ * @returns {{ success: boolean, built: object[], errors: object[], summary: string }}
107
+ */
108
+ HarnessBuild.prototype.build = function build() {
109
+ this._buildResults = [];
110
+
111
+ var registry = this._readRegistry();
112
+ var pluginEntries = this._getPluginEntries(registry);
113
+
114
+ if (pluginEntries.length === 0) {
115
+ return {
116
+ success: true,
117
+ built: [],
118
+ errors: [],
119
+ summary: 'Registry is empty, build produced no output.',
120
+ };
121
+ }
122
+
123
+ var buildErrors = [];
124
+
125
+ for (var i = 0; i < pluginEntries.length; i++) {
126
+ try {
127
+ var result = this._buildPlugin(pluginEntries[i]);
128
+ this._buildResults.push(result);
129
+ } catch (err) {
130
+ buildErrors.push({
131
+ plugin: pluginEntries[i].name || 'unknown',
132
+ error: err.message,
133
+ });
134
+ }
135
+ }
136
+
137
+ var success = buildErrors.length === 0;
138
+ var summary = this._formatBuildSummary(this._buildResults, buildErrors);
139
+
140
+ return {
141
+ success: success,
142
+ built: this._buildResults,
143
+ errors: buildErrors,
144
+ summary: summary,
145
+ };
146
+ };
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Registry reading
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Read and parse the plugin registry.
154
+ * @returns {object}
155
+ */
156
+ HarnessBuild.prototype._readRegistry = function _readRegistry() {
157
+ try {
158
+ var content = fs.readFileSync(this._registryPath, 'utf-8');
159
+ return JSON.parse(content);
160
+ } catch (err) {
161
+ this._log('warn', 'Could not read registry: ' + err.message + '. Using empty registry.');
162
+ return { version: '1.0.0', plugins: [] };
163
+ }
164
+ };
165
+
166
+ /**
167
+ * Extract plugin entries from the registry.
168
+ * Supports array format: [{ name, source, enabled, config }]
169
+ *
170
+ * @param {object} registry
171
+ * @returns {object[]}
172
+ */
173
+ HarnessBuild.prototype._getPluginEntries = function _getPluginEntries(registry) {
174
+ var plugins = registry.plugins;
175
+ if (!plugins || !Array.isArray(plugins)) {
176
+ return [];
177
+ }
178
+ // Filter out disabled plugins
179
+ var entries = [];
180
+ for (var i = 0; i < plugins.length; i++) {
181
+ var entry = plugins[i];
182
+ if (entry && entry.enabled !== false) {
183
+ entries.push(entry);
184
+ }
185
+ }
186
+ return entries;
187
+ };
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Validation
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Validate a single plugin entry.
195
+ * @param {object} entry - registry entry with at least a name field
196
+ */
197
+ HarnessBuild.prototype._validatePlugin = function _validatePlugin(entry) {
198
+ var pluginName = entry.name || entry.source || 'unknown';
199
+ var pluginDir = this._resolvePluginDir(entry);
200
+
201
+ // 1. Check plugin directory exists
202
+ if (!fs.existsSync(pluginDir)) {
203
+ this._validationErrors.push({
204
+ plugin: pluginName,
205
+ field: 'directory',
206
+ message: 'Plugin directory not found: ' + pluginDir,
207
+ });
208
+ return; // can't validate further without directory
209
+ }
210
+
211
+ // 2. Check plugin.json exists
212
+ var metaPath = path.join(pluginDir, 'plugin.json');
213
+ if (!fs.existsSync(metaPath)) {
214
+ this._validationErrors.push({
215
+ plugin: pluginName,
216
+ field: 'plugin.json',
217
+ message: 'plugin.json not found in ' + pluginDir,
218
+ });
219
+ return;
220
+ }
221
+
222
+ // 3. Parse and validate plugin.json
223
+ var meta;
224
+ try {
225
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
226
+ } catch (err) {
227
+ this._validationErrors.push({
228
+ plugin: pluginName,
229
+ field: 'plugin.json',
230
+ message: 'Invalid JSON in plugin.json: ' + err.message,
231
+ });
232
+ return;
233
+ }
234
+
235
+ // 4. Check required fields
236
+ for (var i = 0; i < PLUGIN_REQUIRED_FIELDS.length; i++) {
237
+ var field = PLUGIN_REQUIRED_FIELDS[i];
238
+ if (!meta[field]) {
239
+ this._validationErrors.push({
240
+ plugin: pluginName,
241
+ field: field,
242
+ message: 'Missing required field: ' + field,
243
+ });
244
+ }
245
+ }
246
+
247
+ // 5. Check plugin type is valid
248
+ if (meta.type && VALID_PLUGIN_TYPES.indexOf(meta.type) === -1) {
249
+ this._validationErrors.push({
250
+ plugin: pluginName,
251
+ field: 'type',
252
+ message: 'Invalid plugin type: "' + meta.type + '". Must be one of: ' + VALID_PLUGIN_TYPES.join(', '),
253
+ });
254
+ }
255
+
256
+ // 6. Type-specific file checks
257
+ if (meta.type === 'skill') {
258
+ var skillMdPath = path.join(pluginDir, 'skill.md');
259
+ if (!fs.existsSync(skillMdPath)) {
260
+ this._validationErrors.push({
261
+ plugin: pluginName,
262
+ field: 'skill.md',
263
+ message: 'Skill plugin is missing skill.md file',
264
+ });
265
+ }
266
+ }
267
+
268
+ if (meta.type === 'hook') {
269
+ var indexPath = path.join(pluginDir, 'index.js');
270
+ if (!fs.existsSync(indexPath)) {
271
+ this._validationWarnings.push({
272
+ plugin: pluginName,
273
+ field: 'index.js',
274
+ message: 'Hook plugin is missing index.js file (build will generate stub)',
275
+ });
276
+ }
277
+ }
278
+
279
+ // 7. Version format check (basic semver)
280
+ if (meta.version && !/^\d+\.\d+\.\d+/.test(meta.version)) {
281
+ this._validationWarnings.push({
282
+ plugin: pluginName,
283
+ field: 'version',
284
+ message: 'Version "' + meta.version + '" does not follow semver format (x.y.z)',
285
+ });
286
+ }
287
+ };
288
+
289
+ /**
290
+ * Format validation summary for output.
291
+ * @param {number} totalPlugins
292
+ * @returns {string}
293
+ */
294
+ HarnessBuild.prototype._formatValidationSummary = function _formatValidationSummary(totalPlugins) {
295
+ var lines = [];
296
+ lines.push('');
297
+ lines.push('=== Validation Report ===');
298
+ lines.push('Plugins checked: ' + totalPlugins);
299
+ lines.push('Errors: ' + this._validationErrors.length);
300
+ lines.push('Warnings: ' + this._validationWarnings.length);
301
+
302
+ if (this._validationErrors.length > 0) {
303
+ lines.push('');
304
+ lines.push('--- Errors ---');
305
+ for (var i = 0; i < this._validationErrors.length; i++) {
306
+ var e = this._validationErrors[i];
307
+ lines.push(' [' + e.plugin + '] ' + e.message);
308
+ }
309
+ }
310
+
311
+ if (this._validationWarnings.length > 0) {
312
+ lines.push('');
313
+ lines.push('--- Warnings ---');
314
+ for (var j = 0; j < this._validationWarnings.length; j++) {
315
+ var w = this._validationWarnings[j];
316
+ lines.push(' [' + w.plugin + '] ' + w.message);
317
+ }
318
+ }
319
+
320
+ lines.push('');
321
+ lines.push(this._validationErrors.length === 0 ? 'Validation PASSED' : 'Validation FAILED');
322
+ lines.push('');
323
+
324
+ return lines.join('\n');
325
+ };
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Building
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * Resolve the plugin directory from a registry entry.
333
+ * Uses entry.source if provided, otherwise derives from entry.name.
334
+ *
335
+ * @param {object} entry
336
+ * @returns {string}
337
+ */
338
+ HarnessBuild.prototype._resolvePluginDir = function _resolvePluginDir(entry) {
339
+ var source = entry.source || entry.name;
340
+ if (!source) {
341
+ return path.join(this._pluginsDir, 'unknown');
342
+ }
343
+ // If source is an absolute path, use it directly
344
+ if (path.isAbsolute(source)) {
345
+ return source;
346
+ }
347
+ return path.join(this._pluginsDir, source);
348
+ };
349
+
350
+ /**
351
+ * Build a single plugin.
352
+ *
353
+ * @param {object} entry - registry entry
354
+ * @returns {{ name: string, type: string, outputPath: string, files: string[] }}
355
+ */
356
+ HarnessBuild.prototype._buildPlugin = function _buildPlugin(entry) {
357
+ var pluginDir = this._resolvePluginDir(entry);
358
+ var metaPath = path.join(pluginDir, 'plugin.json');
359
+
360
+ // Read plugin metadata
361
+ if (!fs.existsSync(metaPath)) {
362
+ throw new Error('plugin.json not found in ' + pluginDir);
363
+ }
364
+ var meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
365
+
366
+ var pluginName = meta.name || entry.name;
367
+ var pluginType = meta.type;
368
+
369
+ this._log('info', 'Building plugin: ' + pluginName + ' (type: ' + pluginType + ')');
370
+
371
+ // Dispatch by type
372
+ switch (pluginType) {
373
+ case 'skill':
374
+ return this._buildSkillPlugin(pluginName, pluginDir, meta);
375
+ case 'hook':
376
+ return this._buildHookPlugin(pluginName, pluginDir, meta);
377
+ case 'validator':
378
+ return this._buildValidatorPlugin(pluginName, pluginDir, meta);
379
+ case 'provider':
380
+ return this._buildProviderPlugin(pluginName, pluginDir, meta);
381
+ default:
382
+ throw new Error('Unknown plugin type: ' + pluginType);
383
+ }
384
+ };
385
+
386
+ /**
387
+ * Build a skill plugin.
388
+ * Copies skill.md from plugin directory to .claude/skills/{name}/skill.md.
389
+ *
390
+ * @param {string} name
391
+ * @param {string} pluginDir
392
+ * @param {object} meta
393
+ * @returns {{ name: string, type: string, outputPath: string, files: string[] }}
394
+ */
395
+ HarnessBuild.prototype._buildSkillPlugin = function _buildSkillPlugin(name, pluginDir, meta) {
396
+ var skillMdSrc = path.join(pluginDir, 'skill.md');
397
+ var outputDir = path.join(this._outputSkillsDir, name);
398
+ var skillMdDest = path.join(outputDir, 'skill.md');
399
+ var files = [];
400
+
401
+ // Ensure output directory exists
402
+ this._ensureDir(outputDir);
403
+
404
+ // Check if source skill.md exists
405
+ if (fs.existsSync(skillMdSrc)) {
406
+ // Read the source skill content
407
+ var content = fs.readFileSync(skillMdSrc, 'utf-8');
408
+
409
+ // If the skill.md has a front-matter header, keep it.
410
+ // If not, generate one from plugin.json metadata.
411
+ if (!this._hasFrontMatter(content)) {
412
+ content = this._generateSkillFrontMatter(meta) + '\n' + content;
413
+ }
414
+
415
+ // Inject context window configuration
416
+ content = this._injectContextConfig(content, name);
417
+
418
+ fs.writeFileSync(skillMdDest, content, 'utf-8');
419
+ files.push(skillMdDest);
420
+ this._log('info', ' -> ' + skillMdDest);
421
+ } else {
422
+ // Generate a minimal skill.md from metadata
423
+ var generated = this._generateSkillContent(meta);
424
+ fs.writeFileSync(skillMdDest, generated, 'utf-8');
425
+ files.push(skillMdDest);
426
+ this._log('info', ' -> ' + skillMdDest + ' (generated from metadata)');
427
+ }
428
+
429
+ return {
430
+ name: name,
431
+ type: 'skill',
432
+ outputPath: outputDir,
433
+ files: files,
434
+ };
435
+ };
436
+
437
+ /**
438
+ * Build a hook plugin.
439
+ * Copies index.js from plugin directory to .claude/hooks/{name}/index.js.
440
+ *
441
+ * @param {string} name
442
+ * @param {string} pluginDir
443
+ * @param {object} meta
444
+ * @returns {{ name: string, type: string, outputPath: string, files: string[] }}
445
+ */
446
+ HarnessBuild.prototype._buildHookPlugin = function _buildHookPlugin(name, pluginDir, meta) {
447
+ var indexJsSrc = path.join(pluginDir, 'index.js');
448
+ var outputDir = path.join(this._outputHooksDir, name);
449
+ var indexJsDest = path.join(outputDir, 'index.js');
450
+ var files = [];
451
+
452
+ this._ensureDir(outputDir);
453
+
454
+ if (fs.existsSync(indexJsSrc)) {
455
+ var content = fs.readFileSync(indexJsSrc, 'utf-8');
456
+ fs.writeFileSync(indexJsDest, content, 'utf-8');
457
+ files.push(indexJsDest);
458
+ this._log('info', ' -> ' + indexJsDest);
459
+ } else {
460
+ // Generate a stub hook
461
+ var stub = this._generateHookStub(meta);
462
+ fs.writeFileSync(indexJsDest, stub, 'utf-8');
463
+ files.push(indexJsDest);
464
+ this._log('info', ' -> ' + indexJsDest + ' (stub generated)');
465
+ }
466
+
467
+ return {
468
+ name: name,
469
+ type: 'hook',
470
+ outputPath: outputDir,
471
+ files: files,
472
+ };
473
+ };
474
+
475
+ /**
476
+ * Build a validator plugin.
477
+ * Validator plugins don't have a native Claude Code output format,
478
+ * so we record them but don't write files to skills/hooks.
479
+ *
480
+ * @param {string} name
481
+ * @param {string} pluginDir
482
+ * @param {object} meta
483
+ * @returns {{ name: string, type: string, outputPath: string, files: string[] }}
484
+ */
485
+ HarnessBuild.prototype._buildValidatorPlugin = function _buildValidatorPlugin(name, pluginDir, meta) {
486
+ this._log('info', ' -> Validator plugin (no native output, registered internally)');
487
+
488
+ return {
489
+ name: name,
490
+ type: 'validator',
491
+ outputPath: '(internal)',
492
+ files: [],
493
+ };
494
+ };
495
+
496
+ /**
497
+ * Build a provider plugin.
498
+ * Provider plugins are runtime-only, no native Claude Code output.
499
+ *
500
+ * @param {string} name
501
+ * @param {string} pluginDir
502
+ * @param {object} meta
503
+ * @returns {{ name: string, type: string, outputPath: string, files: string[] }}
504
+ */
505
+ HarnessBuild.prototype._buildProviderPlugin = function _buildProviderPlugin(name, pluginDir, meta) {
506
+ this._log('info', ' -> Provider plugin (no native output, registered internally)');
507
+
508
+ return {
509
+ name: name,
510
+ type: 'provider',
511
+ outputPath: '(internal)',
512
+ files: [],
513
+ };
514
+ };
515
+
516
+ /**
517
+ * Format build summary for output.
518
+ *
519
+ * @param {object[]} results
520
+ * @param {object[]} errors
521
+ * @returns {string}
522
+ */
523
+ HarnessBuild.prototype._formatBuildSummary = function _formatBuildSummary(results, errors) {
524
+ var lines = [];
525
+ lines.push('');
526
+ lines.push('=== Build Report ===');
527
+
528
+ var skillCount = 0;
529
+ var hookCount = 0;
530
+ var validatorCount = 0;
531
+ var providerCount = 0;
532
+ var totalFiles = 0;
533
+
534
+ for (var i = 0; i < results.length; i++) {
535
+ var r = results[i];
536
+ totalFiles += r.files.length;
537
+ switch (r.type) {
538
+ case 'skill': skillCount++; break;
539
+ case 'hook': hookCount++; break;
540
+ case 'validator': validatorCount++; break;
541
+ case 'provider': providerCount++; break;
542
+ }
543
+ }
544
+
545
+ lines.push('Plugins built: ' + results.length);
546
+ lines.push(' Skills: ' + skillCount);
547
+ lines.push(' Hooks: ' + hookCount);
548
+ lines.push(' Validators: ' + validatorCount);
549
+ lines.push(' Providers: ' + providerCount);
550
+ lines.push('Files written: ' + totalFiles);
551
+
552
+ if (results.length > 0) {
553
+ lines.push('');
554
+ lines.push('--- Output Details ---');
555
+ for (var j = 0; j < results.length; j++) {
556
+ var item = results[j];
557
+ lines.push(' [' + item.type + '] ' + item.name + ' -> ' + item.outputPath);
558
+ for (var k = 0; k < item.files.length; k++) {
559
+ lines.push(' ' + item.files[k]);
560
+ }
561
+ }
562
+ }
563
+
564
+ if (errors.length > 0) {
565
+ lines.push('');
566
+ lines.push('--- Build Errors ---');
567
+ for (var m = 0; m < errors.length; m++) {
568
+ lines.push(' [' + errors[m].plugin + '] ' + errors[m].error);
569
+ }
570
+ }
571
+
572
+ lines.push('');
573
+ lines.push(errors.length === 0 ? 'Build SUCCEEDED' : 'Build COMPLETED WITH ERRORS');
574
+ lines.push('');
575
+
576
+ return lines.join('\n');
577
+ };
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Content generation helpers
581
+ // ---------------------------------------------------------------------------
582
+
583
+ /**
584
+ * Check if a skill.md already has YAML front matter.
585
+ * @param {string} content
586
+ * @returns {boolean}
587
+ */
588
+ HarnessBuild.prototype._hasFrontMatter = function _hasFrontMatter(content) {
589
+ return content.trimLeft().indexOf('---') === 0;
590
+ };
591
+
592
+ /**
593
+ * Generate YAML front matter for a skill from plugin metadata.
594
+ * @param {object} meta
595
+ * @returns {string}
596
+ */
597
+ HarnessBuild.prototype._generateSkillFrontMatter = function _generateSkillFrontMatter(meta) {
598
+ var lines = ['---'];
599
+ lines.push('name: ' + (meta.name || ''));
600
+ lines.push('description: ' + (meta.description || ''));
601
+
602
+ if (meta.triggers && meta.triggers.length > 0) {
603
+ lines.push('triggers:');
604
+ for (var i = 0; i < meta.triggers.length; i++) {
605
+ lines.push(' - ' + meta.triggers[i]);
606
+ }
607
+ }
608
+
609
+ if (meta.config) {
610
+ if (meta.config.plan_mode_required) {
611
+ lines.push('plan_mode_required: true');
612
+ }
613
+ }
614
+
615
+ lines.push('---');
616
+ return lines.join('\n');
617
+ };
618
+
619
+ /**
620
+ * Generate a full skill.md content from plugin metadata when no skill.md exists.
621
+ * @param {object} meta
622
+ * @returns {string}
623
+ */
624
+ HarnessBuild.prototype._generateSkillContent = function _generateSkillContent(meta) {
625
+ var frontMatter = this._generateSkillFrontMatter(meta);
626
+
627
+ var body = '\n# ' + (meta.name || 'Unnamed Skill') + '\n';
628
+ body += '\n' + (meta.description || '') + '\n';
629
+
630
+ if (meta.triggers && meta.triggers.length > 0) {
631
+ body += '\n## Triggers\n';
632
+ for (var i = 0; i < meta.triggers.length; i++) {
633
+ body += '- ' + meta.triggers[i] + '\n';
634
+ }
635
+ }
636
+
637
+ body += '\n> Auto-generated by harness-build from plugin.json metadata.\n';
638
+
639
+ return frontMatter + body;
640
+ };
641
+
642
+ /**
643
+ * Generate a stub hook index.js from plugin metadata.
644
+ * @param {object} meta
645
+ * @returns {string}
646
+ */
647
+ HarnessBuild.prototype._generateHookStub = function _generateHookStub(meta) {
648
+ var lines = [
649
+ '/**',
650
+ ' * Hook plugin: ' + (meta.name || 'unnamed'),
651
+ ' *',
652
+ ' * Auto-generated stub by harness-build.',
653
+ ' * Replace with actual hook implementation.',
654
+ ' */',
655
+ '',
656
+ '\'use strict\';',
657
+ '',
658
+ 'module.exports = {',
659
+ ' name: \'' + (meta.name || 'unnamed-hook') + '\',',
660
+ ' version: \'' + (meta.version || '0.0.0') + '\',',
661
+ '',
662
+ ' /**',
663
+ ' * Handle hook invocation.',
664
+ ' * @param {object} context',
665
+ ' * @returns {Promise<{ allowed: boolean, reason?: string }>}',
666
+ ' */',
667
+ ' async handle(context) {',
668
+ ' return { allowed: true };',
669
+ ' },',
670
+ '};',
671
+ '',
672
+ ];
673
+ return lines.join('\n');
674
+ };
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // Utilities
678
+ // ---------------------------------------------------------------------------
679
+
680
+ /**
681
+ * Ensure a directory exists, creating it recursively if needed.
682
+ * @param {string} dirPath
683
+ */
684
+ HarnessBuild.prototype._ensureDir = function _ensureDir(dirPath) {
685
+ if (!fs.existsSync(dirPath)) {
686
+ this._mkdirRecursive(dirPath);
687
+ }
688
+ };
689
+
690
+ /**
691
+ * Recursively create directories (like mkdir -p).
692
+ * @param {string} dirPath
693
+ */
694
+ HarnessBuild.prototype._mkdirRecursive = function _mkdirRecursive(dirPath) {
695
+ var parent = path.dirname(dirPath);
696
+ if (!fs.existsSync(parent)) {
697
+ this._mkdirRecursive(parent);
698
+ }
699
+ fs.mkdirSync(dirPath);
700
+ };
701
+
702
+ /**
703
+ * Internal logging.
704
+ * @param {string} level
705
+ * @param {string} message
706
+ */
707
+ HarnessBuild.prototype._log = function _log(level, message) {
708
+ var prefix = '[harness-build] [' + level + ']';
709
+ if (level === 'error') {
710
+ console.error(prefix, message);
711
+ } else if (level === 'warn') {
712
+ console.warn(prefix, message);
713
+ } else {
714
+ console.log(prefix, message);
715
+ }
716
+ };
717
+
718
+ /**
719
+ * Read and cache the harness config YAML file.
720
+ * @returns {object}
721
+ */
722
+ HarnessBuild.prototype._readHarnessConfig = function _readHarnessConfig() {
723
+ if (this._harnessConfig !== null) {
724
+ return this._harnessConfig;
725
+ }
726
+
727
+ var configPath = path.join(this._rootDir, '.claude', 'config', 'harness-config.yaml');
728
+ try {
729
+ var content = fs.readFileSync(configPath, 'utf-8');
730
+ // Extract context_window section using simple regex
731
+ var result = {};
732
+ var inSection = false;
733
+ var sectionIndent = -1;
734
+ var lines = content.split('\n');
735
+
736
+ for (var i = 0; i < lines.length; i++) {
737
+ var line = lines[i];
738
+
739
+ // Detect context_window section start
740
+ if (/^context_window\s*:/.test(line.trim())) {
741
+ inSection = true;
742
+ sectionIndent = line.search(/\S/);
743
+ result = { _source: 'context_window' };
744
+ continue;
745
+ }
746
+
747
+ if (inSection) {
748
+ var trimmed = line.trim();
749
+
750
+ // Stop at next top-level section (--- separator or same-indent key)
751
+ if (trimmed === '---' || (line.search(/\S/) >= 0 && line.search(/\S/) <= sectionIndent && !/^[\s#]/.test(trimmed) && trimmed.indexOf('context_window') === -1 && /^[a-z]/.test(trimmed))) {
752
+ break;
753
+ }
754
+
755
+ // Skip empty lines and comments
756
+ if (!trimmed || trimmed.indexOf('#') === 0) {
757
+ continue;
758
+ }
759
+
760
+ // Parse simple key-value pairs (flat or one level nested)
761
+ var colonIdx = trimmed.indexOf(':');
762
+ if (colonIdx === -1) continue;
763
+
764
+ var key = trimmed.substring(0, colonIdx).trim();
765
+ var valuePart = trimmed.substring(colonIdx + 1).trim();
766
+
767
+ // Remove inline comments
768
+ var commentIdx = valuePart.indexOf(' #');
769
+ if (commentIdx >= 0) {
770
+ valuePart = valuePart.substring(0, commentIdx).trim();
771
+ }
772
+
773
+ // Skip nested keys like "thresholds:" (we handle them inline)
774
+ if (valuePart === '' || valuePart === null) {
775
+ // This is a nested section header, read its children
776
+ var nestedIndent = lines[i] ? lines[i].search(/\S/) : -1;
777
+ var nested = {};
778
+ var j = i + 1;
779
+ for (; j < lines.length; j++) {
780
+ var nLine = lines[j];
781
+ if (!nLine.trim() || nLine.trim().indexOf('#') === 0) continue;
782
+ var nIndent = nLine.search(/\S/);
783
+ if (nIndent <= nestedIndent) break;
784
+ var nTrimmed = nLine.trim();
785
+ // Handle list items "- key: value" or "- value"
786
+ if (nTrimmed.indexOf('- ') === 0) {
787
+ nTrimmed = nTrimmed.substring(2);
788
+ }
789
+ var nColonIdx = nTrimmed.indexOf(':');
790
+ if (nColonIdx >= 0) {
791
+ var nKey = nTrimmed.substring(0, nColonIdx).trim();
792
+ var nVal = nTrimmed.substring(nColonIdx + 1).trim();
793
+ var nCommentIdx = nVal.indexOf(' #');
794
+ if (nCommentIdx >= 0) nVal = nVal.substring(0, nCommentIdx).trim();
795
+ nested[nKey] = _parseValue(nVal);
796
+ }
797
+ }
798
+ result[key] = nested;
799
+ // Advance outer loop past the nested section
800
+ i = j - 1;
801
+ continue;
802
+ }
803
+
804
+ // Remove surrounding quotes
805
+ if ((valuePart.charAt(0) === '"' && valuePart.charAt(valuePart.length - 1) === '"') ||
806
+ (valuePart.charAt(0) === "'" && valuePart.charAt(valuePart.length - 1) === "'")) {
807
+ valuePart = valuePart.substring(1, valuePart.length - 1);
808
+ }
809
+
810
+ result[key] = _parseValue(valuePart);
811
+ }
812
+ }
813
+
814
+ this._harnessConfig = (inSection && result._source) ? result : {};
815
+ delete this._harnessConfig._source;
816
+ } catch (err) {
817
+ this._harnessConfig = {};
818
+ }
819
+
820
+ return this._harnessConfig;
821
+ };
822
+
823
+ /**
824
+ * Parse a YAML scalar value.
825
+ */
826
+ function _parseValue(val) {
827
+ if (val === 'true') return true;
828
+ if (val === 'false') return false;
829
+ if (val === 'null' || val === '~') return null;
830
+ var num = Number(val);
831
+ if (!isNaN(num) && val !== '') return num;
832
+ return val;
833
+ }
834
+
835
+ /**
836
+ * Inject context window configuration into a skill.md file.
837
+ * Inserts a <!-- CONTEXT-CONFIG --> block after front matter.
838
+ *
839
+ * @param {string} content - the skill.md content
840
+ * @param {string} pluginName - the plugin name for per-plugin overrides
841
+ * @returns {string} content with injected config block
842
+ */
843
+ HarnessBuild.prototype._injectContextConfig = function _injectContextConfig(content, pluginName) {
844
+ var config = this._readHarnessConfig();
845
+ if (!config || Object.keys(config).length === 0) {
846
+ return content; // No context_window config, skip injection
847
+ }
848
+
849
+ // Apply per-plugin override if exists
850
+ if (config.overrides && config.overrides[pluginName]) {
851
+ var override = config.overrides[pluginName];
852
+ for (var k in override) {
853
+ if (override.hasOwnProperty(k)) {
854
+ config[k] = override[k];
855
+ }
856
+ }
857
+ }
858
+ delete config.overrides;
859
+
860
+ // Build config block
861
+ var lines = ['\n<!-- CONTEXT-CONFIG'];
862
+ for (var key in config) {
863
+ if (!config.hasOwnProperty(key)) continue;
864
+ var val = config[key];
865
+ if (typeof val === 'object' && val !== null) {
866
+ // Flatten nested objects (e.g., thresholds)
867
+ var parts = [];
868
+ for (var sk in val) {
869
+ if (val.hasOwnProperty(sk)) {
870
+ parts.push(sk + '=' + val[sk]);
871
+ }
872
+ }
873
+ lines.push(key + ': ' + parts.join(', '));
874
+ } else {
875
+ lines.push(key + ': ' + val);
876
+ }
877
+ }
878
+ lines.push('CONTEXT-CONFIG -->\n');
879
+
880
+ var configBlock = lines.join('\n');
881
+
882
+ // Find insertion point: after front matter closing ---
883
+ var fmCloseIdx = content.indexOf('---', 1); // skip first ---
884
+ if (fmCloseIdx === -1) {
885
+ // No front matter, prepend
886
+ return configBlock + content;
887
+ }
888
+
889
+ var afterFm = content.indexOf('\n', fmCloseIdx + 3);
890
+ if (afterFm === -1) {
891
+ afterFm = content.length;
892
+ } else {
893
+ afterFm += 1; // skip the newline itself
894
+ }
895
+
896
+ return content.substring(0, afterFm) + configBlock + content.substring(afterFm);
897
+ };
898
+
899
+ // ---------------------------------------------------------------------------
900
+ // CLI entry point
901
+ // ---------------------------------------------------------------------------
902
+
903
+ /**
904
+ * Run the CLI.
905
+ * @param {string[]} argv - process.argv
906
+ */
907
+ HarnessBuild.run = function run(argv) {
908
+ var args = argv.slice(2);
909
+ var mode = 'build'; // default mode
910
+
911
+ for (var i = 0; i < args.length; i++) {
912
+ if (args[i] === '--validate') {
913
+ mode = 'validate';
914
+ } else if (args[i] === '--help' || args[i] === '-h') {
915
+ console.log('Usage: node plugins/runtime/harness-build.js [--validate]');
916
+ console.log('');
917
+ console.log('Options:');
918
+ console.log(' --validate Validate plugin.json files without building');
919
+ console.log(' --help, -h Show this help message');
920
+ process.exit(0);
921
+ }
922
+ }
923
+
924
+ var builder = new HarnessBuild();
925
+
926
+ if (mode === 'validate') {
927
+ var result = builder.validate();
928
+ console.log(result.summary);
929
+ process.exit(result.valid ? 0 : 1);
930
+ } else {
931
+ var buildResult = builder.build();
932
+ console.log(buildResult.summary);
933
+ process.exit(buildResult.success ? 0 : 1);
934
+ }
935
+ };
936
+
937
+ // ---------------------------------------------------------------------------
938
+ // Settings merge
939
+ // ---------------------------------------------------------------------------
940
+
941
+ /**
942
+ * Merge tackle-harness hooks into the target project's .claude/settings.json.
943
+ * Reads existing settings, adds tackle-harness-specific hooks, and writes back.
944
+ * Idempotent: skips hooks that are already registered.
945
+ *
946
+ * @param {string} targetRoot - target project root directory
947
+ * @param {string} packageRoot - this package's root directory (node_modules/tackle-harness/)
948
+ */
949
+ HarnessBuild.prototype.updateSettings = function updateSettings(targetRoot, packageRoot) {
950
+ var fs = require('fs');
951
+ var path = require('path');
952
+ var settingsPath = path.join(targetRoot, '.claude', 'settings.json');
953
+ var settings = {};
954
+
955
+ // Read existing settings if present
956
+ if (fs.existsSync(settingsPath)) {
957
+ try {
958
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
959
+ } catch (e) {
960
+ settings = {};
961
+ }
962
+ }
963
+
964
+ // Ensure hooks structure exists
965
+ if (!settings.hooks) settings.hooks = {};
966
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
967
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
968
+
969
+ // Resolve hook script path relative to target project
970
+ var hookScriptPath = path.join(packageRoot, 'plugins', 'core', 'hook-skill-gate', 'index.js');
971
+ // Use forward slashes for cross-platform compatibility
972
+ var hookScriptRelative = path.relative(targetRoot, hookScriptPath).replace(/\\/g, '/');
973
+ var hookCmd = 'node "' + hookScriptRelative + '"';
974
+
975
+ // Add PreToolUse hook for Edit|Write (if not already present)
976
+ var preMatcher = 'Edit|Write';
977
+ var preExists = settings.hooks.PreToolUse.some(function (h) {
978
+ return h.matcher === preMatcher;
979
+ });
980
+ if (!preExists) {
981
+ settings.hooks.PreToolUse.push({
982
+ matcher: preMatcher,
983
+ hooks: [{
984
+ type: 'command',
985
+ command: hookCmd + ' --pre-tool'
986
+ }]
987
+ });
988
+ }
989
+
990
+ // Add PostToolUse hook for Skill (if not already present)
991
+ var postMatcher = 'Skill';
992
+ var postExists = settings.hooks.PostToolUse.some(function (h) {
993
+ return h.matcher === postMatcher;
994
+ });
995
+ if (!postExists) {
996
+ settings.hooks.PostToolUse.push({
997
+ matcher: postMatcher,
998
+ hooks: [{
999
+ type: 'command',
1000
+ command: hookCmd + ' --post-skill'
1001
+ }]
1002
+ });
1003
+ }
1004
+
1005
+ // Write back
1006
+ this._ensureDir(path.dirname(settingsPath));
1007
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
1008
+ };
1009
+
1010
+ // ---------------------------------------------------------------------------
1011
+ // Module exports
1012
+ // ---------------------------------------------------------------------------
1013
+
1014
+ module.exports = HarnessBuild;
1015
+
1016
+ // Run directly if executed as main
1017
+ if (require.main === module) {
1018
+ HarnessBuild.run(process.argv);
1019
+ }