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,486 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * Wogi Flow — `flow schedule` CLI (Phase 1A — wf-b211a076).
7
+ *
8
+ * Installs platform-native unit files that invoke `flow-scheduled-runner.js`
9
+ * for users who prefer not to use GitHub Actions.
10
+ *
11
+ * Subcommands:
12
+ * flow schedule install --target=launchd|cron|systemd [--dry-run]
13
+ * flow schedule status
14
+ * flow schedule remove --target=launchd|cron|systemd
15
+ *
16
+ * Targets:
17
+ * launchd — macOS user-domain LaunchAgent plists in ~/Library/LaunchAgents/
18
+ * cron — crontab entries appended for the current user
19
+ * systemd — systemd --user .service + .timer units in ~/.config/systemd/user/
20
+ *
21
+ * The same 4 jobs are installed regardless of target. Schedules:
22
+ * nightly-regression 03:00 daily
23
+ * weekly-audit Mon 09:00
24
+ * weekly-digest Fri 17:00
25
+ * per-pr-review on-demand only (no schedule entry — invoked by gh webhook
26
+ * or `flow scheduled-runner per-pr-review` manually)
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const os = require('node:os');
31
+ const path = require('node:path');
32
+ const { JOB_NAMES } = require('../lib/scheduled-mode');
33
+
34
+ // ============================================================
35
+ // Schedule table — shared across all targets
36
+ // ============================================================
37
+
38
+ /**
39
+ * Job schedule definitions.
40
+ * - cronExpr is in classic 5-field cron format (UTC where the platform supports it).
41
+ * - macOS launchd uses StartCalendarInterval (hour/minute/weekday fields).
42
+ * - systemd uses OnCalendar with extended cron-like syntax.
43
+ *
44
+ * per-pr-review is intentionally omitted from schedules — it runs on PR events
45
+ * (via the GH Actions workflow), or on demand via the CLI.
46
+ */
47
+ const SCHEDULES = Object.freeze({
48
+ 'nightly-regression': {
49
+ cronExpr: '0 3 * * *',
50
+ launchd: { Hour: 3, Minute: 0 },
51
+ systemdOnCalendar: '*-*-* 03:00:00',
52
+ },
53
+ 'weekly-audit': {
54
+ cronExpr: '0 9 * * 1',
55
+ launchd: { Hour: 9, Minute: 0, Weekday: 1 },
56
+ systemdOnCalendar: 'Mon *-*-* 09:00:00',
57
+ },
58
+ 'weekly-digest': {
59
+ cronExpr: '0 17 * * 5',
60
+ launchd: { Hour: 17, Minute: 0, Weekday: 5 },
61
+ systemdOnCalendar: 'Fri *-*-* 17:00:00',
62
+ },
63
+ });
64
+
65
+ const SCHEDULED_JOB_NAMES = Object.keys(SCHEDULES);
66
+
67
+ const ALLOWED_TARGETS = new Set(['launchd', 'cron', 'systemd']);
68
+
69
+ // ============================================================
70
+ // Path helpers
71
+ // ============================================================
72
+
73
+ function repoRoot() {
74
+ // Best-effort: walk up looking for package.json with name "wogiflow"
75
+ let dir = process.cwd();
76
+ for (let i = 0; i < 25; i++) {
77
+ const pkg = path.join(dir, 'package.json');
78
+ if (fs.existsSync(pkg)) {
79
+ try {
80
+ const data = fs.readFileSync(pkg, 'utf-8');
81
+ if (data.includes('"wogiflow"') || data.includes('wogi-flow')) return dir;
82
+ } catch (_err) { /* */ }
83
+ }
84
+ const parent = path.dirname(dir);
85
+ if (parent === dir) break;
86
+ dir = parent;
87
+ }
88
+ return process.cwd();
89
+ }
90
+
91
+ function runnerScriptPath() {
92
+ return path.join(repoRoot(), 'scripts', 'flow-scheduled-runner.js');
93
+ }
94
+
95
+ function nodeBinary() {
96
+ return process.execPath; // canonical, absolute
97
+ }
98
+
99
+ // ============================================================
100
+ // Unit content generators (pure — no I/O)
101
+ // ============================================================
102
+
103
+ // F8 (R-379): paths that get inlined into shell-interpreted contexts
104
+ // (crontab lines, systemd ExecStart) MUST be single-quoted with embedded
105
+ // single-quotes escaped. Without this, a project at `/Users/Alice Smith/...`
106
+ // breaks the schedule because cron treats the space as an argument boundary.
107
+ function shellQuote(s) {
108
+ // POSIX single-quote escape: replace each ' with '\''
109
+ return `'${String(s).replace(/'/g, "'\\''")}'`;
110
+ }
111
+
112
+ function generateCrontabLines(opts = {}) {
113
+ const node = opts.node || nodeBinary();
114
+ const runner = opts.runner || runnerScriptPath();
115
+ const projectRoot = opts.projectRoot || repoRoot();
116
+ const lines = [
117
+ `# === wogi-scheduled (managed by flow schedule) — DO NOT EDIT BELOW ===`,
118
+ ];
119
+ for (const jobName of SCHEDULED_JOB_NAMES) {
120
+ const spec = SCHEDULES[jobName];
121
+ const logPath = path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`);
122
+ lines.push(
123
+ `${spec.cronExpr} cd ${shellQuote(projectRoot)} && ` +
124
+ `${shellQuote(node)} ${shellQuote(runner)} ${jobName} ` +
125
+ `>> ${shellQuote(logPath)} 2>&1`
126
+ );
127
+ }
128
+ lines.push(`# === wogi-scheduled end ===`);
129
+ return lines;
130
+ }
131
+
132
+ function generateLaunchdPlist(jobName, opts = {}) {
133
+ const node = opts.node || nodeBinary();
134
+ const runner = opts.runner || runnerScriptPath();
135
+ const projectRoot = opts.projectRoot || repoRoot();
136
+ const spec = SCHEDULES[jobName];
137
+ if (!spec) throw new Error(`No schedule defined for "${jobName}"`);
138
+
139
+ const calBlock = [
140
+ ` <key>Hour</key>`,
141
+ ` <integer>${spec.launchd.Hour}</integer>`,
142
+ ` <key>Minute</key>`,
143
+ ` <integer>${spec.launchd.Minute}</integer>`,
144
+ ];
145
+ if (typeof spec.launchd.Weekday === 'number') {
146
+ calBlock.push(` <key>Weekday</key>`);
147
+ calBlock.push(` <integer>${spec.launchd.Weekday}</integer>`);
148
+ }
149
+
150
+ return `<?xml version="1.0" encoding="UTF-8"?>
151
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
152
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
153
+ <plist version="1.0">
154
+ <dict>
155
+ <key>Label</key>
156
+ <string>io.wogi.scheduled.${jobName}</string>
157
+ <key>ProgramArguments</key>
158
+ <array>
159
+ <string>${node}</string>
160
+ <string>${runner}</string>
161
+ <string>${jobName}</string>
162
+ </array>
163
+ <key>WorkingDirectory</key>
164
+ <string>${projectRoot}</string>
165
+ <key>StandardOutPath</key>
166
+ <string>${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`)}</string>
167
+ <key>StandardErrorPath</key>
168
+ <string>${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.err.log`)}</string>
169
+ <key>StartCalendarInterval</key>
170
+ <dict>
171
+ ${calBlock.join('\n')}
172
+ </dict>
173
+ <key>RunAtLoad</key>
174
+ <false/>
175
+ </dict>
176
+ </plist>
177
+ `;
178
+ }
179
+
180
+ function generateSystemdServiceUnit(jobName, opts = {}) {
181
+ const node = opts.node || nodeBinary();
182
+ const runner = opts.runner || runnerScriptPath();
183
+ const projectRoot = opts.projectRoot || repoRoot();
184
+ if (!SCHEDULES[jobName]) throw new Error(`No schedule defined for "${jobName}"`);
185
+
186
+ // F8 (R-379): systemd ExecStart with a path containing spaces needs each
187
+ // arg surrounded by quotes — systemd's argument parser splits on spaces.
188
+ // WorkingDirectory and Standard{Output,Error} accept literal paths (no
189
+ // splitting), but quoting them too is harmless and consistent.
190
+ return `[Unit]
191
+ Description=Wogi Flow scheduled job: ${jobName}
192
+ After=network-online.target
193
+
194
+ [Service]
195
+ Type=oneshot
196
+ WorkingDirectory="${projectRoot}"
197
+ ExecStart="${node}" "${runner}" "${jobName}"
198
+ StandardOutput=append:${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`)}
199
+ StandardError=append:${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.err.log`)}
200
+ `;
201
+ }
202
+
203
+ function generateSystemdTimerUnit(jobName) {
204
+ const spec = SCHEDULES[jobName];
205
+ if (!spec) throw new Error(`No schedule defined for "${jobName}"`);
206
+ return `[Unit]
207
+ Description=Wogi Flow scheduled timer: ${jobName}
208
+
209
+ [Timer]
210
+ OnCalendar=${spec.systemdOnCalendar}
211
+ Persistent=true
212
+ Unit=wogi-scheduled-${jobName}.service
213
+
214
+ [Install]
215
+ WantedBy=timers.target
216
+ `;
217
+ }
218
+
219
+ // ============================================================
220
+ // Install / remove / status (with FS injection for tests)
221
+ // ============================================================
222
+
223
+ function installLaunchd(opts = {}, deps = {}) {
224
+ const fsx = deps.fs || fs;
225
+ const homeDir = opts.homeDir || os.homedir();
226
+ const dir = path.join(homeDir, 'Library', 'LaunchAgents');
227
+ const dryRun = Boolean(opts.dryRun);
228
+ const written = [];
229
+
230
+ for (const jobName of SCHEDULED_JOB_NAMES) {
231
+ const filename = `io.wogi.scheduled.${jobName}.plist`;
232
+ const dest = path.join(dir, filename);
233
+ const content = generateLaunchdPlist(jobName, opts);
234
+ if (!dryRun) {
235
+ fsx.mkdirSync(dir, { recursive: true });
236
+ fsx.writeFileSync(dest, content);
237
+ }
238
+ written.push({ path: dest, jobName, content });
239
+ }
240
+ return { target: 'launchd', dryRun, written };
241
+ }
242
+
243
+ function installCron(opts = {}, deps = {}) {
244
+ const fsx = deps.fs || fs;
245
+ const homeDir = opts.homeDir || os.homedir();
246
+ const fragmentPath = opts.fragmentPath ||
247
+ path.join(homeDir, '.config', 'wogi-flow', 'crontab-fragment');
248
+ const dryRun = Boolean(opts.dryRun);
249
+ const lines = generateCrontabLines(opts);
250
+ const content = lines.join('\n') + '\n';
251
+
252
+ if (!dryRun) {
253
+ fsx.mkdirSync(path.dirname(fragmentPath), { recursive: true });
254
+ fsx.writeFileSync(fragmentPath, content);
255
+ }
256
+
257
+ return {
258
+ target: 'cron',
259
+ dryRun,
260
+ written: [{ path: fragmentPath, content }],
261
+ note:
262
+ `Crontab fragment written. Activate with:\n` +
263
+ ` (crontab -l 2>/dev/null; cat ${fragmentPath}) | crontab -\n` +
264
+ `Idempotency: re-running install OVERWRITES the fragment but does NOT auto-install ` +
265
+ `into crontab — the user runs the command above.`,
266
+ };
267
+ }
268
+
269
+ function installSystemd(opts = {}, deps = {}) {
270
+ const fsx = deps.fs || fs;
271
+ const homeDir = opts.homeDir || os.homedir();
272
+ const dir = path.join(homeDir, '.config', 'systemd', 'user');
273
+ const dryRun = Boolean(opts.dryRun);
274
+ const written = [];
275
+
276
+ for (const jobName of SCHEDULED_JOB_NAMES) {
277
+ const serviceUnit = generateSystemdServiceUnit(jobName, opts);
278
+ const timerUnit = generateSystemdTimerUnit(jobName);
279
+ const servicePath = path.join(dir, `wogi-scheduled-${jobName}.service`);
280
+ const timerPath = path.join(dir, `wogi-scheduled-${jobName}.timer`);
281
+ if (!dryRun) {
282
+ fsx.mkdirSync(dir, { recursive: true });
283
+ fsx.writeFileSync(servicePath, serviceUnit);
284
+ fsx.writeFileSync(timerPath, timerUnit);
285
+ }
286
+ written.push({ path: servicePath, jobName, content: serviceUnit });
287
+ written.push({ path: timerPath, jobName, content: timerUnit });
288
+ }
289
+ return {
290
+ target: 'systemd',
291
+ dryRun,
292
+ written,
293
+ note:
294
+ `Activate with:\n` +
295
+ SCHEDULED_JOB_NAMES.map(
296
+ (j) => ` systemctl --user enable --now wogi-scheduled-${j}.timer`
297
+ ).join('\n'),
298
+ };
299
+ }
300
+
301
+ function removeLaunchd(opts = {}, deps = {}) {
302
+ const fsx = deps.fs || fs;
303
+ const homeDir = opts.homeDir || os.homedir();
304
+ const dir = path.join(homeDir, 'Library', 'LaunchAgents');
305
+ const removed = [];
306
+ for (const jobName of SCHEDULED_JOB_NAMES) {
307
+ const dest = path.join(dir, `io.wogi.scheduled.${jobName}.plist`);
308
+ try {
309
+ if (fsx.existsSync(dest)) {
310
+ fsx.unlinkSync(dest);
311
+ removed.push(dest);
312
+ }
313
+ } catch (_err) { /* fail-open */ }
314
+ }
315
+ return { target: 'launchd', removed };
316
+ }
317
+
318
+ function removeCron(opts = {}, deps = {}) {
319
+ const fsx = deps.fs || fs;
320
+ const homeDir = opts.homeDir || os.homedir();
321
+ const fragmentPath = opts.fragmentPath ||
322
+ path.join(homeDir, '.config', 'wogi-flow', 'crontab-fragment');
323
+ const removed = [];
324
+ try {
325
+ if (fsx.existsSync(fragmentPath)) {
326
+ fsx.unlinkSync(fragmentPath);
327
+ removed.push(fragmentPath);
328
+ }
329
+ } catch (_err) { /* */ }
330
+ return {
331
+ target: 'cron',
332
+ removed,
333
+ note:
334
+ `Fragment file removed. Manually purge the lines between the\n` +
335
+ `'# === wogi-scheduled ...' markers from your active crontab with:\n` +
336
+ ` crontab -e`,
337
+ };
338
+ }
339
+
340
+ function removeSystemd(opts = {}, deps = {}) {
341
+ const fsx = deps.fs || fs;
342
+ const homeDir = opts.homeDir || os.homedir();
343
+ const dir = path.join(homeDir, '.config', 'systemd', 'user');
344
+ const removed = [];
345
+ for (const jobName of SCHEDULED_JOB_NAMES) {
346
+ for (const ext of ['service', 'timer']) {
347
+ const dest = path.join(dir, `wogi-scheduled-${jobName}.${ext}`);
348
+ try {
349
+ if (fsx.existsSync(dest)) {
350
+ fsx.unlinkSync(dest);
351
+ removed.push(dest);
352
+ }
353
+ } catch (_err) { /* */ }
354
+ }
355
+ }
356
+ return { target: 'systemd', removed };
357
+ }
358
+
359
+ // ============================================================
360
+ // Status — list what is currently installed
361
+ // ============================================================
362
+
363
+ function getStatus(deps = {}) {
364
+ const fsx = deps.fs || fs;
365
+ const homeDir = deps.homeDir || os.homedir();
366
+ const status = { launchd: [], cron: [], systemd: [] };
367
+
368
+ // launchd
369
+ const ldir = path.join(homeDir, 'Library', 'LaunchAgents');
370
+ for (const jobName of SCHEDULED_JOB_NAMES) {
371
+ const p = path.join(ldir, `io.wogi.scheduled.${jobName}.plist`);
372
+ if (fsx.existsSync(p)) status.launchd.push(p);
373
+ }
374
+
375
+ // cron
376
+ const cfrag = path.join(homeDir, '.config', 'wogi-flow', 'crontab-fragment');
377
+ if (fsx.existsSync(cfrag)) status.cron.push(cfrag);
378
+
379
+ // systemd — both .service and .timer files are tracked under the same key.
380
+ // F21 (R-379): the prior ternary `ext === 'timer' ? 'systemd' : 'systemd'`
381
+ // had identical branches — a meaningless conditional. Just push directly.
382
+ const sdir = path.join(homeDir, '.config', 'systemd', 'user');
383
+ for (const jobName of SCHEDULED_JOB_NAMES) {
384
+ for (const ext of ['service', 'timer']) {
385
+ const p = path.join(sdir, `wogi-scheduled-${jobName}.${ext}`);
386
+ if (fsx.existsSync(p)) status.systemd.push(p);
387
+ }
388
+ }
389
+ return status;
390
+ }
391
+
392
+ // ============================================================
393
+ // CLI dispatch
394
+ // ============================================================
395
+
396
+ function parseCliArgs(argv) {
397
+ const args = { subcommand: argv[0] || null, target: null, dryRun: false };
398
+ for (let i = 1; i < argv.length; i++) {
399
+ const a = argv[i];
400
+ if (a.startsWith('--target=')) args.target = a.slice('--target='.length);
401
+ else if (a === '--dry-run') args.dryRun = true;
402
+ }
403
+ return args;
404
+ }
405
+
406
+ function dispatch(argv, deps = {}) {
407
+ const args = parseCliArgs(argv);
408
+
409
+ if (!args.subcommand || args.subcommand === '--help' || args.subcommand === '-h') {
410
+ return { ok: true, output: helpText() };
411
+ }
412
+
413
+ if (args.subcommand === 'status') {
414
+ const status = getStatus(deps);
415
+ return { ok: true, output: JSON.stringify(status, null, 2), status };
416
+ }
417
+
418
+ if (args.subcommand === 'install' || args.subcommand === 'remove') {
419
+ if (!ALLOWED_TARGETS.has(args.target)) {
420
+ return {
421
+ ok: false,
422
+ output:
423
+ `Error: --target=<launchd|cron|systemd> is required\n\n${helpText()}`,
424
+ };
425
+ }
426
+ const op = args.subcommand === 'install' ? installFor(args.target) : removeFor(args.target);
427
+ const opOpts = { dryRun: args.dryRun };
428
+ if (deps.homeDir) opOpts.homeDir = deps.homeDir;
429
+ const result = op(opOpts, deps);
430
+ return { ok: true, output: JSON.stringify(result, null, 2), result };
431
+ }
432
+
433
+ return { ok: false, output: `Unknown subcommand: ${args.subcommand}\n\n${helpText()}` };
434
+ }
435
+
436
+ function installFor(target) {
437
+ return ({ launchd: installLaunchd, cron: installCron, systemd: installSystemd })[target];
438
+ }
439
+
440
+ function removeFor(target) {
441
+ return ({ launchd: removeLaunchd, cron: removeCron, systemd: removeSystemd })[target];
442
+ }
443
+
444
+ function helpText() {
445
+ return `flow schedule — install platform-native scheduled-mode units
446
+
447
+ Subcommands:
448
+ install --target=<launchd|cron|systemd> [--dry-run]
449
+ remove --target=<launchd|cron|systemd>
450
+ status
451
+
452
+ Jobs installed: ${SCHEDULED_JOB_NAMES.join(', ')}
453
+ (per-pr-review runs via GH Actions / on-demand only, no schedule entry.)
454
+ `;
455
+ }
456
+
457
+ // ============================================================
458
+ // Exports + CLI
459
+ // ============================================================
460
+
461
+ module.exports = {
462
+ SCHEDULES,
463
+ SCHEDULED_JOB_NAMES,
464
+ ALLOWED_TARGETS,
465
+ generateCrontabLines,
466
+ generateLaunchdPlist,
467
+ generateSystemdServiceUnit,
468
+ generateSystemdTimerUnit,
469
+ installLaunchd,
470
+ installCron,
471
+ installSystemd,
472
+ removeLaunchd,
473
+ removeCron,
474
+ removeSystemd,
475
+ getStatus,
476
+ dispatch,
477
+ parseCliArgs,
478
+ // Re-export for convenience to consumers
479
+ JOB_NAMES,
480
+ };
481
+
482
+ if (require.main === module) {
483
+ const result = dispatch(process.argv.slice(2));
484
+ if (result.output) console.log(result.output);
485
+ process.exit(result.ok ? 0 : 1);
486
+ }