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.
- package/.claude/docs/claude-code-compatibility.md +51 -0
- package/.claude/docs/scheduled-mode.md +213 -0
- package/.claude/docs/skill-portability.md +190 -0
- package/.claude/rules/alternative-hook-args-exec-form.md +6 -0
- package/.claude/settings.json +2 -1
- package/.claude/skills/_template/skill.md +1 -0
- package/.claude/skills/conventional-commit/knowledge/examples.md +65 -0
- package/.claude/skills/conventional-commit/skill.md +76 -0
- package/bin/flow +16 -0
- package/lib/scheduled-mode.js +374 -0
- package/lib/skill-export-agentskills.js +211 -0
- package/lib/skill-export-claude-plugin.js +183 -0
- package/lib/skill-portability.js +342 -0
- package/lib/skill-registry.js +32 -2
- package/lib/workspace-channel-server.js +106 -3
- package/lib/workspace-channel-tracking.js +102 -1
- package/lib/workspace-dispatch-tracking.js +28 -0
- package/lib/workspace-messages.js +32 -4
- package/lib/workspace-subtask-state.js +215 -0
- package/lib/workspace.js +81 -0
- package/package.json +2 -2
- package/scripts/flow +25 -0
- package/scripts/flow-config-defaults.js +20 -0
- package/scripts/flow-constants.js +3 -1
- package/scripts/flow-schedule.js +486 -0
- package/scripts/flow-scheduled-runner.js +659 -0
- package/scripts/flow-skill-export.js +334 -0
- package/scripts/flow-standards-checker.js +37 -0
- package/scripts/hooks/adapters/claude-code.js +18 -3
- package/scripts/hooks/core/git-safety-gate.js +118 -27
- package/scripts/hooks/core/long-input-enforcement.js +139 -4
- package/scripts/hooks/core/overdue-dispatches.js +28 -6
- package/scripts/hooks/core/session-start-worker.js +52 -0
- package/scripts/hooks/core/stop-orchestrator.js +17 -2
- package/scripts/hooks/core/validation.js +8 -0
- package/scripts/hooks/core/worker-continuation-gate.js +326 -0
- package/scripts/hooks/core/workspace-stop-gates.js +21 -0
- package/scripts/hooks/core/workspace-stop-notify.js +174 -59
- 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
|
+
};
|