wogiflow 2.29.4 → 2.29.6

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.
@@ -213,3 +213,77 @@ The user can dump N items, say "go until you finish" / "autonomous mode" / "run
213
213
  **Exit conditions**: ready queue drains, user types "stop"/"pause", or fatal error. On exit, render the completion summary (terminal block + JSON payload at `.workflow/state/autonomous-run-summary-<runId>.json`) and clear the flag.
214
214
 
215
215
  Enforced by: `flow-autonomous-detector.js`, `flow-question-queue.js`, `flow-decision-authority.js` (autonomous param + `queue-for-review` + `adversary-loop` buckets), `flow-completion-summary.js`, and the SessionStart context injection in `scripts/hooks/core/session-context.js`.
216
+
217
+ ---
218
+
219
+ ### Mechanical Deferral Authorization Gate (wf-f9912af6)
220
+
221
+ The textual "Review-Findings Anti-Deferral" rule above (incident-driven 2026-04-15) is enforced mechanically by the deferral gate. The AI cannot silently mark review/audit findings as `status: deferred*` without explicit user authorization — the PreToolUse hook intercepts every Write/Edit/Bash that targets `.workflow/state/last-review.json` or `.workflow/state/last-audit.json` and BLOCKS the write when:
222
+
223
+ 1. The new content introduces one or more findings whose `status` matches `/^deferred(?:[-_].*)?$|^wont-?fix$|^skipped$/i`, AND
224
+ 2. No valid authorization marker exists at `.workflow/state/deferral-authorization.json`, AND
225
+ 3. The `no-defer-pin.json` is not active (a pin overrides any auth — set when the user says "fix everything" / "no deferrals" / "I don't want tech debt").
226
+
227
+ **Authorization sources** (one of):
228
+
229
+ - **User-prompt classifier** (`scripts/hooks/core/deferral-classifier.js`): regex-detects explicit defer phrases in UserPromptSubmit messages — "defer X", "fix critical only", "ship as-is", "option 2"/"option 4" from the /wogi-review menu, etc. Writes auth marker with TTL 10 min by default.
230
+ - **Explicit CLI**: `node scripts/flow-defer-auth.js grant --scope=all --reason="<verbatim user phrase>"` (or `--findings=F5,F6,...`). Used when the AI needs to record explicit authorization (e.g., user picked option 4).
231
+
232
+ **Negative intent overrides positive**: phrases like "fix everything", "no deferrals", "don't defer", "I don't want tech debt" delete any existing auth and write a `no-defer-pin.json` that hard-blocks deferrals for ~30 minutes.
233
+
234
+ **Bash-mutating commands** that write to the target files AND mention `deferred|wont-fix|skipped|dismissed` are blocked when no auth is active — this catches `node -e "fs.writeFileSync('.workflow/state/last-review.json', ...)"` patterns that bypass Write/Edit. Reads (`cat`/`jq`/`grep`) are not blocked.
235
+
236
+ **Audit trail**: every blocked attempt logs to `.workflow/state/deferral-block-log.json` (last 100 entries) for telemetry.
237
+
238
+ **Why mechanical enforcement matters:** the textual rule has been violated multiple times in incidents — the AI decides "low risk / can wait / pre-existing" and writes `status: deferred` to last-review.json based on its own judgment. The gate makes this structurally impossible without the user's word.
239
+
240
+ **Anti-rationalization** (if any of these thoughts cross your mind, you are about to violate the gate):
241
+ - *"This finding is pre-existing, not introduced by my changes"* → WRONG. Pre-existing is a reason to fix it now (continuous improvement) or to surface it to the user with an explicit "ship / fix / defer" question, not to silently `status: deferred-pre-existing`.
242
+ - *"This is LOW severity, the user won't care"* → WRONG. Severity is the user's call, not yours.
243
+ - *"The adversary already verified it's not a real bug"* → WRONG. If it's not a bug, mark it `dismissed-not-a-bug` only AFTER the user confirms; otherwise leave it `open`.
244
+ - *"I'll batch deferrals into the next review cycle"* → WRONG. There is no "next cycle" — the user reads the findings now.
245
+
246
+ Config: `deferralGate.{enabled,authTtlSeconds,classifyUserPrompts}` in `.workflow/config.json` (defaults: true / 600 / true).
247
+
248
+ Enforced by: `scripts/hooks/core/deferral-gate.js` (core), `scripts/hooks/core/deferral-classifier.js` (intent detection), `scripts/flow-defer-auth.js` (CLI), wired into `scripts/hooks/core/pre-tool-orchestrator.js` (PreToolUse) and `scripts/hooks/entry/claude-code/user-prompt-submit.js` (UserPromptSubmit).
249
+
250
+ ---
251
+
252
+ ### Mechanical Research-Required Gate (wf-5cd71b1f)
253
+
254
+ The textual rules in CLAUDE.md ("Research Before Propose," Tier 2/3 routing protocol) say the AI must read evidence before answering diagnostic questions. The research-required gate makes this mechanical: it intercepts diagnostic prompts at UserPromptSubmit and re-prompts the AI at Stop hook if the assistant turn produced text without enough Read calls against evidence paths.
255
+
256
+ **How it works**:
257
+
258
+ 1. **UserPromptSubmit classifier** (`scripts/hooks/core/research-required-classifier.js`): regex-classifies each prompt into `command` / `factual` / `diagnostic` / `none`.
259
+ - `command` — task IDs, action imperatives ("add X"), follow-ups ("yes", "continue", "option N"), AI's own slash commands
260
+ - `factual` — Tier 1 markers ("what is", "where is", "show me", "list all")
261
+ - `diagnostic` — Tier 2/3 markers ("why", "should I", "what do you think", "is this correct", "explain why", "did you fix")
262
+ - On `diagnostic`: writes `.workflow/state/research-required-this-turn.json` with `{requiredEvidence: 2, attemptCount: 0, classifiedAt}`.
263
+
264
+ 2. **Override**: prompt prefix `!` skips the gate entirely. For when the user knows their question is conversational and doesn't need evidence reading.
265
+
266
+ 3. **Stop-hook gate** (`scripts/hooks/core/research-required-gate.js`): if marker exists, parses the JSONL transcript for the current turn (since the most recent user entry), counts:
267
+ - `Read` tool calls where `file_path` matches an evidence prefix
268
+ - `Bash` tool calls where the command starts with `cat|head|tail|grep|rg|jq|less|view|awk|sed` and targets an evidence-prefix path
269
+ - `Glob`/`Grep` tool calls (any pattern counts)
270
+
271
+ 4. **If count < requiredEvidence**:
272
+ - Increments `attemptCount` in the marker
273
+ - Returns `{continue: true, stopReason: <violation message>}` — Claude Code re-prompts the AI with the message; the AI must redo the turn with reads
274
+ - After `maxAttempts` (default 3): returns `{continue: false, stopReason: <hard-stop message>}` — visible to the user, marker cleared
275
+
276
+ 5. **If count ≥ requiredEvidence**: marker is consumed (deleted), Stop proceeds normally.
277
+
278
+ **Evidence prefixes** (shared with `research-evidence-gate.js`): `.workflow/state/`, `.workflow/changes/`, `.workflow/specs/`, `.workflow/epics/`, `lib/`, `scripts/`, `src/`, `tests/`, `app/`. Reading code in answer to "why does X happen" is the legitimate path.
279
+
280
+ **Why mechanical enforcement matters**: the textual Tier 2/3 protocol relies on the AI self-classifying its own question's complexity, which is the rubber-stamp pattern. The gate uses structural markers + Stop-hook redo loop — same proven architecture as `worker-tool-first-gate.js` G1/G4. The AI cannot bypass: UserPromptSubmit fires on every user message, Stop fires on every assistant turn end, and `{continue: true, stopReason}` is honored by Claude Code as a forced redo.
281
+
282
+ **Anti-rationalization**:
283
+ - *"I already know the answer from context"* → WRONG. Confidence is not evidence. The gate fires on the question's structure, not your perceived certainty.
284
+ - *"This question is conversational, doesn't need code reading"* → WRONG. If you genuinely believe that, the user can prefix `!` next time. Within a turn, the gate is final.
285
+ - *"I'll cite the evidence in my next answer instead of reading it now"* → WRONG. Citations require reads in the same turn. The transcript proves it.
286
+
287
+ Config: `researchRequiredGate.{enabled,requiredEvidence,maxAttempts}` in `.workflow/config.json` (defaults: true / 2 / 3). The override prefix `!` is hard-coded.
288
+
289
+ Enforced by: `scripts/hooks/core/research-required-classifier.js` (UserPromptSubmit), `scripts/hooks/core/research-required-gate.js` (Stop), wired into `scripts/hooks/entry/claude-code/user-prompt-submit.js` and `scripts/hooks/entry/claude-code/stop.js`.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # WogiFlow
2
2
 
3
- A self-improving AI development workflow that learns from your feedback. Currently supports **Claude Code 2.1.33+**.
3
+ A self-improving AI development workflow that learns from your feedback. Currently supports **Claude Code 2.1.33+**. Claude Code **2.1.121+** is recommended for native MCP startup retries (transient channel-server failures auto-recover), Bash resilience to deleted CWDs (worktree cleanup is safe mid-session), and `Always allow` permission persistence across worker restarts.
4
4
 
5
5
  ```bash
6
6
  npm install -D wogiflow
package/lib/wogi-claude CHANGED
@@ -21,6 +21,12 @@
21
21
  # WOGI_MAX_RESTARTS — safety cap, default 50 (prevents runaway restart storms)
22
22
  # WOGI_WRAPPER_PID — exported to child; hook checks this to confirm wrapper is present
23
23
  # WOGI_CLAUDE_BIN — override path to claude binary (default: found via PATH)
24
+ # WOGI_BASH_BIN — (wf-ee4e343b cleanup) override the bash binary used
25
+ # by the PID-alignment subshell trick. Defaults to
26
+ # `bash` on PATH. Useful on minimal containers
27
+ # (Alpine, distroless) where bash lives at a
28
+ # non-standard path or where the shell wrapping the
29
+ # claude CLI is not bash-by-default.
24
30
  # WOGI_USE_EXPECT — (EXPERIMENTAL, v2.22.4+) set to 1 to opt IN to the
25
31
  # expect-based auto-dismiss of the "Loading development
26
32
  # channels" dialog. OFF BY DEFAULT because Ink's
@@ -99,7 +105,7 @@ if [ "$__wogi_is_worker" -eq 1 ]; then
99
105
  try {
100
106
  const cfg = require(process.cwd() + "/.workflow/config.json");
101
107
  process.stdout.write(String(!!(cfg.workspace && cfg.workspace.inheritClaudeAiMcpIntegrations)));
102
- } catch (_e) { process.stdout.write("false"); }
108
+ } catch (_err) { process.stdout.write("false"); }
103
109
  ' 2>/dev/null)"
104
110
  if [ "$__wogi_config_inherit" = "true" ]; then
105
111
  __wogi_strip_mcp=0
@@ -205,7 +211,7 @@ if [ "$__wogi_strip_mcp" -eq 1 ]; then
205
211
  const ws = cfg && cfg.mcpServers && cfg.mcpServers["wogi-workspace-channel"];
206
212
  if (ws) channelEntry = ws;
207
213
  }
208
- } catch (_e) {}
214
+ } catch (_err) {}
209
215
  const payload = channelEntry
210
216
  ? { mcpServers: { "wogi-workspace-channel": channelEntry } }
211
217
  : { mcpServers: {} };
@@ -284,12 +290,37 @@ __wogi_build_argv() {
284
290
 
285
291
  # run_claude — invoke claude, routing through expect when we can auto-dismiss
286
292
  # the dev-channels dialog. Preserves stdin/stdout/stderr exactly.
293
+ #
294
+ # wf-ee4e343b: PID-alignment via bash-c-exec trick. The Stop hook's SEC-006
295
+ # check (task-boundary-reset.js:200-206) requires WOGI_WRAPPER_PID === process.ppid
296
+ # in any hook running under claude. Plain `"$CLAUDE_BIN" ...` without `exec`
297
+ # causes bash to fork: claude gets a NEW PID that does not match $$ (this bash
298
+ # wrapper's PID). The check then fails silently, breaking auto-restart for
299
+ # everyone since 2026-04-26.
300
+ #
301
+ # Fix: spawn claude through `bash -c '...'` which forks a fresh bash with its
302
+ # OWN $$, sets WOGI_WRAPPER_PID to that $$, then `exec` replaces the new bash
303
+ # with claude — preserving the same PID. Result: claude's PID equals the
304
+ # WOGI_WRAPPER_PID it inherits, and process.ppid in any hook child of claude
305
+ # equals that same value. The strict-equality SEC-006 check now holds.
306
+ #
307
+ # Why `bash -c` and not a `( ... )` subshell: in bash 3.x (macOS system bash),
308
+ # `$$` inside a `( ... )` subshell returns the OUTER shell's PID, not the
309
+ # subshell's own PID. Bash 4+ adds $BASHPID for that purpose, but we cannot
310
+ # rely on bash 4+ being installed. `bash -c` always returns its own PID via
311
+ # `$$`, regardless of version.
312
+ #
313
+ # Bash -c argv form: `bash -c COMMAND COMMAND_NAME ARG1 ARG2 ...` — COMMAND_NAME
314
+ # becomes $0 inside the script and ARG1..N become $1..$N, so `exec "$0" "$@"`
315
+ # invokes claude with all original args without quoting hazards.
316
+ #
317
+ # For expect mode, the same alignment is performed inside wogi-claude-expect.exp.
287
318
  run_claude() {
288
319
  __wogi_build_argv "$@"
289
320
  if [ "$__wogi_use_expect" -eq 1 ]; then
290
321
  expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
291
322
  else
292
- "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
323
+ "${WOGI_BASH_BIN:-bash}" -c 'export WOGI_WRAPPER_PID=$$; exec "$0" "$@"' "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
293
324
  fi
294
325
  }
295
326
 
@@ -90,12 +90,37 @@ set claude_bin [lindex $argv 0]
90
90
  set claude_args [lrange $argv 1 end]
91
91
 
92
92
  # Spawn claude in a pseudo-TTY so its Ink UI renders normally.
93
- # Use {*} list-splice rather than `eval spawn` — `eval` reparses its
94
- # arguments as Tcl script, which lets an argument containing bracket syntax
95
- # (e.g. `[exec attacker-cmd]`) escape to command execution. The splice form
96
- # expands the list without reparsing.
93
+ #
94
+ # wf-ee4e343b PID-alignment: spawn claude through `bash -c` so we can
95
+ # re-export WOGI_WRAPPER_PID=$$ (the subshell's PID) before exec this
96
+ # makes claude inherit a WOGI_WRAPPER_PID equal to its own PID, satisfying
97
+ # the SEC-006 strict-equality check in task-boundary-reset.js. Without this,
98
+ # expect's spawn gives claude a PID different from WOGI_WRAPPER_PID and the
99
+ # Stop-hook restart trigger silently fails.
100
+ #
101
+ # The bash -c form `bash -c COMMAND COMMAND_NAME ARG1 ARG2 ...` makes
102
+ # COMMAND_NAME = $0 and the remaining args = $1..$N, so we use `exec "$0" "$@"`
103
+ # to invoke claude with all original args — no quoting hazards.
97
104
  _wogi_boot_mark "before spawn"
98
- spawn $claude_bin {*}$claude_args
105
+
106
+ # F4 fix (wf-ee4e343b cleanup): defensive list construction. `lrange $argv 1
107
+ # end` already returns a clean Tcl list, and `{*}$claude_args` splices it
108
+ # without re-parsing element contents — so brace-containing args are
109
+ # preserved as single elements. The Sonnet review flagged this as a quoting
110
+ # hazard; the Opus adversary verified it's safe. We rebuild the list
111
+ # explicitly via [list ...] anyway for defense-in-depth and to make the
112
+ # safety property obvious to future readers (no implicit dependency on
113
+ # lrange's return contract).
114
+ set claude_args_safe [list]
115
+ foreach _arg $claude_args { lappend claude_args_safe $_arg }
116
+
117
+ # Honor WOGI_BASH_BIN (wf-ee4e343b cleanup) for non-standard shell layouts.
118
+ set _wogi_bash_bin "bash"
119
+ if {[info exists env(WOGI_BASH_BIN)] && $env(WOGI_BASH_BIN) ne ""} {
120
+ set _wogi_bash_bin $env(WOGI_BASH_BIN)
121
+ }
122
+
123
+ spawn $_wogi_bash_bin -c "export WOGI_WRAPPER_PID=\$\$; exec \"\$0\" \"\$@\"" $claude_bin {*}$claude_args_safe
99
124
  _wogi_boot_mark "after spawn (pid=$spawn_id)"
100
125
 
101
126
  # ============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.29.4",
3
+ "version": "2.29.6",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "flow": "./scripts/flow",
13
- "test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/workspace-ipc-sqlite.test.js tests/workspace-ipc-multi-worker.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.test.js tests/flow-wave1-integrations.test.js tests/flow-wave2-integrations.test.js tests/flow-wave3-integrations.test.js tests/flow-commit-claims-gate.test.js tests/auto-review.test.js tests/gate-telemetry-surface.test.js tests/agents-md-alias.test.js tests/flow-skill-manage.test.js tests/fuzzy-patch.test.js tests/mode-schema.test.js tests/flow-feature-dossier.test.js tests/flow-autonomous-mode.test.js tests/flow-epic-cascade.test.js tests/flow-workspace-summary.test.js tests/flow-hooks-research-evidence-gate.test.js tests/flow-worker-mcp-strip.test.js tests/flow-orchestrate-corrections.test.js tests/flow-source-fidelity.test.js tests/flow-hooks-long-input-enforcement.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
13
+ "test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/workspace-ipc-sqlite.test.js tests/workspace-ipc-multi-worker.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.test.js tests/flow-wave1-integrations.test.js tests/flow-wave2-integrations.test.js tests/flow-wave3-integrations.test.js tests/flow-commit-claims-gate.test.js tests/auto-review.test.js tests/gate-telemetry-surface.test.js tests/agents-md-alias.test.js tests/flow-skill-manage.test.js tests/fuzzy-patch.test.js tests/mode-schema.test.js tests/flow-feature-dossier.test.js tests/flow-autonomous-mode.test.js tests/flow-epic-cascade.test.js tests/flow-workspace-summary.test.js tests/flow-hooks-research-evidence-gate.test.js tests/flow-worker-mcp-strip.test.js tests/flow-orchestrate-corrections.test.js tests/flow-source-fidelity.test.js tests/flow-hooks-long-input-enforcement.test.js tests/workspace-channel-tracking.test.js tests/flow-hooks-deletion-log.test.js tests/flow-task-boundary-reset.test.js tests/flow-deferral-gate.test.js tests/flow-research-required-gate.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
14
14
  "test:syntax": "find scripts/ lib/ -name '*.js' -not -path '*/node_modules/*' -exec node --check {} +",
15
15
  "lint": "eslint scripts/ lib/ tests/",
16
16
  "lint:ci": "eslint scripts/ lib/ tests/ --max-warnings 0",
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow — Deferral Authorization CLI (wf-f9912af6)
5
+ *
6
+ * Explicit user-authorization helper for the deferral gate. Used when the AI
7
+ * needs to record that the user picked a defer-style menu option in
8
+ * /wogi-review (e.g., "Create tasks for all - fix later in batches").
9
+ *
10
+ * Usage:
11
+ * flow defer-auth grant --scope=all --reason="<verbatim user phrase>"
12
+ * flow defer-auth grant --findings=F5,F6,F7 --reason="..."
13
+ * flow defer-auth clear
14
+ * flow defer-auth status
15
+ */
16
+
17
+ const path = require('node:path');
18
+ const gate = require('./hooks/core/deferral-gate');
19
+
20
+ function parseArgs(argv) {
21
+ const args = {};
22
+ for (const a of argv) {
23
+ if (a.startsWith('--')) {
24
+ const eq = a.indexOf('=');
25
+ if (eq === -1) {
26
+ args[a.slice(2)] = true;
27
+ } else {
28
+ args[a.slice(2, eq)] = a.slice(eq + 1);
29
+ }
30
+ }
31
+ }
32
+ return args;
33
+ }
34
+
35
+ function cmdGrant(args) {
36
+ let scope = 'all';
37
+ if (args.findings) {
38
+ scope = String(args.findings)
39
+ .split(',')
40
+ .map(s => s.trim())
41
+ .filter(Boolean);
42
+ if (scope.length === 0) {
43
+ console.error('grant: --findings must be a non-empty comma-separated list');
44
+ process.exit(2);
45
+ }
46
+ } else if (args.scope === 'all' || args.scope === undefined) {
47
+ scope = 'all';
48
+ } else {
49
+ scope = String(args.scope);
50
+ }
51
+ const reason = args.reason ? String(args.reason) : 'cli-grant';
52
+ const ttlSec = args['ttl-sec'] ? parseInt(args['ttl-sec'], 10) : undefined;
53
+
54
+ const payload = gate.writeAuth({
55
+ scope,
56
+ source: reason,
57
+ grantedBy: 'explicit-cli',
58
+ ttlSec
59
+ });
60
+
61
+ if (!payload) {
62
+ console.error('grant: failed to write authorization marker');
63
+ process.exit(1);
64
+ }
65
+ console.log(JSON.stringify({ status: 'granted', ...payload }, null, 2));
66
+ }
67
+
68
+ function cmdClear() {
69
+ gate.clearAuth();
70
+ gate.clearNoDeferPin();
71
+ console.log(JSON.stringify({ status: 'cleared' }, null, 2));
72
+ }
73
+
74
+ function cmdStatus() {
75
+ const auth = gate.loadAuth();
76
+ const pin = gate.loadNoDeferPin();
77
+ console.log(JSON.stringify({
78
+ authorization: auth || null,
79
+ noDeferPin: pin || null,
80
+ authPath: gate.getAuthPath(),
81
+ pinPath: gate.getNoDeferPinPath()
82
+ }, null, 2));
83
+ }
84
+
85
+ function usage() {
86
+ console.log('Usage: flow defer-auth <grant|clear|status> [--scope=all|<id>] [--findings=F1,F2] [--reason="..."] [--ttl-sec=600]');
87
+ process.exit(2);
88
+ }
89
+
90
+ function main() {
91
+ const [, , subcommand, ...rest] = process.argv;
92
+ const args = parseArgs(rest);
93
+ switch (subcommand) {
94
+ case 'grant': return cmdGrant(args);
95
+ case 'clear': return cmdClear();
96
+ case 'status': return cmdStatus();
97
+ default: return usage();
98
+ }
99
+ }
100
+
101
+ if (require.main === module) main();
102
+
103
+ module.exports = { parseArgs, cmdGrant, cmdClear, cmdStatus };
@@ -336,6 +336,7 @@ function saveReadyData(data) {
336
336
  const toSave = { ...data, lastUpdated: new Date().toISOString() };
337
337
  const result = writeJson(PATHS.ready, toSave);
338
338
  invalidateReadyDataCache(); // Invalidate AFTER write completes to avoid stale cache race
339
+ maybeArmTaskBoundaryRestart(previousData, toSave);
339
340
  return result;
340
341
  }
341
342
 
@@ -359,10 +360,61 @@ async function saveReadyDataAsync(data) {
359
360
  const toSave = { ...data, lastUpdated: new Date().toISOString() };
360
361
  const result = writeJson(PATHS.ready, toSave);
361
362
  invalidateReadyDataCache(); // Invalidate AFTER write completes
363
+ maybeArmTaskBoundaryRestart(previousData, toSave);
362
364
  return result;
363
365
  });
364
366
  }
365
367
 
368
+ /**
369
+ * wf-ee4e343b — Phase 1 chokepoint for task-boundary auto-restart.
370
+ *
371
+ * Why this exists: Phase 1 marker writes were previously split across three
372
+ * disjoint paths (`flow done`, `task-completed.js` hook, Stop-hook fallback
373
+ * with a 5-min freshness window). The Stop-hook fallback misses real-world
374
+ * timing (user takes >5min to type next message → fallback rejects) and the
375
+ * other two paths are not always called. By detecting "new entry in
376
+ * recentlyCompleted" right here in saveReadyData — the actual chokepoint
377
+ * every completion goes through — we arm the marker at the moment of
378
+ * completion regardless of who completed the task.
379
+ *
380
+ * Gated on WOGI_WRAPPER_PID so test/CLI/non-wrapper invocations don't
381
+ * write spurious markers. Lazy-required to avoid circular dependency
382
+ * (task-boundary-reset.js → flow-utils.js).
383
+ */
384
+ function maybeArmTaskBoundaryRestart(previousData, savedData) {
385
+ try {
386
+ if (!process.env.WOGI_WRAPPER_PID) return;
387
+ // First-save guard (F2): when ready.json doesn't yet exist, previousData
388
+ // is null. If savedData arrives pre-populated (fresh install seeded from
389
+ // backup, init script bootstrapping recentlyCompleted, etc.) we MUST NOT
390
+ // arm a restart marker — there's no completion event, just an initial
391
+ // state snapshot. Real completions always have a previousData to diff
392
+ // against because saveReadyData is the only writer.
393
+ if (!previousData) return;
394
+ // F7 fix (wf-ee4e343b cleanup): F2 was asymmetric — readJson returns {}
395
+ // (truthy) on corrupt JSON or missing top-level keys, so the !previousData
396
+ // guard would not catch that case. A corrupt ready.json that recovers as
397
+ // {} followed by a save with populated recentlyCompleted would still
398
+ // false-positive. Require previousData.recentlyCompleted to be an actual
399
+ // array for the diff to be meaningful — anything else is "we don't know
400
+ // the prior state," which is structurally identical to first-save and
401
+ // must NOT arm.
402
+ if (!Array.isArray(previousData.recentlyCompleted)) return;
403
+ const prevTop = previousData.recentlyCompleted[0];
404
+ const curTop = savedData?.recentlyCompleted?.[0];
405
+ if (!curTop || !curTop.id) return;
406
+ if (prevTop && prevTop.id === curTop.id) return; // no new completion
407
+ const { markRestartPending } = require('./hooks/core/task-boundary-reset');
408
+ markRestartPending({
409
+ taskId: curTop.id,
410
+ taskTitle: curTop.title,
411
+ source: 'saveReadyData'
412
+ });
413
+ } catch (_err) {
414
+ // Fail-open — never let an observability/marker write break ready.json save
415
+ }
416
+ }
417
+
366
418
  /**
367
419
  * Archive overflow completed tasks to a log file (v3.2)
368
420
  * When recentlyCompleted exceeds 10 items, archive the overflow
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow — Deferral Intent Classifier (wf-f9912af6)
5
+ *
6
+ * Regex-based detector for explicit user deferral intent in UserPromptSubmit
7
+ * messages. Cheap (no Haiku call), deterministic, runs every prompt.
8
+ *
9
+ * NEGATIVE intent takes precedence over POSITIVE — if the user says both
10
+ * "fix everything" and "skip Y" in the same message, we assume they want
11
+ * everything fixed (the defer-everything pattern is the dangerous one this
12
+ * gate exists to stop).
13
+ *
14
+ * Negative match → write `no-defer-pin.json` (HARD block, overrides any auth)
15
+ * Positive match → write `deferral-authorization.json` (allows specific scope)
16
+ * Neither → no-op
17
+ *
18
+ * Fail-open: any error in classification falls through silently.
19
+ */
20
+
21
+ // Negative phrases (HIGH PRIORITY — clear auth, write no-defer pin)
22
+ const NEGATIVE_PATTERNS = [
23
+ /\bfix\s+(everything|all\s+of\s+(them|it)|all\s+findings?)\b/i,
24
+ /\bno\s+deferr?als?\b/i,
25
+ /\b(don'?t|do\s+not)\s+defer\b/i,
26
+ /\bi\s+don'?t\s+(want|like)\s+(tech\s*-?\s*debt|technical\s*-?\s*debt|deferr?al)/i,
27
+ /\bnever\s+defer\b/i,
28
+ /\balways\s+fix\s+(what'?s\s+broken|what\s+needs?\s+fixing)/i,
29
+ /\bnothing\s+(should\s+be|gets)\s+deferr?ed\b/i,
30
+ ];
31
+
32
+ // Positive phrases (MEDIUM PRIORITY — write auth marker)
33
+ // We're conservative: require defer/skip phrasing to be coupled with finding
34
+ // context (this/that/those/it/option N/F\d+/severity word) to avoid catching
35
+ // unrelated mentions like "let's defer the meeting".
36
+ const POSITIVE_PATTERNS = [
37
+ // "defer X" / "skip X" with a referent
38
+ /\b(defer|skip|ignore|drop)\s+(this|that|those|it|them|f\d+|finding\s+\w+)\b/i,
39
+ /\bleave\s+(this|that|those|f\d+|.*?)\s+(for\s+)?later\b/i,
40
+
41
+ // /wogi-review menu options that mean defer
42
+ /\boption\s*[24]\b/i, // option 2 = "fix critical only"; option 4 = "create tasks for all (defer)"
43
+ /\bcreate\s+tasks?\s+for\s+(all|the\s+rest|remaining)\b/i,
44
+
45
+ // Severity-scoped deferrals
46
+ /\bfix\s+(only\s+)?(critical|high)\s*(\s*\/\s*high)?\s+only\b/i,
47
+ /\bfix\s+(critical|high)\s+(only|first)\b/i,
48
+ /\bskip\s+(low|medium|low\s*\/\s*medium)\b/i,
49
+
50
+ // Ship-as-is style
51
+ /\bship\s+(it\s+)?as\s*-?\s*is\b/i,
52
+ /\bgood\s+enough\s+(as\s*-?\s*is|for\s+now)\b/i,
53
+ /\bcall\s+it\s+(done|good)\b/i,
54
+ ];
55
+
56
+ /**
57
+ * Classify a user prompt for deferral intent.
58
+ *
59
+ * @param {string} prompt - the user's UserPromptSubmit text
60
+ * @returns {{ intent: 'negative'|'positive'|'none', match?: string, scope?: string|string[] }}
61
+ */
62
+ function classifyDeferralIntent(prompt) {
63
+ if (!prompt || typeof prompt !== 'string') return { intent: 'none' };
64
+
65
+ // Negative first — overrides positive
66
+ for (const rx of NEGATIVE_PATTERNS) {
67
+ const m = prompt.match(rx);
68
+ if (m) return { intent: 'negative', match: m[0] };
69
+ }
70
+
71
+ // Positive
72
+ for (const rx of POSITIVE_PATTERNS) {
73
+ const m = prompt.match(rx);
74
+ if (m) {
75
+ // Try to extract scope — look for F\d+ ids in the prompt
76
+ const findingIds = Array.from(prompt.matchAll(/\bF\d+\b/g)).map(x => x[0]);
77
+ return {
78
+ intent: 'positive',
79
+ match: m[0],
80
+ scope: findingIds.length > 0 ? findingIds : 'all'
81
+ };
82
+ }
83
+ }
84
+
85
+ return { intent: 'none' };
86
+ }
87
+
88
+ /**
89
+ * Apply classification result to the gate's state files. Wired into
90
+ * UserPromptSubmit. Fail-open throughout.
91
+ */
92
+ function applyClassification(prompt, config) {
93
+ try {
94
+ if (config?.deferralGate?.classifyUserPrompts === false) return { applied: false, reason: 'classifier-disabled' };
95
+
96
+ const result = classifyDeferralIntent(prompt);
97
+ if (result.intent === 'none') return { applied: false, reason: 'no-match' };
98
+
99
+ // Lazy-require to avoid load-order coupling
100
+ const gate = require('./deferral-gate');
101
+
102
+ if (result.intent === 'negative') {
103
+ gate.writeNoDeferPin({ source: result.match });
104
+ return { applied: true, intent: 'negative', match: result.match };
105
+ }
106
+
107
+ if (result.intent === 'positive') {
108
+ gate.writeAuth({
109
+ scope: result.scope,
110
+ source: result.match,
111
+ grantedBy: 'user-prompt',
112
+ config
113
+ });
114
+ return { applied: true, intent: 'positive', match: result.match, scope: result.scope };
115
+ }
116
+
117
+ return { applied: false, reason: 'unhandled-intent' };
118
+ } catch (err) {
119
+ if (process.env.DEBUG) console.error(`[deferral-classifier] applyClassification error (fail-open): ${err.message}`);
120
+ return { applied: false, reason: `error: ${err.message}` };
121
+ }
122
+ }
123
+
124
+ module.exports = {
125
+ classifyDeferralIntent,
126
+ applyClassification,
127
+ NEGATIVE_PATTERNS,
128
+ POSITIVE_PATTERNS
129
+ };