wogiflow 2.32.0 → 2.34.1

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 (39) hide show
  1. package/.claude/docs/claude-code-compatibility.md +51 -0
  2. package/.claude/docs/scheduled-mode.md +213 -0
  3. package/.claude/docs/skill-portability.md +190 -0
  4. package/.claude/rules/alternative-hook-args-exec-form.md +6 -0
  5. package/.claude/settings.json +2 -1
  6. package/.claude/skills/_template/skill.md +1 -0
  7. package/.claude/skills/conventional-commit/knowledge/examples.md +65 -0
  8. package/.claude/skills/conventional-commit/skill.md +76 -0
  9. package/bin/flow +16 -0
  10. package/lib/scheduled-mode.js +374 -0
  11. package/lib/skill-export-agentskills.js +211 -0
  12. package/lib/skill-export-claude-plugin.js +183 -0
  13. package/lib/skill-portability.js +342 -0
  14. package/lib/skill-registry.js +32 -2
  15. package/lib/workspace-channel-server.js +106 -3
  16. package/lib/workspace-channel-tracking.js +102 -1
  17. package/lib/workspace-dispatch-tracking.js +28 -0
  18. package/lib/workspace-messages.js +32 -4
  19. package/lib/workspace-subtask-state.js +215 -0
  20. package/lib/workspace.js +81 -0
  21. package/package.json +2 -2
  22. package/scripts/flow +25 -0
  23. package/scripts/flow-config-defaults.js +20 -0
  24. package/scripts/flow-constants.js +3 -1
  25. package/scripts/flow-schedule.js +486 -0
  26. package/scripts/flow-scheduled-runner.js +659 -0
  27. package/scripts/flow-skill-export.js +334 -0
  28. package/scripts/flow-standards-checker.js +37 -0
  29. package/scripts/hooks/adapters/claude-code.js +18 -3
  30. package/scripts/hooks/core/git-safety-gate.js +118 -27
  31. package/scripts/hooks/core/long-input-enforcement.js +139 -4
  32. package/scripts/hooks/core/overdue-dispatches.js +28 -6
  33. package/scripts/hooks/core/session-start-worker.js +52 -0
  34. package/scripts/hooks/core/stop-orchestrator.js +17 -2
  35. package/scripts/hooks/core/validation.js +8 -0
  36. package/scripts/hooks/core/worker-continuation-gate.js +326 -0
  37. package/scripts/hooks/core/workspace-stop-gates.js +21 -0
  38. package/scripts/hooks/core/workspace-stop-notify.js +174 -59
  39. package/scripts/hooks/entry/claude-code/post-tool-use.js +26 -0
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Scheduled-mode shared helpers (Phase 1A of epic-quality-loop, wf-b211a076).
5
+ *
6
+ * Pure business-logic helpers consumed by `scripts/flow-scheduled-runner.js`.
7
+ * CLI-agnostic per the hook three-layer principle — no stdin parsing, no exit
8
+ * codes, no `gh`/`claude` invocation here. Entry/runner files orchestrate; this
9
+ * module computes, classifies, and reads state.
10
+ *
11
+ * Exports:
12
+ * - JOB_NAMES, MODEL_RATES (constants)
13
+ * - clearStaleMarkers(stateDir): remove routing-pending.json + pending-question.json
14
+ * - loadHeadlessProfile(config, jobName): per-job model + cost projection inputs
15
+ * - projectMonthlyCost(jobsConfig): $/month estimate for the configured schedule
16
+ * - withTimeout(fn, ms, opts): wraps an async fn with AbortController + hard kill
17
+ * - enforceTokenBudget(usageLog, budget, now, jobName): { allowed, reason, usedToday }
18
+ * - updateDedupIssue(jobName, body, opts): build the `gh` argv to update/create
19
+ * - validateModelName(name): allowlist guard for subprocess args
20
+ *
21
+ * Read-only-by-default invariants (enforced by the runner, documented here):
22
+ * - No `git push` to non-bot refs
23
+ * - No `gh pr merge`
24
+ * - No writes to `.workflow/state/decisions.md`
25
+ * - Operates only on the default branch
26
+ * - All work in a temp worktree from `scripts/flow-worktree.js`
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+
32
+ // ============================================================
33
+ // Constants
34
+ // ============================================================
35
+
36
+ /** Canonical job names — used by the workflow YAML, runner, and dedup labels. */
37
+ const JOB_NAMES = Object.freeze([
38
+ 'nightly-regression',
39
+ 'weekly-audit',
40
+ 'weekly-digest',
41
+ 'per-pr-review',
42
+ ]);
43
+
44
+ /**
45
+ * Token-cost estimates per million tokens (USD), used only for $/month
46
+ * projection in --dry-run mode. Numbers are deliberately conservative
47
+ * (input+output blended) and meant for budgeting, not billing.
48
+ */
49
+ const MODEL_RATES = Object.freeze({
50
+ haiku: 1.25,
51
+ sonnet: 6.00,
52
+ opus: 30.00,
53
+ });
54
+
55
+ /** Hard timeout for a single headless job invocation. */
56
+ const DEFAULT_JOB_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
57
+
58
+ /** Backoff before single retry on transient failure. */
59
+ const TRANSIENT_RETRY_DELAY_MS = 30 * 1000;
60
+
61
+ /** Allowed model names for the `--model=...` CLI arg (subprocess injection guard). */
62
+ const ALLOWED_MODELS = new Set(['haiku', 'sonnet', 'opus']);
63
+
64
+ /** Average tokens per invocation used in monthly projection (per-job). */
65
+ const DEFAULT_TOKENS_PER_INVOCATION = Object.freeze({
66
+ 'nightly-regression': 40_000,
67
+ 'weekly-audit': 150_000,
68
+ 'weekly-digest': 30_000,
69
+ 'per-pr-review': 80_000,
70
+ });
71
+
72
+ /** Default invocations-per-month per job (matching the cron schedule). */
73
+ const DEFAULT_INVOCATIONS_PER_MONTH = Object.freeze({
74
+ 'nightly-regression': 30, // daily
75
+ 'weekly-audit': 4, // weekly
76
+ 'weekly-digest': 4, // weekly
77
+ 'per-pr-review': 20, // ~weekday PRs
78
+ });
79
+
80
+ // ============================================================
81
+ // Helpers
82
+ // ============================================================
83
+
84
+ /**
85
+ * Remove stale routing / pending-question markers before each headless invocation.
86
+ *
87
+ * The runner clears these so that a headless Claude session starts clean and
88
+ * does not inherit interactive-session state from the developer's working tree.
89
+ *
90
+ * @param {string} stateDir - Path to `.workflow/state/`
91
+ * @returns {{ cleared: string[], skipped: string[] }}
92
+ */
93
+ function clearStaleMarkers(stateDir) {
94
+ const targets = ['routing-pending.json', 'pending-question.json'];
95
+ const cleared = [];
96
+ const skipped = [];
97
+ for (const name of targets) {
98
+ const p = path.join(stateDir, name);
99
+ try {
100
+ if (fs.existsSync(p)) {
101
+ fs.unlinkSync(p);
102
+ cleared.push(name);
103
+ } else {
104
+ skipped.push(name);
105
+ }
106
+ } catch (_err) {
107
+ // Fail-open: a stuck marker is recoverable next cycle.
108
+ skipped.push(name);
109
+ }
110
+ }
111
+ return { cleared, skipped };
112
+ }
113
+
114
+ /**
115
+ * Validate a model name against the subprocess allowlist.
116
+ * Throws if invalid. Use BEFORE passing to execFileSync.
117
+ *
118
+ * @param {string} name
119
+ * @returns {string} validated name
120
+ */
121
+ function validateModelName(name) {
122
+ if (!ALLOWED_MODELS.has(name)) {
123
+ throw new Error(
124
+ `scheduled-mode: invalid model "${name}". Allowed: ${[...ALLOWED_MODELS].join(', ')}`
125
+ );
126
+ }
127
+ return name;
128
+ }
129
+
130
+ /**
131
+ * Resolve the headless config profile for a given job.
132
+ *
133
+ * @param {object} config - The merged WogiFlow config (config.scheduledMode subtree)
134
+ * @param {string} jobName
135
+ * @returns {{ model: string, dryRun: boolean, enabled: boolean, dailyTokenBudget: number }}
136
+ */
137
+ function loadHeadlessProfile(config, jobName) {
138
+ const sm = (config && config.scheduledMode) || {};
139
+ const perJob = sm.perJobModel || {};
140
+ const rawModel = perJob[jobName] || 'sonnet';
141
+ const model = ALLOWED_MODELS.has(rawModel) ? rawModel : 'sonnet';
142
+ return {
143
+ model,
144
+ dryRun: Boolean(sm.dryRun),
145
+ enabled: Boolean(sm.enabled),
146
+ dailyTokenBudget: Number.isFinite(sm.dailyTokenBudget) ? sm.dailyTokenBudget : 5_000_000,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Project monthly USD cost for a set of jobs.
152
+ *
153
+ * @param {object} jobsConfig - { perJobModel: { [name]: model } }
154
+ * @param {object} [opts]
155
+ * @param {string[]} [opts.jobs] - Subset of jobs to include (defaults to all enabled)
156
+ * @returns {{ total: number, byJob: object<string, {invocations:number, tokens:number, model:string, cost:number}> }}
157
+ */
158
+ function projectMonthlyCost(jobsConfig, opts = {}) {
159
+ const sm = (jobsConfig && jobsConfig.scheduledMode) || jobsConfig || {};
160
+ const perJobModel = sm.perJobModel || {};
161
+ const jobs = opts.jobs || sm.jobs || JOB_NAMES;
162
+ const byJob = {};
163
+ let total = 0;
164
+
165
+ for (const name of jobs) {
166
+ if (!JOB_NAMES.includes(name)) continue;
167
+ const model = perJobModel[name] || 'sonnet';
168
+ const rate = MODEL_RATES[model] ?? MODEL_RATES.sonnet;
169
+ const invocations = DEFAULT_INVOCATIONS_PER_MONTH[name] ?? 0;
170
+ const tokensEach = DEFAULT_TOKENS_PER_INVOCATION[name] ?? 0;
171
+ const monthlyTokens = invocations * tokensEach;
172
+ const cost = (monthlyTokens / 1_000_000) * rate;
173
+ byJob[name] = {
174
+ invocations,
175
+ tokens: monthlyTokens,
176
+ model,
177
+ cost: Number(cost.toFixed(2)),
178
+ };
179
+ total += cost;
180
+ }
181
+ return { total: Number(total.toFixed(2)), byJob };
182
+ }
183
+
184
+ /**
185
+ * Wrap an async function with a hard timeout that aborts via AbortController.
186
+ *
187
+ * The wrapped function MUST accept `{ signal }` as its first arg and forward it
188
+ * to any child-process or fetch call so abort actually propagates.
189
+ *
190
+ * @param {(args: { signal: AbortSignal }) => Promise<any>} fn
191
+ * @param {number} ms
192
+ * @param {object} [opts]
193
+ * @param {() => void} [opts.onTimeout]
194
+ * @returns {Promise<{ ok: true, result: any } | { ok: false, timedOut: boolean, error: Error }>}
195
+ */
196
+ async function withTimeout(fn, ms, opts = {}) {
197
+ if (!Number.isFinite(ms) || ms <= 0) {
198
+ throw new Error(`withTimeout: invalid timeout ${ms}`);
199
+ }
200
+ const controller = new AbortController();
201
+ let timerId;
202
+ const timeoutPromise = new Promise((resolve) => {
203
+ timerId = setTimeout(() => {
204
+ try { if (typeof opts.onTimeout === 'function') opts.onTimeout(); } catch (_err) { /* */ }
205
+ controller.abort(new Error(`scheduled-mode: job timed out after ${ms}ms`));
206
+ resolve({ ok: false, timedOut: true, error: new Error(`Job timed out after ${ms}ms`) });
207
+ }, ms);
208
+ });
209
+
210
+ try {
211
+ const work = (async () => {
212
+ try {
213
+ const result = await fn({ signal: controller.signal });
214
+ return { ok: true, result };
215
+ } catch (err) {
216
+ // If the abort fired, mark as timeout; otherwise it's a regular error.
217
+ if (controller.signal.aborted) {
218
+ return { ok: false, timedOut: true, error: err };
219
+ }
220
+ return { ok: false, timedOut: false, error: err };
221
+ }
222
+ })();
223
+ return await Promise.race([work, timeoutPromise]);
224
+ } finally {
225
+ if (timerId) clearTimeout(timerId);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Token-budget enforcement. Reads a per-day usage log and decides whether the
231
+ * current job is allowed to run.
232
+ *
233
+ * @param {object} usageLog - { [YYYY-MM-DD]: { [jobName]: tokens } }
234
+ * @param {number} dailyBudget - Total tokens allowed per day across all jobs
235
+ * @param {Date|number|string} now - Current time (for testability)
236
+ * @param {string} jobName - Job about to run
237
+ * @param {number} [estimatedTokens] - Pre-flight estimate; defaults from table
238
+ * @returns {{ allowed: boolean, reason: string, usedToday: number, estimated: number, projectedAfter: number }}
239
+ */
240
+ function enforceTokenBudget(usageLog, dailyBudget, now, jobName, estimatedTokens) {
241
+ const d = new Date(now);
242
+ if (Number.isNaN(d.getTime())) {
243
+ throw new Error('enforceTokenBudget: invalid "now"');
244
+ }
245
+ const key = d.toISOString().slice(0, 10); // YYYY-MM-DD
246
+ const dayLog = (usageLog && usageLog[key]) || {};
247
+ // F17 (R-379): use explicit Number.isFinite guard so a legitimate 0 isn't
248
+ // collapsed by `|| 0` falsy-fallthrough (per naming-conventions.md).
249
+ const usedToday = Object.values(dayLog).reduce(
250
+ (a, b) => a + (Number.isFinite(Number(b)) ? Number(b) : 0),
251
+ 0
252
+ );
253
+ const estimated = Number.isFinite(estimatedTokens)
254
+ ? estimatedTokens
255
+ : (DEFAULT_TOKENS_PER_INVOCATION[jobName] ?? 0);
256
+ const projectedAfter = usedToday + estimated;
257
+ if (!Number.isFinite(dailyBudget) || dailyBudget <= 0) {
258
+ return {
259
+ allowed: true,
260
+ reason: 'no budget configured',
261
+ usedToday,
262
+ estimated,
263
+ projectedAfter,
264
+ };
265
+ }
266
+ if (projectedAfter > dailyBudget) {
267
+ return {
268
+ allowed: false,
269
+ reason: `daily token budget exceeded (would be ${projectedAfter}/${dailyBudget})`,
270
+ usedToday,
271
+ estimated,
272
+ projectedAfter,
273
+ };
274
+ }
275
+ return {
276
+ allowed: true,
277
+ reason: 'within budget',
278
+ usedToday,
279
+ estimated,
280
+ projectedAfter,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Build the `gh` CLI argv to update an existing dedup issue (label-scoped) or
286
+ * create a new one if none exists. Pure function — does NOT execute. Caller
287
+ * runs this via execFileSync with explicit env scrubbing.
288
+ *
289
+ * Strategy:
290
+ * 1. List issues with label `wogi/scheduled-${jobName}` (state:open)
291
+ * 2. If one or more exist, UPDATE the most-recent one (`gh issue comment`)
292
+ * 3. If none, CREATE one (`gh issue create`)
293
+ *
294
+ * @param {string} jobName
295
+ * @param {string} body - Markdown body to post
296
+ * @param {object} [opts]
297
+ * @param {string[]} [opts.existingIssueNumbers] - Pre-fetched issue numbers; if
298
+ * non-empty, returns the UPDATE argv; otherwise the CREATE argv.
299
+ * @param {string} [opts.title] - Title for the create branch
300
+ * @returns {{ mode: 'update'|'create', argv: string[] }}
301
+ */
302
+ function updateDedupIssue(jobName, body, opts = {}) {
303
+ if (!JOB_NAMES.includes(jobName)) {
304
+ throw new Error(`updateDedupIssue: unknown job "${jobName}"`);
305
+ }
306
+ const label = `wogi/scheduled-${jobName}`;
307
+ const existing = Array.isArray(opts.existingIssueNumbers) ? opts.existingIssueNumbers : [];
308
+
309
+ if (existing.length > 0) {
310
+ // Update path: comment on the most-recent existing labelled issue.
311
+ const issueNumber = String(existing[0]);
312
+ return {
313
+ mode: 'update',
314
+ argv: ['issue', 'comment', issueNumber, '--body', body],
315
+ };
316
+ }
317
+
318
+ const title = opts.title || `[scheduled] ${jobName} — tracker`;
319
+ return {
320
+ mode: 'create',
321
+ argv: [
322
+ 'issue', 'create',
323
+ '--title', title,
324
+ '--body', body,
325
+ '--label', label,
326
+ ],
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Classify an error as transient (worth retrying once) vs permanent.
332
+ *
333
+ * Transient: network blip, gh rate-limit, ETIMEDOUT, ECONNRESET, EAI_AGAIN.
334
+ * Permanent: anything else — config error, auth fail, file not found.
335
+ *
336
+ * @param {Error|null|undefined} err
337
+ * @returns {boolean}
338
+ */
339
+ function isTransientError(err) {
340
+ if (!err) return false;
341
+ const msg = String(err.message || err).toLowerCase();
342
+ const code = (err.code || '').toString();
343
+ if (['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ECONNREFUSED', 'EPIPE'].includes(code)) {
344
+ return true;
345
+ }
346
+ if (/rate.?limit|temporar(y|ily)|timeout|network|reset by peer/i.test(msg)) {
347
+ return true;
348
+ }
349
+ return false;
350
+ }
351
+
352
+ // F20 (R-379): removed `yesterdayIsoDate(now)` — exported but never imported
353
+ // anywhere in scope. The runner uses `git log --since="24 hours ago"` for
354
+ // CI-portability reasons (shallow checkouts don't have reflog state for
355
+ // `@{yesterday}`), and no other consumer wants the ISO-date form. Removed
356
+ // to avoid a maintenance-trap export. Re-add if a real consumer materializes.
357
+
358
+ module.exports = {
359
+ JOB_NAMES,
360
+ MODEL_RATES,
361
+ ALLOWED_MODELS,
362
+ DEFAULT_JOB_TIMEOUT_MS,
363
+ TRANSIENT_RETRY_DELAY_MS,
364
+ DEFAULT_TOKENS_PER_INVOCATION,
365
+ DEFAULT_INVOCATIONS_PER_MONTH,
366
+ clearStaleMarkers,
367
+ loadHeadlessProfile,
368
+ projectMonthlyCost,
369
+ withTimeout,
370
+ enforceTokenBudget,
371
+ updateDedupIssue,
372
+ validateModelName,
373
+ isTransientError,
374
+ };
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Skill Exporter — agentskills.io v1 (Phase 1B — wf-0342fc33)
5
+ *
6
+ * Produces a manifest + file list for publishing a portable WogiFlow skill to
7
+ * agentskills.io under the v1 schema.
8
+ *
9
+ * IMPORTANT — Schema assumption:
10
+ * This module has NO network access; the agentskills.io v1 schema is not
11
+ * fetched at build time. We pin our serializer output to the field map
12
+ * sketched in epic-quality-loop.md Phase 1B and lock the `schemaVersion`
13
+ * string to `agentskills@v1`. A future contract-test in CI (Phase 1B's
14
+ * "Contract test in CI validates output against agentskills.io schema")
15
+ * will catch any drift once the network gate exists. Until then, this is
16
+ * our authoritative interpretation. Field map:
17
+ *
18
+ * {
19
+ * schemaVersion: "agentskills@v1", // pinned
20
+ * name: string, // skill identifier (kebab-case)
21
+ * version: string, // semver
22
+ * description: string, // one-line summary
23
+ * license: string, // SPDX (default MIT)
24
+ * source: { type, url? }, // provenance
25
+ * compatibility: string, // env requirements
26
+ * instructions: string, // skill.md body (post-frontmatter)
27
+ * files: string[], // relative paths included in bundle
28
+ * triggers: object, // optional trigger metadata
29
+ * dependencies: string[], // optional, default []
30
+ * }
31
+ *
32
+ * Callers MUST verify portability via lib/skill-portability before calling.
33
+ * This module deliberately does not re-check — separation of concerns.
34
+ *
35
+ * @module lib/skill-export-agentskills
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+
43
+ const { parseFrontmatter } = require('./skill-portability');
44
+
45
+ const AGENTSKILLS_SCHEMA_VERSION = 'agentskills@v1';
46
+
47
+ const DEFAULT_LICENSE = 'MIT';
48
+
49
+ // File extensions we bundle. Everything else (binaries, dotfiles, etc.) is
50
+ // skipped to keep exports lean and predictable.
51
+ const BUNDLE_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.yaml', '.yml', '.json', '.js', '.ts', '.sh', '.template', '.hbs']);
52
+
53
+ // Files always included regardless of extension (manifest aliases).
54
+ const ALWAYS_INCLUDE = new Set(['LICENSE', 'README', 'README.md']);
55
+
56
+ const MAX_BUNDLE_FILES = 200;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Helpers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Strip YAML frontmatter from a skill.md body, returning the prose portion.
64
+ *
65
+ * @param {string} content
66
+ * @returns {string}
67
+ */
68
+ function stripFrontmatter(content) {
69
+ if (typeof content !== 'string') return '';
70
+ const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
71
+ if (!match) return content;
72
+ return content.slice(match[0].length).trimStart();
73
+ }
74
+
75
+ /**
76
+ * Recursively list files under a directory that qualify for bundling.
77
+ *
78
+ * @param {string} rootDir
79
+ * @returns {string[]} Absolute file paths, capped at MAX_BUNDLE_FILES.
80
+ */
81
+ function listBundleFiles(rootDir) {
82
+ const out = [];
83
+ const stack = [rootDir];
84
+
85
+ while (stack.length > 0 && out.length < MAX_BUNDLE_FILES) {
86
+ const dir = stack.pop();
87
+ let entries;
88
+ try {
89
+ entries = fs.readdirSync(dir, { withFileTypes: true });
90
+ } catch (_err) {
91
+ continue;
92
+ }
93
+ for (const entry of entries) {
94
+ const full = path.join(dir, entry.name);
95
+ if (entry.isDirectory()) {
96
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
97
+ stack.push(full);
98
+ continue;
99
+ }
100
+ if (!entry.isFile()) continue;
101
+ const ext = path.extname(entry.name).toLowerCase();
102
+ if (BUNDLE_EXTENSIONS.has(ext) || ALWAYS_INCLUDE.has(entry.name)) {
103
+ out.push(full);
104
+ if (out.length >= MAX_BUNDLE_FILES) break;
105
+ }
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Read a file safely, returning empty string on failure.
113
+ *
114
+ * @param {string} filePath
115
+ * @returns {string}
116
+ */
117
+ function safeReadFile(filePath) {
118
+ try {
119
+ return fs.readFileSync(filePath, 'utf-8');
120
+ } catch (_err) {
121
+ return '';
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Public API
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Export a skill to agentskills.io v1 manifest + file list.
131
+ *
132
+ * @param {string} skillDir - Absolute path to skill directory
133
+ * @param {Object} [opts]
134
+ * @param {string} [opts.name] - Override skill name (default: from frontmatter or dir basename)
135
+ * @param {string} [opts.version] - Override version (default: from frontmatter or 0.0.0)
136
+ * @param {string} [opts.sourceUrl] - Provenance URL for `source.url`
137
+ * @returns {{manifest: Object, files: Array<{path: string, content: string}>, skillMdPath: string|null}}
138
+ */
139
+ function exportToAgentskills(skillDir, opts = {}) {
140
+ if (typeof skillDir !== 'string' || !skillDir) {
141
+ throw new Error('exportToAgentskills: skillDir must be a non-empty string');
142
+ }
143
+ if (!fs.existsSync(skillDir) || !fs.statSync(skillDir).isDirectory()) {
144
+ throw new Error(`exportToAgentskills: not a directory: ${skillDir}`);
145
+ }
146
+
147
+ // Locate skill.md
148
+ let skillMdPath = null;
149
+ for (const candidate of ['skill.md', 'SKILL.md', 'Skill.md']) {
150
+ const p = path.join(skillDir, candidate);
151
+ if (fs.existsSync(p)) {
152
+ skillMdPath = p;
153
+ break;
154
+ }
155
+ }
156
+
157
+ let frontmatter = {};
158
+ let instructions = '';
159
+ if (skillMdPath) {
160
+ const content = safeReadFile(skillMdPath);
161
+ frontmatter = parseFrontmatter(content);
162
+ instructions = stripFrontmatter(content);
163
+ }
164
+
165
+ const name = opts.name ?? frontmatter.name ?? path.basename(skillDir);
166
+ const version = opts.version ?? frontmatter.version ?? '0.0.0';
167
+ const description = frontmatter.description ?? '';
168
+ const license = frontmatter.license ?? DEFAULT_LICENSE;
169
+ const compatibility = frontmatter.compatibility ?? '';
170
+
171
+ // Build file bundle (relative paths, content strings)
172
+ const absFiles = listBundleFiles(skillDir);
173
+ const files = absFiles.map((abs) => {
174
+ const rel = path.relative(skillDir, abs) || path.basename(abs);
175
+ return {
176
+ path: rel.split(path.sep).join('/'), // POSIX-style for cross-OS portability
177
+ content: safeReadFile(abs),
178
+ };
179
+ });
180
+
181
+ // agentskills v1 shape (see header comment for source of this field map)
182
+ const manifest = {
183
+ schemaVersion: AGENTSKILLS_SCHEMA_VERSION,
184
+ name,
185
+ version,
186
+ description,
187
+ license,
188
+ compatibility,
189
+ source: {
190
+ type: 'wogiflow',
191
+ ...(opts.sourceUrl ? { url: opts.sourceUrl } : {}),
192
+ },
193
+ instructions,
194
+ files: files.map((f) => f.path),
195
+ dependencies: [],
196
+ // Pass through any trigger metadata declared in the skill manifest
197
+ ...(frontmatter['user-invocable'] !== undefined
198
+ ? { userInvocable: frontmatter['user-invocable'] === 'true' }
199
+ : {}),
200
+ };
201
+
202
+ return { manifest, files, skillMdPath };
203
+ }
204
+
205
+ module.exports = {
206
+ exportToAgentskills,
207
+ AGENTSKILLS_SCHEMA_VERSION,
208
+ // exposed for tests
209
+ stripFrontmatter,
210
+ listBundleFiles,
211
+ };