wogiflow 2.29.9 → 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.
- package/.claude/commands/wogi-decide.md +28 -0
- package/.claude/commands/wogi-learn.md +1 -0
- package/.workflow/state/forbidden-patterns.json.template +15 -0
- package/package.json +2 -2
- package/scripts/flow-architect-pass.js +17 -0
- package/scripts/flow-architect-runs.js +194 -0
- package/scripts/flow-feature-dossier.js +3 -9
- package/scripts/flow-glob.js +104 -0
- package/scripts/flow-logic-rules.js +3 -9
- package/scripts/flow-standards-checker.js +203 -14
- package/scripts/hooks/core/architect-required-gate.js +124 -0
- package/scripts/hooks/core/pre-tool-deps.js +10 -0
- package/scripts/hooks/core/pre-tool-helpers.js +38 -0
- package/scripts/hooks/core/pre-tool-orchestrator.js +27 -0
- package/scripts/hooks/core/session-end.js +12 -0
- package/scripts/postinstall.js +25 -0
|
@@ -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
|
|
|
@@ -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.
|
|
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 && 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",
|
|
@@ -479,6 +479,23 @@ function recordTelemetry({ taskId, parseResult, gateResult, runCtx = {} }) {
|
|
|
479
479
|
sessionId: runCtx.sessionId ?? null,
|
|
480
480
|
},
|
|
481
481
|
});
|
|
482
|
+
|
|
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).
|
|
488
|
+
if (taskId && (telemetryVerdict === 'PASS' || telemetryVerdict === 'PASS_WITH_NOTES')) {
|
|
489
|
+
try {
|
|
490
|
+
const archRuns = require('./flow-architect-runs');
|
|
491
|
+
archRuns.writeArchitectRunMarker({
|
|
492
|
+
taskId,
|
|
493
|
+
model: runCtx.model || null,
|
|
494
|
+
plan: parseResult?.plan ? { sections: sectionsPresent } : null,
|
|
495
|
+
specPath: runCtx.specPath || null, // AC15: enables specHash staleness detection
|
|
496
|
+
});
|
|
497
|
+
} catch (_err) { /* fail-open */ }
|
|
498
|
+
}
|
|
482
499
|
}
|
|
483
500
|
|
|
484
501
|
// ============================================================
|
|
@@ -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,
|
|
@@ -77,23 +78,23 @@ const MATCH_LEVEL_SEVERITY = {
|
|
|
77
78
|
};
|
|
78
79
|
|
|
79
80
|
// Task type to check type mapping for smart scoping
|
|
80
|
-
// wf-00c5067b: 'hook-three-layer' added
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
81
|
+
// wf-00c5067b: 'hook-three-layer' added — entry-file LOC + import-count rule.
|
|
82
|
+
// wf-037f8d66: 'forbidden-patterns' added — declarative project-specific
|
|
83
|
+
// patterns (agnosticism rules, no-hardcoding rules, etc.) loaded from
|
|
84
|
+
// .workflow/state/forbidden-patterns.json. Empty file = no-op.
|
|
84
85
|
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']
|
|
86
|
+
'component': ['naming', 'components', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
87
|
+
'utility': ['naming', 'functions', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
88
|
+
'api': ['naming', 'api', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
89
|
+
'feature': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
90
|
+
'bugfix': ['naming', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
91
|
+
'refactor': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
92
|
+
'story': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
93
|
+
'default': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns']
|
|
93
94
|
};
|
|
94
95
|
|
|
95
96
|
// All available check types
|
|
96
|
-
const ALL_CHECK_TYPES = ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'];
|
|
97
|
+
const ALL_CHECK_TYPES = ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'];
|
|
97
98
|
|
|
98
99
|
// ============================================================================
|
|
99
100
|
// Parse Standards Files
|
|
@@ -639,6 +640,181 @@ function checkHookThreeLayer(file, hookThreeLayerConfig = {}) {
|
|
|
639
640
|
return violations;
|
|
640
641
|
}
|
|
641
642
|
|
|
643
|
+
/**
|
|
644
|
+
* wf-037f8d66 — Check forbidden patterns from project's declarative rule pack.
|
|
645
|
+
*
|
|
646
|
+
* Projects declare their forbidden patterns in `.workflow/state/forbidden-patterns.json`.
|
|
647
|
+
* This is the GENERIC, DATA-DRIVEN counterpart to checkSecurityPatterns (which is
|
|
648
|
+
* source-coded). Projects can encode "agnosticism" rules, "no-hardcoding" rules,
|
|
649
|
+
* "no-claude-code-literal-outside-docs" rules, etc., without modifying the checker.
|
|
650
|
+
*
|
|
651
|
+
* Format (forbidden-patterns.json):
|
|
652
|
+
* [
|
|
653
|
+
* {
|
|
654
|
+
* "id": "no-claude-code-literal",
|
|
655
|
+
* "pattern": "['\"]claude-code['\"]", // RegExp source string
|
|
656
|
+
* "flags": "g", // optional, defaults to 'g'
|
|
657
|
+
* "exemptions": ["docs/**", "*.md", "reference/**"],
|
|
658
|
+
* "severity": "must-fix", // 'must-fix' | 'warning'
|
|
659
|
+
* "message": "wogiflow-cli is agnostic; ..."
|
|
660
|
+
* }
|
|
661
|
+
* ]
|
|
662
|
+
*
|
|
663
|
+
* @param {Object} file — { path, content }
|
|
664
|
+
* @param {Object[]} patterns — array of pattern entries
|
|
665
|
+
* @returns {Object[]} violations
|
|
666
|
+
*/
|
|
667
|
+
function checkForbiddenPatterns(file, patterns) {
|
|
668
|
+
const violations = [];
|
|
669
|
+
if (!Array.isArray(patterns) || patterns.length === 0) return violations;
|
|
670
|
+
|
|
671
|
+
const content = file.content || '';
|
|
672
|
+
const relPath = file.path.startsWith('/')
|
|
673
|
+
? path.relative(PATHS.root, file.path)
|
|
674
|
+
: file.path;
|
|
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
|
+
|
|
682
|
+
for (const entry of patterns) {
|
|
683
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
684
|
+
if (!entry.pattern || typeof entry.pattern !== 'string') continue;
|
|
685
|
+
|
|
686
|
+
// Exemption check (glob match against relative path)
|
|
687
|
+
const exemptions = Array.isArray(entry.exemptions) ? entry.exemptions : [];
|
|
688
|
+
const exempted = exemptions.some(glob => globMatch(relPath, glob));
|
|
689
|
+
if (exempted) continue;
|
|
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
|
+
|
|
705
|
+
// Compile regex
|
|
706
|
+
let re;
|
|
707
|
+
try {
|
|
708
|
+
const flags = entry.flags && typeof entry.flags === 'string' ? entry.flags : 'g';
|
|
709
|
+
re = new RegExp(entry.pattern, flags);
|
|
710
|
+
} catch (_err) {
|
|
711
|
+
// Invalid regex — skip (don't crash check)
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Match — with wall-clock budget (AC3/M1 fix).
|
|
716
|
+
let match;
|
|
717
|
+
re.lastIndex = 0;
|
|
718
|
+
const deadline = Date.now() + DEFAULT_MATCH_BUDGET_MS;
|
|
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
|
+
}
|
|
734
|
+
const before = content.substring(0, match.index);
|
|
735
|
+
const lineNumber = (before.match(/\n/g) || []).length + 1;
|
|
736
|
+
violations.push({
|
|
737
|
+
type: 'forbidden-pattern',
|
|
738
|
+
severity: entry.severity === 'warning' ? 'warning' : 'must-fix',
|
|
739
|
+
file: file.path,
|
|
740
|
+
line: lineNumber,
|
|
741
|
+
message: entry.message || `Forbidden pattern matched: ${entry.id || entry.pattern}`,
|
|
742
|
+
rule: `forbidden-patterns.json: ${entry.id || '(unnamed)'}`
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Avoid infinite loop on zero-length matches
|
|
746
|
+
if (match.index === re.lastIndex) re.lastIndex++;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return violations;
|
|
751
|
+
}
|
|
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
|
+
|
|
756
|
+
/**
|
|
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).
|
|
763
|
+
*/
|
|
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';
|
|
771
|
+
}
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const DEFAULT_MAX_CONTENT_BYTES = 1_000_000; // 1MB
|
|
776
|
+
const DEFAULT_MATCH_BUDGET_MS = 200;
|
|
777
|
+
|
|
778
|
+
/**
|
|
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).
|
|
783
|
+
* @returns {Object[]} pattern entries (empty array if missing/invalid)
|
|
784
|
+
*/
|
|
785
|
+
function loadForbiddenPatterns(projectRoot) {
|
|
786
|
+
const root = projectRoot || PATHS.root;
|
|
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
|
+
}
|
|
812
|
+
return [];
|
|
813
|
+
}
|
|
814
|
+
if (!Array.isArray(parsed)) return [];
|
|
815
|
+
return parsed;
|
|
816
|
+
}
|
|
817
|
+
|
|
642
818
|
function checkApiDuplication(file, existingEndpoints, matchConfig) {
|
|
643
819
|
const violations = [];
|
|
644
820
|
const content = file.content || '';
|
|
@@ -1090,6 +1266,8 @@ function runStandardsCheck(files, options = {}) {
|
|
|
1090
1266
|
const functions = checksToRun.includes('functions') ? parseFunctionMap() : [];
|
|
1091
1267
|
const endpoints = checksToRun.includes('api') ? parseApiMap() : [];
|
|
1092
1268
|
const rulesFiles = checksToRun.includes('security') ? loadRulesDir() : [];
|
|
1269
|
+
// wf-037f8d66: load forbidden-patterns.json (empty array if missing)
|
|
1270
|
+
const forbiddenPatterns = checksToRun.includes('forbidden-patterns') ? loadForbiddenPatterns() : [];
|
|
1093
1271
|
|
|
1094
1272
|
// Load schema/service registries if needed
|
|
1095
1273
|
const schemas = checksToRun.includes('schemas') ? parseSchemaMap() : [];
|
|
@@ -1115,7 +1293,8 @@ function runStandardsCheck(files, options = {}) {
|
|
|
1115
1293
|
'service-map.md': { checked: checksToRun.includes('services') && services.length > 0, violations: 0 },
|
|
1116
1294
|
'naming-conventions': { checked: checksToRun.includes('naming'), violations: 0 },
|
|
1117
1295
|
'security-patterns': { checked: checksToRun.includes('security'), violations: 0 },
|
|
1118
|
-
'hook-three-layer': { checked: checksToRun.includes('hook-three-layer'), violations: 0 }
|
|
1296
|
+
'hook-three-layer': { checked: checksToRun.includes('hook-three-layer'), violations: 0 },
|
|
1297
|
+
'forbidden-patterns': { checked: checksToRun.includes('forbidden-patterns') && forbiddenPatterns.length > 0, violations: 0 }
|
|
1119
1298
|
};
|
|
1120
1299
|
|
|
1121
1300
|
for (const file of files) {
|
|
@@ -1181,6 +1360,13 @@ function runStandardsCheck(files, options = {}) {
|
|
|
1181
1360
|
allViolations.push(...hookViolations);
|
|
1182
1361
|
checksSummary['hook-three-layer'].violations += hookViolations.length;
|
|
1183
1362
|
}
|
|
1363
|
+
|
|
1364
|
+
// Forbidden patterns from project rule pack (wf-037f8d66)
|
|
1365
|
+
if (checksToRun.includes('forbidden-patterns') && forbiddenPatterns.length > 0) {
|
|
1366
|
+
const fpViolations = checkForbiddenPatterns(file, forbiddenPatterns);
|
|
1367
|
+
allViolations.push(...fpViolations);
|
|
1368
|
+
checksSummary['forbidden-patterns'].violations += fpViolations.length;
|
|
1369
|
+
}
|
|
1184
1370
|
}
|
|
1185
1371
|
|
|
1186
1372
|
// Count must-fix violations
|
|
@@ -1415,6 +1601,9 @@ module.exports = {
|
|
|
1415
1601
|
checkRegistryDuplication,
|
|
1416
1602
|
checkSecurityPatterns,
|
|
1417
1603
|
checkHookThreeLayer,
|
|
1604
|
+
checkForbiddenPatterns, // wf-037f8d66: declarative project rule pack
|
|
1605
|
+
loadForbiddenPatterns, // wf-037f8d66: exposed for tests + external use
|
|
1606
|
+
globMatch, // wf-037f8d66: exposed for unit testing
|
|
1418
1607
|
extractDeclaredNames,
|
|
1419
1608
|
discoverAllRegistries,
|
|
1420
1609
|
collectReuseCandidates,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Architect-Required Gate (wf-037f8d66, hardened in wf-2eafdab0)
|
|
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/Bash during the `coding` phase for L1+ tasks
|
|
11
|
+
* when config.intentGroundedReasoning.enabled is true and no evidence of an
|
|
12
|
+
* Architect run exists for the current task.
|
|
13
|
+
*
|
|
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).
|
|
17
|
+
*
|
|
18
|
+
* Scope:
|
|
19
|
+
* - L0 (epic) / L1 (story) tasks: Architect required → gate enforces
|
|
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)
|
|
22
|
+
*
|
|
23
|
+
* Mutation set: Edit, Write, Bash (NOT TodoWrite — review finding M8: blocking
|
|
24
|
+
* planning before coding is chicken-and-egg).
|
|
25
|
+
*
|
|
26
|
+
* Fail-open: any error reading state/config → allow tool call.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const archRuns = require('../../flow-architect-runs');
|
|
30
|
+
const { hasArchitectRun, getArchitectRunPath, writeArchitectRunMarker } = archRuns;
|
|
31
|
+
|
|
32
|
+
/**
|
|
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
|
|
43
|
+
*/
|
|
44
|
+
function requiresArchitect(taskMeta) {
|
|
45
|
+
if (!taskMeta || typeof taskMeta !== 'object') return false;
|
|
46
|
+
const level = (taskMeta.level || '').toUpperCase();
|
|
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;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read whether the gate is enabled from config.
|
|
61
|
+
* Default: enabled when IGR is enabled.
|
|
62
|
+
*/
|
|
63
|
+
function isGateEnabled(config) {
|
|
64
|
+
const igr = config?.intentGroundedReasoning;
|
|
65
|
+
if (!igr || igr.enabled === false) return false;
|
|
66
|
+
if (config?.architectRequiredGate?.enabled === false) return false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Main gate check.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} ctx — { phase, taskId, taskMeta, config, toolName, specPath? }
|
|
74
|
+
* @returns {{ blocked: boolean, reason?: string, message?: string }}
|
|
75
|
+
*/
|
|
76
|
+
function checkArchitectRequired(ctx) {
|
|
77
|
+
const { phase, taskId, taskMeta, config, toolName, specPath } = ctx || {};
|
|
78
|
+
|
|
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 };
|
|
82
|
+
|
|
83
|
+
// Only fires during coding phase
|
|
84
|
+
if (phase !== 'coding') return { blocked: false };
|
|
85
|
+
|
|
86
|
+
// Gate disabled (or IGR off)
|
|
87
|
+
if (!isGateEnabled(config)) return { blocked: false };
|
|
88
|
+
|
|
89
|
+
// No active task → not in scope
|
|
90
|
+
if (!taskId) return { blocked: false };
|
|
91
|
+
|
|
92
|
+
// L2/L3 tasks correctly bypass spec_review — gate is a no-op
|
|
93
|
+
if (!requiresArchitect(taskMeta)) return { blocked: false };
|
|
94
|
+
|
|
95
|
+
// Check evidence marker (with content validation + optional specHash check)
|
|
96
|
+
if (hasArchitectRun(taskId, specPath)) return { blocked: false };
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
blocked: true,
|
|
100
|
+
reason: 'architect-required',
|
|
101
|
+
message: [
|
|
102
|
+
`ARCHITECT-REQUIRED GATE: task ${taskId} is L1+ in coding phase but no `,
|
|
103
|
+
`valid Architect run is recorded at ${getArchitectRunPath(taskId)}.\n\n`,
|
|
104
|
+
`Per .claude/docs/phases/02-spec.md Step 1.55, L1+ tasks must run an `,
|
|
105
|
+
`Architect pass before coding. Invoke:\n\n`,
|
|
106
|
+
` node scripts/flow-architect-pass.js run --task=${taskId}\n\n`,
|
|
107
|
+
`Then retry your edit. To opt out for this task only, set `,
|
|
108
|
+
`config.architectRequiredGate.enabled = false (project-level).`
|
|
109
|
+
].join('')
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
checkArchitectRequired,
|
|
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.
|
|
119
|
+
requiresArchitect,
|
|
120
|
+
isGateEnabled,
|
|
121
|
+
hasArchitectRun,
|
|
122
|
+
getArchitectRunPath,
|
|
123
|
+
writeArchitectRunMarker
|
|
124
|
+
};
|
|
@@ -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,
|
|
@@ -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,6 +118,33 @@ function runPreToolGates(ctx, deps) {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Architect-required gate (wf-037f8d66, hardened wf-2eafdab0)
|
|
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).
|
|
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).
|
|
126
|
+
// Fail-open on any error.
|
|
127
|
+
if (typeof deps.checkArchitectRequired === 'function' &&
|
|
128
|
+
(toolName === 'Edit' || toolName === 'Write' || toolName === 'Bash')) {
|
|
129
|
+
try {
|
|
130
|
+
const { resolveCurrentTaskContext } = require('./pre-tool-helpers');
|
|
131
|
+
const { phase, taskId, taskMeta } = resolveCurrentTaskContext();
|
|
132
|
+
const archResult = deps.checkArchitectRequired({
|
|
133
|
+
phase, taskId, taskMeta, config, toolName
|
|
134
|
+
});
|
|
135
|
+
if (archResult.blocked) {
|
|
136
|
+
return {
|
|
137
|
+
allowed: false,
|
|
138
|
+
blocked: true,
|
|
139
|
+
reason: archResult.reason || 'architect-required',
|
|
140
|
+
message: archResult.message,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
} catch (_err) {
|
|
144
|
+
if (process.env.DEBUG) console.error(`[Hook] Architect-required gate error (fail-open): ${_err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
121
148
|
// wf-f9912af6: Deferral-authorization gate. Blocks Write/Edit to
|
|
122
149
|
// last-review.json / last-audit.json when the new content introduces
|
|
123
150
|
// `status: deferred*` on findings without explicit user authorization.
|
|
@@ -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 {
|
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
/**
|