wogiflow 2.33.0 → 2.34.2

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 (58) hide show
  1. package/.workflow/templates/partials/methodology-rules.hbs +3 -1
  2. package/lib/scheduled-mode.js +12 -15
  3. package/lib/skill-export-claude-plugin.js +41 -1
  4. package/lib/skill-portability.js +21 -3
  5. package/lib/workspace-channel-server.js +116 -3
  6. package/lib/workspace-channel-tracking.js +102 -1
  7. package/lib/workspace-dispatch-tracking.js +28 -0
  8. package/lib/workspace-messages.js +32 -4
  9. package/lib/workspace-subtask-state.js +215 -0
  10. package/lib/workspace.js +81 -0
  11. package/package.json +2 -2
  12. package/scripts/flow +17 -0
  13. package/scripts/flow-constants.js +3 -1
  14. package/scripts/flow-io.js +17 -0
  15. package/scripts/flow-paths.js +81 -0
  16. package/scripts/flow-schedule.js +23 -6
  17. package/scripts/flow-scheduled-runner.js +53 -8
  18. package/scripts/flow-standards-checker.js +37 -0
  19. package/scripts/flow-utils.js +2 -0
  20. package/scripts/hooks/adapters/claude-code.js +6 -2
  21. package/scripts/hooks/core/git-safety-gate.js +34 -15
  22. package/scripts/hooks/core/long-input-enforcement.js +49 -39
  23. package/scripts/hooks/core/overdue-dispatches.js +28 -6
  24. package/scripts/hooks/core/phase-gate.js +34 -5
  25. package/scripts/hooks/core/phase-read-gate.js +62 -10
  26. package/scripts/hooks/core/session-start-worker.js +52 -0
  27. package/scripts/hooks/core/stop-orchestrator.js +17 -2
  28. package/scripts/hooks/core/validation.js +8 -0
  29. package/scripts/hooks/core/worker-continuation-gate.js +487 -0
  30. package/scripts/hooks/core/workspace-stop-gates.js +21 -0
  31. package/scripts/hooks/core/workspace-stop-notify.js +174 -59
  32. package/scripts/hooks/entry/claude-code/post-tool-use.js +26 -0
  33. package/.claude/rules/README.md +0 -36
  34. package/.claude/rules/_internal/README.md +0 -64
  35. package/.claude/rules/_internal/document-structure.md +0 -77
  36. package/.claude/rules/_internal/dual-repo-management.md +0 -174
  37. package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
  38. package/.claude/rules/_internal/github-releases.md +0 -71
  39. package/.claude/rules/_internal/model-management.md +0 -35
  40. package/.claude/rules/_internal/self-maintenance.md +0 -87
  41. package/.claude/rules/_internal/worker-tool-first-turn.md +0 -82
  42. package/.claude/rules/alternative-execpolicy-toml-command-policy.md +0 -11
  43. package/.claude/rules/alternative-hand-edit-ready-json-to-register-orpha.md +0 -11
  44. package/.claude/rules/alternative-hook-args-exec-form.md +0 -6
  45. package/.claude/rules/alternative-permission-ruleset-per-phase.md +0 -11
  46. package/.claude/rules/alternative-short-name.md +0 -12
  47. package/.claude/rules/alternative-wogi-flow-as-mcp-client-oauth-manager.md +0 -11
  48. package/.claude/rules/architecture/component-reuse.md +0 -38
  49. package/.claude/rules/architecture/hook-three-layer.md +0 -68
  50. package/.claude/rules/code-style/naming-conventions.md +0 -107
  51. package/.claude/rules/dual-repo-architecture-2026-02-28.md +0 -18
  52. package/.claude/rules/github-release-workflow-2026-01-30.md +0 -16
  53. package/.claude/rules/operations/git-workflows.md +0 -92
  54. package/.claude/rules/operations/scratch-directory.md +0 -54
  55. package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
  56. package/.workflow/specs/architecture.md.template +0 -24
  57. package/.workflow/specs/stack.md.template +0 -33
  58. package/.workflow/specs/testing.md.template +0 -36
@@ -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
@@ -781,6 +781,8 @@ const {
781
781
  module.exports = {
782
782
  // Explicit re-exports from flow-paths.js
783
783
  getProjectRoot: flowPaths.getProjectRoot,
784
+ getCanonicalStateDir: flowPaths.getCanonicalStateDir,
785
+ isLinkedWorktree: flowPaths.isLinkedWorktree,
784
786
  PROJECT_ROOT: flowPaths.PROJECT_ROOT,
785
787
  PACKAGE_ROOT: flowPaths.PACKAGE_ROOT,
786
788
  PACKAGE_PATHS: flowPaths.PACKAGE_PATHS,
@@ -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
  };
@@ -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) {
@@ -170,27 +182,34 @@ function rotateBackupBranches(keep) {
170
182
  * `git checkout .` / `git restore .` through either way. This was BUG-1.
171
183
  *
172
184
  * @param {Object} [opts]
173
- * @param {Function} [opts.exec] - execSync replacement for testing (string cmd → string stdout, throws on error).
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.
174
189
  * @returns {{ status: 'no-changes' | 'stashed' | 'failed', error?: string, stashRef?: string }}
175
190
  */
176
191
  function autoStash(opts = {}) {
177
- const exec = opts.exec || require('node:child_process').execSync;
192
+ const exec = opts.exec || require('node:child_process').execFileSync;
178
193
  const runOpts = { encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe'] };
179
194
 
180
195
  // 1. Anything to stash?
181
196
  let status;
182
197
  try {
183
- status = exec('git status --porcelain', runOpts).trim();
198
+ status = exec('git', ['status', '--porcelain'], runOpts).trim();
184
199
  } catch (err) {
185
200
  return { status: 'failed', error: `git status failed: ${err.message || err}` };
186
201
  }
187
202
  if (!status) return { status: 'no-changes' };
188
203
 
189
- // 2. Attempt the stash.
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.
190
208
  const timestamp = new Date().toISOString().slice(0, 19);
191
- const stashMessage = `auto-backup-${timestamp}`;
209
+ const nonce = require('node:crypto').randomBytes(6).toString('hex');
210
+ const stashMessage = `auto-backup-${timestamp}-${nonce}`;
192
211
  try {
193
- exec(`git stash push -m "${stashMessage}"`, runOpts);
212
+ exec('git', ['stash', 'push', '-m', stashMessage], runOpts);
194
213
  } catch (err) {
195
214
  return { status: 'failed', error: `git stash push failed: ${err.message || err}` };
196
215
  }
@@ -198,11 +217,11 @@ function autoStash(opts = {}) {
198
217
  // 3. VERIFY the stash actually saved (BUG-1 / wf-2d3d09b8). A zero exit code
199
218
  // from `git stash push` is NOT proof: lock contention, broken hooks, or
200
219
  // edge cases can leave the working tree unchanged with status 0. Confirm
201
- // by reading `git stash list` and matching our timestamped message at
220
+ // by reading `git stash list` and matching our nonce-suffixed message at
202
221
  // stash@{0}.
203
222
  let stashList;
204
223
  try {
205
- stashList = exec('git stash list', runOpts);
224
+ stashList = exec('git', ['stash', 'list'], runOpts);
206
225
  } catch (err) {
207
226
  return { status: 'failed', error: `stash verification (git stash list) failed: ${err.message || err}` };
208
227
  }
@@ -252,18 +252,28 @@ function hasTaskSignals(text) {
252
252
  }
253
253
 
254
254
  /**
255
- * Detect whether the current prompt is a channel-dispatched message in
256
- * worker mode. UserPromptSubmit gets `parsedInput.source` from Claude
257
- * Code's hook payload — channel-dispatched prompts arrive with a
258
- * channel-specific source identifier. We also check env vars to confirm
259
- * worker context. Defensive: returns false in any edge case.
255
+ * Detect ANY channel-source message manager↔worker dispatches and worker→
256
+ * manager `## Results` status replies, in either direction (wf-e5e57361 / RC3).
257
+ * Channel traffic is inter-agent transport, NOT user input. Dual detection:
258
+ * 1. `source` carries the channel/notification marker (channel server delivers
259
+ * via `notifications/claude/channel`), OR
260
+ * 2. the content arrives wrapped in a leading `<channel ...>` tag
261
+ * (workspace-channel-server buildInstructions).
262
+ * Independent of worker/manager mode — a status reply trips the gate on the
263
+ * MANAGER too, which is the deadlock this skip removes.
260
264
  */
261
- function isChannelDispatchInWorker(source, env = process.env) {
262
- if (!env.WOGI_WORKSPACE_ROOT) return false;
263
- if (!env.WOGI_REPO_NAME || env.WOGI_REPO_NAME === 'manager') return false;
264
- // Channel-dispatched prompts have specific source markers.
265
- if (typeof source !== 'string') return false;
266
- return /channel|notifications/i.test(source);
265
+ function isChannelSourceMessage(text, source, env = process.env) {
266
+ // Source field is set by the channel notification path — unconditional signal.
267
+ if (typeof source === 'string' && /channel|notifications/i.test(source)) return true;
268
+ // Leading <channel> tag: trust it ONLY in workspace mode (F4, wf-0381b27b).
269
+ // Otherwise a solo user whose prose literally starts with "<channel" (e.g.
270
+ // asking about the channel tag) would be mis-skipped. Channel-wrapped
271
+ // messages only ever arrive when WOGI_WORKSPACE_ROOT is set.
272
+ if (env && env.WOGI_WORKSPACE_ROOT && typeof text === 'string') {
273
+ const lead = text.trimStart().slice(0, 16).toLowerCase();
274
+ if (lead.startsWith('<channel')) return true;
275
+ }
276
+ return false;
267
277
  }
268
278
 
269
279
  /**
@@ -289,6 +299,17 @@ function shouldForceExtractReview({ text, source, env = process.env } = {}) {
289
299
  if (isSkillBodyEcho(text)) {
290
300
  return { forced: false, level: 'pass', reason: 'skill-body-echo' };
291
301
  }
302
+ // wf-e5e57361 (RC3): channel-source messages are INTER-AGENT transport
303
+ // (manager↔worker dispatches and `## Results` replies), not user prompts.
304
+ // Firing the gate on them wrote a long-input-pending marker that deadlocked
305
+ // against the routing gate (worker/manager could reach neither /wogi-start nor
306
+ // the dismiss path). Source fidelity for dispatched specs is enforced at the
307
+ // manager AUTHORING layer (Logic Constitution 11.6 + flow-source-fidelity.js),
308
+ // where the USER's verbatim prompt still trips this gate normally — not here on
309
+ // the transport layer. Skip channel traffic entirely.
310
+ if (isChannelSourceMessage(text, source, env)) {
311
+ return { forced: false, level: 'pass', reason: 'channel-source' };
312
+ }
292
313
  if (!detectLongFormPrompt(text)) {
293
314
  return { forced: false, level: 'pass', reason: 'below-long-input-threshold' };
294
315
  }
@@ -302,38 +323,27 @@ function shouldForceExtractReview({ text, source, env = process.env } = {}) {
302
323
  if (!hasTaskSignals(text)) {
303
324
  return { forced: false, level: 'suggest', reason: 'long-but-no-task-signals' };
304
325
  }
305
- // Worker receiving channel-dispatched long-form without source-link:
306
- // STRICT this is the wogi-hub 2026-04-27 failure mode.
307
- if (isChannelDispatchInWorker(source, env)) {
308
- return { forced: true, level: 'strict', reason: 'channel-dispatch-without-source-link' };
309
- }
310
- // Any other session: long-form + task-like + no source-link → force.
326
+ // (RC3) The former STRICT branch for channel-dispatch-in-worker is superseded
327
+ // by the channel-source skip above: channel traffic never reaches here. The
328
+ // wogi-hub 2026-04-27 manager-compression failure is now prevented at the
329
+ // manager AUTHORING layer, not by force-blocking the worker on receipt.
330
+ // Any non-channel session: long-form + task-like + no source-link → force.
311
331
  return { forced: true, level: 'force', reason: 'long-form-task-without-source-link' };
312
332
  }
313
333
 
314
- function buildEnforcementMessage(reason, level) {
315
- const header = level === 'strict'
316
- ? '🚨 STRICT P11.5 ENFORCEMENT manager compression detected'
317
- : '🚨 P11.5 ENFORCEMENT long-form prompt without source-link';
334
+ function buildEnforcementMessage(reason, _level) {
335
+ // Only the 'force' level reaches here now — channel-source (the former
336
+ // 'strict' path) is skipped upstream in shouldForceExtractReview (RC3,
337
+ // wf-e5e57361). `_level` is retained for signature/back-compat.
318
338
  const body = [];
319
- body.push(header);
339
+ body.push('🚨 P11.5 ENFORCEMENT — long-form prompt without source-link');
320
340
  body.push('');
321
- if (level === 'strict') {
322
- body.push('This prompt arrived via channel-dispatch in worker mode and qualifies as');
323
- body.push('long-form (>40 lines OR ≥5 discrete items) without a source-link. The');
324
- body.push('manager that dispatched this message SHOULD have included a path to a spec');
325
- body.push('with `## Original Request (verbatim)`. It did not. This is the exact failure');
326
- body.push('shape that caused the wogi-hub 2026-04-27 Customers > Services regression.');
327
- body.push('');
328
- body.push('You MUST reverse the compression at this layer:');
329
- } else {
330
- body.push('This prompt qualifies as long-form (>40 lines OR ≥5 discrete items) AND');
331
- body.push('contains task-creating signals (imperatives + structured items). Per P11.5,');
332
- body.push('long-form work-creating prompts MUST go through /wogi-extract-review so');
333
- body.push('every item is captured and reconciled.');
334
- body.push('');
335
- body.push('You MUST:');
336
- }
341
+ body.push('This prompt qualifies as long-form (>40 lines OR ≥5 discrete items) AND');
342
+ body.push('contains task-creating signals (imperatives + structured items). Per P11.5,');
343
+ body.push('long-form work-creating prompts MUST go through /wogi-extract-review so');
344
+ body.push('every item is captured and reconciled.');
345
+ body.push('');
346
+ body.push('You MUST:');
337
347
  body.push(' 1. Invoke `Skill(skill="wogi-extract-review")` BEFORE any other work.');
338
348
  body.push(' 2. Let extract-review run its 6-phase pipeline (extract → review → topics →');
339
349
  body.push(' map → clarify → stories) on this prompt.');
@@ -483,7 +493,7 @@ module.exports = {
483
493
  hasTaskSignals,
484
494
  isSystemOriginatedContent,
485
495
  isSkillBodyEcho,
486
- isChannelDispatchInWorker,
496
+ isChannelSourceMessage,
487
497
  shouldForceExtractReview,
488
498
  buildEnforcementMessage,
489
499
  markLongInputPending,
@@ -53,7 +53,7 @@ function formatLine(record, now) {
53
53
  */
54
54
  function sweepAndReconcile(workspaceRoot) {
55
55
  let reconciled = 0;
56
- let readMessages, reconcileDispatch, readDispatches;
56
+ let readMessages, reconcileDispatch, readDispatches, refreshDispatchDeadline;
57
57
  try {
58
58
  const libMessages = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages.js');
59
59
  const libTracking = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
@@ -61,6 +61,7 @@ function sweepAndReconcile(workspaceRoot) {
61
61
  const tracking = require(libTracking);
62
62
  reconcileDispatch = tracking.reconcileDispatch;
63
63
  readDispatches = tracking.readDispatches;
64
+ refreshDispatchDeadline = tracking.refreshDispatchDeadline;
64
65
  } catch (_err) {
65
66
  return 0; // Fail-open
66
67
  }
@@ -78,13 +79,32 @@ function sweepAndReconcile(workspaceRoot) {
78
79
  if (r.taskId && !byTaskId.has(r.taskId)) byTaskId.set(r.taskId, r);
79
80
  }
80
81
 
81
- // Pull both message types. readMessages throws on missing dir internally
82
- // but guards with existsSync, so it's safe.
82
+ // S3 (wf-d3ae1717): heartbeats refresh the deadline (work ongoing, NOT a
83
+ // silent halt); terminal types resolve the dispatch. worker-progress is
84
+ // applied FIRST so a heartbeat that arrived before a terminal doesn't keep a
85
+ // since-resolved dispatch alive.
86
+ try {
87
+ const heartbeats = readMessages(workspaceRoot, { type: 'worker-progress' });
88
+ if (refreshDispatchDeadline) {
89
+ for (const hb of heartbeats) {
90
+ const taskId = hb.taskId;
91
+ if (!taskId || !byTaskId.has(taskId)) continue;
92
+ try { refreshDispatchDeadline(workspaceRoot, taskId); } catch (_err) { /* per-record */ }
93
+ }
94
+ }
95
+ } catch (_err) { /* heartbeats are best-effort */ }
96
+
97
+ // Pull terminal message types. readMessages throws on missing dir internally
98
+ // but guards with existsSync, so it's safe. worker-blocked / worker-idle /
99
+ // worker-awaiting-approval are terminal stops alongside the legacy pair.
83
100
  let messages = [];
84
101
  try {
85
102
  const completes = readMessages(workspaceRoot, { type: 'task-complete' });
86
103
  const stops = readMessages(workspaceRoot, { type: 'worker-stopped' });
87
- messages = completes.concat(stops);
104
+ const blocked = readMessages(workspaceRoot, { type: 'worker-blocked' });
105
+ const idle = readMessages(workspaceRoot, { type: 'worker-idle' });
106
+ const awaiting = readMessages(workspaceRoot, { type: 'worker-awaiting-approval' });
107
+ messages = completes.concat(stops, blocked, idle, awaiting);
88
108
  } catch (_err) {
89
109
  return 0;
90
110
  }
@@ -93,8 +113,10 @@ function sweepAndReconcile(workspaceRoot) {
93
113
  const taskId = msg.taskId || (msg.type === 'task-complete' ? msg.subject : null);
94
114
  if (!taskId || !byTaskId.has(taskId)) continue;
95
115
  try {
96
- const status = msg.type === 'worker-stopped' ? 'graceful-stop' : 'completed';
97
- const reason = msg.type === 'worker-stopped' ? (msg.reason || 'graceful') : null;
116
+ // task-complete completed; everything else is a non-overdue graceful
117
+ // stop (the reason field distinguishes blocked / awaiting / idle / graceful).
118
+ const status = msg.type === 'task-complete' ? 'completed' : 'graceful-stop';
119
+ const reason = msg.type === 'task-complete' ? null : (msg.reason || msg.type);
98
120
  const result = reconcileDispatch(workspaceRoot, taskId, status, reason);
99
121
  if (result) {
100
122
  reconciled++;
@@ -14,9 +14,13 @@
14
14
 
15
15
  const path = require('node:path');
16
16
  const fs = require('node:fs');
17
- const { getConfig, PATHS, safeJsonParse } = require('../../flow-utils');
17
+ const { getConfig, safeJsonParse, getCanonicalStateDir, isLinkedWorktree } = require('../../flow-utils');
18
18
 
19
- const PHASE_FILE = path.join(PATHS.state, 'workflow-phase.json');
19
+ // RC2 (wf-e5e57361): resolve the phase file from the CANONICAL (main-repo) state
20
+ // dir, worktree-stable, so the gate cannot be evaded by operating from a git
21
+ // worktree where the gitignored phase file is absent. In the main working tree
22
+ // the canonical path equals `PATHS.state/workflow-phase.json`.
23
+ function phaseFilePath() { return path.join(getCanonicalStateDir(), 'workflow-phase.json'); }
20
24
 
21
25
  // 2 hours in milliseconds
22
26
  const STALE_PHASE_TTL_MS = 2 * 60 * 60 * 1000;
@@ -94,7 +98,7 @@ function isPhaseGateEnabled(config) {
94
98
  function getCurrentPhase() {
95
99
  const defaults = { phase: 'idle', taskId: null, updatedAt: null, previousPhase: null };
96
100
  try {
97
- const data = safeJsonParse(PHASE_FILE, null);
101
+ const data = safeJsonParse(phaseFilePath(), null);
98
102
  if (!data || !data.phase || !PHASES.includes(data.phase)) {
99
103
  return defaults;
100
104
  }
@@ -114,11 +118,12 @@ function getCurrentPhase() {
114
118
  */
115
119
  function writePhaseState(state) {
116
120
  try {
117
- const dir = path.dirname(PHASE_FILE);
121
+ const phaseFile = phaseFilePath();
122
+ const dir = path.dirname(phaseFile);
118
123
  if (!fs.existsSync(dir)) {
119
124
  fs.mkdirSync(dir, { recursive: true });
120
125
  }
121
- fs.writeFileSync(PHASE_FILE, JSON.stringify(state, null, 2) + '\n', 'utf-8');
126
+ fs.writeFileSync(phaseFile, JSON.stringify(state, null, 2) + '\n', 'utf-8');
122
127
  // Update aggregated hook status
123
128
  try {
124
129
  const { setPhase } = require('../../flow-hook-status');
@@ -298,6 +303,30 @@ function checkPhaseGate(toolName, toolInput, config) {
298
303
  }
299
304
  }
300
305
 
306
+ // RC2 (wf-e5e57361) fail-CLOSED: if the phase resolves to the idle default
307
+ // (no phase file found) while running inside a linked git worktree AND a task
308
+ // is in-progress per canonical state, this is the gate-evasion shape. Block
309
+ // mutation tools rather than letting "idle" wave them through.
310
+ if (current.phase === 'idle' && !current.taskId &&
311
+ (toolName === 'Edit' || toolName === 'Write' || toolName === 'Bash')) {
312
+ try {
313
+ if (isLinkedWorktree()) {
314
+ const ready = safeJsonParse(path.join(getCanonicalStateDir(), 'ready.json'), { inProgress: [] });
315
+ if (Array.isArray(ready.inProgress) && ready.inProgress.length > 0) {
316
+ return {
317
+ allowed: false,
318
+ blocked: true,
319
+ reason: 'phase_worktree_failclosed',
320
+ message: 'Phase gate (RC2): operating from a git worktree while a task is ' +
321
+ 'in progress, but no phase state is resolvable. Gates are NOT evadable from a ' +
322
+ 'worktree — return to the main working tree and satisfy the gate legitimately, ' +
323
+ 'or channel-escalate. Do NOT create worktrees or write markers to bypass gating.'
324
+ };
325
+ }
326
+ }
327
+ } catch (_err) { /* fail-open on detection error */ }
328
+ }
329
+
301
330
  const bashCommand = toolInput?.command || '';
302
331
  const allowed = isToolAllowedInPhase(toolName, current.phase, bashCommand);
303
332