wogiflow 2.31.2 → 2.33.0

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.
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * flow skill export — Phase 1B (wf-0342fc33)
5
+ *
6
+ * Exports a portable skill to one of two open distribution formats:
7
+ * --format=agentskills@v1 (agentskills.io v1 manifest + file bundle)
8
+ * --format=claude-plugin (Claude Code plugin layout, ready for `claude plugin tag`)
9
+ *
10
+ * The export refuses (exit 1) if the portability checker reports any blocker.
11
+ * Blockers are printed to stderr in a citation-friendly `file:line` format.
12
+ *
13
+ * IMPORT IS NOT IMPLEMENTED. See `.claude/docs/skill-portability.md` for why
14
+ * (security model — quarantine + content scanner + opt-in enable — is deferred
15
+ * to a follow-up). Comment marker `[import would go here]` below marks the
16
+ * intended insertion point.
17
+ *
18
+ * Usage:
19
+ * flow skill export <name> [--format=<format>] [--out=<dir>]
20
+ *
21
+ * @file scripts/flow-skill-export.js
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ // Resolve project root via the same env-var contract scripts/flow uses.
30
+ const PROJECT_ROOT = process.env.WOGIFLOW_PROJECT_ROOT
31
+ || process.env.WOGI_PROJECT_ROOT
32
+ || process.cwd();
33
+
34
+ const { assessSkillPortability, formatBlockers } = require('../lib/skill-portability');
35
+ const { exportToAgentskills, AGENTSKILLS_SCHEMA_VERSION } = require('../lib/skill-export-agentskills');
36
+ const { exportToClaudePlugin } = require('../lib/skill-export-claude-plugin');
37
+
38
+ const SUPPORTED_FORMATS = new Set(['agentskills@v1', 'claude-plugin']);
39
+ const DEFAULT_FORMAT = 'agentskills@v1';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Argument parsing
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Parse CLI args for `flow skill export`.
47
+ *
48
+ * @param {string[]} argv
49
+ * @returns {{name: string|null, format: string, out: string|null, help: boolean, force: boolean}}
50
+ */
51
+ function parseArgs(argv) {
52
+ const options = {
53
+ name: null,
54
+ format: DEFAULT_FORMAT,
55
+ out: null,
56
+ help: false,
57
+ force: false, // bypass directory-exists check; never bypasses portability
58
+ };
59
+
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const arg = argv[i];
62
+ if (arg === '--help' || arg === '-h') {
63
+ options.help = true;
64
+ } else if (arg === '--force' || arg === '-f') {
65
+ options.force = true;
66
+ } else if (arg.startsWith('--format=')) {
67
+ options.format = arg.slice('--format='.length);
68
+ } else if (arg === '--format') {
69
+ if (i + 1 >= argv.length) {
70
+ throw new Error('--format requires a value');
71
+ }
72
+ options.format = argv[++i];
73
+ } else if (arg.startsWith('--out=')) {
74
+ options.out = arg.slice('--out='.length);
75
+ } else if (arg === '--out') {
76
+ if (i + 1 >= argv.length) {
77
+ throw new Error('--out requires a value');
78
+ }
79
+ options.out = argv[++i];
80
+ } else if (!arg.startsWith('-') && options.name === null) {
81
+ options.name = arg;
82
+ }
83
+ }
84
+
85
+ return options;
86
+ }
87
+
88
+ function showHelp() {
89
+ const lines = [
90
+ '',
91
+ 'Usage: flow skill export <name> [options]',
92
+ '',
93
+ 'Export a portable skill to an open distribution format.',
94
+ '',
95
+ 'Arguments:',
96
+ ' <name> Skill name (must exist under .claude/skills/)',
97
+ '',
98
+ 'Options:',
99
+ ' --format=<fmt> Output format: agentskills@v1 | claude-plugin',
100
+ ` (default: ${DEFAULT_FORMAT})`,
101
+ ' --out=<dir> Output directory (default: ./dist/skills/<name>/)',
102
+ ' --force, -f Overwrite existing output directory',
103
+ ' --help, -h Show this help',
104
+ '',
105
+ 'Behavior:',
106
+ ' The portability checker runs first. Any WogiFlow-specific reference',
107
+ ' (.workflow/, /wogi-*, flow-utils, ready.json, etc.) blocks the export',
108
+ ' with a citation. Fix the skill or mark it explicitly non-portable.',
109
+ '',
110
+ 'Examples:',
111
+ ' flow skill export commit',
112
+ ' flow skill export commit --format=claude-plugin',
113
+ ' flow skill export commit --format=agentskills@v1 --out=/tmp/commit-export',
114
+ '',
115
+ ];
116
+ console.log(lines.join('\n'));
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Filesystem writer
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Write the manifest + bundle files to the output directory.
125
+ *
126
+ * For agentskills, the manifest is written to `<out>/manifest.json` and
127
+ * bundled files are written under `<out>/` preserving their relative paths.
128
+ *
129
+ * For claude-plugin, the manifest is already inside the `files` array under
130
+ * `.claude-plugin/plugin.json`, so we only iterate `files`.
131
+ *
132
+ * @param {string} outDir
133
+ * @param {string} format
134
+ * @param {{manifest: Object, files: Array<{path: string, content: string}>}} bundle
135
+ */
136
+ function writeBundle(outDir, format, bundle) {
137
+ fs.mkdirSync(outDir, { recursive: true });
138
+
139
+ if (format === 'agentskills@v1') {
140
+ // Manifest at root
141
+ fs.writeFileSync(
142
+ path.join(outDir, 'manifest.json'),
143
+ JSON.stringify(bundle.manifest, null, 2) + '\n'
144
+ );
145
+ for (const file of bundle.files) {
146
+ const dest = path.join(outDir, file.path);
147
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
148
+ fs.writeFileSync(dest, file.content);
149
+ }
150
+ return;
151
+ }
152
+
153
+ if (format === 'claude-plugin') {
154
+ // claude-plugin embeds plugin.json in the files list — just write everything.
155
+ for (const file of bundle.files) {
156
+ const dest = path.join(outDir, file.path);
157
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
158
+ fs.writeFileSync(dest, file.content);
159
+ }
160
+ return;
161
+ }
162
+
163
+ throw new Error(`writeBundle: unknown format "${format}"`);
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Pure orchestration (exported for tests; does not exit/log)
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Run the export pipeline. Returns a structured result instead of exiting,
172
+ * so tests can assert behavior without spawning a child process.
173
+ *
174
+ * @param {Object} args
175
+ * @param {string} args.skillName
176
+ * @param {string} args.skillDir - Absolute path to the skill's source directory
177
+ * @param {string} args.format - Either 'agentskills@v1' or 'claude-plugin'
178
+ * @param {string} [args.outDir] - Destination dir; if omitted, no write happens
179
+ * @param {boolean} [args.force=false] - Overwrite existing destination dir
180
+ * @returns {{ok: true, bundle: Object, portability: Object, format: string, outDir: string|null}
181
+ * | {ok: false, error: string, portability?: Object}}
182
+ */
183
+ function runExport(args) {
184
+ const { skillName, skillDir, format, outDir = null, force = false } = args;
185
+
186
+ if (!SUPPORTED_FORMATS.has(format)) {
187
+ return {
188
+ ok: false,
189
+ error: `Unsupported format "${format}". Supported: ${[...SUPPORTED_FORMATS].join(', ')}`,
190
+ };
191
+ }
192
+
193
+ if (!fs.existsSync(skillDir) || !fs.statSync(skillDir).isDirectory()) {
194
+ return {
195
+ ok: false,
196
+ error: `Skill "${skillName}" not found at ${skillDir}`,
197
+ };
198
+ }
199
+
200
+ // Portability gate — fail-loud on any blocker.
201
+ const portability = assessSkillPortability(skillDir);
202
+ if (!portability.portable) {
203
+ return {
204
+ ok: false,
205
+ error: `Skill "${skillName}" is not portable. ${formatBlockers(portability.blockers)}`,
206
+ portability,
207
+ };
208
+ }
209
+
210
+ // Build the bundle.
211
+ let bundle;
212
+ try {
213
+ if (format === 'agentskills@v1') {
214
+ bundle = exportToAgentskills(skillDir);
215
+ } else {
216
+ bundle = exportToClaudePlugin(skillDir);
217
+ }
218
+ } catch (err) {
219
+ return { ok: false, error: `Export failed: ${err.message}`, portability };
220
+ }
221
+
222
+ // Optional disk write.
223
+ if (outDir) {
224
+ if (fs.existsSync(outDir)) {
225
+ if (!force) {
226
+ return {
227
+ ok: false,
228
+ error: `Output directory exists: ${outDir} (use --force to overwrite)`,
229
+ portability,
230
+ };
231
+ }
232
+ // Best-effort clean — only remove our known output paths to avoid surprises.
233
+ try {
234
+ fs.rmSync(outDir, { recursive: true, force: true });
235
+ } catch (err) {
236
+ return { ok: false, error: `Failed to clear ${outDir}: ${err.message}`, portability };
237
+ }
238
+ }
239
+ try {
240
+ writeBundle(outDir, format, bundle);
241
+ } catch (err) {
242
+ return { ok: false, error: `Failed to write bundle: ${err.message}`, portability };
243
+ }
244
+ }
245
+
246
+ return { ok: true, bundle, portability, format, outDir };
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // CLI entry
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function main(argv) {
254
+ let opts;
255
+ try {
256
+ opts = parseArgs(argv);
257
+ } catch (err) {
258
+ console.error(`Error: ${err.message}`);
259
+ showHelp();
260
+ process.exit(1);
261
+ }
262
+
263
+ if (opts.help) {
264
+ showHelp();
265
+ return;
266
+ }
267
+
268
+ if (!opts.name) {
269
+ console.error('Error: skill name is required.');
270
+ showHelp();
271
+ process.exit(1);
272
+ }
273
+
274
+ if (!SUPPORTED_FORMATS.has(opts.format)) {
275
+ console.error(`Error: unsupported --format "${opts.format}". Supported: ${[...SUPPORTED_FORMATS].join(', ')}`);
276
+ process.exit(1);
277
+ }
278
+
279
+ const skillDir = path.join(PROJECT_ROOT, '.claude', 'skills', opts.name);
280
+ const outDir = opts.out
281
+ ? path.resolve(PROJECT_ROOT, opts.out)
282
+ : path.join(PROJECT_ROOT, 'dist', 'skills', opts.name);
283
+
284
+ const result = runExport({
285
+ skillName: opts.name,
286
+ skillDir,
287
+ format: opts.format,
288
+ outDir,
289
+ force: opts.force,
290
+ });
291
+
292
+ if (!result.ok) {
293
+ console.error(`\n✗ ${result.error}`);
294
+ if (result.portability && result.portability.blockers && result.portability.blockers.length > 0) {
295
+ console.error('\nFix these blockers or mark the skill as non-portable, then re-run:');
296
+ for (const b of result.portability.blockers) {
297
+ const where = `${b.file}:${b.line}`;
298
+ const detail = b.match ? ` — "${b.match}"` : '';
299
+ console.error(` • [${b.label}] ${where}${detail}`);
300
+ }
301
+ }
302
+ process.exit(1);
303
+ }
304
+
305
+ console.log(`✓ Exported skill "${opts.name}" to ${result.format}`);
306
+ console.log(` Files: ${result.bundle.files.length}`);
307
+ console.log(` Output: ${result.outDir}`);
308
+ if (opts.format === 'agentskills@v1') {
309
+ console.log(` Schema: ${AGENTSKILLS_SCHEMA_VERSION}`);
310
+ }
311
+ }
312
+
313
+ // =============================================================================
314
+ // [import would go here]
315
+ //
316
+ // Future `flow skill import <archive>` will land here. Deferred per Phase 1B
317
+ // spec: "Import deferred to a follow-up post-security-model design (quarantine
318
+ // + content scanner + opt-in enable)." Implementing it now would land code
319
+ // without a security model around running untrusted skill content (untrusted
320
+ // .md is mostly harmless, but skills can include scripts, templates, and
321
+ // `allowed-tools` declarations that grant tool access). See
322
+ // .claude/docs/skill-portability.md for the design constraints.
323
+ // =============================================================================
324
+
325
+ if (require.main === module) {
326
+ main(process.argv.slice(2));
327
+ }
328
+
329
+ module.exports = {
330
+ runExport,
331
+ parseArgs,
332
+ SUPPORTED_FORMATS,
333
+ DEFAULT_FORMAT,
334
+ };
@@ -446,6 +446,9 @@ Run: /wogi-start ${coreResult.nextTaskId}`;
446
446
  else if (coreResult.message) pieces.push(coreResult.message);
447
447
  }
448
448
  // Info pieces always pass through.
449
+ // wf-1bcc67d5: research-required nudge — placed FIRST among info pieces
450
+ // because it's a "do this BEFORE you answer" instruction, not background.
451
+ if (coreResult.researchRequiredNudge) pieces.push(coreResult.researchRequiredNudge);
449
452
  if (coreResult.phasePrompt) pieces.push(coreResult.phasePrompt);
450
453
  if (coreResult.overduePrompt) pieces.push(coreResult.overduePrompt);
451
454
 
@@ -669,9 +672,20 @@ Run: /wogi-start ${coreResult.nextTaskId}`;
669
672
  // with zero validation benefit. This was causing unnecessary token consumption
670
673
  // in target projects where generateConfig() produces the settings.
671
674
  if (rules.validation?.enabled !== false) {
675
+ const postToolUseEntry = hookEntry('PostToolUse', 'post-tool-use.js', HOOK_TIMEOUTS.POST_TOOL_USE);
676
+ // continueOnBlock (Claude Code 2.1.139+): when the PostToolUse hook returns a
677
+ // blocking decision (lint/typecheck failure after an Edit/Write — see
678
+ // transformPostToolUse → decision: 'block'), feed the reason back to Claude and
679
+ // continue the turn so it fixes the error in-loop instead of dead-ending. This is
680
+ // what CLAUDE.md's "validate after EVERY file edit" rule needs. Unknown field on
681
+ // older Claude Code → silently ignored, so it is safe to emit unconditionally.
682
+ // Only meaningful for the local 'command' transport.
683
+ if (postToolUseEntry.type === 'command') {
684
+ postToolUseEntry.continueOnBlock = true;
685
+ }
672
686
  hooks.PostToolUse = [{
673
687
  matcher: 'Edit|Write|Bash',
674
- hooks: [hookEntry('PostToolUse', 'post-tool-use.js', HOOK_TIMEOUTS.POST_TOOL_USE)]
688
+ hooks: [postToolUseEntry]
675
689
  }];
676
690
  }
677
691
 
@@ -153,25 +153,68 @@ function rotateBackupBranches(keep) {
153
153
  }
154
154
 
155
155
  /**
156
- * Auto-stash current changes.
157
- * @returns {boolean} True if stash was created.
156
+ * Auto-stash current changes with verification.
157
+ *
158
+ * Returns a discriminated result so callers can distinguish:
159
+ * - `no-changes` — working tree was clean; nothing to stash. Safe to proceed.
160
+ * - `stashed` — stash created AND verified present at stash@{0}. Safe to proceed.
161
+ * - `failed` — something went wrong (git error, lock contention, or stash
162
+ * silently no-op'd). Caller MUST NOT proceed with destructive
163
+ * operations — the user's uncommitted work is still in the
164
+ * working tree and would be lost.
165
+ *
166
+ * Historically (pre wf-2d3d09b8) this returned a bare boolean, which collapsed
167
+ * `no-changes` and `failed` into the same `false`. Callers couldn't tell the
168
+ * difference, so a stash failure on a dirty working tree was indistinguishable
169
+ * from a no-op on a clean one — and the discard-all branch let the destructive
170
+ * `git checkout .` / `git restore .` through either way. This was BUG-1.
171
+ *
172
+ * @param {Object} [opts]
173
+ * @param {Function} [opts.exec] - execSync replacement for testing (string cmd → string stdout, throws on error).
174
+ * @returns {{ status: 'no-changes' | 'stashed' | 'failed', error?: string, stashRef?: string }}
158
175
  */
159
- function autoStash() {
160
- const { execSync } = require('node:child_process');
176
+ function autoStash(opts = {}) {
177
+ const exec = opts.exec || require('node:child_process').execSync;
178
+ const runOpts = { encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe'] };
179
+
180
+ // 1. Anything to stash?
181
+ let status;
161
182
  try {
162
- const status = execSync('git status --porcelain', {
163
- encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
164
- }).trim();
165
- if (!status) return false; // Nothing to stash
183
+ status = exec('git status --porcelain', runOpts).trim();
184
+ } catch (err) {
185
+ return { status: 'failed', error: `git status failed: ${err.message || err}` };
186
+ }
187
+ if (!status) return { status: 'no-changes' };
166
188
 
167
- const timestamp = new Date().toISOString().slice(0, 19);
168
- execSync(`git stash push -m "auto-backup-${timestamp}"`, {
169
- encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
170
- });
171
- return true;
172
- } catch (_err) {
173
- return false;
189
+ // 2. Attempt the stash.
190
+ const timestamp = new Date().toISOString().slice(0, 19);
191
+ const stashMessage = `auto-backup-${timestamp}`;
192
+ try {
193
+ exec(`git stash push -m "${stashMessage}"`, runOpts);
194
+ } catch (err) {
195
+ return { status: 'failed', error: `git stash push failed: ${err.message || err}` };
196
+ }
197
+
198
+ // 3. VERIFY the stash actually saved (BUG-1 / wf-2d3d09b8). A zero exit code
199
+ // from `git stash push` is NOT proof: lock contention, broken hooks, or
200
+ // edge cases can leave the working tree unchanged with status 0. Confirm
201
+ // by reading `git stash list` and matching our timestamped message at
202
+ // stash@{0}.
203
+ let stashList;
204
+ try {
205
+ stashList = exec('git stash list', runOpts);
206
+ } catch (err) {
207
+ return { status: 'failed', error: `stash verification (git stash list) failed: ${err.message || err}` };
174
208
  }
209
+ const firstLine = (stashList || '').split('\n')[0] || '';
210
+ if (!firstLine.includes(stashMessage)) {
211
+ return {
212
+ status: 'failed',
213
+ error: `stash verification failed: expected "${stashMessage}" at stash@{0}, found: ${firstLine || '(empty)'}`
214
+ };
215
+ }
216
+
217
+ return { status: 'stashed', stashRef: 'stash@{0}' };
175
218
  }
176
219
 
177
220
  // ============================================================
@@ -230,9 +273,13 @@ function parseGitCommand(command) {
230
273
  * Check git safety for Bash commands (PreToolUse).
231
274
  * @param {string} command - Bash command
232
275
  * @param {Object} [config]
233
- * @returns {{ allowed: boolean, blocked: boolean, reason?: string, message?: string, autoAction?: string }}
276
+ * @param {Object} [_deps] - Dependency injection seam (test-only).
277
+ * @param {Function} [_deps.autoStash] - Override the autoStash helper (test-only).
278
+ * @returns {{ allowed: boolean, blocked: boolean, reason?: string, message?: string, autoAction?: string, warning?: boolean }}
234
279
  */
235
- function checkGitSafety(command, config) {
280
+ function checkGitSafety(command, config, _deps) {
281
+ const autoStashFn = (_deps && _deps.autoStash) || autoStash;
282
+
236
283
  if (!isGitSafetyEnabled(config)) {
237
284
  return { allowed: true, blocked: false };
238
285
  }
@@ -253,16 +300,41 @@ function checkGitSafety(command, config) {
253
300
  // Handle: git checkout . / git restore .
254
301
  if (parsed.type === 'discard-all') {
255
302
  if (gitConfig.autoBackup) {
256
- const stashed = autoStash();
303
+ const stashResult = autoStashFn();
304
+
305
+ // BUG-1 / wf-2d3d09b8: previously this branch returned `allowed:true`
306
+ // unconditionally — letting the destructive `git checkout .` /
307
+ // `git restore .` proceed even when the auto-backup stash silently
308
+ // failed, which destroyed the user's uncommitted work. Now we block on
309
+ // stash failure so the user can recover their work manually first.
310
+ if (stashResult && stashResult.status === 'failed') {
311
+ return {
312
+ allowed: false,
313
+ blocked: true,
314
+ reason: 'git-safety-stash-failed',
315
+ message:
316
+ `GIT SAFETY NET: Auto-backup stash FAILED.\n\n` +
317
+ `${stashResult.error || 'Unknown stash failure.'}\n\n` +
318
+ `Refusing to proceed with the discard operation — your uncommitted ` +
319
+ `work would be lost with no recovery path.\n\n` +
320
+ `Recover with one of:\n` +
321
+ ` - Commit your work first: git add -A && git commit -m "WIP"\n` +
322
+ ` - Stash manually + verify: git stash push -u -m "manual"; git stash list\n` +
323
+ ` - Checkpoint to a branch: git checkout -b backup-WIP && git add -A && git commit -m "snapshot"\n\n` +
324
+ `Then re-run the discard command.`
325
+ };
326
+ }
327
+
328
+ const stashed = stashResult && stashResult.status === 'stashed';
257
329
  const stashMsg = stashed
258
- ? 'Auto-stashed your changes before executing. Recover with: git stash pop'
330
+ ? `Auto-stashed your changes (${stashResult.stashRef}). Recover with: git stash pop`
259
331
  : 'No uncommitted changes to stash.';
260
332
 
261
333
  return {
262
334
  allowed: true,
263
335
  blocked: false,
264
336
  warning: true,
265
- autoAction: 'stash',
337
+ autoAction: stashed ? 'stash' : 'no-op',
266
338
  message: `GIT SAFETY NET: ${stashMsg}\n\nProceeding with discard operation.`
267
339
  };
268
340
  }