wogiflow 2.30.0 → 2.30.1

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.
@@ -0,0 +1,15 @@
1
+ [
2
+ {
3
+ "id": "no-claude-code-literal-in-core",
4
+ "pattern": "claude-code|tool_name|tool_input",
5
+ "flags": "i",
6
+ "severity": "must-fix",
7
+ "message": "Hooks/core files must not reference CLI-specific identifiers (claude-code, tool_name, tool_input). Use the normalized toolName / toolInput parameters from the orchestrator (CLI-agnostic). See .claude/rules/architecture/hook-three-layer.md.",
8
+ "exemptions": [
9
+ "scripts/hooks/entry/**",
10
+ "scripts/hooks/adapters/**",
11
+ "tests/**",
12
+ "**/*.test.js"
13
+ ]
14
+ }
15
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.30.0",
3
+ "version": "2.30.1",
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-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",
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 tests/flow-architect-runs.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",
@@ -480,16 +480,19 @@ function recordTelemetry({ taskId, parseResult, gateResult, runCtx = {} }) {
480
480
  },
481
481
  });
482
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.
483
+ // wf-037f8d66 / wf-2eafdab0: Write Architect-run marker for the architect-required
484
+ // PreToolUse gate. Marker proves Architect ran for this task — gate consults it
485
+ // before allowing Edit/Write/Bash in coding phase for L1+ tasks. Fail-open on any
486
+ // error. Imports from flow-architect-runs.js (neutral location — review M5 fix:
487
+ // hooks/core no longer owns the marker store, killing the cross-direction dep).
486
488
  if (taskId && (telemetryVerdict === 'PASS' || telemetryVerdict === 'PASS_WITH_NOTES')) {
487
489
  try {
488
- const archGate = require('./hooks/core/architect-required-gate');
489
- archGate.writeArchitectRunMarker({
490
+ const archRuns = require('./flow-architect-runs');
491
+ archRuns.writeArchitectRunMarker({
490
492
  taskId,
491
493
  model: runCtx.model || null,
492
494
  plan: parseResult?.plan ? { sections: sectionsPresent } : null,
495
+ specPath: runCtx.specPath || null, // AC15: enables specHash staleness detection
493
496
  });
494
497
  } catch (_err) { /* fail-open */ }
495
498
  }
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Architect-Run Marker Store (wf-2eafdab0 / v2.30.1)
5
+ *
6
+ * Owns the read/write/GC operations for Architect-run evidence markers
7
+ * stored at `.workflow/state/architect-runs/<task-id>.json`.
8
+ *
9
+ * Why this lives in scripts/ (not hooks/core/): the architect-required gate
10
+ * READS markers; flow-architect-pass.js WRITES markers. With both helpers
11
+ * in hooks/core, we'd invert the established hooks-depend-on-scripts
12
+ * direction (review finding M5). This module is the neutral home both
13
+ * callers consume from.
14
+ *
15
+ * Public API:
16
+ * - getArchitectRunPath(taskId) → string|null (validateTaskId guarded)
17
+ * - writeArchitectRunMarker({taskId, model, plan, specPath}) → {written, path}
18
+ * - hasArchitectRun(taskId, currentSpecPath?) → boolean (content-validated)
19
+ * - gcStaleMarkers({maxAgeMs?, retainCompletedTasks?}) → {removed, kept}
20
+ * - ARCHITECT_RUNS_DIR (constant)
21
+ *
22
+ * Hardening this round (review-fix v2.30.1):
23
+ * - AC9: hasArchitectRun validates JSON parse + taskId field match
24
+ * - AC11: validateTaskId guard before path.join (path-traversal defense)
25
+ * - AC14: tmp file unlinked on rename failure
26
+ * - AC15: specHash (sha256) in marker payload for staleness detection
27
+ * - AC8: gcStaleMarkers removes completed-task markers older than maxAgeMs
28
+ */
29
+
30
+ const path = require('node:path');
31
+ const fs = require('node:fs');
32
+ const crypto = require('node:crypto');
33
+ const { PATHS, safeJsonParse, validateTaskId, getReadyData } = require('./flow-utils');
34
+
35
+ const ARCHITECT_RUNS_DIR = path.join(PATHS.state, 'architect-runs');
36
+ const DEFAULT_GC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
37
+
38
+ /**
39
+ * Compute path to the Architect-run evidence marker.
40
+ * Returns null if taskId fails validateTaskId (path-traversal defense).
41
+ */
42
+ function getArchitectRunPath(taskId) {
43
+ if (!taskId || typeof taskId !== 'string') return null;
44
+ // validateTaskId returns { valid, format }, not a bool — check the field.
45
+ const v = validateTaskId(taskId);
46
+ if (!v || v.valid !== true) return null;
47
+ return path.join(ARCHITECT_RUNS_DIR, `${taskId}.json`);
48
+ }
49
+
50
+ /**
51
+ * Compute sha256 of a file's content. Returns null if file missing/unreadable.
52
+ */
53
+ function _hashFile(filePath) {
54
+ if (!filePath) return null;
55
+ try {
56
+ const content = fs.readFileSync(filePath, 'utf-8');
57
+ return crypto.createHash('sha256').update(content).digest('hex');
58
+ } catch (_err) {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Write Architect-run marker. Atomic temp-then-rename. Tmp file unlinked
65
+ * on rename failure (no leaked tmp files).
66
+ */
67
+ function writeArchitectRunMarker(payload) {
68
+ if (!payload || !payload.taskId) {
69
+ return { written: false, path: null };
70
+ }
71
+ const filePath = getArchitectRunPath(payload.taskId);
72
+ if (!filePath) {
73
+ return { written: false, path: null };
74
+ }
75
+ const tmpPath = `${filePath}.tmp-${process.pid}`;
76
+ try {
77
+ if (!fs.existsSync(ARCHITECT_RUNS_DIR)) {
78
+ fs.mkdirSync(ARCHITECT_RUNS_DIR, { recursive: true });
79
+ }
80
+ const marker = {
81
+ taskId: payload.taskId,
82
+ completedAt: payload.completedAt || new Date().toISOString(),
83
+ model: payload.model || null,
84
+ plan: payload.plan || null,
85
+ specHash: payload.specPath ? _hashFile(payload.specPath) : null,
86
+ specPath: payload.specPath || null
87
+ };
88
+ fs.writeFileSync(tmpPath, JSON.stringify(marker, null, 2));
89
+ fs.renameSync(tmpPath, filePath);
90
+ return { written: true, path: filePath };
91
+ } catch (_err) {
92
+ // Clean up the tmp file if rename failed — don't leak.
93
+ try { fs.unlinkSync(tmpPath); } catch (_e) { /* fail-open */ }
94
+ return { written: false, path: null };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validate that a marker exists AND its content is well-formed AND, if
100
+ * `currentSpecPath` is provided, the spec hasn't changed since the marker
101
+ * was written.
102
+ *
103
+ * Returns false on:
104
+ * - missing file
105
+ * - JSON parse failure (corrupted marker)
106
+ * - taskId field mismatch (wrong marker for this task)
107
+ * - specHash mismatch (spec was edited after Architect ran — stale)
108
+ */
109
+ function hasArchitectRun(taskId, currentSpecPath) {
110
+ const p = getArchitectRunPath(taskId);
111
+ if (!p) return false;
112
+ if (!fs.existsSync(p)) return false;
113
+ const marker = safeJsonParse(p, null);
114
+ if (!marker || typeof marker !== 'object') return false;
115
+ if (marker.taskId !== taskId) return false;
116
+ // specHash check (AC15 — stale-spec invalidation)
117
+ if (currentSpecPath && marker.specHash) {
118
+ const currentHash = _hashFile(currentSpecPath);
119
+ if (currentHash && currentHash !== marker.specHash) return false;
120
+ }
121
+ return true;
122
+ }
123
+
124
+ /**
125
+ * Remove markers whose task is in `recentlyCompleted` and whose mtime is
126
+ * older than maxAgeMs. Idempotent; safe to call repeatedly.
127
+ *
128
+ * @param {Object} opts
129
+ * @param {number} [opts.maxAgeMs=7d] — markers older than this are eligible
130
+ * @param {boolean} [opts.retainCompletedTasks=false] — keep markers for completed tasks
131
+ * (default: GC them)
132
+ * @returns {{removed: string[], kept: string[]}}
133
+ */
134
+ function gcStaleMarkers(opts = {}) {
135
+ const maxAgeMs = typeof opts.maxAgeMs === 'number' ? opts.maxAgeMs : DEFAULT_GC_MAX_AGE_MS;
136
+ const retainCompletedTasks = opts.retainCompletedTasks === true;
137
+ const result = { removed: [], kept: [] };
138
+ if (!fs.existsSync(ARCHITECT_RUNS_DIR)) return result;
139
+
140
+ // Build set of completed task IDs (markers eligible for GC by default)
141
+ let completedIds = new Set();
142
+ try {
143
+ const ready = getReadyData();
144
+ if (ready && Array.isArray(ready.recentlyCompleted)) {
145
+ for (const t of ready.recentlyCompleted) {
146
+ if (t && t.id) completedIds.add(t.id);
147
+ }
148
+ }
149
+ } catch (_err) { /* fail-open */ }
150
+
151
+ const now = Date.now();
152
+ let entries = [];
153
+ try {
154
+ entries = fs.readdirSync(ARCHITECT_RUNS_DIR);
155
+ } catch (_err) { return result; }
156
+
157
+ for (const entry of entries) {
158
+ if (!entry.endsWith('.json')) continue;
159
+ const taskId = entry.slice(0, -5); // strip .json
160
+ if (!validateTaskId(taskId).valid) {
161
+ // Stray file — leave alone (could be backup or someone else's data)
162
+ result.kept.push(taskId);
163
+ continue;
164
+ }
165
+ const fullPath = path.join(ARCHITECT_RUNS_DIR, entry);
166
+ let stat;
167
+ try { stat = fs.statSync(fullPath); } catch (_err) { continue; }
168
+ const ageMs = now - stat.mtimeMs;
169
+ const isCompleted = completedIds.has(taskId);
170
+ const isExpired = ageMs > maxAgeMs;
171
+
172
+ // Eligible for GC: task is completed AND marker is older than maxAge,
173
+ // unless retainCompletedTasks is true.
174
+ if (isCompleted && isExpired && !retainCompletedTasks) {
175
+ try {
176
+ fs.unlinkSync(fullPath);
177
+ result.removed.push(taskId);
178
+ } catch (_err) {
179
+ result.kept.push(taskId);
180
+ }
181
+ } else {
182
+ result.kept.push(taskId);
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ module.exports = {
189
+ ARCHITECT_RUNS_DIR,
190
+ getArchitectRunPath,
191
+ writeArchitectRunMarker,
192
+ hasArchitectRun,
193
+ gcStaleMarkers
194
+ };
@@ -36,6 +36,9 @@ const fs = require('node:fs');
36
36
  const path = require('node:path');
37
37
  const { execSync } = require('node:child_process');
38
38
  const { PATHS, safeJsonParse } = require('./flow-utils');
39
+ const { globToRegex: _gtr } = require('./flow-glob');
40
+ // Local convenience: case-insensitive glob (this module's historical default)
41
+ const globToRegex = (pat) => _gtr(pat, 'i');
39
42
 
40
43
  const DOSSIER_DIRNAME = 'dossiers';
41
44
  const INDEX_FILENAME = 'index.json';
@@ -254,15 +257,6 @@ function matchFeatures(input = {}) {
254
257
  return Object.values(scores).sort((a, b) => b.score - a.score);
255
258
  }
256
259
 
257
- function globToRegex(pat) {
258
- const escaped = pat
259
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
260
- .replace(/\*\*/g, '.__DBLSTAR__')
261
- .replace(/\*/g, '[^/]*')
262
- .replace(/\.__DBLSTAR__/g, '.*')
263
- .replace(/\?/g, '[^/]');
264
- return new RegExp(`^${escaped}$`, 'i');
265
- }
266
260
 
267
261
  function scaffoldDossier(slug, meta = {}) {
268
262
  if (RESERVED_SLUGS.has(slug)) {
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Glob Matcher (wf-2eafdab0 / v2.30.1)
5
+ *
6
+ * Single source of truth for glob → regex conversion across:
7
+ * - flow-standards-checker.js (forbidden-patterns exemptions)
8
+ * - flow-feature-dossier.js (dossier scope matching)
9
+ * - flow-logic-rules.js (logic-rule applies-to scope)
10
+ *
11
+ * Replaces 3 separate inline implementations (review finding M4).
12
+ *
13
+ * Semantics (security-patterns.md §4 compliant):
14
+ * - single-star matches any chars EXCEPT path separators (uses [^/]*, not .*)
15
+ * - double-star matches any chars INCLUDING path separators (uses .*)
16
+ * - question matches a single non-separator char ([^/])
17
+ * - "dir/double-star" ALSO matches "dir" itself (the directory case — review M3)
18
+ * - "double-star/foo" requires a directory boundary or root — does NOT match
19
+ * "xfoo" as a substring of another segment (review M3)
20
+ * - All regex metacharacters in literal portions are escaped.
21
+ *
22
+ * Public API:
23
+ * - globToRegex(glob) → RegExp (anchored ^...$, throws if input invalid)
24
+ * - globMatch(filePath, glob) → boolean (false-on-error fail-open)
25
+ */
26
+
27
+ /** Regex metacharacters that need escaping when they appear as literal chars. */
28
+ const REGEX_METACHARS = '.^$+(){}[]|\\';
29
+
30
+ /**
31
+ * Convert a glob pattern to an anchored regex source string (no flags).
32
+ * Returns the regex source; caller wraps in `new RegExp(src)` if needed.
33
+ *
34
+ * Edge cases (notation: ★ = star — literal asterisk-slash ends JSDoc blocks).
35
+ * "dir/★★" → directory-or-anything-under regex; "★★/foo" → optional-prefix
36
+ * regex that requires a directory boundary; bare "★★" → match-anything regex.
37
+ */
38
+ function globToRegexSource(glob) {
39
+ if (typeof glob !== 'string') throw new TypeError('glob must be a string');
40
+ let re = '^';
41
+ let i = 0;
42
+ while (i < glob.length) {
43
+ const c = glob[i];
44
+ // Detect `**/` — anchored anywhere or at start
45
+ if (c === '*' && glob[i + 1] === '*' && glob[i + 2] === '/') {
46
+ // `**/` matches zero-or-more directory segments + separator
47
+ re += '(?:.*/)?';
48
+ i += 3;
49
+ } else if (c === '/' && glob[i + 1] === '*' && glob[i + 2] === '*' && (i + 3 === glob.length || glob[i + 3] === undefined)) {
50
+ // `dir/**` at end: match `/anything` OR nothing (dir itself)
51
+ re += '(?:/.*)?';
52
+ i += 3;
53
+ } else if (c === '*' && glob[i + 1] === '*') {
54
+ // `**` not followed by `/` and not preceded by `/` — bare `**` matches anything
55
+ re += '.*';
56
+ i += 2;
57
+ } else if (c === '*') {
58
+ // single `*` — no path separators
59
+ re += '[^/]*';
60
+ i++;
61
+ } else if (c === '?') {
62
+ re += '[^/]';
63
+ i++;
64
+ } else if (REGEX_METACHARS.includes(c)) {
65
+ re += '\\' + c;
66
+ i++;
67
+ } else {
68
+ re += c;
69
+ i++;
70
+ }
71
+ }
72
+ re += '$';
73
+ return re;
74
+ }
75
+
76
+ /**
77
+ * Compile a glob to a RegExp object. Always anchored.
78
+ * @param {string} glob — pattern string
79
+ * @param {string} [flags=''] — RegExp flags (e.g., 'i' for case-insensitive)
80
+ */
81
+ function globToRegex(glob, flags = '') {
82
+ return new RegExp(globToRegexSource(glob), flags);
83
+ }
84
+
85
+ /**
86
+ * Match a file path against a glob. Normalizes Windows-style separators
87
+ * to forward slashes. Returns false on any error (fail-open for callers
88
+ * that don't want to handle exceptions).
89
+ */
90
+ function globMatch(filePath, glob) {
91
+ if (typeof filePath !== 'string' || typeof glob !== 'string') return false;
92
+ const normalized = filePath.replace(/\\/g, '/');
93
+ try {
94
+ return globToRegex(glob).test(normalized);
95
+ } catch (_err) {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ module.exports = {
101
+ globToRegex,
102
+ globToRegexSource,
103
+ globMatch
104
+ };
@@ -33,6 +33,9 @@ const fs = require('node:fs');
33
33
  const path = require('node:path');
34
34
  const { execSync } = require('node:child_process');
35
35
  const { PATHS } = require('./flow-utils');
36
+ const { globToRegex: _gtr } = require('./flow-glob');
37
+ // Local convenience: case-insensitive glob (this module's historical default)
38
+ const globToRegex = (pat) => _gtr(pat, 'i');
36
39
  const { getDossierRoots, DOSSIER_DIRNAME: _DOSSIER_DIRNAME } = require('./flow-feature-dossier');
37
40
 
38
41
  const LOGIC_RULES_FILENAME = '_logic-rules.md';
@@ -133,15 +136,6 @@ function listRules() {
133
136
  return all;
134
137
  }
135
138
 
136
- function globToRegex(pat) {
137
- const escaped = pat
138
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
139
- .replace(/\*\*/g, '.__DBLSTAR__')
140
- .replace(/\*/g, '[^/]*')
141
- .replace(/\.__DBLSTAR__/g, '.*')
142
- .replace(/\?/g, '[^/]');
143
- return new RegExp(`^${escaped}$`, 'i');
144
- }
145
139
 
146
140
  function matchRulesForFiles(files = [], extraKeywords = []) {
147
141
  if (!Array.isArray(files)) files = [files];
@@ -23,6 +23,7 @@ const {
23
23
  color,
24
24
  getConfig
25
25
  } = require('./flow-utils');
26
+ const { globMatch } = require('./flow-glob');
26
27
  const {
27
28
  calculateCombinedSimilarity,
28
29
  getMatchLevel,
@@ -672,6 +673,12 @@ function checkForbiddenPatterns(file, patterns) {
672
673
  ? path.relative(PATHS.root, file.path)
673
674
  : file.path;
674
675
 
676
+ // AC3 (M1 fix): per-file content size cap. Skip files that exceed the cap;
677
+ // attacker-supplied or accidentally-huge inputs can't lock the matcher.
678
+ if (content.length > DEFAULT_MAX_CONTENT_BYTES) {
679
+ return violations; // silently skip — large file, no scan
680
+ }
681
+
675
682
  for (const entry of patterns) {
676
683
  if (!entry || typeof entry !== 'object') continue;
677
684
  if (!entry.pattern || typeof entry.pattern !== 'string') continue;
@@ -681,6 +688,20 @@ function checkForbiddenPatterns(file, patterns) {
681
688
  const exempted = exemptions.some(glob => globMatch(relPath, glob));
682
689
  if (exempted) continue;
683
690
 
691
+ // AC3 (M1 fix): pre-compile reject of catastrophic-backtracking patterns.
692
+ const catRisk = detectCatastrophicPattern(entry.pattern);
693
+ if (catRisk) {
694
+ violations.push({
695
+ type: 'forbidden-pattern-malformed',
696
+ severity: 'warning',
697
+ file: file.path,
698
+ line: 1,
699
+ message: `Pattern "${entry.id || entry.pattern}" rejected pre-compile: ${catRisk}`,
700
+ rule: `forbidden-patterns.json: ${entry.id || '(unnamed)'}`
701
+ });
702
+ continue; // don't compile or run the dangerous pattern
703
+ }
704
+
684
705
  // Compile regex
685
706
  let re;
686
707
  try {
@@ -691,10 +712,25 @@ function checkForbiddenPatterns(file, patterns) {
691
712
  continue;
692
713
  }
693
714
 
694
- // Match
715
+ // Match — with wall-clock budget (AC3/M1 fix).
695
716
  let match;
696
717
  re.lastIndex = 0;
718
+ const deadline = Date.now() + DEFAULT_MATCH_BUDGET_MS;
697
719
  while ((match = re.exec(content)) !== null) {
720
+ // Budget check: bail out gracefully if the matcher takes too long.
721
+ // Acceptable to drop late matches — defends against ReDoS, not a
722
+ // correctness guarantee.
723
+ if (Date.now() > deadline) {
724
+ violations.push({
725
+ type: 'forbidden-pattern-timeout',
726
+ severity: 'warning',
727
+ file: file.path,
728
+ line: 1,
729
+ message: `Pattern "${entry.id || entry.pattern}" exceeded ${DEFAULT_MATCH_BUDGET_MS}ms budget on this file — partial results, scan aborted.`,
730
+ rule: `forbidden-patterns.json: ${entry.id || '(unnamed)'}`
731
+ });
732
+ break;
733
+ }
698
734
  const before = content.substring(0, match.index);
699
735
  const lineNumber = (before.match(/\n/g) || []).length + 1;
700
736
  violations.push({
@@ -714,63 +750,69 @@ function checkForbiddenPatterns(file, patterns) {
714
750
  return violations;
715
751
  }
716
752
 
753
+ // Note: globMatch was extracted to scripts/flow-glob.js (review M4 fix —
754
+ // kills 3 separate inline implementations). Imported at top of this file.
755
+
717
756
  /**
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).
757
+ * Detect catastrophic-backtracking regex patterns BEFORE compilation.
758
+ * Returns a reason string if the pattern looks ReDoS-prone, null otherwise.
759
+ *
760
+ * Heuristic: nested quantifiers like (a+)+, (.*)*, ([a-z]+)+ are the canonical
761
+ * shape. We don't claim to catch every adversarial pattern — just the common
762
+ * ones. Per AC3 (review M1 fix).
722
763
  */
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;
764
+ function detectCatastrophicPattern(patternSrc) {
765
+ if (typeof patternSrc !== 'string') return null;
766
+ // Match: ( ... <quantifier> ) <quantifier>
767
+ // Where quantifier is one of: +, *, {n,m}
768
+ const NESTED = /\([^()]*[+*][^()]*\)\s*[+*]/;
769
+ if (NESTED.test(patternSrc)) {
770
+ return 'nested-quantifier (e.g., (a+)+, (.*)*, ([a-z]+)+) — catastrophic backtracking risk';
755
771
  }
772
+ return null;
756
773
  }
757
774
 
775
+ const DEFAULT_MAX_CONTENT_BYTES = 1_000_000; // 1MB
776
+ const DEFAULT_MATCH_BUDGET_MS = 200;
777
+
758
778
  /**
759
779
  * Load forbidden-patterns.json from project state dir.
780
+ * Uses safeJsonParse (per security-patterns.md §2 — review H1 fix).
781
+ * Emits stderr warning when state dir exists but file does NOT (AC2/H2 — silent
782
+ * no-op detection) and when file exists but parse fails (AC12/L2).
760
783
  * @returns {Object[]} pattern entries (empty array if missing/invalid)
761
784
  */
762
785
  function loadForbiddenPatterns(projectRoot) {
763
786
  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) {
787
+ const stateDir = path.join(root, '.workflow', 'state');
788
+ const filePath = path.join(stateDir, 'forbidden-patterns.json');
789
+ if (!fs.existsSync(filePath)) {
790
+ // AC2/H2: warn when state dir is initialized but pack is missing.
791
+ // Mirrors the CL-004 stderr-warn pattern in pre-tool-deps.js — silently
792
+ // shimming a missing rule pack masks broken installs.
793
+ if (fs.existsSync(stateDir) && process.env.WOGI_QUIET !== '1') {
794
+ console.error(
795
+ '[Standards] forbidden-patterns.json not found at .workflow/state/ — feature ' +
796
+ 'inactive this run. Run `flow init --force` or copy from ' +
797
+ '.workflow/state/forbidden-patterns.json.template to enable.'
798
+ );
799
+ }
800
+ return [];
801
+ }
802
+ // AC1/H1 fix: use safeJsonParse instead of raw JSON.parse(fs.readFileSync(...))
803
+ const parsed = safeJsonParse(filePath, null);
804
+ if (parsed === null) {
805
+ // AC12/L2: warn when the file exists but parse failed (syntax error).
806
+ if (process.env.WOGI_QUIET !== '1') {
807
+ console.error(
808
+ '[Standards] forbidden-patterns.json failed to parse — feature disabled this run. ' +
809
+ 'Check for trailing commas / mismatched braces.'
810
+ );
811
+ }
772
812
  return [];
773
813
  }
814
+ if (!Array.isArray(parsed)) return [];
815
+ return parsed;
774
816
  }
775
817
 
776
818
  function checkApiDuplication(file, existingEndpoints, matchConfig) {
@@ -1,97 +1,59 @@
1
- #!/usr/bin/env node
1
+ 'use strict';
2
2
 
3
3
  /**
4
- * Wogi Flow - Architect-Required Gate (wf-037f8d66)
4
+ * Wogi Flow - Architect-Required Gate (wf-037f8d66, hardened in wf-2eafdab0)
5
5
  *
6
6
  * Closes the methodology gap where the IGR Architect/Adversary pass at
7
7
  * spec_review IS specced (per .claude/docs/phases/02-spec.md) but enforcement
8
8
  * is prompt-only — the agent can skip Architect and go straight to coding.
9
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
10
+ * This gate fires on Edit/Write/Bash during the `coding` phase for L1+ tasks
11
+ * when config.intentGroundedReasoning.enabled is true and no evidence of an
12
12
  * Architect run exists for the current task.
13
13
  *
14
- * Evidence marker: `.workflow/state/architect-runs/<task-id>.json` written
15
- * by flow-architect-pass.js on successful completion.
14
+ * Evidence marker: read from flow-architect-runs.js (the neutral-location
15
+ * module that BOTH this gate and flow-architect-pass.js consume keeps
16
+ * hooks/core from owning state hooks/core's parents need to write to).
16
17
  *
17
18
  * Scope:
18
19
  * - L0 (epic) / L1 (story) tasks: Architect required → gate enforces
19
20
  * - L2 / L3 tasks: skip spec_review entirely (correctly) → gate is a no-op
21
+ * - type=story/epic with MISSING level: fail-closed (treat as L1)
20
22
  *
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.
23
+ * Mutation set: Edit, Write, Bash (NOT TodoWrite review finding M8: blocking
24
+ * planning before coding is chicken-and-egg).
42
25
  *
43
- * @param {Object} payload { taskId, completedAt, model, plan }
44
- * @returns {{ written: boolean, path: string|null }}
26
+ * Fail-open: any error reading state/config allow tool call.
45
27
  */
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
28
 
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
- }
29
+ const archRuns = require('../../flow-architect-runs');
30
+ const { hasArchitectRun, getArchitectRunPath, writeArchitectRunMarker } = archRuns;
83
31
 
84
32
  /**
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}
33
+ * Determine whether the current task requires Architect.
34
+ *
35
+ * Returns true for:
36
+ * - L0 (epic), L1 (story)
37
+ * - type=story OR type=epic with MISSING/empty level (fail-closed — review M2)
38
+ *
39
+ * Returns false for:
40
+ * - explicit L2 / L3 (regardless of type)
41
+ * - missing/null taskMeta
42
+ * - unknown type with no level signal
88
43
  */
89
44
  function requiresArchitect(taskMeta) {
90
45
  if (!taskMeta || typeof taskMeta !== 'object') return false;
91
46
  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';
47
+ // Explicit level takes precedence
48
+ if (level === 'L0' || level === 'L1') return true;
49
+ if (level === 'L2' || level === 'L3') return false;
50
+ // No explicit level — fail-closed for stories/epics (M2 fix).
51
+ // A task created without a level field is untracked-by-pipeline; treating
52
+ // it as "doesn't need architect" lets bypass slip through. Stories/epics
53
+ // are exactly the work-types Architect is for.
54
+ const type = (taskMeta.type || '').toLowerCase();
55
+ if (type === 'story' || type === 'epic') return true;
56
+ return false;
95
57
  }
96
58
 
97
59
  /**
@@ -101,7 +63,6 @@ function requiresArchitect(taskMeta) {
101
63
  function isGateEnabled(config) {
102
64
  const igr = config?.intentGroundedReasoning;
103
65
  if (!igr || igr.enabled === false) return false;
104
- // Explicit toggle on the gate itself overrides
105
66
  if (config?.architectRequiredGate?.enabled === false) return false;
106
67
  return true;
107
68
  }
@@ -109,67 +70,55 @@ function isGateEnabled(config) {
109
70
  /**
110
71
  * Main gate check.
111
72
  *
112
- * @param {Object} ctx — { phase, taskId, taskMeta, config, toolName }
73
+ * @param {Object} ctx — { phase, taskId, taskMeta, config, toolName, specPath? }
113
74
  * @returns {{ blocked: boolean, reason?: string, message?: string }}
114
75
  */
115
76
  function checkArchitectRequired(ctx) {
116
- const { phase, taskId, taskMeta, config, toolName } = ctx || {};
77
+ const { phase, taskId, taskMeta, config, toolName, specPath } = ctx || {};
117
78
 
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
- }
79
+ // Mutation set: Edit/Write/Bash only. TodoWrite removed (M8).
80
+ const mutationTools = new Set(['Edit', 'Write', 'Bash']);
81
+ if (!toolName || !mutationTools.has(toolName)) return { blocked: false };
123
82
 
124
83
  // Only fires during coding phase
125
- if (phase !== 'coding') {
126
- return { blocked: false };
127
- }
84
+ if (phase !== 'coding') return { blocked: false };
128
85
 
129
86
  // Gate disabled (or IGR off)
130
- if (!isGateEnabled(config)) {
131
- return { blocked: false };
132
- }
87
+ if (!isGateEnabled(config)) return { blocked: false };
133
88
 
134
- // No active task → not in scope (gate doesn't apply)
135
- if (!taskId) {
136
- return { blocked: false };
137
- }
89
+ // No active task → not in scope
90
+ if (!taskId) return { blocked: false };
138
91
 
139
- // L2/L3 tasks bypass spec_review correctly — gate is a no-op
140
- if (!requiresArchitect(taskMeta)) {
141
- return { blocked: false };
142
- }
92
+ // L2/L3 tasks correctly bypass spec_review — gate is a no-op
93
+ if (!requiresArchitect(taskMeta)) return { blocked: false };
143
94
 
144
- // Check for evidence marker
145
- if (hasArchitectRun(taskId)) {
146
- return { blocked: false };
147
- }
95
+ // Check evidence marker (with content validation + optional specHash check)
96
+ if (hasArchitectRun(taskId, specPath)) return { blocked: false };
148
97
 
149
- // Block: Architect required but no evidence
150
98
  return {
151
99
  blocked: true,
152
100
  reason: 'architect-required',
153
101
  message: [
154
102
  `ARCHITECT-REQUIRED GATE: task ${taskId} is L1+ in coding phase but no `,
155
- `Architect run is recorded at ${getArchitectRunPath(taskId)}.\n\n`,
103
+ `valid Architect run is recorded at ${getArchitectRunPath(taskId)}.\n\n`,
156
104
  `Per .claude/docs/phases/02-spec.md Step 1.55, L1+ tasks must run an `,
157
105
  `Architect pass before coding. Invoke:\n\n`,
158
106
  ` node scripts/flow-architect-pass.js run --task=${taskId}\n\n`,
159
107
  `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).`
108
+ `config.architectRequiredGate.enabled = false (project-level).`
163
109
  ].join('')
164
110
  };
165
111
  }
166
112
 
167
113
  module.exports = {
168
114
  checkArchitectRequired,
169
- writeArchitectRunMarker,
170
- hasArchitectRun,
115
+ // Re-exports from flow-architect-runs for backward compat with existing
116
+ // test suite + flow-architect-pass.js. These pass-throughs let consumers
117
+ // who already require the gate continue to work; new consumers should
118
+ // prefer require('../../flow-architect-runs') directly.
171
119
  requiresArchitect,
172
- getArchitectRunPath,
173
120
  isGateEnabled,
174
- ARCHITECT_RUNS_DIR
121
+ hasArchitectRun,
122
+ getArchitectRunPath,
123
+ writeArchitectRunMarker
175
124
  };
@@ -64,9 +64,47 @@ function isAllGatesDisabled(hookStatus) {
64
64
  );
65
65
  }
66
66
 
67
+ /**
68
+ * Resolve the current workflow phase + task meta from state files.
69
+ *
70
+ * Reads `.workflow/state/workflow-phase.json` for { phase, taskId }, then
71
+ * looks up the matching task in `ready.json.inProgress` for full taskMeta.
72
+ *
73
+ * Fail-open everywhere: any read/parse error → returns the partial state
74
+ * resolved so far. Multiple gates may need this context; centralizing here
75
+ * stops the inline-block proliferation flagged by review L3.
76
+ *
77
+ * @returns {{phase: string, taskId: string|null, taskMeta: object|null}}
78
+ */
79
+ function resolveCurrentTaskContext() {
80
+ const path = require('node:path');
81
+ const flowUtils = require('../../flow-utils');
82
+ const flowIo = require('../../flow-io');
83
+ let phase = 'idle';
84
+ let taskId = null;
85
+ let taskMeta = null;
86
+ try {
87
+ const phaseStatePath = path.join(flowUtils.PATHS.state, 'workflow-phase.json');
88
+ const ps = flowIo.safeJsonParse(phaseStatePath, null);
89
+ if (ps) {
90
+ phase = ps.phase || 'idle';
91
+ taskId = ps.taskId || null;
92
+ }
93
+ } catch (_err) { /* fail-open */ }
94
+ if (taskId) {
95
+ try {
96
+ const ready = flowUtils.getReadyData ? flowUtils.getReadyData() : null;
97
+ const inProgress = (ready && Array.isArray(ready.inProgress)) ? ready.inProgress : [];
98
+ taskMeta = inProgress.find(t => t && t.id === taskId) || null;
99
+ } catch (_err) { /* fail-open */ }
100
+ }
101
+ return { phase, taskId, taskMeta };
102
+ }
103
+
67
104
  module.exports = {
68
105
  VALID_AGENT_TYPES,
69
106
  READ_ONLY_AGENT_TYPES,
70
107
  parseSubagentContext,
71
108
  isAllGatesDisabled,
109
+ resolveCurrentTaskContext,
72
110
  };
@@ -118,35 +118,17 @@ function runPreToolGates(ctx, deps) {
118
118
  }
119
119
  }
120
120
 
121
- // Architect-required gate (wf-037f8d66)
121
+ // Architect-required gate (wf-037f8d66, hardened wf-2eafdab0)
122
122
  // L1+ tasks in coding phase must have run Architect/Adversary before any Edit/Write/Bash.
123
+ // TodoWrite removed from gate set (review M8 fix — planning-tool chicken-and-egg).
123
124
  // L2/L3 tasks bypass spec_review entirely (correctly), so the gate is a no-op there.
125
+ // Task context resolution extracted to resolveCurrentTaskContext (review L3 fix).
124
126
  // Fail-open on any error.
125
127
  if (typeof deps.checkArchitectRequired === 'function' &&
126
- (toolName === 'Edit' || toolName === 'Write' || toolName === 'Bash' || toolName === 'TodoWrite')) {
128
+ (toolName === 'Edit' || toolName === 'Write' || toolName === 'Bash')) {
127
129
  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
-
130
+ const { resolveCurrentTaskContext } = require('./pre-tool-helpers');
131
+ const { phase, taskId, taskMeta } = resolveCurrentTaskContext();
150
132
  const archResult = deps.checkArchitectRequired({
151
133
  phase, taskId, taskMeta, config, toolName
152
134
  });
@@ -68,6 +68,18 @@ function handleSessionEnd(input) {
68
68
  result.logged = false;
69
69
  }
70
70
 
71
+ // wf-2eafdab0 (AC8): GC stale architect-run markers for completed tasks.
72
+ // Markers in `.workflow/state/architect-runs/` accumulate forever otherwise;
73
+ // task-id collision (re-used fixtures, manual ID re-use) bypasses the gate.
74
+ // Fail-open everywhere — never block session end.
75
+ try {
76
+ const { gcStaleMarkers } = require('../../flow-architect-runs');
77
+ const gc = gcStaleMarkers(); // default 7-day retention for completed tasks
78
+ if (gc && gc.removed && gc.removed.length > 0) {
79
+ result.architectMarkerGc = { removed: gc.removed.length };
80
+ }
81
+ } catch (_err) { /* non-critical */ }
82
+
71
83
  // Surface pending skill proposals staged by `flow skill propose|patch|remove`.
72
84
  // These await user approval (`flow skill promote|reject`) at session end.
73
85
  try {
@@ -148,6 +148,31 @@ function createMinimalStructure() {
148
148
  }, null, 2), { mode: FILE_MODE });
149
149
  }
150
150
  }
151
+
152
+ // wf-2eafdab0 (AC2/H2): Create forbidden-patterns.json from template on fresh
153
+ // installs. Idempotent — never overwrites an existing file. Without this, the
154
+ // declarative-forbidden-pattern feature ships as a silent no-op (the v2.30.0
155
+ // bug this commit fixes).
156
+ const forbiddenPatternsPath = path.join(STATE_DIR, 'forbidden-patterns.json');
157
+ if (!fs.existsSync(forbiddenPatternsPath)) {
158
+ const fpTemplate = path.join(STATE_DIR, 'forbidden-patterns.json.template');
159
+ try {
160
+ const rawContent = fs.existsSync(fpTemplate)
161
+ ? fs.readFileSync(fpTemplate, 'utf-8')
162
+ : '[]';
163
+ const parsed = safeJsonParseString(rawContent, []);
164
+ fs.writeFileSync(
165
+ forbiddenPatternsPath,
166
+ JSON.stringify(parsed, null, 2),
167
+ { mode: FILE_MODE }
168
+ );
169
+ } catch (err) {
170
+ if (process.env.DEBUG) {
171
+ console.error(`[postinstall] forbidden-patterns template error: ${err.message}`);
172
+ }
173
+ fs.writeFileSync(forbiddenPatternsPath, '[]\n', { mode: FILE_MODE });
174
+ }
175
+ }
151
176
  }
152
177
 
153
178
  /**