wogiflow 2.30.0 → 2.30.2
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/.workflow/state/forbidden-patterns.json.template +15 -0
- package/lib/installer.js +30 -0
- package/package.json +2 -2
- package/scripts/flow-architect-pass.js +8 -5
- 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-phase.js +9 -4
- package/scripts/flow-standards-checker.js +87 -45
- package/scripts/flow-standards-gate.js +15 -0
- package/scripts/hooks/adapters/claude-code.js +24 -9
- package/scripts/hooks/core/architect-required-gate.js +55 -106
- package/scripts/hooks/core/gate-orchestrator.js +104 -0
- package/scripts/hooks/core/long-input-enforcement.js +49 -0
- package/scripts/hooks/core/pre-tool-helpers.js +38 -0
- package/scripts/hooks/core/pre-tool-orchestrator.js +6 -24
- package/scripts/hooks/core/research-required-classifier.js +5 -1
- package/scripts/hooks/core/session-end.js +12 -0
- package/scripts/hooks/core/task-gate.js +77 -1
- package/scripts/hooks/entry/claude-code/stop.js +24 -1
- package/scripts/postinstall.js +25 -0
|
@@ -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/lib/installer.js
CHANGED
|
@@ -583,6 +583,36 @@ function createWorkflowStructure(projectRoot, config) {
|
|
|
583
583
|
}
|
|
584
584
|
}
|
|
585
585
|
|
|
586
|
+
// wf-d5fcb880 (H2): scaffold forbidden-patterns.json from its template.
|
|
587
|
+
// The template ships in the npm package (per package.json `files` →
|
|
588
|
+
// `.workflow/state/*.template`). Without this step, the standards-checker's
|
|
589
|
+
// forbidden-patterns feature is a silent no-op on fresh installs — the loader
|
|
590
|
+
// returns [] because no rule pack exists on disk. The previous review flagged
|
|
591
|
+
// this as a HIGH finding (H2 in last-review.json).
|
|
592
|
+
const forbiddenPath = path.join(workflowDir, 'state', 'forbidden-patterns.json');
|
|
593
|
+
if (!fs.existsSync(forbiddenPath)) {
|
|
594
|
+
const templatePath = path.join(workflowDir, 'state', 'forbidden-patterns.json.template');
|
|
595
|
+
if (fs.existsSync(templatePath)) {
|
|
596
|
+
try {
|
|
597
|
+
const templateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
598
|
+
fs.writeFileSync(forbiddenPath, templateContent);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
// Fall back to an empty array so the file exists and is parseable.
|
|
601
|
+
// safeJsonParse on this returns [] (valid empty pack); loader behaves
|
|
602
|
+
// identically to the no-file path but the user no longer sees the
|
|
603
|
+
// "forbidden-patterns.json not found" stderr warning.
|
|
604
|
+
if (process.env.DEBUG) {
|
|
605
|
+
console.error(`[installer] forbidden-patterns template copy failed: ${err.message}`);
|
|
606
|
+
}
|
|
607
|
+
fs.writeFileSync(forbiddenPath, '[]\n');
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
// Template not shipped (unusual install layout) — still scaffold an
|
|
611
|
+
// empty pack so loader is silent.
|
|
612
|
+
fs.writeFileSync(forbiddenPath, '[]\n');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
586
616
|
// Create registry manifest (dynamic registry discovery)
|
|
587
617
|
const registryManifestPath = path.join(workflowDir, 'state', 'registry-manifest.json');
|
|
588
618
|
if (!fs.existsSync(registryManifestPath)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wogiflow",
|
|
3
|
-
"version": "2.30.
|
|
3
|
+
"version": "2.30.2",
|
|
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
|
|
484
|
-
// Marker proves Architect ran for this task — gate consults it
|
|
485
|
-
// Edit/Write/Bash in coding phase for L1+ tasks. Fail-open on any
|
|
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
|
|
489
|
-
|
|
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];
|
package/scripts/flow-phase.js
CHANGED
|
@@ -22,12 +22,17 @@ if (command === 'transition') {
|
|
|
22
22
|
console.error('Usage: flow-phase.js transition <from> <to> [taskId]');
|
|
23
23
|
process.exit(1);
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// wf-88a08fd4: previously this exited silently when `phaseGate.enabled` was
|
|
26
|
+
// false, which is the default. The CLI is an explicit caller action — honor
|
|
27
|
+
// it even when gate enforcement is off. State tracking (workflow-phase.json)
|
|
28
|
+
// is independent of gate enforcement (blocking Edit/Write until phase file
|
|
29
|
+
// is read). Callers that depend on phase state always need the write; the
|
|
30
|
+
// gate flag only controls whether PreToolUse blocks tools.
|
|
31
|
+
const gateActive = isPhaseGateEnabled();
|
|
28
32
|
const success = transitionPhase(from, to, taskId || null);
|
|
29
33
|
if (success) {
|
|
30
|
-
|
|
34
|
+
const suffix = gateActive ? '' : ' (gate enforcement disabled — state updated only)';
|
|
35
|
+
console.log(`Phase: ${from} → ${to}${suffix}`);
|
|
31
36
|
// wf-8d635d0e / E1: fire background auto-review on coding → validating.
|
|
32
37
|
// Fails open — any error here must not fail the primary transition.
|
|
33
38
|
try {
|
|
@@ -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
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
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
|
|
724
|
-
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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) {
|