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,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
- case 'SubagentStop':
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 || false,
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: [hookEntry('PostToolUse', 'post-tool-use.js', HOOK_TIMEOUTS.POST_TOOL_USE)]
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 { execSync } = require('node:child_process');
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
- execSync(`git branch "${branchName}"`, {
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 { execSync } = require('node:child_process');
142
+ const { execFileSync } = require('node:child_process');
133
143
  try {
134
- const branches = execSync('git branch --list "backup/pre-reset-*" --sort=-creatordate', {
135
- encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
136
- }).trim().split('\n').map(b => b.trim()).filter(Boolean);
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
- execSync(`git branch -D "${branch}"`, {
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
- * @returns {boolean} True if stash was created.
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 { execSync } = require('node:child_process');
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
- const status = execSync('git status --porcelain', {
163
- encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
164
- }).trim();
165
- if (!status) return false; // Nothing to stash
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
- const timestamp = new Date().toISOString().slice(0, 19);
168
- execSync(`git stash push -m "auto-backup-${timestamp}"`, {
169
- encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
170
- });
171
- return true;
172
- } catch (_err) {
173
- return false;
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
- * @returns {{ allowed: boolean, blocked: boolean, reason?: string, message?: string, autoAction?: string }}
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 stashed = autoStash();
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
- ? 'Auto-stashed your changes before executing. Recover with: git stash pop'
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
  }