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.
- package/.workflow/templates/partials/methodology-rules.hbs +3 -1
- package/lib/workspace-channel-server.js +10 -0
- package/package.json +1 -1
- package/scripts/flow-io.js +17 -0
- package/scripts/flow-paths.js +81 -0
- package/scripts/flow-utils.js +2 -0
- package/scripts/hooks/core/long-input-enforcement.js +49 -39
- package/scripts/hooks/core/phase-gate.js +34 -5
- package/scripts/hooks/core/phase-read-gate.js +62 -10
- package/scripts/hooks/core/worker-continuation-gate.js +169 -8
- package/.claude/rules/README.md +0 -36
- package/.claude/rules/_internal/README.md +0 -64
- package/.claude/rules/_internal/document-structure.md +0 -77
- package/.claude/rules/_internal/dual-repo-management.md +0 -174
- package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
- package/.claude/rules/_internal/github-releases.md +0 -71
- package/.claude/rules/_internal/model-management.md +0 -35
- package/.claude/rules/_internal/self-maintenance.md +0 -87
- package/.claude/rules/_internal/worker-tool-first-turn.md +0 -82
- package/.claude/rules/alternative-execpolicy-toml-command-policy.md +0 -11
- package/.claude/rules/alternative-hand-edit-ready-json-to-register-orpha.md +0 -11
- package/.claude/rules/alternative-hook-args-exec-form.md +0 -6
- package/.claude/rules/alternative-permission-ruleset-per-phase.md +0 -11
- package/.claude/rules/alternative-short-name.md +0 -12
- package/.claude/rules/alternative-wogi-flow-as-mcp-client-oauth-manager.md +0 -11
- package/.claude/rules/architecture/component-reuse.md +0 -38
- package/.claude/rules/architecture/hook-three-layer.md +0 -68
- package/.claude/rules/code-style/naming-conventions.md +0 -107
- package/.claude/rules/dual-repo-architecture-2026-02-28.md +0 -18
- package/.claude/rules/github-release-workflow-2026-01-30.md +0 -16
- package/.claude/rules/operations/git-workflows.md +0 -92
- package/.claude/rules/operations/scratch-directory.md +0 -54
- package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
- package/.workflow/specs/architecture.md.template +0 -24
- package/.workflow/specs/stack.md.template +0 -33
- 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
|
|
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
package/scripts/flow-io.js
CHANGED
|
@@ -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 });
|
package/scripts/flow-paths.js
CHANGED
|
@@ -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,
|
package/scripts/flow-utils.js
CHANGED
|
@@ -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
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
|
262
|
-
|
|
263
|
-
if (
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
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,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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(
|
|
339
|
+
body.push('🚨 P11.5 ENFORCEMENT — long-form prompt without source-link');
|
|
320
340
|
body.push('');
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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,
|
|
17
|
+
const { getConfig, safeJsonParse, getCanonicalStateDir, isLinkedWorktree } = require('../../flow-utils');
|
|
18
18
|
|
|
19
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
133
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
};
|