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,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
|
+
};
|
|
@@ -451,6 +451,43 @@ function checkSecurityPatterns(file, _securityRules) {
|
|
|
451
451
|
|
|
452
452
|
// Hard-coded security checks from security-patterns.md
|
|
453
453
|
|
|
454
|
+
// 0. execSync / execAsync with template-string commands containing
|
|
455
|
+
// interpolated values (R-379 standards-gate hardening).
|
|
456
|
+
// security-patterns.md §8 mandates execFile* with array args for any
|
|
457
|
+
// subprocess that includes dynamic data. Three independent review rounds
|
|
458
|
+
// have caught this pattern in scripts/hooks/core/git-safety-gate.js;
|
|
459
|
+
// making the check mechanical so it can't slip past again.
|
|
460
|
+
//
|
|
461
|
+
// Scoped to scripts/hooks/ and lib/ — these are the places the rule binds.
|
|
462
|
+
// Test files and CLI tools that build complex pipelines often legitimately
|
|
463
|
+
// use template-string shells (e.g. for documented one-off scripts).
|
|
464
|
+
//
|
|
465
|
+
// Match shape: execSync(`...${...}...`) — any backtick literal containing
|
|
466
|
+
// a ${...} expression passed to execSync (or its aliases).
|
|
467
|
+
const inScopeForExecSyncCheck =
|
|
468
|
+
/(?:^|\/)scripts\/hooks\//.test(file.path) ||
|
|
469
|
+
/(?:^|\/)lib\//.test(file.path);
|
|
470
|
+
if (inScopeForExecSyncCheck) {
|
|
471
|
+
const execSyncTemplateRe =
|
|
472
|
+
/\b(?:execSync|execAsync)\s*\(\s*`[^`]*\$\{[^`]*`/g;
|
|
473
|
+
let m;
|
|
474
|
+
while ((m = execSyncTemplateRe.exec(content)) !== null) {
|
|
475
|
+
const beforeMatch = content.substring(0, m.index);
|
|
476
|
+
const lineNumber = (beforeMatch.match(/\n/g) || []).length + 1;
|
|
477
|
+
violations.push({
|
|
478
|
+
type: 'security',
|
|
479
|
+
severity: 'must-fix',
|
|
480
|
+
file: file.path,
|
|
481
|
+
line: lineNumber,
|
|
482
|
+
message:
|
|
483
|
+
'execSync with template-string command (contains ${...} interpolation) — ' +
|
|
484
|
+
'use execFileSync("bin", ["arg1", interpolatedVar]) instead (no shell layer). ' +
|
|
485
|
+
'Three review rounds have caught this exact pattern; mechanical now.',
|
|
486
|
+
rule: 'security-patterns.md §8 (R-379 standards-gate hardening)',
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
454
491
|
// 1. Raw JSON.parse — strengthened by Track B (2026-04-13).
|
|
455
492
|
// Original heuristic only flagged JSON.parse OUTSIDE try blocks. This missed
|
|
456
493
|
// SEC-001 (raw JSON.parse on user-config inside a try block — which loses the
|
|
@@ -191,7 +191,11 @@ class ClaudeCodeAdapter extends BaseAdapter {
|
|
|
191
191
|
case 'PostToolUse':
|
|
192
192
|
return this.transformPostToolUse(coreResult);
|
|
193
193
|
case 'Stop':
|
|
194
|
-
|
|
194
|
+
// SubagentStop intentionally omitted: not in CLAUDE_CODE_EVENTS
|
|
195
|
+
// (commented out at line 70), so generateConfig() never emits a
|
|
196
|
+
// hook entry for it. The fall-through case is unreachable by
|
|
197
|
+
// construction (F12 / R-379). If SubagentStop support is wanted,
|
|
198
|
+
// re-add to CLAUDE_CODE_EVENTS + HOOK_TIMEOUTS + this switch.
|
|
195
199
|
return this.transformStop(coreResult);
|
|
196
200
|
case 'SessionEnd':
|
|
197
201
|
return this.transformSessionEnd(coreResult);
|
|
@@ -537,7 +541,7 @@ Run: /wogi-start ${coreResult.nextTaskId}`;
|
|
|
537
541
|
...(coreResult.message && { systemMessage: coreResult.message }),
|
|
538
542
|
hookSpecificOutput: {
|
|
539
543
|
hookEventName: 'TaskCreated',
|
|
540
|
-
linked: coreResult.linked
|
|
544
|
+
linked: coreResult.linked ?? false,
|
|
541
545
|
wogiTaskId: coreResult.wogiTaskId || null
|
|
542
546
|
}
|
|
543
547
|
};
|
|
@@ -672,9 +676,20 @@ Run: /wogi-start ${coreResult.nextTaskId}`;
|
|
|
672
676
|
// with zero validation benefit. This was causing unnecessary token consumption
|
|
673
677
|
// in target projects where generateConfig() produces the settings.
|
|
674
678
|
if (rules.validation?.enabled !== false) {
|
|
679
|
+
const postToolUseEntry = hookEntry('PostToolUse', 'post-tool-use.js', HOOK_TIMEOUTS.POST_TOOL_USE);
|
|
680
|
+
// continueOnBlock (Claude Code 2.1.139+): when the PostToolUse hook returns a
|
|
681
|
+
// blocking decision (lint/typecheck failure after an Edit/Write — see
|
|
682
|
+
// transformPostToolUse → decision: 'block'), feed the reason back to Claude and
|
|
683
|
+
// continue the turn so it fixes the error in-loop instead of dead-ending. This is
|
|
684
|
+
// what CLAUDE.md's "validate after EVERY file edit" rule needs. Unknown field on
|
|
685
|
+
// older Claude Code → silently ignored, so it is safe to emit unconditionally.
|
|
686
|
+
// Only meaningful for the local 'command' transport.
|
|
687
|
+
if (postToolUseEntry.type === 'command') {
|
|
688
|
+
postToolUseEntry.continueOnBlock = true;
|
|
689
|
+
}
|
|
675
690
|
hooks.PostToolUse = [{
|
|
676
691
|
matcher: 'Edit|Write|Bash',
|
|
677
|
-
hooks: [
|
|
692
|
+
hooks: [postToolUseEntry]
|
|
678
693
|
}];
|
|
679
694
|
}
|
|
680
695
|
|
|
@@ -108,14 +108,19 @@ function getAffectedFileCount(targetRef) {
|
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* Create a backup branch at current HEAD.
|
|
111
|
+
*
|
|
112
|
+
* Uses `execFileSync` (no shell) per `security-patterns.md §8` — the branch
|
|
113
|
+
* name is timestamp-derived and currently safe, but going through the shell
|
|
114
|
+
* with template-string interpolation is the wrong-by-default pattern.
|
|
115
|
+
*
|
|
111
116
|
* @returns {string|null} Branch name or null on failure.
|
|
112
117
|
*/
|
|
113
118
|
function createBackupBranch() {
|
|
114
|
-
const {
|
|
119
|
+
const { execFileSync } = require('node:child_process');
|
|
115
120
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
116
121
|
const branchName = `backup/pre-reset-${timestamp}`;
|
|
117
122
|
try {
|
|
118
|
-
|
|
123
|
+
execFileSync('git', ['branch', branchName], {
|
|
119
124
|
encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
|
|
120
125
|
});
|
|
121
126
|
return branchName;
|
|
@@ -126,20 +131,27 @@ function createBackupBranch() {
|
|
|
126
131
|
|
|
127
132
|
/**
|
|
128
133
|
* Clean up old backup branches, keeping only the most recent N.
|
|
134
|
+
*
|
|
135
|
+
* Uses `execFileSync` (no shell) per `security-patterns.md §8`. Branch names
|
|
136
|
+
* pulled from `git branch --list` output and passed back to `git branch -D`
|
|
137
|
+
* never touch a shell.
|
|
138
|
+
*
|
|
129
139
|
* @param {number} keep
|
|
130
140
|
*/
|
|
131
141
|
function rotateBackupBranches(keep) {
|
|
132
|
-
const {
|
|
142
|
+
const { execFileSync } = require('node:child_process');
|
|
133
143
|
try {
|
|
134
|
-
const branches =
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
const branches = execFileSync(
|
|
145
|
+
'git',
|
|
146
|
+
['branch', '--list', 'backup/pre-reset-*', '--sort=-creatordate'],
|
|
147
|
+
{ encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
148
|
+
).trim().split('\n').map(b => b.trim()).filter(Boolean);
|
|
137
149
|
|
|
138
150
|
if (branches.length > keep) {
|
|
139
151
|
const toDelete = branches.slice(keep);
|
|
140
152
|
for (const branch of toDelete) {
|
|
141
153
|
try {
|
|
142
|
-
|
|
154
|
+
execFileSync('git', ['branch', '-D', branch], {
|
|
143
155
|
cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
|
|
144
156
|
});
|
|
145
157
|
} catch (_err) {
|
|
@@ -153,25 +165,75 @@ function rotateBackupBranches(keep) {
|
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
/**
|
|
156
|
-
* Auto-stash current changes.
|
|
157
|
-
*
|
|
168
|
+
* Auto-stash current changes with verification.
|
|
169
|
+
*
|
|
170
|
+
* Returns a discriminated result so callers can distinguish:
|
|
171
|
+
* - `no-changes` — working tree was clean; nothing to stash. Safe to proceed.
|
|
172
|
+
* - `stashed` — stash created AND verified present at stash@{0}. Safe to proceed.
|
|
173
|
+
* - `failed` — something went wrong (git error, lock contention, or stash
|
|
174
|
+
* silently no-op'd). Caller MUST NOT proceed with destructive
|
|
175
|
+
* operations — the user's uncommitted work is still in the
|
|
176
|
+
* working tree and would be lost.
|
|
177
|
+
*
|
|
178
|
+
* Historically (pre wf-2d3d09b8) this returned a bare boolean, which collapsed
|
|
179
|
+
* `no-changes` and `failed` into the same `false`. Callers couldn't tell the
|
|
180
|
+
* difference, so a stash failure on a dirty working tree was indistinguishable
|
|
181
|
+
* from a no-op on a clean one — and the discard-all branch let the destructive
|
|
182
|
+
* `git checkout .` / `git restore .` through either way. This was BUG-1.
|
|
183
|
+
*
|
|
184
|
+
* @param {Object} [opts]
|
|
185
|
+
* @param {Function} [opts.exec] - execFileSync-shaped replacement for testing.
|
|
186
|
+
* Signature: `(file, args, opts) → stdout`. Throws on non-zero exit. Default
|
|
187
|
+
* uses `child_process.execFileSync` so no shell layer is involved — closes
|
|
188
|
+
* the security-patterns.md §8 violation that earlier versions had.
|
|
189
|
+
* @returns {{ status: 'no-changes' | 'stashed' | 'failed', error?: string, stashRef?: string }}
|
|
158
190
|
*/
|
|
159
|
-
function autoStash() {
|
|
160
|
-
const
|
|
191
|
+
function autoStash(opts = {}) {
|
|
192
|
+
const exec = opts.exec || require('node:child_process').execFileSync;
|
|
193
|
+
const runOpts = { encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe'] };
|
|
194
|
+
|
|
195
|
+
// 1. Anything to stash?
|
|
196
|
+
let status;
|
|
161
197
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
198
|
+
status = exec('git', ['status', '--porcelain'], runOpts).trim();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return { status: 'failed', error: `git status failed: ${err.message || err}` };
|
|
201
|
+
}
|
|
202
|
+
if (!status) return { status: 'no-changes' };
|
|
203
|
+
|
|
204
|
+
// 2. Attempt the stash. TOCTOU-resistant message (F18): include a random
|
|
205
|
+
// UUID-style suffix so two parallel runs can't collide on the stash@{0}
|
|
206
|
+
// verification step and so substring matches can't be forged by an
|
|
207
|
+
// attacker-controlled stash with the same timestamp prefix.
|
|
208
|
+
const timestamp = new Date().toISOString().slice(0, 19);
|
|
209
|
+
const nonce = require('node:crypto').randomBytes(6).toString('hex');
|
|
210
|
+
const stashMessage = `auto-backup-${timestamp}-${nonce}`;
|
|
211
|
+
try {
|
|
212
|
+
exec('git', ['stash', 'push', '-m', stashMessage], runOpts);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { status: 'failed', error: `git stash push failed: ${err.message || err}` };
|
|
215
|
+
}
|
|
166
216
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
217
|
+
// 3. VERIFY the stash actually saved (BUG-1 / wf-2d3d09b8). A zero exit code
|
|
218
|
+
// from `git stash push` is NOT proof: lock contention, broken hooks, or
|
|
219
|
+
// edge cases can leave the working tree unchanged with status 0. Confirm
|
|
220
|
+
// by reading `git stash list` and matching our nonce-suffixed message at
|
|
221
|
+
// stash@{0}.
|
|
222
|
+
let stashList;
|
|
223
|
+
try {
|
|
224
|
+
stashList = exec('git', ['stash', 'list'], runOpts);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return { status: 'failed', error: `stash verification (git stash list) failed: ${err.message || err}` };
|
|
174
227
|
}
|
|
228
|
+
const firstLine = (stashList || '').split('\n')[0] || '';
|
|
229
|
+
if (!firstLine.includes(stashMessage)) {
|
|
230
|
+
return {
|
|
231
|
+
status: 'failed',
|
|
232
|
+
error: `stash verification failed: expected "${stashMessage}" at stash@{0}, found: ${firstLine || '(empty)'}`
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { status: 'stashed', stashRef: 'stash@{0}' };
|
|
175
237
|
}
|
|
176
238
|
|
|
177
239
|
// ============================================================
|
|
@@ -230,9 +292,13 @@ function parseGitCommand(command) {
|
|
|
230
292
|
* Check git safety for Bash commands (PreToolUse).
|
|
231
293
|
* @param {string} command - Bash command
|
|
232
294
|
* @param {Object} [config]
|
|
233
|
-
* @
|
|
295
|
+
* @param {Object} [_deps] - Dependency injection seam (test-only).
|
|
296
|
+
* @param {Function} [_deps.autoStash] - Override the autoStash helper (test-only).
|
|
297
|
+
* @returns {{ allowed: boolean, blocked: boolean, reason?: string, message?: string, autoAction?: string, warning?: boolean }}
|
|
234
298
|
*/
|
|
235
|
-
function checkGitSafety(command, config) {
|
|
299
|
+
function checkGitSafety(command, config, _deps) {
|
|
300
|
+
const autoStashFn = (_deps && _deps.autoStash) || autoStash;
|
|
301
|
+
|
|
236
302
|
if (!isGitSafetyEnabled(config)) {
|
|
237
303
|
return { allowed: true, blocked: false };
|
|
238
304
|
}
|
|
@@ -253,16 +319,41 @@ function checkGitSafety(command, config) {
|
|
|
253
319
|
// Handle: git checkout . / git restore .
|
|
254
320
|
if (parsed.type === 'discard-all') {
|
|
255
321
|
if (gitConfig.autoBackup) {
|
|
256
|
-
const
|
|
322
|
+
const stashResult = autoStashFn();
|
|
323
|
+
|
|
324
|
+
// BUG-1 / wf-2d3d09b8: previously this branch returned `allowed:true`
|
|
325
|
+
// unconditionally — letting the destructive `git checkout .` /
|
|
326
|
+
// `git restore .` proceed even when the auto-backup stash silently
|
|
327
|
+
// failed, which destroyed the user's uncommitted work. Now we block on
|
|
328
|
+
// stash failure so the user can recover their work manually first.
|
|
329
|
+
if (stashResult && stashResult.status === 'failed') {
|
|
330
|
+
return {
|
|
331
|
+
allowed: false,
|
|
332
|
+
blocked: true,
|
|
333
|
+
reason: 'git-safety-stash-failed',
|
|
334
|
+
message:
|
|
335
|
+
`GIT SAFETY NET: Auto-backup stash FAILED.\n\n` +
|
|
336
|
+
`${stashResult.error || 'Unknown stash failure.'}\n\n` +
|
|
337
|
+
`Refusing to proceed with the discard operation — your uncommitted ` +
|
|
338
|
+
`work would be lost with no recovery path.\n\n` +
|
|
339
|
+
`Recover with one of:\n` +
|
|
340
|
+
` - Commit your work first: git add -A && git commit -m "WIP"\n` +
|
|
341
|
+
` - Stash manually + verify: git stash push -u -m "manual"; git stash list\n` +
|
|
342
|
+
` - Checkpoint to a branch: git checkout -b backup-WIP && git add -A && git commit -m "snapshot"\n\n` +
|
|
343
|
+
`Then re-run the discard command.`
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const stashed = stashResult && stashResult.status === 'stashed';
|
|
257
348
|
const stashMsg = stashed
|
|
258
|
-
?
|
|
349
|
+
? `Auto-stashed your changes (${stashResult.stashRef}). Recover with: git stash pop`
|
|
259
350
|
: 'No uncommitted changes to stash.';
|
|
260
351
|
|
|
261
352
|
return {
|
|
262
353
|
allowed: true,
|
|
263
354
|
blocked: false,
|
|
264
355
|
warning: true,
|
|
265
|
-
autoAction: 'stash',
|
|
356
|
+
autoAction: stashed ? 'stash' : 'no-op',
|
|
266
357
|
message: `GIT SAFETY NET: ${stashMsg}\n\nProceeding with discard operation.`
|
|
267
358
|
};
|
|
268
359
|
}
|