wogiflow 2.34.1 → 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 (36) hide show
  1. package/.workflow/templates/partials/methodology-rules.hbs +3 -1
  2. package/lib/workspace-channel-server.js +10 -0
  3. package/package.json +1 -1
  4. package/scripts/flow-io.js +17 -0
  5. package/scripts/flow-paths.js +81 -0
  6. package/scripts/flow-utils.js +2 -0
  7. package/scripts/hooks/core/long-input-enforcement.js +49 -39
  8. package/scripts/hooks/core/phase-gate.js +34 -5
  9. package/scripts/hooks/core/phase-read-gate.js +62 -10
  10. package/scripts/hooks/core/worker-continuation-gate.js +169 -8
  11. package/.claude/rules/README.md +0 -36
  12. package/.claude/rules/_internal/README.md +0 -64
  13. package/.claude/rules/_internal/document-structure.md +0 -77
  14. package/.claude/rules/_internal/dual-repo-management.md +0 -174
  15. package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
  16. package/.claude/rules/_internal/github-releases.md +0 -71
  17. package/.claude/rules/_internal/model-management.md +0 -35
  18. package/.claude/rules/_internal/self-maintenance.md +0 -87
  19. package/.claude/rules/_internal/worker-tool-first-turn.md +0 -82
  20. package/.claude/rules/alternative-execpolicy-toml-command-policy.md +0 -11
  21. package/.claude/rules/alternative-hand-edit-ready-json-to-register-orpha.md +0 -11
  22. package/.claude/rules/alternative-hook-args-exec-form.md +0 -6
  23. package/.claude/rules/alternative-permission-ruleset-per-phase.md +0 -11
  24. package/.claude/rules/alternative-short-name.md +0 -12
  25. package/.claude/rules/alternative-wogi-flow-as-mcp-client-oauth-manager.md +0 -11
  26. package/.claude/rules/architecture/component-reuse.md +0 -38
  27. package/.claude/rules/architecture/hook-three-layer.md +0 -68
  28. package/.claude/rules/code-style/naming-conventions.md +0 -107
  29. package/.claude/rules/dual-repo-architecture-2026-02-28.md +0 -18
  30. package/.claude/rules/github-release-workflow-2026-01-30.md +0 -16
  31. package/.claude/rules/operations/git-workflows.md +0 -92
  32. package/.claude/rules/operations/scratch-directory.md +0 -54
  33. package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
  34. package/.workflow/specs/architecture.md.template +0 -24
  35. package/.workflow/specs/stack.md.template +0 -33
  36. package/.workflow/specs/testing.md.template +0 -36
@@ -48,10 +48,12 @@ All fail-open. Bypass for tests via `--skip-gates`. Config: `storyFlow.*`.
48
48
 
49
49
  - **Tool-First Turn**: every turn after `UserPromptSubmit` must contain ≥1 tool call. In strict mode (default), the first content block must be `tool_use`. Pure-text responses are invisible to the user.
50
50
  - **Three-State End-of-Turn**: exactly one of ACTION (`/wogi-start <nextId>`), ESCALATION (channel-dispatch `## QUESTION:`), or IDLE.
51
+ - **Never idle while in-progress**: IDLE is valid ONLY when nothing is in-progress. If a task is in-progress and you hit an approval / phase-read / architect / research gate, that is NOT a stopping point — PROCEED by satisfying the gate legitimately (read the phase doc, decompose, provide evidence; autonomous = pre-approved), or ESCALATE via channel. The Stop-hook continuation gate drives a proceed-or-escalate continuation and auto-escalates to the manager after repeated no-progress turns.
52
+ - **Gate circumvention is PROHIBITED**: never create a git worktree to reach an "ungated" context, never hand-write gate-satisfying markers, never edit `.workflow/state` files to fake gate satisfaction, never change directory to dodge a gate. Gates resolve phase from the canonical main-repo state, not your cwd — circumvention is both forbidden and ineffective.
51
53
  - **Hedging forbidden**: "awaiting your signal", "let me know", "standing by", "should I continue".
52
54
  - **No direct user prompts**: `AskUserQuestion` is blocked; questions go through channel dispatch.
53
55
 
54
- Enforced by: `worker-tool-first-gate.js` (G1/G4/Gap B), `worker-boundary-gate.js`, `flow-worker-question-classifier.js`. Config: `workspace.toolFirstTurnGate.{enabled,strict}`, `workspace.blockAskUserQuestionInWorker`, `workspace.aiWorkerQuestionClassifier.*`. Long-form: `.claude/rules/_internal/worker-tool-first-turn.md`.
56
+ Enforced by: `worker-tool-first-gate.js` (G1/G4/Gap B), `worker-continuation-gate.js` (in-progress stall fallback — RC1, wf-e5e57361), `worker-boundary-gate.js`, `flow-worker-question-classifier.js`; phase resolved worktree-stably via `getCanonicalStateDir()` with fail-closed for in-progress tasks (RC2). Config: `workspace.toolFirstTurnGate.{enabled,strict}`, `workspace.continuationGate.*`, `workspace.blockAskUserQuestionInWorker`, `workspace.aiWorkerQuestionClassifier.*`. Long-form: `.claude/rules/_internal/worker-tool-first-turn.md`.
55
57
 
56
58
  ---
57
59
 
@@ -147,6 +147,16 @@ When you receive a message:
147
147
  SUSTAINED EXECUTION — a task dispatch runs to COMPLETION across turns:
148
148
  A "/wogi-" dispatch (especially one you decompose into sub-tasks) is NOT a one-turn request. Work through ALL sub-tasks in the same session; the Stop hook's continuation gate will keep you going while the task is in-progress with work remaining. Do NOT stop to "report progress" mid-task — only reply when the task is COMPLETE or you are ESCALATING a blocker.
149
149
 
150
+ NEVER IDLE WHILE A TASK IS IN-PROGRESS: If you hit an approval / phase-read / architect / research gate, that is NOT a stopping point. Exactly one of these must happen:
151
+ • PROCEED by SATISFYING the gate legitimately — read the required phase doc, decompose the task, provide the required evidence. In autonomous mode you are PRE-APPROVED; do not wait for approval that no one is there to give.
152
+ • ESCALATE to the manager via channel ("## QUESTION: <blocker>") if you genuinely need the manager/user, then end the turn.
153
+
154
+ GATE CIRCUMVENTION IS PROHIBITED (and pointless — gates resolve phase from the canonical main-repo state, not your working directory):
155
+ ✗ Do NOT create a git worktree to reach an "ungated" context.
156
+ ✗ Do NOT hand-write gate-satisfying markers, or edit .workflow/state files, to fake gate satisfaction.
157
+ ✗ Do NOT change directory to dodge a gate.
158
+ A blocked tool call is an instruction to satisfy the gate, never a puzzle to route around.
159
+
150
160
  CRITICAL — REPLY TO THE MANAGER WHEN THE TASK IS DONE OR BLOCKED:
151
161
  When the dispatched task is complete (or you must escalate), you MUST send results back using the workspace_send_message tool with to: "manager". The user only sees the manager terminal — if you don't reply, they never see your results.
152
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.34.1",
3
+ "version": "2.34.2",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -607,6 +607,23 @@ async function acquireLock(filePath, options = {}) {
607
607
  await require('node:timers/promises').setTimeout(delay);
608
608
  continue;
609
609
  }
610
+ } else if (err.code === 'ENOENT' && attempt < retries) {
611
+ // Race: a concurrent cleanup (another process's release() or
612
+ // cleanupStaleLocks) removed the lock dir between our mkdirSync and the
613
+ // info.json write, so writeFileSync(lockInfoFile) failed ENOENT. This is
614
+ // transient — retry rather than failing hard. (Pre-existing flaky-lock
615
+ // root cause surfaced by parallel test runs; wf-0381b27b.)
616
+ //
617
+ // Remove only our EMPTY orphan dir if one lingers: rmdirSync succeeds
618
+ // only when the dir is empty, so we never clobber another holder's lock
619
+ // (its info.json would make this ENOTEMPTY → we leave it for the normal
620
+ // EEXIST/stale path on the next attempt).
621
+ try { fs.rmdirSync(lockDir); } catch (_e) { /* gone, or now held by another */ }
622
+ const delay = exponentialBackoff
623
+ ? retryDelay * Math.pow(2, attempt)
624
+ : retryDelay * (attempt + 1);
625
+ await require('node:timers/promises').setTimeout(delay);
626
+ continue;
610
627
  }
611
628
 
612
629
  throw new Error(`Failed to acquire lock for ${filePath}: ${err.message}`, { cause: err });
@@ -64,6 +64,85 @@ function getProjectRoot() {
64
64
  return process.cwd();
65
65
  }
66
66
 
67
+ // ============================================================
68
+ // Worktree-stable canonical resolution (wf-e5e57361 / RC2)
69
+ // ============================================================
70
+ //
71
+ // `git rev-parse --show-toplevel` is NOT worktree-stable: from inside a linked
72
+ // git worktree it returns the WORKTREE root, not the main repository root. State
73
+ // files under `.workflow/state/` are gitignored, so a worktree never carries them
74
+ // (e.g. `workflow-phase.json`). That made the phase gates fail-open to an
75
+ // unrestricted "idle" phase when a process ran from a worktree — an "ungated
76
+ // context" a worker could reach by `git worktree add`. The fix: resolve gate
77
+ // state from the CANONICAL (main-repo) location via `--git-common-dir`, which IS
78
+ // worktree-stable (it always points at the main repo's `.git`).
79
+ //
80
+ // Both values come from a single `git rev-parse` call, memoized per-process.
81
+
82
+ let _gitInfo; // { topLevel, mainRoot } | null
83
+ function resolveGitInfo() {
84
+ if (_gitInfo !== undefined) return _gitInfo;
85
+ try {
86
+ // One call returns two lines: show-toplevel, then git-common-dir (absolute).
87
+ const out = execSync('git rev-parse --path-format=absolute --show-toplevel --git-common-dir', {
88
+ encoding: 'utf-8',
89
+ stdio: ['pipe', 'pipe', 'pipe']
90
+ }).trim();
91
+ const [topLevel, commonDir] = out.split('\n').map(s => s.trim());
92
+ if (topLevel && commonDir) {
93
+ // commonDir is `<mainRoot>/.git` in both the main tree and any worktree.
94
+ _gitInfo = { topLevel: path.resolve(topLevel), mainRoot: path.dirname(path.resolve(commonDir)) };
95
+ return _gitInfo;
96
+ }
97
+ } catch (_err) { /* not a git repo / git unavailable */ }
98
+ _gitInfo = null;
99
+ return _gitInfo;
100
+ }
101
+
102
+ /**
103
+ * Resolve the canonical (main-repo) `.workflow/state` directory, stable across
104
+ * git worktrees. Falls back to the cwd-relative STATE_DIR when git is
105
+ * unavailable or the canonical state dir doesn't exist (no regression vs prior
106
+ * behavior). An explicit `WOGI_CANONICAL_STATE_DIR` env var overrides everything
107
+ * (used by tests and by callers that already resolved it).
108
+ *
109
+ * @returns {string} Absolute path to the canonical `.workflow/state` directory
110
+ */
111
+ let _canonicalStateDir; // memo
112
+ function getCanonicalStateDir() {
113
+ if (_canonicalStateDir !== undefined) return _canonicalStateDir;
114
+ if (process.env.WOGI_CANONICAL_STATE_DIR && fs.existsSync(process.env.WOGI_CANONICAL_STATE_DIR)) {
115
+ _canonicalStateDir = process.env.WOGI_CANONICAL_STATE_DIR;
116
+ return _canonicalStateDir;
117
+ }
118
+ const info = resolveGitInfo();
119
+ if (info && info.mainRoot) {
120
+ const candidate = path.join(info.mainRoot, '.workflow', 'state');
121
+ if (fs.existsSync(candidate)) {
122
+ _canonicalStateDir = candidate;
123
+ return _canonicalStateDir;
124
+ }
125
+ }
126
+ _canonicalStateDir = STATE_DIR; // fallback: cwd-relative (current behavior)
127
+ return _canonicalStateDir;
128
+ }
129
+
130
+ /**
131
+ * True when the current working directory is inside a LINKED git worktree
132
+ * (i.e. the working-tree top-level differs from the main repository root). Used
133
+ * to fail gates CLOSED for in-progress tasks rather than fail-open to "idle"
134
+ * when phase state cannot be resolved. Returns false outside git or on the main
135
+ * working tree.
136
+ *
137
+ * @returns {boolean}
138
+ */
139
+ function isLinkedWorktree() {
140
+ if (process.env.WOGI_FORCE_WORKTREE === '1') return true; // test seam
141
+ const info = resolveGitInfo();
142
+ if (!info) return false;
143
+ return info.topLevel !== info.mainRoot;
144
+ }
145
+
67
146
  // ============================================================
68
147
  // Package Root (where wogiflow npm package lives)
69
148
  // ============================================================
@@ -283,6 +362,8 @@ function checkSpecMigration() {
283
362
 
284
363
  module.exports = {
285
364
  getProjectRoot,
365
+ getCanonicalStateDir,
366
+ isLinkedWorktree,
286
367
  PROJECT_ROOT,
287
368
  PACKAGE_ROOT,
288
369
  PACKAGE_PATHS,
@@ -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,
@@ -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,
@@ -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
 
@@ -23,10 +23,31 @@
23
23
 
24
24
  const path = require('node:path');
25
25
  const fs = require('node:fs');
26
- const { PATHS, safeJsonParse } = require('../../flow-utils');
26
+ const { PATHS, safeJsonParse, getCanonicalStateDir, isLinkedWorktree } = require('../../flow-utils');
27
27
 
28
+ // Exported for tests (main-tree path). The gate resolves these paths CANONICALLY
29
+ // at call time (see workflowPhasePath / phaseReadsPath) so it cannot be evaded
30
+ // from a git worktree, where the gitignored phase file is absent (wf-e5e57361 /
31
+ // RC2). In the main working tree the canonical path equals this constant.
28
32
  const PHASE_READS_FILE = path.join(PATHS.state, 'phase-reads.json');
29
- const WORKFLOW_PHASE_FILE = path.join(PATHS.state, 'workflow-phase.json');
33
+
34
+ // Lazy canonical resolvers — `stateDir` override is injectable for tests.
35
+ function workflowPhasePath(stateDir) { return path.join(stateDir || getCanonicalStateDir(), 'workflow-phase.json'); }
36
+ function phaseReadsPath(stateDir) { return path.join(stateDir || getCanonicalStateDir(), 'phase-reads.json'); }
37
+
38
+ /**
39
+ * True if the canonical ready.json shows a task in-progress. Used to fail the
40
+ * gate CLOSED (rather than open) when phase state is unresolvable inside a
41
+ * worktree of an in-progress task — the gate-evasion shape from RC2.
42
+ */
43
+ function hasCanonicalInProgressTask(stateDir) {
44
+ try {
45
+ const ready = safeJsonParse(path.join(stateDir || getCanonicalStateDir(), 'ready.json'), { inProgress: [] });
46
+ return Array.isArray(ready.inProgress) && ready.inProgress.length > 0;
47
+ } catch (_err) {
48
+ return false;
49
+ }
50
+ }
30
51
 
31
52
  // Maps workflow phases to required instruction files
32
53
  const PHASE_FILE_REGISTRY = {
@@ -80,7 +101,8 @@ function recordPhaseRead(filePath) {
80
101
  // fail-open semantics mean a lost write just means the gate asks for a
81
102
  // re-read — safe degradation, not a correctness bug.
82
103
  try {
83
- const existing = safeJsonParse(PHASE_READS_FILE, {});
104
+ const readsFile = phaseReadsPath();
105
+ const existing = safeJsonParse(readsFile, {});
84
106
  if (!existing.reads) existing.reads = {};
85
107
 
86
108
  existing.reads[matchedPhase] = {
@@ -88,7 +110,8 @@ function recordPhaseRead(filePath) {
88
110
  at: new Date().toISOString()
89
111
  };
90
112
 
91
- fs.writeFileSync(PHASE_READS_FILE, JSON.stringify(existing, null, 2));
113
+ fs.mkdirSync(path.dirname(readsFile), { recursive: true });
114
+ fs.writeFileSync(readsFile, JSON.stringify(existing, null, 2));
92
115
 
93
116
  if (process.env.DEBUG) {
94
117
  console.error(`[PhaseReadGate] Recorded read of ${PHASE_FILE_REGISTRY[matchedPhase]} for phase ${matchedPhase}`);
@@ -113,8 +136,10 @@ function recordPhaseRead(filePath) {
113
136
  *
114
137
  * @param {string} toolName
115
138
  * @param {Object} [config] - Optional config object
139
+ * @param {Object} [deps] - Injectable seams for tests:
140
+ * { stateDir, isLinkedWorktree, hasInProgressTask }
116
141
  */
117
- function checkPhaseReadGate(toolName, config) {
142
+ function checkPhaseReadGate(toolName, config, deps = {}) {
118
143
  try {
119
144
  // Respect phaseReadGate config with fallback to phaseGate (backwards compat).
120
145
  // If phaseReadGate.enabled is explicitly false, skip. If it's undefined,
@@ -129,10 +154,32 @@ function checkPhaseReadGate(toolName, config) {
129
154
  return { blocked: false };
130
155
  }
131
156
 
132
- // Read current phase
133
- const phaseData = safeJsonParse(WORKFLOW_PHASE_FILE, null);
157
+ // RC2 (wf-e5e57361): resolve phase from the CANONICAL state dir, not cwd —
158
+ // a git worktree lacks the gitignored phase file, so a cwd-relative read
159
+ // would fail-open to an unrestricted "idle" phase ("ungated context").
160
+ const stateDir = deps.stateDir || getCanonicalStateDir();
161
+ const inWorktree = (deps.isLinkedWorktree || isLinkedWorktree);
162
+ const hasInProgress = (deps.hasInProgressTask || hasCanonicalInProgressTask);
163
+
164
+ // Read current phase (canonically)
165
+ const phaseData = safeJsonParse(workflowPhasePath(stateDir), null);
134
166
  if (!phaseData || !phaseData.phase) {
135
- return { blocked: false }; // No phase data = fail-open
167
+ // RC2 fail-CLOSED: a missing phase file inside a linked worktree while a
168
+ // task is in-progress (per canonical ready.json) is the gate-evasion
169
+ // shape — block mutation tools instead of failing open.
170
+ if ((toolName === 'Edit' || toolName === 'Write' || toolName === 'Bash') &&
171
+ inWorktree(stateDir) && hasInProgress(stateDir)) {
172
+ return {
173
+ blocked: true,
174
+ message: 'Phase gate (RC2): a task is in progress but the workflow phase ' +
175
+ 'could not be resolved, and you appear to be operating from a git worktree. ' +
176
+ 'Gates are NOT evadable by working from a worktree — phase is resolved from ' +
177
+ 'the canonical (main-repo) state. Return to the main working tree and satisfy ' +
178
+ 'the gate legitimately, or channel-escalate to the manager. Do NOT create ' +
179
+ 'worktrees or write gate-satisfying markers to bypass this.'
180
+ };
181
+ }
182
+ return { blocked: false }; // No phase data = fail-open (normal main-tree)
136
183
  }
137
184
 
138
185
  const currentPhase = phaseData.phase;
@@ -149,7 +196,7 @@ function checkPhaseReadGate(toolName, config) {
149
196
  }
150
197
 
151
198
  // Check if that file has been read
152
- const readData = safeJsonParse(PHASE_READS_FILE, {});
199
+ const readData = safeJsonParse(phaseReadsPath(stateDir), {});
153
200
  const reads = readData.reads || {};
154
201
 
155
202
  if (reads[currentPhase]) {
@@ -182,7 +229,9 @@ function checkPhaseReadGate(toolName, config) {
182
229
  */
183
230
  function clearPhaseReads() {
184
231
  try {
185
- fs.writeFileSync(PHASE_READS_FILE, JSON.stringify({ reads: {} }, null, 2));
232
+ const readsFile = phaseReadsPath();
233
+ fs.mkdirSync(path.dirname(readsFile), { recursive: true });
234
+ fs.writeFileSync(readsFile, JSON.stringify({ reads: {} }, null, 2));
186
235
  } catch (_err) {
187
236
  if (process.env.DEBUG) {
188
237
  console.error(`[PhaseReadGate] Failed to clear phase reads: ${_err.message}`);
@@ -194,6 +243,9 @@ module.exports = {
194
243
  recordPhaseRead,
195
244
  checkPhaseReadGate,
196
245
  clearPhaseReads,
246
+ hasCanonicalInProgressTask,
247
+ workflowPhasePath,
248
+ phaseReadsPath,
197
249
  PHASE_FILE_REGISTRY,
198
250
  PHASE_READS_FILE
199
251
  };