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.
- package/.claude/commands/wogi-start.md +4 -1
- 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 +377 -0
- package/lib/skill-export-agentskills.js +211 -0
- package/lib/skill-export-claude-plugin.js +143 -0
- package/lib/skill-portability.js +324 -0
- package/lib/skill-registry.js +32 -2
- package/package.json +2 -2
- package/scripts/flow +8 -0
- package/scripts/flow-config-defaults.js +20 -0
- package/scripts/flow-schedule.js +469 -0
- package/scripts/flow-scheduled-runner.js +614 -0
- package/scripts/flow-skill-export.js +334 -0
- package/scripts/hooks/adapters/claude-code.js +15 -1
- package/scripts/hooks/core/git-safety-gate.js +92 -20
- package/scripts/hooks/core/long-input-enforcement.js +139 -4
- package/scripts/hooks/core/research-required-classifier.js +73 -17
- package/scripts/hooks/core/research-required-gate.js +16 -6
- package/scripts/hooks/core/user-prompt-orchestrator.js +10 -3
|
@@ -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: [
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
} catch (
|
|
173
|
-
return
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
?
|
|
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
|
}
|