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,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
|
+
};
|
package/lib/skill-registry.js
CHANGED
|
@@ -288,13 +288,39 @@ async function listSkills(projectRoot) {
|
|
|
288
288
|
? `(installed: ${isInstalled.version})`
|
|
289
289
|
: `(v${skill.version})`;
|
|
290
290
|
|
|
291
|
-
|
|
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 };
|