wogiflow 2.33.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.
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Durable Sub-task State (epic-workspace-sustained-exec / S1, wf-e72350bf)
5
+ *
6
+ * Sub-tasks of a decomposed task are normally tracked ONLY in Claude Code's
7
+ * in-context TodoWrite list, which is ephemeral: a Stop hook running in a fresh
8
+ * `node` process can't read it, and a session restart loses it entirely. That's
9
+ * the root cause behind workers re-executing completed sub-tasks after a restart
10
+ * and behind the continuation gate (S2) having no reliable "work remaining"
11
+ * signal.
12
+ *
13
+ * This module mirrors the decomposition to a durable, atomically-written ledger
14
+ * at `.workflow/state/subtask-state.json` so that:
15
+ * - a fresh-process Stop hook can read "N of M sub-tasks remain" (S2),
16
+ * - a restarted session can resume without redoing completed sub-tasks (S5).
17
+ *
18
+ * The ledger holds the CURRENT in-progress task's decomposition. There is one
19
+ * active task per worker at a time, so a single-task shape is sufficient and
20
+ * avoids stale cross-task contamination — read* helpers take an optional taskId
21
+ * and return empty when it doesn't match the ledger's task.
22
+ *
23
+ * Atomic write: tmp + fsync(file) + rename + fsync(dir), mirroring
24
+ * task-boundary-reset.js:writeCleanCompletionMarker so the ledger survives the
25
+ * SIGTERM/relaunch boundary and concurrent readers never see torn JSON.
26
+ *
27
+ * Fail-open throughout: any error degrades to "no durable state" (callers fall
28
+ * back to their prior behavior). Never throws.
29
+ */
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const { PATHS } = require('../scripts/flow-utils');
35
+ const { safeJsonParse } = require('../scripts/flow-io');
36
+
37
+ const LEDGER_FILE = 'subtask-state.json';
38
+ const LEDGER_VERSION = 1;
39
+ // Statuses that count as "still needs work" for remaining().
40
+ const OPEN_STATUSES = new Set(['pending', 'in_progress']);
41
+ const TERMINAL_STATUSES = new Set(['completed', 'blocked']);
42
+
43
+ function getLedgerPath() {
44
+ return path.join(PATHS.state, LEDGER_FILE);
45
+ }
46
+
47
+ /**
48
+ * Normalize a free-form sub-task entry (TodoWrite item or plain object/string)
49
+ * into the ledger shape { id, title, status }.
50
+ */
51
+ function normalizeSubtask(entry, index) {
52
+ if (entry == null) return null;
53
+ if (typeof entry === 'string') {
54
+ return { id: String(index + 1).padStart(2, '0'), title: entry.slice(0, 500), status: 'pending' };
55
+ }
56
+ if (typeof entry !== 'object') return null;
57
+ const rawStatus = typeof entry.status === 'string' ? entry.status.toLowerCase() : 'pending';
58
+ const status = OPEN_STATUSES.has(rawStatus) || TERMINAL_STATUSES.has(rawStatus) ? rawStatus : 'pending';
59
+ const title = entry.title || entry.content || entry.text || entry.description || entry.activeForm || `Sub-task ${index + 1}`;
60
+ const id = entry.id != null ? String(entry.id) : String(index + 1).padStart(2, '0');
61
+ return { id, title: String(title).slice(0, 500), status };
62
+ }
63
+
64
+ /**
65
+ * Map a Claude Code TodoWrite toolInput ({ todos: [{ content, status }] })
66
+ * into normalized sub-tasks. Returns [] for anything unparseable.
67
+ */
68
+ function subtasksFromTodos(toolInput) {
69
+ try {
70
+ const todos = toolInput && Array.isArray(toolInput.todos) ? toolInput.todos : null;
71
+ if (!todos) return [];
72
+ return todos.map(normalizeSubtask).filter(Boolean);
73
+ } catch (_err) {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Read the full ledger object, or null if absent/unreadable.
80
+ * @returns {{version:number, taskId:string, updatedAt:string, subtasks:Array}|null}
81
+ */
82
+ function readLedger() {
83
+ try {
84
+ const data = safeJsonParse(getLedgerPath(), null);
85
+ if (!data || typeof data !== 'object' || !Array.isArray(data.subtasks)) return null;
86
+ return data;
87
+ } catch (_err) {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Read the sub-tasks for a given task. When taskId is provided and doesn't match
94
+ * the ledger's task, returns [] (the ledger belongs to a different task).
95
+ * @param {string} [taskId]
96
+ * @returns {Array<{id:string,title:string,status:string}>}
97
+ */
98
+ function read(taskId) {
99
+ const led = readLedger();
100
+ if (!led) return [];
101
+ if (taskId && led.taskId && led.taskId !== taskId) return [];
102
+ return led.subtasks;
103
+ }
104
+
105
+ /**
106
+ * Write/replace the ledger for taskId atomically.
107
+ * @param {string} taskId
108
+ * @param {Array} subtasks raw or normalized entries
109
+ * @returns {{written:boolean, reason?:string, path?:string}}
110
+ */
111
+ function write(taskId, subtasks) {
112
+ if (!taskId) return { written: false, reason: 'no-task-id' };
113
+ try {
114
+ const normalized = (Array.isArray(subtasks) ? subtasks : [])
115
+ .map(normalizeSubtask)
116
+ .filter(Boolean);
117
+ const payload = {
118
+ version: LEDGER_VERSION,
119
+ taskId,
120
+ updatedAt: new Date().toISOString(),
121
+ subtasks: normalized
122
+ };
123
+ const p = getLedgerPath();
124
+ const dir = path.dirname(p);
125
+ fs.mkdirSync(dir, { recursive: true });
126
+ const tmp = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
127
+ const fd = fs.openSync(tmp, 'w');
128
+ try {
129
+ fs.writeSync(fd, JSON.stringify(payload, null, 2));
130
+ fs.fsyncSync(fd);
131
+ } finally {
132
+ fs.closeSync(fd);
133
+ }
134
+ fs.renameSync(tmp, p);
135
+ try {
136
+ const dfd = fs.openSync(dir, 'r');
137
+ try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
138
+ } catch (_err) { /* directory fsync best-effort (not supported on all FS) */ }
139
+ return { written: true, path: p };
140
+ } catch (err) {
141
+ return { written: false, reason: `write-failed: ${err.message}` };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Count sub-tasks still needing work (pending + in_progress) for taskId.
147
+ * blocked and completed do NOT count. Returns 0 when no matching ledger exists
148
+ * (no durable state ⇒ "nothing known to remain"; callers decide what that means).
149
+ * @param {string} [taskId]
150
+ * @returns {number}
151
+ */
152
+ function remaining(taskId) {
153
+ const subs = read(taskId);
154
+ return subs.filter(s => OPEN_STATUSES.has(s.status)).length;
155
+ }
156
+
157
+ /**
158
+ * Summary counts for heartbeats / status (S3, S4).
159
+ * @param {string} [taskId]
160
+ * @returns {{total:number, remaining:number, completed:number, blocked:number}}
161
+ */
162
+ function summary(taskId) {
163
+ const subs = read(taskId);
164
+ return {
165
+ total: subs.length,
166
+ remaining: subs.filter(s => OPEN_STATUSES.has(s.status)).length,
167
+ completed: subs.filter(s => s.status === 'completed').length,
168
+ blocked: subs.filter(s => s.status === 'blocked').length
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Mark one sub-task's status, preserving the rest. No-op if the ledger task
174
+ * doesn't match. Returns the updated count.
175
+ * @param {string} taskId
176
+ * @param {string} subId
177
+ * @param {string} [status='completed']
178
+ */
179
+ function markStatus(taskId, subId, status = 'completed') {
180
+ const led = readLedger();
181
+ if (!led || (taskId && led.taskId !== taskId)) return { written: false, reason: 'no-matching-ledger' };
182
+ const next = led.subtasks.map(s => (s.id === String(subId) ? { ...s, status } : s));
183
+ return write(led.taskId, next);
184
+ }
185
+
186
+ /**
187
+ * Clear the ledger (e.g. on task completion). Best-effort.
188
+ */
189
+ function clear() {
190
+ try {
191
+ fs.unlinkSync(getLedgerPath());
192
+ return { cleared: true };
193
+ } catch (err) {
194
+ if (err && err.code !== 'ENOENT' && process.env.DEBUG) {
195
+ console.error(`[workspace-subtask-state] clear: ${err.code} ${err.message}`);
196
+ }
197
+ return { cleared: false };
198
+ }
199
+ }
200
+
201
+ module.exports = {
202
+ getLedgerPath,
203
+ normalizeSubtask,
204
+ subtasksFromTodos,
205
+ readLedger,
206
+ read,
207
+ write,
208
+ remaining,
209
+ summary,
210
+ markStatus,
211
+ clear,
212
+ LEDGER_FILE,
213
+ OPEN_STATUSES,
214
+ TERMINAL_STATUSES
215
+ };
package/lib/workspace.js CHANGED
@@ -1209,6 +1209,18 @@ You are a workspace worker. There is NO human watching your terminal. You MUST o
1209
1209
 
1210
1210
  The \`TaskCompleted\` hook will inject an auto-pickup directive when channel dispatches are queued (v2.20.0+). The \`Stop\` hook will BLOCK end-of-turn if you try to stop while dispatches are queued and no task is in progress. These enforcements exist because the silent-stall pattern was incident-worthy (2026-04-16).
1211
1211
 
1212
+ ### Sustained Execution — Grind a Dispatched Task to Completion (v2.34.0+)
1213
+
1214
+ **A dispatched task runs to COMPLETION across turns. Do NOT stop to "report progress" after each sub-task.** When you decompose a task into sub-tasks (TodoWrite), work through ALL of them in the same session — the decomposition is mirrored to durable state (\`subtask-state.json\`) so it survives turn boundaries.
1215
+
1216
+ "The work" in *"reply to the manager after completing the work"* means the **entire dispatched task** (all its sub-tasks), not one increment. Reply with \`## Results\` ONLY when:
1217
+ - the task is **complete** (you ran \`flow done <id>\` and it left inProgress), or
1218
+ - you are **escalating** a genuine blocker (\`## QUESTION:\` / \`## BLOCKED:\`).
1219
+
1220
+ The \`Stop\` hook's **in-progress continuation gate** will force you to keep going (\`{continue:true}\`) while a task is in-progress in the coding/validating phase with sub-tasks remaining. If you stop early to "give visibility," it will re-prompt you to continue. Make real progress every turn (an edit, a test, a completed sub-task) — idle turns with no file changes are detected across a few continuations and stop with an escalation.
1221
+
1222
+ **Escalate instead of proceeding** when the next step is destructive, irreversible, touches production, needs external credentials, or genuinely needs a human decision. Otherwise: keep grinding until done.
1223
+
1212
1224
  ### Tool-First Turn Contract (v2.27.0+)
1213
1225
 
1214
1226
  **Every worker turn after a UserPromptSubmit MUST contain at least one tool call. In strict mode (default), the FIRST content block must be a tool call, not text.**
@@ -1768,6 +1780,61 @@ function startWorkerSession(cwd) {
1768
1780
  * Handle workspace subcommands
1769
1781
  * @param {string[]} args
1770
1782
  */
1783
+ /**
1784
+ * S5 (wf-ee87a24e): manager-triggered worker restart. Resolves the worker's
1785
+ * channel port from the workspace config and POSTs /restart. The worker's
1786
+ * channel server writes the wrapper restart flag and SIGTERMs claude, so the
1787
+ * wogi-claude wrapper relaunches it with a fresh require cache (reloading any
1788
+ * upgraded wogiflow code). Resume of the in-progress task is handled by the
1789
+ * worker's SessionStart hook (resume-in-progress branch).
1790
+ */
1791
+ async function restartWorkerSession(cwd, workerName) {
1792
+ // Find workspace root
1793
+ let workspaceRoot = null;
1794
+ let dir = cwd;
1795
+ while (dir !== path.dirname(dir)) {
1796
+ if (fs.existsSync(path.join(dir, WORKSPACE_CONFIG_FILE))) { workspaceRoot = dir; break; }
1797
+ dir = path.dirname(dir);
1798
+ }
1799
+ if (!workspaceRoot) {
1800
+ console.error('Error: Not inside a workspace (no wogi-workspace.json found).');
1801
+ process.exit(1);
1802
+ }
1803
+ const config = safeJsonParse(path.join(workspaceRoot, WORKSPACE_CONFIG_FILE), null);
1804
+ const member = config?.channels?.members?.[workerName];
1805
+ if (!member || !member.port) {
1806
+ console.error(`Error: unknown worker "${workerName}". Known workers: ${Object.keys(config?.channels?.members || {}).join(', ') || '(none)'}`);
1807
+ process.exit(1);
1808
+ }
1809
+
1810
+ const http = require('node:http');
1811
+ const result = await new Promise((resolve) => {
1812
+ const req = http.request({
1813
+ hostname: '127.0.0.1', port: member.port, path: '/restart', method: 'POST',
1814
+ headers: { 'X-Wogi-From': 'manager', 'Content-Length': 0 }, timeout: 5000
1815
+ }, (res) => {
1816
+ let data = '';
1817
+ res.on('data', c => { data += c; });
1818
+ res.on('end', () => resolve({ status: res.statusCode, body: data }));
1819
+ });
1820
+ req.on('error', (err) => resolve({ error: err.message }));
1821
+ req.on('timeout', () => { req.destroy(); resolve({ error: 'timeout' }); });
1822
+ req.end();
1823
+ });
1824
+
1825
+ if (result.error) {
1826
+ console.error(`✗ Could not reach worker "${workerName}" on port ${member.port}: ${result.error}`);
1827
+ console.error(` Is the worker running? Start it with: cd ${member.path || workerName} && flow workspace start`);
1828
+ process.exit(1);
1829
+ }
1830
+ if (result.status === 202) {
1831
+ console.log(`✓ Restart signalled to "${workerName}" (port ${member.port}). The worker will relaunch with fresh code and resume its in-progress task.`);
1832
+ } else {
1833
+ console.error(`✗ Worker "${workerName}" returned HTTP ${result.status}: ${result.body}`);
1834
+ process.exit(1);
1835
+ }
1836
+ }
1837
+
1771
1838
  async function workspace(args) {
1772
1839
  const subcommand = args[0];
1773
1840
 
@@ -1816,6 +1883,18 @@ async function workspace(args) {
1816
1883
  startWorkerSession(process.cwd());
1817
1884
  break;
1818
1885
  }
1886
+ case 'restart': {
1887
+ // S5 (wf-ee87a24e): cycle a worker session so it reloads upgraded code.
1888
+ // POSTs /restart to the worker's channel; the channel server writes the
1889
+ // wrapper flag and SIGTERMs claude → wogi-claude relaunches it fresh.
1890
+ const workerName = args[1];
1891
+ if (!workerName) {
1892
+ console.error('Usage: flow workspace restart <worker>');
1893
+ process.exit(1);
1894
+ }
1895
+ await restartWorkerSession(process.cwd(), workerName);
1896
+ break;
1897
+ }
1819
1898
  default:
1820
1899
  console.log(`
1821
1900
  Wogi Workspace — Multi-Repo Orchestration
@@ -1829,12 +1908,14 @@ Commands:
1829
1908
  add Add a member repo to the workspace
1830
1909
  remove Remove a member repo from the workspace
1831
1910
  start Start a worker session with channel (run from a member repo)
1911
+ restart Cycle a worker session so it reloads upgraded code + resumes its task
1832
1912
 
1833
1913
  Examples:
1834
1914
  flow workspace init # Create workspace from subdirectories
1835
1915
  flow workspace sync # Refresh after external changes
1836
1916
  flow workspace status # Show all repos, tasks, contracts
1837
1917
  cd frontend/ && flow workspace start # Start worker session
1918
+ flow workspace restart backend # Restart the 'backend' worker (after npm upgrade)
1838
1919
  `);
1839
1920
  }
1840
1921
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.33.0",
3
+ "version": "2.34.1",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "flow": "./scripts/flow",
13
- "test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-audit-gates.test.js tests/flow-standards-hook-three-layer.test.js tests/flow-correction-detector-reconcile.test.js tests/flow-correction-backfill.test.js tests/flow-audit-gates-feature-output-health.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/workspace-ipc-sqlite.test.js tests/workspace-ipc-multi-worker.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.test.js tests/flow-wave1-integrations.test.js tests/flow-wave2-integrations.test.js tests/flow-wave3-integrations.test.js tests/flow-commit-claims-gate.test.js tests/auto-review.test.js tests/gate-telemetry-surface.test.js tests/agents-md-alias.test.js tests/flow-skill-manage.test.js tests/fuzzy-patch.test.js tests/mode-schema.test.js tests/flow-feature-dossier.test.js tests/flow-autonomous-mode.test.js tests/flow-epic-cascade.test.js tests/flow-workspace-summary.test.js tests/flow-hooks-research-evidence-gate.test.js tests/flow-worker-mcp-strip.test.js tests/flow-orchestrate-corrections.test.js tests/flow-source-fidelity.test.js tests/flow-hooks-long-input-enforcement.test.js tests/workspace-channel-tracking.test.js tests/flow-hooks-deletion-log.test.js tests/flow-task-boundary-reset.test.js tests/flow-deferral-gate.test.js tests/flow-research-required-gate.test.js tests/flow-standards-forbidden-patterns.test.js tests/flow-hooks-architect-required-gate.test.js tests/flow-architect-runs.test.js tests/flow-installer-forbidden-patterns.test.js tests/flow-deferral-classifier-ai.test.js tests/flow-no-defer-policy.test.js tests/flow-self-adversary-loop.test.js tests/flow-impl-question-classifier.test.js tests/flow-hooks-self-adversary-gate.test.js tests/flow-scheduled-runner.test.js tests/flow-schedule-cli.test.js tests/flow-skill-portability.test.js tests/flow-skill-export.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
13
+ "test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-audit-gates.test.js tests/flow-standards-hook-three-layer.test.js tests/flow-correction-detector-reconcile.test.js tests/flow-correction-backfill.test.js tests/flow-audit-gates-feature-output-health.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/workspace-ipc-sqlite.test.js tests/workspace-ipc-multi-worker.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.test.js tests/flow-wave1-integrations.test.js tests/flow-wave2-integrations.test.js tests/flow-wave3-integrations.test.js tests/flow-commit-claims-gate.test.js tests/auto-review.test.js tests/gate-telemetry-surface.test.js tests/agents-md-alias.test.js tests/flow-skill-manage.test.js tests/fuzzy-patch.test.js tests/mode-schema.test.js tests/flow-feature-dossier.test.js tests/flow-autonomous-mode.test.js tests/flow-epic-cascade.test.js tests/flow-workspace-summary.test.js tests/flow-hooks-research-evidence-gate.test.js tests/flow-worker-mcp-strip.test.js tests/flow-orchestrate-corrections.test.js tests/flow-source-fidelity.test.js tests/flow-hooks-long-input-enforcement.test.js tests/workspace-channel-tracking.test.js tests/flow-hooks-deletion-log.test.js tests/flow-task-boundary-reset.test.js tests/flow-deferral-gate.test.js tests/flow-research-required-gate.test.js tests/flow-standards-forbidden-patterns.test.js tests/flow-hooks-architect-required-gate.test.js tests/flow-architect-runs.test.js tests/flow-installer-forbidden-patterns.test.js tests/flow-deferral-classifier-ai.test.js tests/flow-no-defer-policy.test.js tests/flow-self-adversary-loop.test.js tests/flow-impl-question-classifier.test.js tests/flow-hooks-self-adversary-gate.test.js tests/flow-scheduled-runner.test.js tests/flow-schedule-cli.test.js tests/flow-skill-portability.test.js tests/flow-skill-export.test.js tests/flow-workspace-subtask-state.test.js tests/flow-worker-continuation-gate.test.js tests/flow-workspace-stop-notify.test.js tests/flow-workspace-channel-status.test.js tests/flow-workspace-restart-resume.test.js tests/flow-workspace-sustained-execution.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
14
14
  "test:syntax": "find scripts/ lib/ -name '*.js' -not -path '*/node_modules/*' -exec node --check {} +",
15
15
  "lint": "eslint scripts/ lib/ tests/",
16
16
  "lint:ci": "eslint scripts/ lib/ tests/ --max-warnings 0",
package/scripts/flow CHANGED
@@ -381,6 +381,23 @@ case "${1:-}" in
381
381
  ready)
382
382
  node "$SCRIPT_DIR/flow-ready.js" "${@:2}"
383
383
  ;;
384
+ # F11 (R-379): bin/flow (npm-installed dispatcher) had `schedule` wired
385
+ # at line 232, but scripts/flow (dev/repo dispatcher) did not — so
386
+ # `flow schedule install` failed silently inside the dev repo. Wire it
387
+ # here so both dispatchers stay in sync. Same for `skill export`, which
388
+ # ships in v2.33.0 but was only reachable from the npm-installed path.
389
+ schedule)
390
+ node "$SCRIPT_DIR/flow-schedule.js" "${@:2}"
391
+ ;;
392
+ skill|skills)
393
+ # Sub-subcommand: `flow skill export ...` → flow-skill-export.js
394
+ # Other `flow skill ...` subcommands → flow-skill-manage.js (existing).
395
+ if [ "${2:-}" = "export" ]; then
396
+ node "$SCRIPT_DIR/flow-skill-export.js" "${@:3}"
397
+ else
398
+ node "$SCRIPT_DIR/flow-skill-manage.js" "${@:2}"
399
+ fi
400
+ ;;
384
401
  start)
385
402
  node "$SCRIPT_DIR/flow-start.js" "${@:2}"
386
403
  ;;
@@ -148,7 +148,9 @@ const KNOWN_CONFIG_KEYS = [
148
148
  // Task-boundary session restart (wf-39e9dc09) — opt-in, experimental
149
149
  'taskBoundaryReset',
150
150
  // Session hydration recency filter (wf-729ab5c0)
151
- 'sessionHydration'
151
+ 'sessionHydration',
152
+ // Workspace (multi-repo manager/worker) — epic-workspace-sustained-exec
153
+ 'workspace'
152
154
  ];
153
155
 
154
156
  module.exports = {
@@ -100,6 +100,15 @@ function nodeBinary() {
100
100
  // Unit content generators (pure — no I/O)
101
101
  // ============================================================
102
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
+
103
112
  function generateCrontabLines(opts = {}) {
104
113
  const node = opts.node || nodeBinary();
105
114
  const runner = opts.runner || runnerScriptPath();
@@ -109,9 +118,11 @@ function generateCrontabLines(opts = {}) {
109
118
  ];
110
119
  for (const jobName of SCHEDULED_JOB_NAMES) {
111
120
  const spec = SCHEDULES[jobName];
121
+ const logPath = path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`);
112
122
  lines.push(
113
- `${spec.cronExpr} cd ${projectRoot} && ${node} ${runner} ${jobName} ` +
114
- `>> ${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`)} 2>&1`
123
+ `${spec.cronExpr} cd ${shellQuote(projectRoot)} && ` +
124
+ `${shellQuote(node)} ${shellQuote(runner)} ${jobName} ` +
125
+ `>> ${shellQuote(logPath)} 2>&1`
115
126
  );
116
127
  }
117
128
  lines.push(`# === wogi-scheduled end ===`);
@@ -172,14 +183,18 @@ function generateSystemdServiceUnit(jobName, opts = {}) {
172
183
  const projectRoot = opts.projectRoot || repoRoot();
173
184
  if (!SCHEDULES[jobName]) throw new Error(`No schedule defined for "${jobName}"`);
174
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.
175
190
  return `[Unit]
176
191
  Description=Wogi Flow scheduled job: ${jobName}
177
192
  After=network-online.target
178
193
 
179
194
  [Service]
180
195
  Type=oneshot
181
- WorkingDirectory=${projectRoot}
182
- ExecStart=${node} ${runner} ${jobName}
196
+ WorkingDirectory="${projectRoot}"
197
+ ExecStart="${node}" "${runner}" "${jobName}"
183
198
  StandardOutput=append:${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`)}
184
199
  StandardError=append:${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.err.log`)}
185
200
  `;
@@ -361,12 +376,14 @@ function getStatus(deps = {}) {
361
376
  const cfrag = path.join(homeDir, '.config', 'wogi-flow', 'crontab-fragment');
362
377
  if (fsx.existsSync(cfrag)) status.cron.push(cfrag);
363
378
 
364
- // systemd
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.
365
382
  const sdir = path.join(homeDir, '.config', 'systemd', 'user');
366
383
  for (const jobName of SCHEDULED_JOB_NAMES) {
367
384
  for (const ext of ['service', 'timer']) {
368
385
  const p = path.join(sdir, `wogi-scheduled-${jobName}.${ext}`);
369
- if (fsx.existsSync(p)) status[ext === 'timer' ? 'systemd' : 'systemd'].push(p);
386
+ if (fsx.existsSync(p)) status.systemd.push(p);
370
387
  }
371
388
  }
372
389
  return status;
@@ -58,6 +58,36 @@ const {
58
58
 
59
59
  const { runInWorktree } = require('./flow-worktree');
60
60
 
61
+ // F16 (R-379): named constant for stderr-truncation cap so the meaning is
62
+ // inspectable and tunable in one place (decisions.md Code Quality §1).
63
+ const MAX_STDERR_BYTES = 4096;
64
+
65
+ // F10 (R-379): redact secret-shaped strings from stderr BEFORE we post it
66
+ // to a public GH Issue. Conservative — only strips the well-known token
67
+ // shapes (Anthropic API keys, GitHub PATs/fine-grained). Real users may
68
+ // have other tokens in their environment; this is best-effort, not a
69
+ // guarantee. The right defense remains "don't echo secrets in error
70
+ // messages in the first place"; this is the defense-in-depth pass.
71
+ const SECRET_REDACTION_PATTERNS = [
72
+ // Anthropic API keys — sk-ant-…
73
+ { re: /sk-ant-[a-zA-Z0-9_\-]{20,}/g, replacement: '[REDACTED:anthropic-key]' },
74
+ // GitHub PATs — ghp_… (classic), github_pat_… (fine-grained)
75
+ { re: /ghp_[a-zA-Z0-9]{36}/g, replacement: '[REDACTED:github-pat-classic]' },
76
+ { re: /github_pat_[a-zA-Z0-9_]{22,}/g, replacement: '[REDACTED:github-pat-fg]' },
77
+ // OAuth-shaped bearer tokens — only when they appear next to the literal
78
+ // "Authorization: Bearer" header (avoid stripping every JWT in stderr).
79
+ { re: /(Authorization:\s*Bearer\s+)[A-Za-z0-9_\-.]{20,}/gi, replacement: '$1[REDACTED:bearer]' },
80
+ ];
81
+
82
+ function redactSecrets(text) {
83
+ if (typeof text !== 'string') return '';
84
+ let out = text;
85
+ for (const { re, replacement } of SECRET_REDACTION_PATTERNS) {
86
+ out = out.replace(re, replacement);
87
+ }
88
+ return out;
89
+ }
90
+
61
91
  const { PATHS, getConfig, safeJsonParse } = require('./flow-utils');
62
92
 
63
93
  // ============================================================
@@ -148,7 +178,7 @@ function recordUsage(jobName, tokens, now = Date.now()) {
148
178
  const key = new Date(now).toISOString().slice(0, 10);
149
179
  const log = readUsageLog();
150
180
  if (!log[key]) log[key] = {};
151
- log[key][jobName] = (log[key][jobName] || 0) + tokens;
181
+ log[key][jobName] = (log[key][jobName] ?? 0) + tokens;
152
182
  writeUsageLog(log);
153
183
  return log;
154
184
  }
@@ -180,7 +210,10 @@ function listDedupIssues(jobName, repo) {
180
210
  const r = execSafe('gh', args);
181
211
  if (!r.ok) return [];
182
212
  try {
183
- const parsed = JSON.parse(r.stdout || '[]');
213
+ // F19 (R-379): use safeJsonParse for prototype-pollution guard, even
214
+ // though gh's output is trusted today — consistent with the project
215
+ // convention (security-patterns.md §2).
216
+ const parsed = safeJsonParse(r.stdout || '[]', []);
184
217
  if (!Array.isArray(parsed)) return [];
185
218
  return parsed.map((x) => x && x.number).filter((n) => Number.isFinite(n));
186
219
  } catch (_err) {
@@ -206,9 +239,9 @@ function openFailureIssue(jobName, summary, stderr, repo) {
206
239
  `### Summary`,
207
240
  summary,
208
241
  ``,
209
- `### stderr (last 4 KB)`,
242
+ `### stderr (last ${MAX_STDERR_BYTES} bytes, secrets redacted)`,
210
243
  '```',
211
- (stderr || '').slice(-4096),
244
+ redactSecrets((stderr || '').slice(-MAX_STDERR_BYTES)),
212
245
  '```',
213
246
  ].join('\n');
214
247
  const argv = ['issue', 'create', '--title', title, '--body', body, '--label', FAILURE_LABEL];
@@ -409,19 +442,31 @@ async function runOnce(jobName, ctx) {
409
442
  }
410
443
  return withTimeout(
411
444
  ({ signal }) => handler({ ...ctx, signal }),
412
- ctx.timeoutMs || DEFAULT_JOB_TIMEOUT_MS,
445
+ ctx.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
413
446
  );
414
447
  }
415
448
 
416
449
  async function runJobWithRetry(jobName, ctx) {
417
450
  const first = await runOnce(jobName, ctx);
418
- if (first.ok) return first;
419
451
 
420
- // Retry once on transient failures only.
421
- if (first.error && isTransientError(first.error)) {
452
+ // F5 (R-379): handlers catch internally (via execSafe / try-catch) and
453
+ // return `{passed:false, ...}` rather than throwing, so `withTimeout`
454
+ // wraps them as `{ok:true, result:{...}}`. Without this branch, the
455
+ // transient-retry path is unreachable because `first.ok` is almost
456
+ // always true. Look at the INNER result for transient signals too.
457
+ const transientInOuter = !first.ok && first.error && isTransientError(first.error);
458
+ const transientInInner =
459
+ first.ok &&
460
+ first.result &&
461
+ first.result.passed === false &&
462
+ typeof first.result.message === 'string' &&
463
+ isTransientError({ message: first.result.message });
464
+
465
+ if (transientInOuter || transientInInner) {
422
466
  await new Promise((r) => setTimeout(r, TRANSIENT_RETRY_DELAY_MS));
423
467
  return runOnce(jobName, ctx);
424
468
  }
469
+
425
470
  return first;
426
471
  }
427
472
 
@@ -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
  };