wogiflow 2.29.8 → 2.30.0

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.
@@ -105,6 +105,34 @@ Options:
105
105
 
106
106
  Use `AskUserQuestion` to present these options.
107
107
 
108
+ ### Step 2.5: Mechanical-Enforcement Check (wf-037f8d66)
109
+
110
+ **Rules without mechanical enforcement are vibes.** Before writing any new rule, classify how it will be enforced:
111
+
112
+ | Enforcement type | Examples | Reliability |
113
+ |---|---|---|
114
+ | **Mechanical gate** (PreToolUse/Stop hook, type system, lint rule, standards-checker pattern) | deferral-gate, research-evidence-gate, phase-read-gate, hook-three-layer LOC check, forbidden-patterns | High — agent cannot skip |
115
+ | **Test assertion** (unit/integration test that fails on violation) | architectural test asserting no hardcoded paths | High — fails CI |
116
+ | **Schema constraint** (config schema, JSON schema, Go type) | required fields, enum values | High — fails parse/compile |
117
+ | **Vibes-only** (rule text in CLAUDE.md, decisions.md, or rule file with no mechanical check) | "use kebab-case", "prefer composition over inheritance", "always X / never Y" | **Low — agent skips it** |
118
+
119
+ **The rule must declare its enforcement type.** If "vibes-only," call it out explicitly:
120
+
121
+ > ⚠ This rule has no mechanical gate. It will be inconsistently enforced. To make it stick, propose one of:
122
+ > - A standards-checker pattern in `.workflow/state/forbidden-patterns.json`
123
+ > - A new gate in `scripts/hooks/core/`
124
+ > - A unit test in `tests/`
125
+ > - A type-system constraint
126
+ >
127
+ > Without one of those, expect the same class of violation to recur.
128
+
129
+ **Anti-rationalization**:
130
+ - "I'll just trust the AI to follow it" → WRONG. The reason this rule exists is the AI didn't follow it last time.
131
+ - "It's documented in CLAUDE.md" → Documentation ≠ enforcement. Both can coexist.
132
+ - "We can add the gate later" → "Later" is "never" for vibes-rules. Either commit to a gate now, or accept inconsistent enforcement.
133
+
134
+ If user accepts vibes-only, proceed with the rule but tag it `enforcementType: "vibes-only"` in decisions.md so future audits surface it.
135
+
108
136
  ### Step 3: Assess Clarity
109
137
 
110
138
  Evaluate if the rule needs clarification. **Skip questions if the rule is already clear and specific.**
@@ -174,6 +174,7 @@ Given a pattern to promote:
174
174
  - Correction examples → Verification criteria
175
175
 
176
176
  3. **Delegate duplicate checking to `/wogi-decide --from-pattern`** which handles duplicate detection and the full rule-writing flow. This ensures a single source of truth for all rule-creation logic.
177
+ - **Note (wf-037f8d66)**: `/wogi-decide` Step 2.5 will require declaring the rule's enforcement type (mechanical gate / test / schema constraint / vibes-only). Patterns being promoted to rules MUST answer "what's the gate?" — if no mechanical enforcement exists, the rule is vibes-only and will not reliably enforce. The fact that this pattern recurred enough to be promoted is itself evidence that vibes-only didn't work.
177
178
 
178
179
  4. **Ask user for any additions:**
179
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.29.8",
3
+ "version": "2.30.0",
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-audit-gates.test.js tests/flow-standards-hook-three-layer.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 tests/flow-standards-forbidden-patterns.test.js tests/flow-hooks-architect-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",
@@ -479,6 +479,20 @@ function recordTelemetry({ taskId, parseResult, gateResult, runCtx = {} }) {
479
479
  sessionId: runCtx.sessionId ?? null,
480
480
  },
481
481
  });
482
+
483
+ // wf-037f8d66: Write Architect-run marker for the architect-required PreToolUse gate.
484
+ // Marker proves Architect ran for this task — gate consults it before allowing
485
+ // Edit/Write/Bash in coding phase for L1+ tasks. Fail-open on any error.
486
+ if (taskId && (telemetryVerdict === 'PASS' || telemetryVerdict === 'PASS_WITH_NOTES')) {
487
+ try {
488
+ const archGate = require('./hooks/core/architect-required-gate');
489
+ archGate.writeArchitectRunMarker({
490
+ taskId,
491
+ model: runCtx.model || null,
492
+ plan: parseResult?.plan ? { sections: sectionsPresent } : null,
493
+ });
494
+ } catch (_err) { /* fail-open */ }
495
+ }
482
496
  }
483
497
 
484
498
  // ============================================================
@@ -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
@@ -641,6 +642,114 @@ function compareTrend(currentResults, previousAudit) {
641
642
  // Main: Run All Gates
642
643
  // ============================================================
643
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
+
644
753
  /**
645
754
  * Run all Gate 0 checks and return consolidated results.
646
755
  * @returns {Object} gate results with score cap
@@ -654,6 +763,7 @@ function runAllGates() {
654
763
  gates.push(checkLintConfigIntegrity());
655
764
  gates.push(checkTests());
656
765
  gates.push(checkScriptCompleteness());
766
+ gates.push(checkFeatureOutputHealth());
657
767
 
658
768
  const cap = calculateScoreCap(gates);
659
769
  const framework = detectFramework();
@@ -716,6 +826,14 @@ function main() {
716
826
  console.log(JSON.stringify(checkScriptCompleteness(), null, 2));
717
827
  break;
718
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
+
719
837
  case 'eslint-disable':
720
838
  console.log(JSON.stringify(countEslintDisables(), null, 2));
721
839
  break;
@@ -827,6 +945,7 @@ module.exports = {
827
945
  checkTests,
828
946
  parseTestErrorCount, // wf-e111d850: exposed for unit testing
829
947
  checkScriptCompleteness,
948
+ checkFeatureOutputHealth, // wf-6c58953a: feature output health gate
830
949
 
831
950
  // Extended checks
832
951
  countEslintDisables,
@@ -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,
@@ -77,23 +77,23 @@ const MATCH_LEVEL_SEVERITY = {
77
77
  };
78
78
 
79
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).
80
+ // wf-00c5067b: 'hook-three-layer' added — entry-file LOC + import-count rule.
81
+ // wf-037f8d66: 'forbidden-patterns' added declarative project-specific
82
+ // patterns (agnosticism rules, no-hardcoding rules, etc.) loaded from
83
+ // .workflow/state/forbidden-patterns.json. Empty file = no-op.
84
84
  const TASK_CHECK_MAP = {
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']
85
+ 'component': ['naming', 'components', 'security', 'hook-three-layer', 'forbidden-patterns'],
86
+ 'utility': ['naming', 'functions', 'security', 'hook-three-layer', 'forbidden-patterns'],
87
+ 'api': ['naming', 'api', 'security', 'hook-three-layer', 'forbidden-patterns'],
88
+ 'feature': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
89
+ 'bugfix': ['naming', 'security', 'hook-three-layer', 'forbidden-patterns'],
90
+ 'refactor': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
91
+ 'story': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
92
+ 'default': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns']
93
93
  };
94
94
 
95
95
  // All available check types
96
- const ALL_CHECK_TYPES = ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'];
96
+ const ALL_CHECK_TYPES = ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'];
97
97
 
98
98
  // ============================================================================
99
99
  // Parse Standards Files
@@ -639,6 +639,140 @@ function checkHookThreeLayer(file, hookThreeLayerConfig = {}) {
639
639
  return violations;
640
640
  }
641
641
 
642
+ /**
643
+ * wf-037f8d66 — Check forbidden patterns from project's declarative rule pack.
644
+ *
645
+ * Projects declare their forbidden patterns in `.workflow/state/forbidden-patterns.json`.
646
+ * This is the GENERIC, DATA-DRIVEN counterpart to checkSecurityPatterns (which is
647
+ * source-coded). Projects can encode "agnosticism" rules, "no-hardcoding" rules,
648
+ * "no-claude-code-literal-outside-docs" rules, etc., without modifying the checker.
649
+ *
650
+ * Format (forbidden-patterns.json):
651
+ * [
652
+ * {
653
+ * "id": "no-claude-code-literal",
654
+ * "pattern": "['\"]claude-code['\"]", // RegExp source string
655
+ * "flags": "g", // optional, defaults to 'g'
656
+ * "exemptions": ["docs/**", "*.md", "reference/**"],
657
+ * "severity": "must-fix", // 'must-fix' | 'warning'
658
+ * "message": "wogiflow-cli is agnostic; ..."
659
+ * }
660
+ * ]
661
+ *
662
+ * @param {Object} file — { path, content }
663
+ * @param {Object[]} patterns — array of pattern entries
664
+ * @returns {Object[]} violations
665
+ */
666
+ function checkForbiddenPatterns(file, patterns) {
667
+ const violations = [];
668
+ if (!Array.isArray(patterns) || patterns.length === 0) return violations;
669
+
670
+ const content = file.content || '';
671
+ const relPath = file.path.startsWith('/')
672
+ ? path.relative(PATHS.root, file.path)
673
+ : file.path;
674
+
675
+ for (const entry of patterns) {
676
+ if (!entry || typeof entry !== 'object') continue;
677
+ if (!entry.pattern || typeof entry.pattern !== 'string') continue;
678
+
679
+ // Exemption check (glob match against relative path)
680
+ const exemptions = Array.isArray(entry.exemptions) ? entry.exemptions : [];
681
+ const exempted = exemptions.some(glob => globMatch(relPath, glob));
682
+ if (exempted) continue;
683
+
684
+ // Compile regex
685
+ let re;
686
+ try {
687
+ const flags = entry.flags && typeof entry.flags === 'string' ? entry.flags : 'g';
688
+ re = new RegExp(entry.pattern, flags);
689
+ } catch (_err) {
690
+ // Invalid regex — skip (don't crash check)
691
+ continue;
692
+ }
693
+
694
+ // Match
695
+ let match;
696
+ re.lastIndex = 0;
697
+ while ((match = re.exec(content)) !== null) {
698
+ const before = content.substring(0, match.index);
699
+ const lineNumber = (before.match(/\n/g) || []).length + 1;
700
+ violations.push({
701
+ type: 'forbidden-pattern',
702
+ severity: entry.severity === 'warning' ? 'warning' : 'must-fix',
703
+ file: file.path,
704
+ line: lineNumber,
705
+ message: entry.message || `Forbidden pattern matched: ${entry.id || entry.pattern}`,
706
+ rule: `forbidden-patterns.json: ${entry.id || '(unnamed)'}`
707
+ });
708
+
709
+ // Avoid infinite loop on zero-length matches
710
+ if (match.index === re.lastIndex) re.lastIndex++;
711
+ }
712
+ }
713
+
714
+ return violations;
715
+ }
716
+
717
+ /**
718
+ * Minimal glob-to-regex matcher for forbidden-pattern exemptions.
719
+ * Supports: `**` (any depth), `*` (no path separator), exact match.
720
+ * Per security-patterns.md §4: use `[^/]*` not `.*` to prevent path
721
+ * separator matching in `*` (only `**` should cross directories).
722
+ */
723
+ function globMatch(filePath, glob) {
724
+ const normalized = filePath.replace(/\\/g, '/');
725
+ // Build regex from glob
726
+ let re = '^';
727
+ let i = 0;
728
+ while (i < glob.length) {
729
+ const c = glob[i];
730
+ if (c === '*' && glob[i + 1] === '*') {
731
+ // ** matches anything including separators
732
+ re += '.*';
733
+ i += 2;
734
+ // Skip a following / so '**/foo' matches both 'foo' and 'a/b/foo'
735
+ if (glob[i] === '/') i++;
736
+ } else if (c === '*') {
737
+ re += '[^/]*';
738
+ i++;
739
+ } else if (c === '?') {
740
+ re += '[^/]';
741
+ i++;
742
+ } else if ('.^$+(){}[]|\\'.includes(c)) {
743
+ re += '\\' + c;
744
+ i++;
745
+ } else {
746
+ re += c;
747
+ i++;
748
+ }
749
+ }
750
+ re += '$';
751
+ try {
752
+ return new RegExp(re).test(normalized);
753
+ } catch (_err) {
754
+ return false;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Load forbidden-patterns.json from project state dir.
760
+ * @returns {Object[]} pattern entries (empty array if missing/invalid)
761
+ */
762
+ function loadForbiddenPatterns(projectRoot) {
763
+ const root = projectRoot || PATHS.root;
764
+ const filePath = path.join(root, '.workflow', 'state', 'forbidden-patterns.json');
765
+ if (!fs.existsSync(filePath)) return [];
766
+ try {
767
+ const raw = fs.readFileSync(filePath, 'utf-8');
768
+ const parsed = JSON.parse(raw);
769
+ if (!Array.isArray(parsed)) return [];
770
+ return parsed;
771
+ } catch (_err) {
772
+ return [];
773
+ }
774
+ }
775
+
642
776
  function checkApiDuplication(file, existingEndpoints, matchConfig) {
643
777
  const violations = [];
644
778
  const content = file.content || '';
@@ -1090,6 +1224,8 @@ function runStandardsCheck(files, options = {}) {
1090
1224
  const functions = checksToRun.includes('functions') ? parseFunctionMap() : [];
1091
1225
  const endpoints = checksToRun.includes('api') ? parseApiMap() : [];
1092
1226
  const rulesFiles = checksToRun.includes('security') ? loadRulesDir() : [];
1227
+ // wf-037f8d66: load forbidden-patterns.json (empty array if missing)
1228
+ const forbiddenPatterns = checksToRun.includes('forbidden-patterns') ? loadForbiddenPatterns() : [];
1093
1229
 
1094
1230
  // Load schema/service registries if needed
1095
1231
  const schemas = checksToRun.includes('schemas') ? parseSchemaMap() : [];
@@ -1115,7 +1251,8 @@ function runStandardsCheck(files, options = {}) {
1115
1251
  'service-map.md': { checked: checksToRun.includes('services') && services.length > 0, violations: 0 },
1116
1252
  'naming-conventions': { checked: checksToRun.includes('naming'), violations: 0 },
1117
1253
  'security-patterns': { checked: checksToRun.includes('security'), violations: 0 },
1118
- 'hook-three-layer': { checked: checksToRun.includes('hook-three-layer'), violations: 0 }
1254
+ 'hook-three-layer': { checked: checksToRun.includes('hook-three-layer'), violations: 0 },
1255
+ 'forbidden-patterns': { checked: checksToRun.includes('forbidden-patterns') && forbiddenPatterns.length > 0, violations: 0 }
1119
1256
  };
1120
1257
 
1121
1258
  for (const file of files) {
@@ -1181,6 +1318,13 @@ function runStandardsCheck(files, options = {}) {
1181
1318
  allViolations.push(...hookViolations);
1182
1319
  checksSummary['hook-three-layer'].violations += hookViolations.length;
1183
1320
  }
1321
+
1322
+ // Forbidden patterns from project rule pack (wf-037f8d66)
1323
+ if (checksToRun.includes('forbidden-patterns') && forbiddenPatterns.length > 0) {
1324
+ const fpViolations = checkForbiddenPatterns(file, forbiddenPatterns);
1325
+ allViolations.push(...fpViolations);
1326
+ checksSummary['forbidden-patterns'].violations += fpViolations.length;
1327
+ }
1184
1328
  }
1185
1329
 
1186
1330
  // Count must-fix violations
@@ -1415,6 +1559,9 @@ module.exports = {
1415
1559
  checkRegistryDuplication,
1416
1560
  checkSecurityPatterns,
1417
1561
  checkHookThreeLayer,
1562
+ checkForbiddenPatterns, // wf-037f8d66: declarative project rule pack
1563
+ loadForbiddenPatterns, // wf-037f8d66: exposed for tests + external use
1564
+ globMatch, // wf-037f8d66: exposed for unit testing
1418
1565
  extractDeclaredNames,
1419
1566
  discoverAllRegistries,
1420
1567
  collectReuseCandidates,
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Architect-Required Gate (wf-037f8d66)
5
+ *
6
+ * Closes the methodology gap where the IGR Architect/Adversary pass at
7
+ * spec_review IS specced (per .claude/docs/phases/02-spec.md) but enforcement
8
+ * is prompt-only — the agent can skip Architect and go straight to coding.
9
+ *
10
+ * This gate fires on Edit/Write during the `coding` phase for L1+ tasks when
11
+ * config.intentGroundedReasoning.enabled is true and no evidence of an
12
+ * Architect run exists for the current task.
13
+ *
14
+ * Evidence marker: `.workflow/state/architect-runs/<task-id>.json` written
15
+ * by flow-architect-pass.js on successful completion.
16
+ *
17
+ * Scope:
18
+ * - L0 (epic) / L1 (story) tasks: Architect required → gate enforces
19
+ * - L2 / L3 tasks: skip spec_review entirely (correctly) → gate is a no-op
20
+ *
21
+ * Fail-open: any error reading state/config → allow tool call. Same pattern
22
+ * as research-evidence-gate.js.
23
+ */
24
+
25
+ const path = require('node:path');
26
+ const fs = require('node:fs');
27
+ const { PATHS } = require('../../flow-utils');
28
+
29
+ const ARCHITECT_RUNS_DIR = path.join(PATHS.state, 'architect-runs');
30
+
31
+ /**
32
+ * Compute path to the Architect-run evidence marker for a task.
33
+ */
34
+ function getArchitectRunPath(taskId) {
35
+ if (!taskId || typeof taskId !== 'string') return null;
36
+ return path.join(ARCHITECT_RUNS_DIR, `${taskId}.json`);
37
+ }
38
+
39
+ /**
40
+ * Write an Architect-run evidence marker. Called by flow-architect-pass.js
41
+ * on successful completion. Atomic write-temp + rename.
42
+ *
43
+ * @param {Object} payload — { taskId, completedAt, model, plan }
44
+ * @returns {{ written: boolean, path: string|null }}
45
+ */
46
+ function writeArchitectRunMarker(payload) {
47
+ if (!payload || !payload.taskId) {
48
+ return { written: false, path: null };
49
+ }
50
+ try {
51
+ if (!fs.existsSync(ARCHITECT_RUNS_DIR)) {
52
+ fs.mkdirSync(ARCHITECT_RUNS_DIR, { recursive: true });
53
+ }
54
+ const filePath = getArchitectRunPath(payload.taskId);
55
+ const tmpPath = `${filePath}.tmp-${process.pid}`;
56
+ fs.writeFileSync(tmpPath, JSON.stringify({
57
+ taskId: payload.taskId,
58
+ completedAt: payload.completedAt || new Date().toISOString(),
59
+ model: payload.model || null,
60
+ plan: payload.plan || null
61
+ }, null, 2));
62
+ fs.renameSync(tmpPath, filePath);
63
+ return { written: true, path: filePath };
64
+ } catch (_err) {
65
+ return { written: false, path: null };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Check whether an Architect run is recorded for a given task.
71
+ * @param {string} taskId
72
+ * @returns {boolean}
73
+ */
74
+ function hasArchitectRun(taskId) {
75
+ const p = getArchitectRunPath(taskId);
76
+ if (!p) return false;
77
+ try {
78
+ return fs.existsSync(p);
79
+ } catch (_err) {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Determine whether the current task requires Architect (L1+ only).
86
+ * @param {Object} taskMeta — task record from ready.json (has level, type)
87
+ * @returns {boolean}
88
+ */
89
+ function requiresArchitect(taskMeta) {
90
+ if (!taskMeta || typeof taskMeta !== 'object') return false;
91
+ const level = (taskMeta.level || '').toUpperCase();
92
+ // L0 (epic) and L1 (story) require Architect.
93
+ // L2 (task) / L3 (subtask) bypass spec_review correctly.
94
+ return level === 'L0' || level === 'L1';
95
+ }
96
+
97
+ /**
98
+ * Read whether the gate is enabled from config.
99
+ * Default: enabled when IGR is enabled.
100
+ */
101
+ function isGateEnabled(config) {
102
+ const igr = config?.intentGroundedReasoning;
103
+ if (!igr || igr.enabled === false) return false;
104
+ // Explicit toggle on the gate itself overrides
105
+ if (config?.architectRequiredGate?.enabled === false) return false;
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Main gate check.
111
+ *
112
+ * @param {Object} ctx — { phase, taskId, taskMeta, config, toolName }
113
+ * @returns {{ blocked: boolean, reason?: string, message?: string }}
114
+ */
115
+ function checkArchitectRequired(ctx) {
116
+ const { phase, taskId, taskMeta, config, toolName } = ctx || {};
117
+
118
+ // Only fires on tools that mutate state (Edit/Write/Bash/TodoWrite)
119
+ const mutationTools = new Set(['Edit', 'Write', 'TodoWrite', 'Bash']);
120
+ if (!toolName || !mutationTools.has(toolName)) {
121
+ return { blocked: false };
122
+ }
123
+
124
+ // Only fires during coding phase
125
+ if (phase !== 'coding') {
126
+ return { blocked: false };
127
+ }
128
+
129
+ // Gate disabled (or IGR off)
130
+ if (!isGateEnabled(config)) {
131
+ return { blocked: false };
132
+ }
133
+
134
+ // No active task → not in scope (gate doesn't apply)
135
+ if (!taskId) {
136
+ return { blocked: false };
137
+ }
138
+
139
+ // L2/L3 tasks bypass spec_review correctly — gate is a no-op
140
+ if (!requiresArchitect(taskMeta)) {
141
+ return { blocked: false };
142
+ }
143
+
144
+ // Check for evidence marker
145
+ if (hasArchitectRun(taskId)) {
146
+ return { blocked: false };
147
+ }
148
+
149
+ // Block: Architect required but no evidence
150
+ return {
151
+ blocked: true,
152
+ reason: 'architect-required',
153
+ message: [
154
+ `ARCHITECT-REQUIRED GATE: task ${taskId} is L1+ in coding phase but no `,
155
+ `Architect run is recorded at ${getArchitectRunPath(taskId)}.\n\n`,
156
+ `Per .claude/docs/phases/02-spec.md Step 1.55, L1+ tasks must run an `,
157
+ `Architect pass before coding. Invoke:\n\n`,
158
+ ` node scripts/flow-architect-pass.js run --task=${taskId}\n\n`,
159
+ `Then retry your edit. To opt out for this task only, set `,
160
+ `config.architectRequiredGate.enabled = false (project-level), or use `,
161
+ `\`flow architect-skip --task=${taskId} --reason="..."\` (single-task escape; `,
162
+ `not yet implemented — opens follow-up wf if needed).`
163
+ ].join('')
164
+ };
165
+ }
166
+
167
+ module.exports = {
168
+ checkArchitectRequired,
169
+ writeArchitectRunMarker,
170
+ hasArchitectRun,
171
+ requiresArchitect,
172
+ getArchitectRunPath,
173
+ isGateEnabled,
174
+ ARCHITECT_RUNS_DIR
175
+ };
@@ -58,6 +58,15 @@ function loadGateDeps() {
58
58
  if (process.env.DEBUG) console.error(`[Hook] Phase-read gate not loaded: ${_err.message}`);
59
59
  }
60
60
 
61
+ // wf-037f8d66: Architect-required gate (mechanical Layer 2 enforcement)
62
+ let checkArchitectRequired = () => ({ blocked: false });
63
+ try {
64
+ const arg = require('./architect-required-gate');
65
+ checkArchitectRequired = arg.checkArchitectRequired;
66
+ } catch (_err) {
67
+ if (process.env.DEBUG) console.error(`[Hook] Architect-required gate not loaded: ${_err.message}`);
68
+ }
69
+
61
70
  let recordEvidenceRead = () => {};
62
71
  let checkSpecWriteGate = _phaseNoop;
63
72
  let clearResearchEvidence = () => {};
@@ -168,6 +177,7 @@ function loadGateDeps() {
168
177
  checkRoutingGate, clearRoutingPending, hasActiveTask,
169
178
  checkPhaseGate, checkCommitLogGate,
170
179
  recordPhaseRead, checkPhaseReadGate, clearPhaseReads,
180
+ checkArchitectRequired, // wf-037f8d66
171
181
  recordEvidenceRead, checkSpecWriteGate, clearResearchEvidence,
172
182
  checkDeployGate, checkWriteBlock,
173
183
  checkStrikeGate, checkBugfixScope, checkScopeMutation,
@@ -118,6 +118,51 @@ function runPreToolGates(ctx, deps) {
118
118
  }
119
119
  }
120
120
 
121
+ // Architect-required gate (wf-037f8d66)
122
+ // L1+ tasks in coding phase must have run Architect/Adversary before any Edit/Write/Bash.
123
+ // L2/L3 tasks bypass spec_review entirely (correctly), so the gate is a no-op there.
124
+ // Fail-open on any error.
125
+ if (typeof deps.checkArchitectRequired === 'function' &&
126
+ (toolName === 'Edit' || toolName === 'Write' || toolName === 'Bash' || toolName === 'TodoWrite')) {
127
+ try {
128
+ // Resolve current phase + task meta from active state
129
+ const flowUtils = require('../../flow-utils');
130
+ const flowIo = require('../../flow-io');
131
+ let phase = 'idle';
132
+ let taskId = null;
133
+ let taskMeta = null;
134
+ try {
135
+ const phaseStatePath = path.join(flowUtils.PATHS.state, 'workflow-phase.json');
136
+ const ps = flowIo.safeJsonParse(phaseStatePath, null);
137
+ if (ps) {
138
+ phase = ps.phase || 'idle';
139
+ taskId = ps.taskId || null;
140
+ }
141
+ } catch (_err) { /* fail-open */ }
142
+ if (taskId) {
143
+ try {
144
+ const ready = flowUtils.getReadyData ? flowUtils.getReadyData() : null;
145
+ const inProgress = (ready && Array.isArray(ready.inProgress)) ? ready.inProgress : [];
146
+ taskMeta = inProgress.find(t => t && t.id === taskId) || null;
147
+ } catch (_err) { /* fail-open */ }
148
+ }
149
+
150
+ const archResult = deps.checkArchitectRequired({
151
+ phase, taskId, taskMeta, config, toolName
152
+ });
153
+ if (archResult.blocked) {
154
+ return {
155
+ allowed: false,
156
+ blocked: true,
157
+ reason: archResult.reason || 'architect-required',
158
+ message: archResult.message,
159
+ };
160
+ }
161
+ } catch (_err) {
162
+ if (process.env.DEBUG) console.error(`[Hook] Architect-required gate error (fail-open): ${_err.message}`);
163
+ }
164
+ }
165
+
121
166
  // wf-f9912af6: Deferral-authorization gate. Blocks Write/Edit to
122
167
  // last-review.json / last-audit.json when the new content introduces
123
168
  // `status: deferred*` on findings without explicit user authorization.