wogiflow 2.29.7 → 2.29.9

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.
@@ -78,6 +78,23 @@ flow parallel check # See available parallel tasks
78
78
  | 2.27.0+ | 2.1.116+ | Sandbox dangerous-path safety on auto-allow, agent frontmatter hooks for `--agent`, `/resume` large-session speedup, MCP stdio concurrent startup |
79
79
  | 2.27.0+ | 2.1.117+ | Native bfs/ugrep via Bash (hook audit documented), Opus 4.7 /context fix (estimator already percentage-based), Pro/Max effort default shift (advisory delta documented), agent frontmatter `mcpServers` for `--agent`, subagent model-mismatch malware-warning fix, managed-settings plugin marketplace enforcement |
80
80
  | 2.29.6+ | 2.1.132+ | Statusline `context_window` token-count accuracy fix (release notes: was reporting cumulative session totals — may have affected `wogi-statusline-setup` percentage presets if percentage was derived from cumulative tokens), Bedrock/Vertex `ENABLE_PROMPT_CACHING_1H` 400-error fix (recommendation now safe on those providers), `CLAUDE_CODE_SESSION_ID` available in Bash subprocess env |
81
+ | 2.29.7+ | 2.1.133+ | **Subagent skill discovery fix** (CRITICAL for IGR — Architect/Adversary/Skeptical-Evaluator are subagents; if they were missing skills, IGR was silently impaired across versions 2.1.128–2.1.132); **`worktree.baseRef` setting (fresh\|head) reverted to `origin/<default>` default** (was local HEAD since 2.1.128) — wogi-flow's `scripts/flow-worktree.js` users with unpushed local commits should set `worktree.baseRef: "head"` in `.claude/settings.json` to preserve prior behavior; **hooks now receive `effort.level` JSON field + `$CLAUDE_EFFORT` env var** (opportunity for wogi-flow gates to adjust thresholds based on effort — not yet wired); `/effort` no longer leaks across concurrent sessions (workspace-mode benefit); Edit/Write allow rules at drive-root fixed; `sandbox.bwrapPath`/`sandbox.socatPath` managed settings (Linux/WSL); `parentSettingsBehavior` admin-tier key |
82
+ | 2.29.7+ | 2.1.136+ | **`AskUserQuestion` multi-select array discard fixed** (wogi-flow uses AskUserQuestion extensively across skills — automatic fix); **`settings.autoMode.hard_deny`** new unconditional-block mechanism (potential future wogi-flow use for non-bypassable gates like routing/deferral); extended-thinking redacted-block API 400 fixed (wogi-flow's IGR Architect on Opus benefits); MCP servers no longer silently disappear after `/clear`; OAuth refresh-token races fixed (better stability for multi-MCP users); `--resume`/`--continue` no longer fails on underscored project paths; plan mode now correctly blocks file writes when matching Edit allow rule exists; subagent file pickers find files in dirs with >100 entries; many TUI cosmetic fixes (CJK rendering, autocomplete, color artifacts) |
83
+
84
+ ### Worktree-baseRef recommendation (2.1.133+)
85
+
86
+ If you use wogi-flow's worktree feature for parallel task execution AND have unpushed local commits you want available in new worktrees:
87
+
88
+ ```json
89
+ // .claude/settings.json
90
+ {
91
+ "worktree": {
92
+ "baseRef": "head"
93
+ }
94
+ }
95
+ ```
96
+
97
+ Without this, new worktrees branch from `origin/<default>` (the 2.1.133 default) and your unpushed commits won't be available in them. wogi-flow's `scripts/flow-worktree.js` doesn't currently configure this — it inherits whatever Claude Code's default is.
81
98
 
82
99
  ### Environment Variables (2.1.19+)
83
100
 
@@ -170,6 +170,6 @@
170
170
  },
171
171
  "_comment_dynamicHooks": "TaskCreated (2.1.84+) and PermissionDenied (2.1.88+) are added by postinstall.js when the CC version supports them. They must NOT be committed statically — CC rejects the entire settings file if it encounters an unknown hook event name.",
172
172
  "_wogiFlowManaged": true,
173
- "_wogiFlowVersion": "2.27.0",
173
+ "_wogiFlowVersion": "2.29.7",
174
174
  "_comment": "Shared WogiFlow hook configuration. Committed to repo for team use. User-specific overrides go in settings.local.json."
175
175
  }
@@ -79,7 +79,7 @@ const PEERS = parsePeers(PEERS_RAW);
79
79
  // Minimal MCP Protocol (JSON-RPC 2.0 over stdio)
80
80
  // ============================================================
81
81
 
82
- let initialized = false;
82
+ let _initialized = false;
83
83
 
84
84
  /**
85
85
  * Send a JSON-RPC message to Claude Code via stdout.
@@ -198,7 +198,7 @@ function handleRequest(msg) {
198
198
  }
199
199
 
200
200
  if (msg.method === 'notifications/initialized') {
201
- initialized = true;
201
+ _initialized = true;
202
202
  return;
203
203
  }
204
204
 
@@ -20,6 +20,11 @@ const fs = require('node:fs');
20
20
  const path = require('node:path');
21
21
  const crypto = require('node:crypto');
22
22
 
23
+ // wf-3c968989: use safeJsonParse for prototype-pollution protection on
24
+ // the workspace manifest read. Throw-on-failure contract preserved below
25
+ // via explicit null check (manifest is mandatory for workspace mode).
26
+ const { safeJsonParse } = require('../scripts/flow-io');
27
+
23
28
  const VALID_REPO_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
24
29
  const VALID_TASK_ID = /^wf-[0-9a-f]{8}$/i;
25
30
  const REQUIRED_FIELDS = ['id', 'title', 'type'];
@@ -42,11 +47,12 @@ function getWorkerReadyPath(workspaceRoot, repoName) {
42
47
  }
43
48
 
44
49
  const configPath = path.join(workspaceRoot, 'wogi-workspace.json');
45
- let manifest;
46
- try {
47
- manifest = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
48
- } catch (err) {
49
- throw new Error(`Cannot read workspace manifest at ${configPath}: ${err.message}`);
50
+ // wf-3c968989: safeJsonParse adds DANGEROUS_KEYS protection. It returns
51
+ // null on missing/corrupt/array-typed input — manifest is mandatory, so
52
+ // we preserve the original throw-on-failure contract via null check.
53
+ const manifest = safeJsonParse(configPath, null);
54
+ if (!manifest) {
55
+ throw new Error(`Cannot read workspace manifest at ${configPath}`);
50
56
  }
51
57
 
52
58
  const member = manifest.members?.[repoName];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.29.7",
3
+ "version": "2.29.9",
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 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",
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-audit-gates.test.js tests/flow-standards-hook-three-layer.test.js tests/flow-correction-detector-reconcile.test.js tests/flow-correction-backfill.test.js tests/flow-audit-gates-feature-output-health.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",
@@ -74,6 +74,9 @@
74
74
  "engines": {
75
75
  "node": ">=18.0.0"
76
76
  },
77
+ "overrides": {
78
+ "protobufjs": ">=7.5.5"
79
+ },
77
80
  "publishConfig": {
78
81
  "access": "public"
79
82
  }
@@ -34,6 +34,7 @@ const fs = require('node:fs');
34
34
  const path = require('node:path');
35
35
 
36
36
  const { PATHS, safeJsonParse } = require('./flow-utils');
37
+ const { safeJsonParseString } = require('./flow-io');
37
38
 
38
39
  // ============================================================
39
40
  // Score Cap Thresholds
@@ -282,19 +283,77 @@ function checkLintConfigIntegrity() {
282
283
  return result;
283
284
  }
284
285
 
286
+ /**
287
+ * Parse test failure count from Node test runner stdout.
288
+ *
289
+ * Bug fixed 2026-05-08 (wf-e111d850): previously inherited the generic
290
+ * runProjectScript regex `/error TS\d+|Error:|ERROR/gi` which matched the
291
+ * substring "error" in passing test descriptions (e.g., 'trimRetryErrors',
292
+ * 'classifier error path', 'returns null on git unavailable / error'),
293
+ * inflating errorCount even when 0 tests actually failed. Compounded by
294
+ * Node test runner v22 sometimes exiting non-zero on all-pass.
295
+ *
296
+ * Strategy:
297
+ * 1. Primary — parse Node test runner "Results: N passed, M failed" summary
298
+ * lines (one per suite when running multiple files). Sum the M values.
299
+ * 2. Fallback — count TAP "not ok N" lines if no summary present.
300
+ * 3. Default — 0 (graceful) if neither parser finds anything.
301
+ *
302
+ * @param {string} output — combined stdout+stderr from `npm run test`
303
+ * @returns {{ errorCount: number, source: 'summary' | 'tap' | 'default' }}
304
+ */
305
+ function parseTestErrorCount(output) {
306
+ if (typeof output !== 'string' || output.length === 0) {
307
+ return { errorCount: 0, source: 'default' };
308
+ }
309
+
310
+ // Primary: Node test runner summary line(s).
311
+ // Format: "Results: N passed, M failed" (color codes already stripped via
312
+ // FORCE_COLOR=0 / NO_COLOR=1 in runProjectScript).
313
+ const summaryRe = /Results:\s*\d+\s*passed,\s*(\d+)\s*failed/gi;
314
+ let summaryFound = false;
315
+ let total = 0;
316
+ for (const m of output.matchAll(summaryRe)) {
317
+ summaryFound = true;
318
+ total += parseInt(m[1], 10) || 0;
319
+ }
320
+ if (summaryFound) return { errorCount: total, source: 'summary' };
321
+
322
+ // Fallback: TAP "not ok N - ..." line count
323
+ const tap = (output.match(/^not ok \d+/gm) || []).length;
324
+ if (tap > 0) return { errorCount: tap, source: 'tap' };
325
+
326
+ return { errorCount: 0, source: 'default' };
327
+ }
328
+
285
329
  /**
286
330
  * Gate: Tests — do tests pass?
331
+ *
332
+ * Uses parseTestErrorCount() to override the generic regex from
333
+ * runProjectScript. See parseTestErrorCount() comment for bug history.
287
334
  */
288
335
  function checkTests() {
289
336
  const result = runProjectScript('test', 120000);
337
+ const parseSource = result.rawOutput || result.output || '';
338
+ const { errorCount, source: parserSource } = parseTestErrorCount(parseSource);
339
+
340
+ // Trust the parser over npm exit code: Node test runner v22 can exit
341
+ // non-zero in some configurations even when all tests pass. If the parser
342
+ // finds 0 failures via the summary line, that's authoritative.
343
+ const passed = errorCount === 0;
344
+
290
345
  return {
291
346
  gate: 'tests',
292
347
  ...result,
348
+ errorCount,
349
+ passed,
350
+ parserSource,
293
351
  scoreCap: 100, // Test failure doesn't cap, but is a HIGH finding
294
352
  severity: !result.exists ? 'info' :
295
- result.passed ? 'pass' : 'high',
353
+ passed ? 'pass' : 'high',
296
354
  message: !result.exists ? 'No test script defined' :
297
- result.passed ? 'Tests pass' : 'Tests FAIL'
355
+ passed ? 'Tests pass' :
356
+ `Tests FAIL: ${errorCount} failure(s)`
298
357
  };
299
358
  }
300
359
 
@@ -583,6 +642,114 @@ function compareTrend(currentResults, previousAudit) {
583
642
  // Main: Run All Gates
584
643
  // ============================================================
585
644
 
645
+ /**
646
+ * Gate: Feature Output Health (wf-6c58953a)
647
+ *
648
+ * Inspects DATA produced by features, not just CODE that produces it.
649
+ * Catches "silent feature no-op" — feature runs without errors, persists
650
+ * data, but the persisted data has all-null structured fields. This class
651
+ * is invisible to traditional code review/lint/typecheck/tests.
652
+ *
653
+ * Discovered 2026-05-09 when wogiflow-cli investigation found the
654
+ * correction-extractor was capturing user frustration but writing null
655
+ * structured fields. The /wogi-audit ran B+ and missed it because every
656
+ * agent inspects code, not output.
657
+ *
658
+ * Rule registry — explicit per-file checks, NOT a generic walker (per
659
+ * challenge round: blanket "all-null is bug" is false-positive city).
660
+ *
661
+ * @param {string} [projectRoot=PATHS.root] — project to inspect (default: current)
662
+ * @returns {Object} gate result with severity + findings
663
+ */
664
+ function checkFeatureOutputHealth(projectRoot = PATHS.root) {
665
+ const findings = [];
666
+ const stateDir = path.join(projectRoot, '.workflow', 'state');
667
+ const corrDir = path.join(projectRoot, '.workflow', 'corrections');
668
+
669
+ // ---- Rule 1: pending-corrections.json null-fields ratio ----
670
+ // Note: pending-corrections.json is a top-level ARRAY, so safeJsonParse
671
+ // (which rejects arrays) won't work. Use file-read + safeJsonParseString.
672
+ const pcPath = path.join(stateDir, 'pending-corrections.json');
673
+ if (fs.existsSync(pcPath)) {
674
+ let records = [];
675
+ try {
676
+ const content = fs.readFileSync(pcPath, 'utf-8');
677
+ records = safeJsonParseString(content, []);
678
+ } catch (_err) { /* fail-open */ }
679
+ const arr = Array.isArray(records) ? records : [];
680
+ if (arr.length > 0) {
681
+ const nullCount = arr.filter(r =>
682
+ r && typeof r === 'object' &&
683
+ (r.whatWasWrong == null) &&
684
+ (r.whatUserWants == null)
685
+ ).length;
686
+ const ratio = nullCount / arr.length;
687
+ if (ratio >= 0.5) {
688
+ findings.push({
689
+ rule: 'pending-corrections-null-fields',
690
+ severity: ratio === 1 ? 'high' : 'medium',
691
+ message: `${nullCount}/${arr.length} (${Math.round(ratio * 100)}%) pending-corrections records have null structured fields. Likely correction-detector extraction failure. Run \`flow-correction-backfill\` or restore via Layer 2 enrichment.`,
692
+ evidence: `${path.relative(projectRoot, pcPath)}: ${arr.length} records analyzed; ${nullCount} fully null`
693
+ });
694
+ }
695
+ }
696
+ }
697
+
698
+ // ---- Rule 2: prompt-history × corrections cross-reference ----
699
+ // prompt-history.json is also typically a top-level array.
700
+ const phPath = path.join(stateDir, 'prompt-history.json');
701
+ if (fs.existsSync(phPath)) {
702
+ let ph = [];
703
+ try {
704
+ const content = fs.readFileSync(phPath, 'utf-8');
705
+ ph = safeJsonParseString(content, []);
706
+ } catch (_err) { /* fail-open */ }
707
+ const phArr = Array.isArray(ph) ? ph : (ph && Array.isArray(ph.prompts) ? ph.prompts : []);
708
+
709
+ // Frustration markers (regex per known-pattern set)
710
+ const frustrationRe = /\b(don'?t|stop|wait|actually|why did|why is|you keep|you always|fucking|seriously)\b/i;
711
+ let frustrationCount = 0;
712
+ for (const entry of phArr) {
713
+ if (!entry || typeof entry !== 'object') continue;
714
+ const text = entry.prompt || entry.text || entry.userMessage || '';
715
+ if (typeof text === 'string' && frustrationRe.test(text)) frustrationCount++;
716
+ }
717
+
718
+ let corrCount = 0;
719
+ if (fs.existsSync(corrDir)) {
720
+ try {
721
+ corrCount = fs.readdirSync(corrDir).filter(f => f.endsWith('.md')).length;
722
+ } catch (_err) { /* fail-open */ }
723
+ }
724
+
725
+ if (frustrationCount >= 3 && corrCount === 0) {
726
+ findings.push({
727
+ rule: 'prompt-history-vs-corrections-mismatch',
728
+ severity: 'high',
729
+ message: `prompt-history.json has ${frustrationCount} frustration markers but corrections/ is empty. Correction-extractor pipeline appears non-functional (captures input, fails to materialize records).`,
730
+ evidence: `prompt-history: ${frustrationCount} matches across ${phArr.length} entries; corrections/: ${corrCount} files`
731
+ });
732
+ }
733
+ }
734
+
735
+ // Determine overall gate severity
736
+ const hasHigh = findings.some(f => f.severity === 'high');
737
+ const hasMed = findings.some(f => f.severity === 'medium');
738
+ const severity = hasHigh ? 'high' : hasMed ? 'medium' : 'pass';
739
+
740
+ return {
741
+ gate: 'feature-output-health',
742
+ exists: true,
743
+ passed: findings.length === 0,
744
+ findings,
745
+ severity,
746
+ scoreCap: 100, // doesn't cap score directly; surfaces as audit findings
747
+ message: findings.length === 0
748
+ ? 'Feature output health: no issues detected'
749
+ : `Feature output health: ${findings.length} finding(s) — ${findings.map(f => f.rule).join(', ')}`
750
+ };
751
+ }
752
+
586
753
  /**
587
754
  * Run all Gate 0 checks and return consolidated results.
588
755
  * @returns {Object} gate results with score cap
@@ -596,6 +763,7 @@ function runAllGates() {
596
763
  gates.push(checkLintConfigIntegrity());
597
764
  gates.push(checkTests());
598
765
  gates.push(checkScriptCompleteness());
766
+ gates.push(checkFeatureOutputHealth());
599
767
 
600
768
  const cap = calculateScoreCap(gates);
601
769
  const framework = detectFramework();
@@ -658,6 +826,14 @@ function main() {
658
826
  console.log(JSON.stringify(checkScriptCompleteness(), null, 2));
659
827
  break;
660
828
 
829
+ case 'feature-output-health': {
830
+ // Optional --project=<path> argument for cross-project audit
831
+ const projArg = process.argv.find(a => a.startsWith('--project='));
832
+ const projectRoot = projArg ? projArg.slice('--project='.length) : PATHS.root;
833
+ console.log(JSON.stringify(checkFeatureOutputHealth(projectRoot), null, 2));
834
+ break;
835
+ }
836
+
661
837
  case 'eslint-disable':
662
838
  console.log(JSON.stringify(countEslintDisables(), null, 2));
663
839
  break;
@@ -767,7 +943,9 @@ module.exports = {
767
943
  checkLint,
768
944
  checkLintConfigIntegrity,
769
945
  checkTests,
946
+ parseTestErrorCount, // wf-e111d850: exposed for unit testing
770
947
  checkScriptCompleteness,
948
+ checkFeatureOutputHealth, // wf-6c58953a: feature output health gate
771
949
 
772
950
  // Extended checks
773
951
  countEslintDisables,
@@ -1038,6 +1038,24 @@ const CONFIG_DEFAULTS = {
1038
1038
  claudeCode: { installPath: '.claude/settings.local.json' }
1039
1039
  },
1040
1040
 
1041
+ // --- Standards Check (wf-00c5067b) ---
1042
+ // Hook three-layer enforcement: entry files ≤120 LOC + ≤2 core/ imports.
1043
+ // Exemption list documents pre-extraction violators; clear each entry as
1044
+ // its corresponding Phase 2 task ships.
1045
+ standardsCheck: {
1046
+ hookThreeLayer: {
1047
+ enabled: true,
1048
+ maxLoc: 120,
1049
+ maxCoreImports: 2,
1050
+ exemptions: {
1051
+ 'scripts/hooks/entry/claude-code/stop.js': 'Phase 2 — wf-c1e892fa entry-file extraction (orchestration logic to core); remove exemption when extracted',
1052
+ 'scripts/hooks/entry/claude-code/session-start.js': 'Phase 2 — wf-c1e892fa entry-file extraction (orchestration logic to core); remove exemption when extracted',
1053
+ 'scripts/hooks/entry/claude-code/user-prompt-submit.js': 'Phase 2 — wf-c1e892fa entry-file extraction (orchestration logic to core); remove exemption when extracted',
1054
+ 'scripts/hooks/entry/claude-code/post-tool-use.js': 'Phase 2 — wf-c1e892fa entry-file extraction (orchestration logic to core); remove exemption when extracted'
1055
+ }
1056
+ }
1057
+ },
1058
+
1041
1059
  // --- Metrics ---
1042
1060
  metrics: { enabled: false },
1043
1061
 
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow — Pending-Corrections Backfill (wf-6c58953a)
5
+ *
6
+ * Backfills records in `.workflow/state/pending-corrections.json` that have
7
+ * null `whatWasWrong` / `whatUserWants` fields. The fix lands at code level
8
+ * (flow-correction-detector.js Layer 1+2 reconciliation), but historical
9
+ * records persisted before the fix already have null fields. This tool
10
+ * applies the same deterministic-fallback extraction retroactively.
11
+ *
12
+ * Strategy:
13
+ * - Read pending-corrections.json
14
+ * - For each record where userMessage is populated AND
15
+ * (whatWasWrong is null OR whatUserWants is null)
16
+ * - Apply deterministic extraction: whatWasWrong = first 200 chars of
17
+ * userMessage; whatUserWants stays null (intent inference is an LLM job
18
+ * — honest null > wrong guess; live extractor will populate going forward)
19
+ * - Mark `enrichmentSource: "backfill-<date>"` so consumers can distinguish
20
+ * backfilled from live extractions
21
+ * - Atomic write: write-temp + rename
22
+ *
23
+ * Usage:
24
+ * node scripts/flow-correction-backfill.js # current project
25
+ * node scripts/flow-correction-backfill.js --project=<path> # explicit project
26
+ * node scripts/flow-correction-backfill.js --dry-run # report only
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const { PATHS } = require('./flow-utils');
35
+ const { safeJsonParseString } = require('./flow-io');
36
+ const { deterministicWhatWasWrong } = require('./flow-correction-detector');
37
+
38
+ const BACKFILL_DATE = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
39
+
40
+ /**
41
+ * Backfill a single project's pending-corrections.json.
42
+ *
43
+ * @param {string} projectRoot — project directory containing .workflow/
44
+ * @param {Object} [opts]
45
+ * @param {boolean} [opts.dryRun=false] — if true, return what WOULD change without writing
46
+ * @returns {{ found: number, backfilled: number, alreadyPopulated: number, written: boolean, path: string|null, dryRun: boolean }}
47
+ */
48
+ function backfillPendingCorrections(projectRoot, opts = {}) {
49
+ const { dryRun = false } = opts;
50
+ const pcPath = path.join(projectRoot, '.workflow', 'state', 'pending-corrections.json');
51
+
52
+ const result = {
53
+ found: 0,
54
+ backfilled: 0,
55
+ alreadyPopulated: 0,
56
+ written: false,
57
+ path: null,
58
+ dryRun
59
+ };
60
+
61
+ if (!fs.existsSync(pcPath)) {
62
+ result.path = pcPath;
63
+ return result;
64
+ }
65
+
66
+ let content;
67
+ try {
68
+ content = fs.readFileSync(pcPath, 'utf-8');
69
+ } catch (err) {
70
+ throw new Error(`Cannot read pending-corrections at ${pcPath}: ${err.message}`);
71
+ }
72
+
73
+ const records = safeJsonParseString(content, []);
74
+ if (!Array.isArray(records)) {
75
+ throw new Error(`Expected array at ${pcPath}; got ${typeof records}`);
76
+ }
77
+
78
+ result.found = records.length;
79
+ result.path = pcPath;
80
+
81
+ let changed = false;
82
+ for (const r of records) {
83
+ if (!r || typeof r !== 'object') continue;
84
+ const userMsg = r.userMessage;
85
+ if (typeof userMsg !== 'string' || !userMsg.trim()) continue;
86
+
87
+ const needsFill = (r.whatWasWrong == null) && (r.whatUserWants == null);
88
+ if (!needsFill) {
89
+ result.alreadyPopulated += 1;
90
+ continue;
91
+ }
92
+
93
+ // Apply deterministic extraction (whatWasWrong only — whatUserWants
94
+ // stays null; intent inference is the live extractor's job going forward)
95
+ r.whatWasWrong = deterministicWhatWasWrong(userMsg);
96
+ r.enrichmentSource = `backfill-${BACKFILL_DATE}`;
97
+ result.backfilled += 1;
98
+ changed = true;
99
+ }
100
+
101
+ if (changed && !dryRun) {
102
+ // Atomic write: write-temp + rename
103
+ const tmpPath = `${pcPath}.tmp-${process.pid}`;
104
+ fs.writeFileSync(tmpPath, JSON.stringify(records, null, 2) + '\n');
105
+ fs.renameSync(tmpPath, pcPath);
106
+ result.written = true;
107
+ }
108
+
109
+ return result;
110
+ }
111
+
112
+ // ============================================================
113
+ // CLI
114
+ // ============================================================
115
+
116
+ function main() {
117
+ const argv = process.argv.slice(2);
118
+ const projArg = argv.find(a => a.startsWith('--project='));
119
+ const dryRun = argv.includes('--dry-run');
120
+
121
+ const projectRoot = projArg ? projArg.slice('--project='.length) : PATHS.root;
122
+
123
+ let result;
124
+ try {
125
+ result = backfillPendingCorrections(projectRoot, { dryRun });
126
+ } catch (err) {
127
+ console.error(`Error: ${err.message}`);
128
+ process.exit(1);
129
+ }
130
+
131
+ console.log(JSON.stringify({
132
+ project: projectRoot,
133
+ pendingCorrectionsPath: result.path,
134
+ found: result.found,
135
+ backfilled: result.backfilled,
136
+ alreadyPopulated: result.alreadyPopulated,
137
+ written: result.written,
138
+ dryRun: result.dryRun
139
+ }, null, 2));
140
+ }
141
+
142
+ module.exports = {
143
+ backfillPendingCorrections
144
+ };
145
+
146
+ if (require.main === module) {
147
+ main();
148
+ }
@@ -329,14 +329,106 @@ function recordHybridTelemetry(verdict, runCtx = {}) {
329
329
  }
330
330
  }
331
331
 
332
+ // ============================================================================
333
+ // Layer 1 + Layer 2 Reconciliation (wf-6c58953a)
334
+ // ============================================================================
335
+
336
+ /**
337
+ * Deterministic fallback for `whatWasWrong` — preserves the user's literal
338
+ * frustration text when LLM extraction is unavailable or fails. Better than
339
+ * null: a 200-char excerpt is honest signal; null is data loss.
340
+ *
341
+ * @param {string} message — user message
342
+ * @returns {string|null}
343
+ */
344
+ function deterministicWhatWasWrong(message) {
345
+ if (typeof message !== 'string') return null;
346
+ const trimmed = message.trim();
347
+ if (!trimmed) return null;
348
+ return trimmed.slice(0, 200);
349
+ }
350
+
351
+ /**
352
+ * Reconcile Layer 1 (keyword classifier) + Layer 2 (Haiku LLM) results.
353
+ *
354
+ * Pre-fix bug: Layer 1 returned `{whatWasWrong: null, whatUserWants: null}`
355
+ * when keyword matched, never calling Layer 2. The user's actual frustration
356
+ * was captured but structured fields stayed null — silent feature no-op.
357
+ *
358
+ * Post-fix design:
359
+ * - Layer 1 hit + Layer 2 success: trust Layer 1's classification (high-
360
+ * precision keyword match), use Layer 2's strings if non-null else
361
+ * deterministic fallback. Record `llmDisagreed` if Layer 2 said
362
+ * `isCorrection: false` (e.g., user said "I'm just asking a question").
363
+ * - Layer 1 hit + Layer 2 fail/skip: deterministic fallback for `whatWasWrong`
364
+ * (first 200 chars). `whatUserWants` stays null (intent inference is an
365
+ * LLM job; honest null > wrong guess).
366
+ * - Layer 1 miss + Layer 2 success: Layer 2 is primary classifier (existing path).
367
+ * - Both miss: not a correction.
368
+ *
369
+ * Pure function — testable in isolation, no LLM mock needed.
370
+ *
371
+ * @param {Object|null} layer1 — Layer 1 result {isCorrection, confidence, correctionType, method, matchedPattern}
372
+ * @param {Object|null} layer2 — Layer 2 (LLM) result {isCorrection, confidence, correctionType, whatWasWrong, whatUserWants}
373
+ * @param {string} trimmed — trimmed user message (for deterministic fallback)
374
+ * @returns {Object|null} reconciled record OR null if not a correction
375
+ */
376
+ function reconcileExtraction(layer1, layer2, trimmed) {
377
+ // Both layers ran
378
+ if (layer1 && layer2) {
379
+ const what = layer2.whatWasWrong || deterministicWhatWasWrong(trimmed);
380
+ const wants = layer2.whatUserWants || null;
381
+ return {
382
+ isCorrection: true, // Layer 1 high-precision keyword match wins binary
383
+ confidence: layer1.confidence,
384
+ correctionType: layer1.correctionType || layer2.correctionType || 'behavior',
385
+ whatWasWrong: what,
386
+ whatUserWants: wants,
387
+ method: 'keyword+ai',
388
+ matchedPattern: layer1.matchedPattern,
389
+ enrichmentSource: layer2.whatWasWrong ? 'haiku' : 'deterministic-fallback',
390
+ llmDisagreed: layer2.isCorrection === false,
391
+ };
392
+ }
393
+ // Layer 1 only (Layer 2 unavailable: no API key, network error, etc.)
394
+ if (layer1) {
395
+ return {
396
+ isCorrection: true,
397
+ confidence: layer1.confidence,
398
+ correctionType: layer1.correctionType || 'behavior',
399
+ whatWasWrong: deterministicWhatWasWrong(trimmed),
400
+ whatUserWants: null,
401
+ method: layer1.method,
402
+ matchedPattern: layer1.matchedPattern,
403
+ enrichmentSource: 'deterministic-fallback',
404
+ };
405
+ }
406
+ // Layer 2 only (Layer 1 missed)
407
+ if (layer2 && layer2.isCorrection) {
408
+ return {
409
+ isCorrection: true,
410
+ confidence: layer2.confidence,
411
+ correctionType: layer2.correctionType || null,
412
+ whatWasWrong: layer2.whatWasWrong || null,
413
+ whatUserWants: layer2.whatUserWants || null,
414
+ method: 'ai',
415
+ enrichmentSource: 'haiku',
416
+ };
417
+ }
418
+ // Both missed → not a correction
419
+ return null;
420
+ }
421
+
332
422
  // ============================================================================
333
423
  // AI-Based Detection (Haiku — language-agnostic)
334
424
  // ============================================================================
335
425
 
336
426
  /**
337
427
  * Detect if a message is a correction using Claude Haiku.
338
- * This is the ONLY detection method no regex fallback.
339
- * Works in any language.
428
+ * Hybrid: Layer 1 keyword classifier (fast) + Layer 2 Haiku enrichment.
429
+ *
430
+ * wf-6c58953a (2026-05-09): Layer 1 hit no longer short-circuits structured
431
+ * extraction. See reconcileExtraction() for the post-fix design rationale.
340
432
  *
341
433
  * @param {string} userMessage - The user's message
342
434
  * @param {string} previousContext - Summary of what the AI was doing
@@ -354,8 +446,12 @@ async function detectCorrection(userMessage, previousContext = '') {
354
446
  return { isCorrection: false, confidence: 0, method: 'skipped', reason: 'length-filter' };
355
447
  }
356
448
 
357
- // Layer 1 (wf-e6d65edf) — keyword pre-classifier. Skips Haiku entirely on a hit.
449
+ // Layer 1 (wf-e6d65edf) — keyword pre-classifier.
450
+ // wf-6c58953a: NO longer short-circuits structured extraction. Layer 1's
451
+ // classification is captured; reconcile with Layer 2 (or deterministic
452
+ // fallback when Layer 2 unavailable) at end.
358
453
  const hybridCfg = getHybridConfig();
454
+ let layer1Result = null;
359
455
  if (hybridCfg.hybridEnabled) {
360
456
  const matched = findKeywordMatch(trimmed);
361
457
  if (matched) {
@@ -368,12 +464,10 @@ async function detectCorrection(userMessage, previousContext = '') {
368
464
  confidence: conf,
369
465
  durationMs: Date.now() - start,
370
466
  });
371
- return {
467
+ layer1Result = {
372
468
  isCorrection: true,
373
469
  confidence: conf,
374
470
  correctionType: 'behavior',
375
- whatWasWrong: null,
376
- whatUserWants: null,
377
471
  method: 'keyword',
378
472
  matchedPattern: matched.phrase,
379
473
  };
@@ -383,6 +477,10 @@ async function detectCorrection(userMessage, previousContext = '') {
383
477
  // Check if API key is available
384
478
  const apiKey = process.env.ANTHROPIC_API_KEY;
385
479
  if (!apiKey) {
480
+ // wf-6c58953a: Layer 1 hit + no API key → deterministic fallback (not null)
481
+ if (layer1Result) {
482
+ return reconcileExtraction(layer1Result, null, trimmed);
483
+ }
386
484
  return { isCorrection: false, confidence: 0, method: 'skipped', reason: 'no-api-key' };
387
485
  }
388
486
 
@@ -492,11 +590,19 @@ Respond with JSON only (no markdown, no explanation):
492
590
  durationMs: Date.now() - start,
493
591
  });
494
592
 
593
+ // wf-6c58953a: reconcile Layer 1 + Layer 2 (or just Layer 2 if Layer 1 missed)
594
+ const reconciled = reconcileExtraction(layer1Result, aiResult, trimmed);
595
+ if (reconciled) return reconciled;
596
+ // Both layers say no-correction
495
597
  return aiResult;
496
598
  } catch (err) {
497
599
  if (process.env.DEBUG) {
498
600
  console.error(`[DEBUG] AI correction detection failed: ${err.message}`);
499
601
  }
602
+ // wf-6c58953a: Layer 2 failure with Layer 1 hit → deterministic fallback
603
+ if (layer1Result) {
604
+ return reconcileExtraction(layer1Result, null, trimmed);
605
+ }
500
606
  return { isCorrection: false, confidence: 0, method: 'ai', reason: err.message };
501
607
  }
502
608
  }
@@ -1295,11 +1401,15 @@ function correlateWithPriorGates(correction) {
1295
1401
  // ============================================================================
1296
1402
 
1297
1403
  module.exports = {
1298
- // Detection (AI-only)
1404
+ // Detection (hybrid Layer 1 + Layer 2)
1299
1405
  detectCorrection,
1300
1406
  batchAnalyzePrompts,
1301
1407
  spawnBackgroundDetection,
1302
1408
 
1409
+ // wf-6c58953a: reconciliation helpers exposed for unit testing + backfill
1410
+ reconcileExtraction,
1411
+ deterministicWhatWasWrong,
1412
+
1303
1413
  // Queue management
1304
1414
  loadPendingCorrections,
1305
1415
  queuePendingCorrection,
@@ -14,7 +14,6 @@
14
14
  * flow defer-auth status
15
15
  */
16
16
 
17
- const path = require('node:path');
18
17
  const gate = require('./hooks/core/deferral-gate');
19
18
 
20
19
  function parseArgs(argv) {
@@ -20,7 +20,7 @@ const { PATHS, getConfig, readJson, success } = require('./flow-utils');
20
20
 
21
21
  // Default to PATHS.root from flow-utils, can be overridden via setProjectRoot() or CLI arg
22
22
  let PROJECT_ROOT = PATHS.root;
23
- let CONFIG_PATH = path.join(PROJECT_ROOT, '.workflow/config.json');
23
+ let _CONFIG_PATH = path.join(PROJECT_ROOT, '.workflow/config.json');
24
24
  let CACHE_PATH = path.join(PROJECT_ROOT, '.workflow/state/export-map.json');
25
25
  const CACHE_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
26
26
 
@@ -31,7 +31,7 @@ const CACHE_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
31
31
  */
32
32
  function setProjectRoot(root) {
33
33
  PROJECT_ROOT = path.resolve(root);
34
- CONFIG_PATH = path.join(PROJECT_ROOT, '.workflow/config.json');
34
+ _CONFIG_PATH = path.join(PROJECT_ROOT, '.workflow/config.json');
35
35
  CACHE_PATH = path.join(PROJECT_ROOT, '.workflow/state/export-map.json');
36
36
  }
37
37
 
@@ -700,7 +700,7 @@ function formatExportMapForTemplate(exportMap) {
700
700
  // Components
701
701
  if (Object.keys(exportMap.components).length > 0) {
702
702
  lines.push('#### Components');
703
- for (const [name, info] of Object.entries(exportMap.components)) {
703
+ for (const [_name, info] of Object.entries(exportMap.components)) {
704
704
  const exports = info.exports.join(', ') || (info.defaultExport ? `default: ${info.defaultExport}` : '');
705
705
  if (exports) {
706
706
  lines.push(`- \`import { ${info.exports.join(', ')} } from '${info.importPath}'\``);
@@ -712,7 +712,7 @@ function formatExportMapForTemplate(exportMap) {
712
712
  // Hooks
713
713
  if (Object.keys(exportMap.hooks).length > 0) {
714
714
  lines.push('#### Hooks');
715
- for (const [name, info] of Object.entries(exportMap.hooks)) {
715
+ for (const [_name, info] of Object.entries(exportMap.hooks)) {
716
716
  const exports = info.exports.join(', ');
717
717
  if (exports) {
718
718
  lines.push(`- \`import { ${exports} } from '${info.importPath}'\``);
@@ -724,7 +724,7 @@ function formatExportMapForTemplate(exportMap) {
724
724
  // Services
725
725
  if (Object.keys(exportMap.services).length > 0) {
726
726
  lines.push('#### Services');
727
- for (const [name, info] of Object.entries(exportMap.services)) {
727
+ for (const [_name, info] of Object.entries(exportMap.services)) {
728
728
  const exports = info.exports.join(', ');
729
729
  if (exports) {
730
730
  lines.push(`- \`import { ${exports} } from '${info.importPath}'\``);
@@ -736,7 +736,7 @@ function formatExportMapForTemplate(exportMap) {
736
736
  // Types
737
737
  if (Object.keys(exportMap.types).length > 0) {
738
738
  lines.push('#### Types');
739
- for (const [name, info] of Object.entries(exportMap.types)) {
739
+ for (const [_name, info] of Object.entries(exportMap.types)) {
740
740
  const types = info.types.join(', ');
741
741
  if (types) {
742
742
  lines.push(`- \`import type { ${types} } from '${info.importPath}'\``);
@@ -748,7 +748,7 @@ function formatExportMapForTemplate(exportMap) {
748
748
  // Utils
749
749
  if (Object.keys(exportMap.utils).length > 0) {
750
750
  lines.push('#### Utilities');
751
- for (const [name, info] of Object.entries(exportMap.utils)) {
751
+ for (const [_name, info] of Object.entries(exportMap.utils)) {
752
752
  const exports = info.exports.join(', ');
753
753
  if (exports) {
754
754
  lines.push(`- \`import { ${exports} } from '${info.importPath}'\``);
@@ -784,7 +784,7 @@ function validateComponentUsage(code, exportMap = null) {
784
784
 
785
785
  // Collect all array exports from components
786
786
  const arrayExports = new Set();
787
- for (const [name, info] of Object.entries(exportMap.components || {})) {
787
+ for (const [_name, info] of Object.entries(exportMap.components || {})) {
788
788
  if (info.arrayExports) {
789
789
  info.arrayExports.forEach(e => arrayExports.add(e));
790
790
  }
@@ -841,7 +841,7 @@ function validateComponentUsage(code, exportMap = null) {
841
841
  // Check if the actual export exists
842
842
  const wrongName = pattern.source.replace(/\\/g, '').replace(/\(\)/g, '');
843
843
  let found = false;
844
- for (const [name, info] of Object.entries(exportMap.hooks || {})) {
844
+ for (const [_name, info] of Object.entries(exportMap.hooks || {})) {
845
845
  if (info.exports?.includes(wrongName)) {
846
846
  found = true;
847
847
  break;
@@ -572,10 +572,12 @@ async function main() {
572
572
  const matcher = new SimilarityMatcher(registry);
573
573
 
574
574
  // Parse threshold argument
575
- let threshold = MATCH_CONFIG.thresholds.VARIANT_CANDIDATE;
575
+ // _threshold: parsed from --threshold CLI arg but not currently passed to
576
+ // the matcher (real bug; see audit notes; out of scope for lint cleanup).
577
+ let _threshold = MATCH_CONFIG.thresholds.VARIANT_CANDIDATE;
576
578
  const thresholdIndex = args.indexOf('--threshold');
577
579
  if (thresholdIndex !== -1 && args[thresholdIndex + 1]) {
578
- threshold = parseInt(args[thresholdIndex + 1]);
580
+ _threshold = parseInt(args[thresholdIndex + 1]);
579
581
  }
580
582
 
581
583
  if (input === '--stdin') {
@@ -32,12 +32,13 @@ try {
32
32
  adaptiveLearning = null;
33
33
  }
34
34
 
35
- // Import error recovery for integration
36
- let errorRecovery;
35
+ // Import error recovery for integration (currently loaded for side-effect /
36
+ // future-use; not yet referenced — _ prefix per naming convention).
37
+ let _errorRecovery;
37
38
  try {
38
- errorRecovery = require('./flow-error-recovery');
39
+ _errorRecovery = require('./flow-error-recovery');
39
40
  } catch (_err) {
40
- errorRecovery = null;
41
+ _errorRecovery = null;
41
42
  }
42
43
 
43
44
  // ============================================================
@@ -38,10 +38,10 @@ const { loadRegistry, loadStats } = require('./flow-model-types');
38
38
 
39
39
  // Smart Context System integration
40
40
  let contextGatherer = null;
41
- let instructionRichness = null;
41
+ let _instructionRichness = null;
42
42
  try {
43
43
  contextGatherer = require('./flow-context-gatherer');
44
- instructionRichness = require('./flow-instruction-richness');
44
+ _instructionRichness = require('./flow-instruction-richness');
45
45
  } catch (_err) {
46
46
  // Smart Context modules not available
47
47
  }
@@ -17,8 +17,7 @@ const fs = require('node:fs');
17
17
  const path = require('node:path');
18
18
  const {
19
19
  getConfig,
20
- PATHS,
21
- fileExists
20
+ PATHS
22
21
  } = require('./flow-utils');
23
22
 
24
23
  // ============================================================
@@ -59,7 +58,7 @@ function parseSimpleYaml(content) {
59
58
  const lines = content.split('\n');
60
59
  let currentKey = null;
61
60
  let currentSection = null;
62
- let currentList = null;
61
+ let _currentList = null;
63
62
  let indentLevel = 0;
64
63
  let multilineValue = '';
65
64
  let inMultiline = false;
@@ -100,7 +99,7 @@ function parseSimpleYaml(content) {
100
99
  if (BLOCKED_KEYS.has(key)) continue;
101
100
 
102
101
  currentSection = null;
103
- currentList = null;
102
+ _currentList = null;
104
103
 
105
104
  if (value === '' || value === '|') {
106
105
  // Start of nested section or multi-line
@@ -128,7 +127,7 @@ function parseSimpleYaml(content) {
128
127
 
129
128
  if (BLOCKED_KEYS.has(key)) continue;
130
129
 
131
- currentList = null;
130
+ _currentList = null;
132
131
  currentKey = key;
133
132
 
134
133
  if (value === '|') {
@@ -23,6 +23,7 @@ const { execFileSync } = require('node:child_process');
23
23
 
24
24
  const { PATHS } = require('./flow-paths');
25
25
  const { getConfig } = require('./flow-config-loader');
26
+ const { safeJsonParse } = require('./flow-io');
26
27
 
27
28
  const DEFAULT_BUDGET_BYTES = 16 * 1024; // ~4k tokens
28
29
  const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.workflow', '.worktrees', 'out']);
@@ -46,12 +47,15 @@ function resolveChangedFiles(opts = {}) {
46
47
  if (Array.isArray(opts.changedFiles)) return opts.changedFiles;
47
48
 
48
49
  // Try task-checkpoint.json
50
+ // wf-3c968989: safeJsonParse adds DANGEROUS_KEYS protection. Returns null
51
+ // (the explicit default) on missing/corrupt/array — preserves the
52
+ // original silent-fallthrough contract via the null check below.
49
53
  const checkpointPath = path.join(PATHS.state, 'task-checkpoint.json');
50
54
  if (fs.existsSync(checkpointPath)) {
51
- try {
52
- const cp = JSON.parse(fs.readFileSync(checkpointPath, 'utf8'));
53
- if (Array.isArray(cp.changedFiles) && cp.changedFiles.length > 0) return cp.changedFiles;
54
- } catch { /* fall through */ }
55
+ const cp = safeJsonParse(checkpointPath, null);
56
+ if (cp && Array.isArray(cp.changedFiles) && cp.changedFiles.length > 0) {
57
+ return cp.changedFiles;
58
+ }
55
59
  }
56
60
 
57
61
  // Git diff
@@ -39,7 +39,6 @@
39
39
  'use strict';
40
40
 
41
41
  const fs = require('node:fs');
42
- const path = require('node:path');
43
42
 
44
43
  const VERBATIM_HEADER_REGEX = /^##\s+Original Request \(verbatim\)\s*$/m;
45
44
  const MANIFEST_HEADER_REGEX = /^##\s+Item Manifest\s*$/m;
@@ -20,7 +20,8 @@ const {
20
20
  fileExists,
21
21
  readFile,
22
22
  safeJsonParse,
23
- color
23
+ color,
24
+ getConfig
24
25
  } = require('./flow-utils');
25
26
  const {
26
27
  calculateCombinedSimilarity,
@@ -76,19 +77,23 @@ const MATCH_LEVEL_SEVERITY = {
76
77
  };
77
78
 
78
79
  // Task type to check type mapping for smart scoping
80
+ // wf-00c5067b: 'hook-three-layer' added to all task types — entry-file LOC
81
+ // + import-count rule (per .claude/rules/architecture/hook-three-layer.md)
82
+ // is universally applicable; the exemption list in config covers known
83
+ // pre-extraction violators (see ARCH-001, ARCH-002 in .workflow/state/last-audit.json).
79
84
  const TASK_CHECK_MAP = {
80
- 'component': ['naming', 'components', 'security'],
81
- 'utility': ['naming', 'functions', 'security'],
82
- 'api': ['naming', 'api', 'security'],
83
- 'feature': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security'],
84
- 'bugfix': ['naming', 'security'],
85
- 'refactor': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security'],
86
- 'story': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security'],
87
- 'default': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security']
85
+ 'component': ['naming', 'components', 'security', 'hook-three-layer'],
86
+ 'utility': ['naming', 'functions', 'security', 'hook-three-layer'],
87
+ 'api': ['naming', 'api', 'security', 'hook-three-layer'],
88
+ 'feature': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'],
89
+ 'bugfix': ['naming', 'security', 'hook-three-layer'],
90
+ 'refactor': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'],
91
+ 'story': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'],
92
+ 'default': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer']
88
93
  };
89
94
 
90
95
  // All available check types
91
- const ALL_CHECK_TYPES = ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security'];
96
+ const ALL_CHECK_TYPES = ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'];
92
97
 
93
98
  // ============================================================================
94
99
  // Parse Standards Files
@@ -552,6 +557,88 @@ function checkSecurityPatterns(file, _securityRules) {
552
557
  * @param {Object} matchConfig - Semantic match config — optional, auto-loaded if omitted
553
558
  * @returns {Object[]} Array of violations
554
559
  */
560
+ /**
561
+ * wf-00c5067b — Hook Three-Layer enforcement.
562
+ *
563
+ * Per `.claude/rules/architecture/hook-three-layer.md`:
564
+ * - Entry files (`scripts/hooks/entry/<cli>/*.js`) must be ≤120 LOC and
565
+ * import from at most 2 `core/` modules (single-entry-point principle).
566
+ * - Core files (`scripts/hooks/core/*.js`) should be CLI-agnostic.
567
+ *
568
+ * This check enforces the LOC + import-count rules. Core CLI-identifier
569
+ * grep is intentionally NOT enforced here (false-positive prone — adversary
570
+ * critique 2026-05-08 found 1/4 supposed violations was actually config data).
571
+ *
572
+ * Exemptions: read from config.standardsCheck.hookThreeLayer.exemptions
573
+ * map of `{relativePath: reason}`. Each exemption MUST cite a rationale
574
+ * (typically a Phase 2 task ID for entries awaiting orchestrator extraction).
575
+ *
576
+ * @param {Object} file - File with path and content
577
+ * @param {Object} hookThreeLayerConfig - {enabled, exemptions, maxLoc, maxCoreImports}
578
+ * @returns {Object[]} Array of violations
579
+ */
580
+ function checkHookThreeLayer(file, hookThreeLayerConfig = {}) {
581
+ const violations = [];
582
+ const {
583
+ enabled = true,
584
+ exemptions = {},
585
+ maxLoc = 120,
586
+ maxCoreImports = 2
587
+ } = hookThreeLayerConfig;
588
+
589
+ if (!enabled) return violations;
590
+
591
+ // Normalize path to repo-root-relative form for exemption lookup
592
+ const relPath = file.path.startsWith('/')
593
+ ? path.relative(PATHS.root, file.path)
594
+ : file.path;
595
+
596
+ // Only apply to hook entry files
597
+ const isEntry = /^scripts\/hooks\/entry\/[^/]+\/[^/]+\.js$/.test(relPath);
598
+ if (!isEntry) return violations;
599
+
600
+ // Skip if exempted (with rationale)
601
+ if (Object.prototype.hasOwnProperty.call(exemptions, relPath)) return violations;
602
+
603
+ const content = file.content || '';
604
+ const lines = content.split('\n');
605
+
606
+ // Rule 1: LOC ceiling
607
+ if (lines.length > maxLoc) {
608
+ violations.push({
609
+ type: 'hook-three-layer',
610
+ severity: 'must-fix',
611
+ file: file.path,
612
+ line: null,
613
+ message: `Hook entry file exceeds ${maxLoc} LOC (${lines.length} lines). Extract orchestration logic to core/. Add to config.standardsCheck.hookThreeLayer.exemptions with rationale to defer.`,
614
+ rule: 'hook-three-layer.md'
615
+ });
616
+ }
617
+
618
+ // Rule 2: Core import count
619
+ // Match `require('../core/...')` or `require('../../core/...')` etc.
620
+ // Capture each core path; count distinct core modules imported.
621
+ const coreImportRegex = /require\(['"][^'"]*\/core\/([^'"/]+)['"]\)/g;
622
+ const coreModules = new Set();
623
+ let match;
624
+ while ((match = coreImportRegex.exec(content)) !== null) {
625
+ coreModules.add(match[1]);
626
+ }
627
+
628
+ if (coreModules.size > maxCoreImports) {
629
+ violations.push({
630
+ type: 'hook-three-layer',
631
+ severity: 'must-fix',
632
+ file: file.path,
633
+ line: null,
634
+ message: `Hook entry imports from ${coreModules.size} core/ modules (limit: ${maxCoreImports}). Single-entry-point principle violated. Refactor to dispatch through one orchestrator-core. Modules: ${[...coreModules].sort().join(', ')}`,
635
+ rule: 'hook-three-layer.md'
636
+ });
637
+ }
638
+
639
+ return violations;
640
+ }
641
+
555
642
  function checkApiDuplication(file, existingEndpoints, matchConfig) {
556
643
  const violations = [];
557
644
  const content = file.content || '';
@@ -1009,6 +1096,16 @@ function runStandardsCheck(files, options = {}) {
1009
1096
  const services = checksToRun.includes('services') ? parseServiceMap() : [];
1010
1097
 
1011
1098
  const allViolations = [];
1099
+
1100
+ // wf-00c5067b: load hook-three-layer config (with sensible defaults if unset)
1101
+ const config = getConfig();
1102
+ const hookThreeLayerConfig = (config?.standardsCheck?.hookThreeLayer) || {
1103
+ enabled: checksToRun.includes('hook-three-layer'),
1104
+ exemptions: {},
1105
+ maxLoc: 120,
1106
+ maxCoreImports: 2
1107
+ };
1108
+
1012
1109
  const checksSummary = {
1013
1110
  'decisions.md': { checked: true, violations: 0 },
1014
1111
  'app-map.md': { checked: checksToRun.includes('components') && components.length > 0, violations: 0 },
@@ -1017,7 +1114,8 @@ function runStandardsCheck(files, options = {}) {
1017
1114
  'schema-map.md': { checked: checksToRun.includes('schemas') && schemas.length > 0, violations: 0 },
1018
1115
  'service-map.md': { checked: checksToRun.includes('services') && services.length > 0, violations: 0 },
1019
1116
  'naming-conventions': { checked: checksToRun.includes('naming'), violations: 0 },
1020
- 'security-patterns': { checked: checksToRun.includes('security'), violations: 0 }
1117
+ 'security-patterns': { checked: checksToRun.includes('security'), violations: 0 },
1118
+ 'hook-three-layer': { checked: checksToRun.includes('hook-three-layer'), violations: 0 }
1021
1119
  };
1022
1120
 
1023
1121
  for (const file of files) {
@@ -1076,6 +1174,13 @@ function runStandardsCheck(files, options = {}) {
1076
1174
  allViolations.push(...securityViolations);
1077
1175
  checksSummary['security-patterns'].violations += securityViolations.length;
1078
1176
  }
1177
+
1178
+ // Hook three-layer architecture (wf-00c5067b)
1179
+ if (checksToRun.includes('hook-three-layer')) {
1180
+ const hookViolations = checkHookThreeLayer(file, hookThreeLayerConfig);
1181
+ allViolations.push(...hookViolations);
1182
+ checksSummary['hook-three-layer'].violations += hookViolations.length;
1183
+ }
1079
1184
  }
1080
1185
 
1081
1186
  // Count must-fix violations
@@ -1309,6 +1414,7 @@ module.exports = {
1309
1414
  checkApiDuplication,
1310
1415
  checkRegistryDuplication,
1311
1416
  checkSecurityPatterns,
1417
+ checkHookThreeLayer,
1312
1418
  extractDeclaredNames,
1313
1419
  discoverAllRegistries,
1314
1420
  collectReuseCandidates,
@@ -192,7 +192,7 @@ function isAuthorized(deferralChanges) {
192
192
  return { authorized: false, reason: 'auth-malformed-scope' };
193
193
  }
194
194
 
195
- function consumeAuth(deferralChanges) {
195
+ function consumeAuth(_deferralChanges) {
196
196
  // Auth is single-use: once a deferral write succeeds, the marker is removed
197
197
  // to prevent reuse on subsequent unrelated deferrals.
198
198
  clearAuth();
@@ -46,6 +46,7 @@
46
46
  const fs = require('node:fs');
47
47
  const path = require('node:path');
48
48
  const { PATHS } = require('../../flow-utils');
49
+ const { safeJsonParse } = require('../../flow-io');
49
50
 
50
51
  const PENDING_PATH = path.join(PATHS.state, 'long-input-pending.json');
51
52
 
@@ -210,10 +211,10 @@ function isLongInputPending() {
210
211
  }
211
212
 
212
213
  function readLongInputPending() {
213
- try {
214
- if (!fs.existsSync(PENDING_PATH)) return null;
215
- return JSON.parse(fs.readFileSync(PENDING_PATH, 'utf-8'));
216
- } catch (_err) { return null; }
214
+ // wf-3c968989: safeJsonParse adds DANGEROUS_KEYS protection. Returns null
215
+ // on missing/corrupt/array input — exact behavior match for the prior
216
+ // try/catch + return-null contract.
217
+ return safeJsonParse(PENDING_PATH, null);
217
218
  }
218
219
 
219
220
  /**