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.
- package/.workflow/templates/partials/methodology-rules.hbs +3 -1
- package/lib/scheduled-mode.js +12 -15
- package/lib/skill-export-claude-plugin.js +41 -1
- package/lib/skill-portability.js +21 -3
- package/lib/workspace-channel-server.js +116 -3
- package/lib/workspace-channel-tracking.js +102 -1
- package/lib/workspace-dispatch-tracking.js +28 -0
- package/lib/workspace-messages.js +32 -4
- package/lib/workspace-subtask-state.js +215 -0
- package/lib/workspace.js +81 -0
- package/package.json +2 -2
- package/scripts/flow +17 -0
- package/scripts/flow-constants.js +3 -1
- package/scripts/flow-io.js +17 -0
- package/scripts/flow-paths.js +81 -0
- package/scripts/flow-schedule.js +23 -6
- package/scripts/flow-scheduled-runner.js +53 -8
- package/scripts/flow-standards-checker.js +37 -0
- package/scripts/flow-utils.js +2 -0
- package/scripts/hooks/adapters/claude-code.js +6 -2
- package/scripts/hooks/core/git-safety-gate.js +34 -15
- package/scripts/hooks/core/long-input-enforcement.js +49 -39
- package/scripts/hooks/core/overdue-dispatches.js +28 -6
- package/scripts/hooks/core/phase-gate.js +34 -5
- package/scripts/hooks/core/phase-read-gate.js +62 -10
- package/scripts/hooks/core/session-start-worker.js +52 -0
- package/scripts/hooks/core/stop-orchestrator.js +17 -2
- package/scripts/hooks/core/validation.js +8 -0
- package/scripts/hooks/core/worker-continuation-gate.js +487 -0
- package/scripts/hooks/core/workspace-stop-gates.js +21 -0
- package/scripts/hooks/core/workspace-stop-notify.js +174 -59
- package/scripts/hooks/entry/claude-code/post-tool-use.js +26 -0
- 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
|
@@ -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]
|
|
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
|
-
|
|
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
|
|
242
|
+
`### stderr (last ${MAX_STDERR_BYTES} bytes, secrets redacted)`,
|
|
210
243
|
'```',
|
|
211
|
-
(stderr || '').slice(-
|
|
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
|
|
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
|
-
//
|
|
421
|
-
|
|
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
|
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,
|
|
@@ -191,7 +191,11 @@ class ClaudeCodeAdapter extends BaseAdapter {
|
|
|
191
191
|
case 'PostToolUse':
|
|
192
192
|
return this.transformPostToolUse(coreResult);
|
|
193
193
|
case 'Stop':
|
|
194
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
142
|
+
const { execFileSync } = require('node:child_process');
|
|
133
143
|
try {
|
|
134
|
-
const branches =
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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] -
|
|
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').
|
|
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
|
|
209
|
+
const nonce = require('node:crypto').randomBytes(6).toString('hex');
|
|
210
|
+
const stashMessage = `auto-backup-${timestamp}-${nonce}`;
|
|
192
211
|
try {
|
|
193
|
-
exec(
|
|
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
|
|
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
|
|
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,
|
|
@@ -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
|
-
//
|
|
82
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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,
|
|
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
|
|