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,183 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Skill Exporter — Claude Code plugin format (Phase 1B — wf-0342fc33)
5
+ *
6
+ * Produces a Claude Code plugin manifest + file list suitable for the
7
+ * `claude plugin tag` distribution path (Claude Code 2.1.118+).
8
+ *
9
+ * Layout produced (the file map this module returns; `flow skill export`
10
+ * is responsible for actually writing these to disk):
11
+ *
12
+ * .claude-plugin/plugin.json (root manifest)
13
+ * skills/<name>/SKILL.md (the skill itself)
14
+ * skills/<name>/<...other files> (knowledge/, templates/, etc.)
15
+ *
16
+ * The plugin.json shape mirrors what shipping Claude Code plugins use
17
+ * (see e.g., the official Figma plugin's `.claude-plugin/plugin.json`):
18
+ *
19
+ * {
20
+ * name: string, // plugin identifier
21
+ * description: string,
22
+ * version: string,
23
+ * author: { name: string } | string,
24
+ * license: string // optional
25
+ * }
26
+ *
27
+ * Like the agentskills exporter, this module assumes the caller has already
28
+ * run the portability checker. We don't double-verify.
29
+ *
30
+ * @module lib/skill-export-claude-plugin
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ const fs = require('node:fs');
36
+ const path = require('node:path');
37
+
38
+ const { parseFrontmatter } = require('./skill-portability');
39
+ const { listBundleFiles } = require('./skill-export-agentskills');
40
+
41
+ const DEFAULT_LICENSE = 'MIT';
42
+
43
+ /**
44
+ * Sanitize a skill name for use in path construction.
45
+ *
46
+ * F9 (R-379): the previous code passed `frontmatter.name` directly into
47
+ * `path.join(outDir, \`skills/${name}/...\`)` — a malicious skill with
48
+ * `name: ../../../etc` would escape the output directory.
49
+ *
50
+ * Strategy: strip path separators and `..` sequences (replace with safe
51
+ * placeholders so we still produce a useful name), reject empty results,
52
+ * and reject names that try to be hidden (.dotfile) or absolute (`/foo`).
53
+ *
54
+ * @param {string} raw
55
+ * @returns {string} sanitized name
56
+ * @throws {Error} if the name is empty after sanitization or otherwise
57
+ * unrecoverable.
58
+ */
59
+ function sanitizePluginName(raw) {
60
+ if (typeof raw !== 'string' || !raw.trim()) {
61
+ throw new Error('sanitizePluginName: name must be a non-empty string');
62
+ }
63
+ let s = raw
64
+ .replace(/[/\\]/g, '-') // path separators → dash
65
+ .replace(/\.\./g, '--') // .. sequences → dash-dash
66
+ .replace(/\0/g, ''); // strip nulls just in case
67
+ // Reject leading dot (would create hidden directory) and leading dash
68
+ // (would look like a CLI flag in some contexts).
69
+ s = s.replace(/^[.\-]+/, '');
70
+ // Trim again after substitutions
71
+ s = s.trim();
72
+ if (!s) {
73
+ throw new Error(`sanitizePluginName: name "${raw}" sanitizes to empty`);
74
+ }
75
+ return s;
76
+ }
77
+ const DEFAULT_AUTHOR = 'wogiflow';
78
+
79
+ /**
80
+ * Read a file safely, returning empty string on failure.
81
+ *
82
+ * @param {string} filePath
83
+ * @returns {string}
84
+ */
85
+ function safeReadFile(filePath) {
86
+ try {
87
+ return fs.readFileSync(filePath, 'utf-8');
88
+ } catch (_err) {
89
+ return '';
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Export a skill to Claude Code plugin format.
95
+ *
96
+ * @param {string} skillDir - Absolute path to skill directory
97
+ * @param {Object} [opts]
98
+ * @param {string} [opts.name] - Override plugin name (default: from frontmatter or dir basename)
99
+ * @param {string} [opts.version] - Override version (default: from frontmatter or 0.0.0)
100
+ * @param {string} [opts.author] - Override author name (default: DEFAULT_AUTHOR)
101
+ * @returns {{manifest: Object, files: Array<{path: string, content: string}>, skillMdPath: string|null}}
102
+ */
103
+ function exportToClaudePlugin(skillDir, opts = {}) {
104
+ if (typeof skillDir !== 'string' || !skillDir) {
105
+ throw new Error('exportToClaudePlugin: skillDir must be a non-empty string');
106
+ }
107
+ if (!fs.existsSync(skillDir) || !fs.statSync(skillDir).isDirectory()) {
108
+ throw new Error(`exportToClaudePlugin: not a directory: ${skillDir}`);
109
+ }
110
+
111
+ // Locate skill.md (we tolerate case variants; output is normalized to SKILL.md
112
+ // which is the prevailing convention in shipping Claude Code plugins).
113
+ let skillMdPath = null;
114
+ for (const candidate of ['skill.md', 'SKILL.md', 'Skill.md']) {
115
+ const p = path.join(skillDir, candidate);
116
+ if (fs.existsSync(p)) {
117
+ skillMdPath = p;
118
+ break;
119
+ }
120
+ }
121
+
122
+ let frontmatter = {};
123
+ if (skillMdPath) {
124
+ frontmatter = parseFrontmatter(safeReadFile(skillMdPath));
125
+ }
126
+
127
+ // F9 (R-379): sanitize `name` before using it in path construction. A
128
+ // skill author who sets `name: ../../../etc` in frontmatter could escape
129
+ // the output directory via `path.join(outDir, 'skills/../../../etc/SKILL.md')`.
130
+ // Strip path separators and `..` sequences; reject empty/dotfile names.
131
+ const rawName = opts.name ?? frontmatter.name ?? path.basename(skillDir);
132
+ const name = sanitizePluginName(rawName);
133
+ const version = opts.version ?? frontmatter.version ?? '0.0.0';
134
+ const description = frontmatter.description ?? '';
135
+ const license = frontmatter.license ?? DEFAULT_LICENSE;
136
+ const author = opts.author ?? DEFAULT_AUTHOR;
137
+
138
+ // Plugin manifest — matches the .claude-plugin/plugin.json shape used by
139
+ // shipping Claude Code plugins. Keeping it tight: name/description/version/
140
+ // author/license. Future plugin fields (commands, hooks, mcpServers) are
141
+ // additive — leave room but don't speculatively fill them in.
142
+ const manifest = {
143
+ name,
144
+ description,
145
+ version,
146
+ author: { name: author },
147
+ license,
148
+ };
149
+
150
+ // Build file bundle. The Claude Code plugin layout puts the skill under
151
+ // `skills/<name>/...`, so we re-root every file under that prefix.
152
+ // skill.md is normalized to SKILL.md in the destination.
153
+ const absFiles = listBundleFiles(skillDir);
154
+ const files = [
155
+ // The plugin.json manifest itself
156
+ {
157
+ path: '.claude-plugin/plugin.json',
158
+ content: JSON.stringify(manifest, null, 2) + '\n',
159
+ },
160
+ ];
161
+
162
+ for (const abs of absFiles) {
163
+ const relRaw = path.relative(skillDir, abs) || path.basename(abs);
164
+ const rel = relRaw.split(path.sep).join('/');
165
+ let destName = rel;
166
+
167
+ // Normalize skill.md → SKILL.md at the skill root only.
168
+ if (rel === 'skill.md' || rel === 'Skill.md') {
169
+ destName = 'SKILL.md';
170
+ }
171
+
172
+ files.push({
173
+ path: `skills/${name}/${destName}`,
174
+ content: safeReadFile(abs),
175
+ });
176
+ }
177
+
178
+ return { manifest, files, skillMdPath };
179
+ }
180
+
181
+ module.exports = {
182
+ exportToClaudePlugin,
183
+ };
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Skill Portability Checker (Phase 1B — wf-0342fc33)
5
+ *
6
+ * Determines whether a skill is portable to the broader Claude Code / agentskills.io
7
+ * ecosystem. A "portable" skill MUST NOT reference WogiFlow-specific paths, state files,
8
+ * imports, or slash-command invocations — because those won't exist outside this project.
9
+ *
10
+ * Fail-loud: every blocker is cited with `path:line`. Callers MUST refuse export when
11
+ * `portable === false`.
12
+ *
13
+ * Usage:
14
+ * const { assessSkillPortability } = require('./skill-portability');
15
+ * const result = assessSkillPortability('/path/to/.claude/skills/commit');
16
+ * // result = { portable: true, blockers: [], manifest: {...}, scannedFiles: [...] }
17
+ *
18
+ * @module lib/skill-portability
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Blocker patterns
28
+ // ---------------------------------------------------------------------------
29
+ // Each entry: { pattern: RegExp, label: string }
30
+ //
31
+ // The pattern is matched line-by-line against every text file under the skill
32
+ // directory. If any line matches any pattern, the skill is non-portable.
33
+ //
34
+ // NOTE on scoping: we deliberately use *substring* matches on canonical WogiFlow
35
+ // path strings rather than language-level imports, because skill content is
36
+ // mostly Markdown. A reference like ".workflow/state/ready.json" in a how-to
37
+ // guide is just as project-locking as a literal `require('./scripts/flow-utils')`.
38
+
39
+ const BLOCKER_PATTERNS = [
40
+ // State-file references
41
+ { pattern: /\.workflow\//, label: 'wogiflow-state-path (.workflow/)' },
42
+ { pattern: /\bwogiflow-cloud\b/, label: 'wogiflow-cloud reference' },
43
+ { pattern: /\bready\.json\b/, label: 'ready.json reference' },
44
+ { pattern: /\bfeedback-patterns\.md\b/, label: 'feedback-patterns.md reference' },
45
+ { pattern: /\bdecisions\.md\b/, label: 'decisions.md reference' },
46
+ { pattern: /\bapp-map\.md\b/, label: 'app-map.md reference' },
47
+ { pattern: /\bfunction-map\.md\b/, label: 'function-map.md reference' },
48
+ { pattern: /\bapi-map\.md\b/, label: 'api-map.md reference' },
49
+ // Imports / requires of WogiFlow modules
50
+ { pattern: /\bflow-utils\b/, label: 'flow-utils import/reference' },
51
+ { pattern: /require\(['"][^'"]*\/scripts\/flow[-/]/, label: 'WogiFlow scripts/ require()' },
52
+ { pattern: /from\s+['"][^'"]*\/scripts\/flow[-/]/, label: 'WogiFlow scripts/ import' },
53
+ // Slash-command invocations (any /wogi-* with a word char after).
54
+ // F7 (R-379): require a lookbehind for start-of-line, whitespace, or
55
+ // quote/bracket — so legitimate file paths like
56
+ // `.claude/skills/wogi-start/skill.md` or `/workflows/wogi-status` don't
57
+ // trip a false-positive blocker. Lookbehind (not capturing group) so the
58
+ // matched substring is the slash-command itself, e.g. `/wogi-finalize`.
59
+ { pattern: /(?<=^|[\s`'"(\[])\/wogi-[a-z][a-z0-9-]*\b/im, label: '/wogi-* slash command' },
60
+ // Shell invocations of the local flow CLI
61
+ { pattern: /\.\/scripts\/flow\b/, label: 'local ./scripts/flow CLI call' },
62
+ { pattern: /\bflow\s+(?:wogi-|skill\s+|story\s+|start\s+|status\b|ready\b|finalize\b)/, label: 'flow CLI subcommand specific to WogiFlow' },
63
+ ];
64
+
65
+ // File extensions we scan for blockers.
66
+ const SCAN_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.yaml', '.yml', '.json', '.js', '.ts', '.sh']);
67
+
68
+ // Max file size to scan (defense against accidentally enormous fixtures).
69
+ const MAX_SCAN_BYTES = 1 * 1024 * 1024; // 1 MiB
70
+
71
+ // Max files to scan (defense against deeply nested fixture trees).
72
+ const MAX_SCAN_FILES = 200;
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Frontmatter parser (kept local — same shape as flow-skill-freshness.js)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
79
+
80
+ /**
81
+ * Parse YAML frontmatter from a skill.md file. Handles colons in values
82
+ * by splitting on the first colon only. Blocks prototype pollution keys.
83
+ *
84
+ * @param {string} content - File content
85
+ * @returns {Object} Parsed frontmatter key-value pairs (empty object on miss)
86
+ */
87
+ function parseFrontmatter(content) {
88
+ if (typeof content !== 'string') return {};
89
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
90
+ if (!match) return {};
91
+
92
+ const result = {};
93
+ for (const rawLine of match[1].split(/\r?\n/)) {
94
+ const line = rawLine.replace(/\s+$/, '');
95
+ if (!line || line.startsWith('#')) continue;
96
+ // Skip list-item lines (we don't parse arrays here — caller pulls those via dedicated logic)
97
+ if (line.startsWith('- ')) continue;
98
+ const colonIdx = line.indexOf(':');
99
+ if (colonIdx === -1) continue;
100
+ const key = line.slice(0, colonIdx).trim();
101
+ if (!key || DANGEROUS_KEYS.has(key)) continue;
102
+ const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
103
+ result[key] = value;
104
+ }
105
+ return result;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Directory walker
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Walk a directory and return absolute paths of files we should scan.
114
+ * Skips hidden dirs (except the skill root itself), node_modules, large files.
115
+ *
116
+ * @param {string} rootDir - Absolute path to skill directory
117
+ * @returns {string[]} Absolute file paths, capped at MAX_SCAN_FILES
118
+ */
119
+ function listScanFiles(rootDir) {
120
+ const out = [];
121
+ const stack = [rootDir];
122
+
123
+ while (stack.length > 0 && out.length < MAX_SCAN_FILES) {
124
+ const dir = stack.pop();
125
+ let entries;
126
+ try {
127
+ entries = fs.readdirSync(dir, { withFileTypes: true });
128
+ } catch (_err) {
129
+ continue;
130
+ }
131
+ for (const entry of entries) {
132
+ const full = path.join(dir, entry.name);
133
+ if (entry.isDirectory()) {
134
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
135
+ stack.push(full);
136
+ continue;
137
+ }
138
+ if (!entry.isFile()) continue;
139
+ const ext = path.extname(entry.name).toLowerCase();
140
+ if (!SCAN_EXTENSIONS.has(ext)) continue;
141
+ let stat;
142
+ try {
143
+ stat = fs.statSync(full);
144
+ } catch (_err) {
145
+ continue;
146
+ }
147
+ if (stat.size > MAX_SCAN_BYTES) continue;
148
+ out.push(full);
149
+ if (out.length >= MAX_SCAN_FILES) break;
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Core: assessSkillPortability
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Assess whether a skill directory is portable to non-WogiFlow consumers.
161
+ *
162
+ * Algorithm:
163
+ * 1. Validate the directory exists and has a `skill.md` (or SKILL.md).
164
+ * 2. Parse the skill.md frontmatter (used by exporters for manifest fields).
165
+ * 3. Walk the directory, scanning every text-y file line by line.
166
+ * 4. For each line, test all BLOCKER_PATTERNS; collect citations.
167
+ * 5. portable = blockers.length === 0.
168
+ *
169
+ * If the frontmatter declares `portable: false` explicitly, that wins even
170
+ * when the content scan finds nothing — the skill author is signaling an
171
+ * implicit-dependency the scanner can't detect (e.g., relies on a project
172
+ * convention that isn't a literal path string).
173
+ *
174
+ * If the frontmatter declares `portable: true` but the scanner finds blockers,
175
+ * the scanner wins — fail-loud is the priority.
176
+ *
177
+ * @param {string} skillDir - Absolute path to skill directory
178
+ * @param {Object} [opts]
179
+ * @param {RegExp[]} [opts.extraPatterns] - Additional blocker patterns
180
+ * @returns {{portable: boolean, blockers: Array<{file: string, line: number, match: string, label: string}>, manifest: Object, scannedFiles: string[], skillMdPath: string|null}}
181
+ */
182
+ function assessSkillPortability(skillDir, opts = {}) {
183
+ if (typeof skillDir !== 'string' || !skillDir) {
184
+ return {
185
+ portable: false,
186
+ blockers: [{ file: '<input>', line: 0, match: '', label: 'invalid skill directory (empty path)' }],
187
+ manifest: {},
188
+ scannedFiles: [],
189
+ skillMdPath: null,
190
+ };
191
+ }
192
+
193
+ let stat;
194
+ try {
195
+ stat = fs.statSync(skillDir);
196
+ } catch (_err) {
197
+ return {
198
+ portable: false,
199
+ blockers: [{ file: skillDir, line: 0, match: '', label: 'skill directory does not exist' }],
200
+ manifest: {},
201
+ scannedFiles: [],
202
+ skillMdPath: null,
203
+ };
204
+ }
205
+ if (!stat.isDirectory()) {
206
+ return {
207
+ portable: false,
208
+ blockers: [{ file: skillDir, line: 0, match: '', label: 'skill path is not a directory' }],
209
+ manifest: {},
210
+ scannedFiles: [],
211
+ skillMdPath: null,
212
+ };
213
+ }
214
+
215
+ // Locate skill.md (case variants accepted)
216
+ let skillMdPath = null;
217
+ for (const candidate of ['skill.md', 'SKILL.md', 'Skill.md']) {
218
+ const p = path.join(skillDir, candidate);
219
+ if (fs.existsSync(p)) {
220
+ skillMdPath = p;
221
+ break;
222
+ }
223
+ }
224
+
225
+ let manifest = {};
226
+ if (skillMdPath) {
227
+ try {
228
+ const content = fs.readFileSync(skillMdPath, 'utf-8');
229
+ manifest = parseFrontmatter(content);
230
+ } catch (_err) {
231
+ // Continue; missing manifest is itself a portability concern handled below.
232
+ }
233
+ }
234
+
235
+ const blockers = [];
236
+ if (!skillMdPath) {
237
+ blockers.push({
238
+ file: skillDir,
239
+ line: 0,
240
+ match: '',
241
+ label: 'skill.md not found at skill root',
242
+ });
243
+ }
244
+
245
+ // Explicit author declaration. F14 (R-379): previously the comment claimed
246
+ // `portable: false` "short-circuits scanning" — but there was no early
247
+ // return; the function scanned anyway, producing a needlessly long blocker
248
+ // list for skills the author already marked non-portable. Short-circuit
249
+ // now matches the comment: return early so the caller gets a single,
250
+ // clear blocker ("author opted out") instead of dozens of pattern hits.
251
+ const declaredPortable = typeof manifest.portable === 'string'
252
+ ? manifest.portable.toLowerCase() === 'true'
253
+ : null;
254
+ if (declaredPortable === false) {
255
+ blockers.push({
256
+ file: skillMdPath ?? skillDir,
257
+ line: 0,
258
+ match: 'portable: false',
259
+ label: 'manifest declares portable: false',
260
+ });
261
+ // Short-circuit: author opted out, no need to enumerate every pattern hit.
262
+ return {
263
+ portable: false,
264
+ blockers,
265
+ manifest,
266
+ scannedFiles: [],
267
+ skillMdPath,
268
+ };
269
+ }
270
+
271
+ // Compose pattern list: builtin + extras.
272
+ const patterns = [...BLOCKER_PATTERNS];
273
+ if (Array.isArray(opts.extraPatterns)) {
274
+ for (const p of opts.extraPatterns) {
275
+ if (p instanceof RegExp) {
276
+ patterns.push({ pattern: p, label: `custom: ${p.source}` });
277
+ }
278
+ }
279
+ }
280
+
281
+ const files = listScanFiles(skillDir);
282
+ for (const file of files) {
283
+ let content;
284
+ try {
285
+ content = fs.readFileSync(file, 'utf-8');
286
+ } catch (_err) {
287
+ continue;
288
+ }
289
+ const rel = path.relative(skillDir, file) || path.basename(file);
290
+ const lines = content.split(/\r?\n/);
291
+ for (let i = 0; i < lines.length; i++) {
292
+ const line = lines[i];
293
+ for (const { pattern, label } of patterns) {
294
+ const match = line.match(pattern);
295
+ if (match) {
296
+ blockers.push({
297
+ file: rel,
298
+ line: i + 1,
299
+ match: match[0],
300
+ label,
301
+ });
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ return {
308
+ portable: blockers.length === 0,
309
+ blockers,
310
+ manifest,
311
+ scannedFiles: files.map((f) => path.relative(skillDir, f) || path.basename(f)),
312
+ skillMdPath,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Format a blockers array as a human-readable report. Useful for CLI output.
318
+ *
319
+ * @param {Array} blockers - Output of assessSkillPortability().blockers
320
+ * @returns {string} Multi-line summary
321
+ */
322
+ function formatBlockers(blockers) {
323
+ if (!Array.isArray(blockers) || blockers.length === 0) {
324
+ return 'No portability blockers found.';
325
+ }
326
+ const lines = [`Found ${blockers.length} portability blocker(s):`];
327
+ for (const b of blockers) {
328
+ const where = `${b.file}:${b.line}`;
329
+ const detail = b.match ? ` — "${b.match}"` : '';
330
+ lines.push(` - [${b.label}] ${where}${detail}`);
331
+ }
332
+ return lines.join('\n');
333
+ }
334
+
335
+ module.exports = {
336
+ assessSkillPortability,
337
+ formatBlockers,
338
+ parseFrontmatter,
339
+ // Exposed for tests
340
+ BLOCKER_PATTERNS,
341
+ SCAN_EXTENSIONS,
342
+ };
@@ -288,13 +288,39 @@ async function listSkills(projectRoot) {
288
288
  ? `(installed: ${isInstalled.version})`
289
289
  : `(v${skill.version})`;
290
290
 
291
- console.log(` ${status} ${skill.name.padEnd(15)} ${versionInfo}`);
291
+ // Phase 1B: surface portability field on listed skills. We treat the
292
+ // field as a boolean tri-state — true/false from the manifest, undefined
293
+ // when missing (renders as no tag). Default-on-display is `false` when
294
+ // missing per Phase 1B spec.
295
+ const portableSource = isInstalled ?? skill;
296
+ const portable = readPortableField(portableSource);
297
+ const portableTag = portable === true ? ' [portable]' : '';
298
+
299
+ console.log(` ${status} ${skill.name.padEnd(15)} ${versionInfo}${portableTag}`);
292
300
  console.log(` ${skill.description}`);
293
301
  }
294
302
 
295
303
  console.log('\nUse `flow skill add <name>` to install a skill');
296
304
  }
297
305
 
306
+ /**
307
+ * Read the `portable` flag from a skill manifest, defaulting to false.
308
+ *
309
+ * Manifest values may arrive as boolean (JSON manifest) or string (YAML
310
+ * frontmatter copied through). Anything other than literal-true is false.
311
+ * Phase 1B (wf-0342fc33).
312
+ *
313
+ * @param {Object} manifest - Skill manifest object
314
+ * @returns {boolean} portable flag, default false
315
+ */
316
+ function readPortableField(manifest) {
317
+ if (!manifest || typeof manifest !== 'object') return false;
318
+ const v = manifest.portable;
319
+ if (v === true) return true;
320
+ if (typeof v === 'string' && v.toLowerCase() === 'true') return true;
321
+ return false;
322
+ }
323
+
298
324
  /**
299
325
  * Install a skill
300
326
  * @param {string} skillName - Skill name
@@ -353,8 +379,12 @@ async function addSkill(skillName, projectRoot, options) {
353
379
  }
354
380
 
355
381
  // Write manifest (safe - we control the filename)
382
+ // Phase 1B: explicitly persist `portable` as boolean (default false). Upstream
383
+ // manifests may omit the field or set it as a string; we normalize here so
384
+ // downstream tools and `flow skill list` see a consistent shape.
356
385
  const localManifest = {
357
386
  ...manifest,
387
+ portable: readPortableField(manifest),
358
388
  installedAt: new Date().toISOString(),
359
389
  installedVersion: manifest.version
360
390
  };
@@ -523,4 +553,4 @@ async function skill(args) {
523
553
  }
524
554
  }
525
555
 
526
- module.exports = { skill };
556
+ module.exports = { skill, readPortableField };