wogiflow 2.29.8 → 2.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/wogi-decide.md +28 -0
- package/.claude/commands/wogi-learn.md +1 -0
- package/package.json +2 -2
- package/scripts/flow-architect-pass.js +14 -0
- package/scripts/flow-audit-gates.js +119 -0
- package/scripts/flow-correction-backfill.js +148 -0
- package/scripts/flow-correction-detector.js +117 -7
- package/scripts/flow-standards-checker.js +161 -14
- package/scripts/hooks/core/architect-required-gate.js +175 -0
- package/scripts/hooks/core/pre-tool-deps.js +10 -0
- package/scripts/hooks/core/pre-tool-orchestrator.js +45 -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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wogiflow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.30.0",
|
|
4
4
|
"description": "AI-powered development workflow management system with multi-model support",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"flow": "./scripts/flow",
|
|
13
|
-
"test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-audit-gates.test.js tests/flow-standards-hook-three-layer.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/workspace-ipc-sqlite.test.js tests/workspace-ipc-multi-worker.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.test.js tests/flow-wave1-integrations.test.js tests/flow-wave2-integrations.test.js tests/flow-wave3-integrations.test.js tests/flow-commit-claims-gate.test.js tests/auto-review.test.js tests/gate-telemetry-surface.test.js tests/agents-md-alias.test.js tests/flow-skill-manage.test.js tests/fuzzy-patch.test.js tests/mode-schema.test.js tests/flow-feature-dossier.test.js tests/flow-autonomous-mode.test.js tests/flow-epic-cascade.test.js tests/flow-workspace-summary.test.js tests/flow-hooks-research-evidence-gate.test.js tests/flow-worker-mcp-strip.test.js tests/flow-orchestrate-corrections.test.js tests/flow-source-fidelity.test.js tests/flow-hooks-long-input-enforcement.test.js tests/workspace-channel-tracking.test.js tests/flow-hooks-deletion-log.test.js tests/flow-task-boundary-reset.test.js tests/flow-deferral-gate.test.js tests/flow-research-required-gate.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
|
|
13
|
+
"test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-audit-gates.test.js tests/flow-standards-hook-three-layer.test.js tests/flow-correction-detector-reconcile.test.js tests/flow-correction-backfill.test.js tests/flow-audit-gates-feature-output-health.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/workspace-ipc-sqlite.test.js tests/workspace-ipc-multi-worker.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.test.js tests/flow-wave1-integrations.test.js tests/flow-wave2-integrations.test.js tests/flow-wave3-integrations.test.js tests/flow-commit-claims-gate.test.js tests/auto-review.test.js tests/gate-telemetry-surface.test.js tests/agents-md-alias.test.js tests/flow-skill-manage.test.js tests/fuzzy-patch.test.js tests/mode-schema.test.js tests/flow-feature-dossier.test.js tests/flow-autonomous-mode.test.js tests/flow-epic-cascade.test.js tests/flow-workspace-summary.test.js tests/flow-hooks-research-evidence-gate.test.js tests/flow-worker-mcp-strip.test.js tests/flow-orchestrate-corrections.test.js tests/flow-source-fidelity.test.js tests/flow-hooks-long-input-enforcement.test.js tests/workspace-channel-tracking.test.js tests/flow-hooks-deletion-log.test.js tests/flow-task-boundary-reset.test.js tests/flow-deferral-gate.test.js tests/flow-research-required-gate.test.js tests/flow-standards-forbidden-patterns.test.js tests/flow-hooks-architect-required-gate.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
|
|
14
14
|
"test:syntax": "find scripts/ lib/ -name '*.js' -not -path '*/node_modules/*' -exec node --check {} +",
|
|
15
15
|
"lint": "eslint scripts/ lib/ tests/",
|
|
16
16
|
"lint:ci": "eslint scripts/ lib/ tests/ --max-warnings 0",
|
|
@@ -479,6 +479,20 @@ function recordTelemetry({ taskId, parseResult, gateResult, runCtx = {} }) {
|
|
|
479
479
|
sessionId: runCtx.sessionId ?? null,
|
|
480
480
|
},
|
|
481
481
|
});
|
|
482
|
+
|
|
483
|
+
// wf-037f8d66: Write Architect-run marker for the architect-required PreToolUse gate.
|
|
484
|
+
// Marker proves Architect ran for this task — gate consults it before allowing
|
|
485
|
+
// Edit/Write/Bash in coding phase for L1+ tasks. Fail-open on any error.
|
|
486
|
+
if (taskId && (telemetryVerdict === 'PASS' || telemetryVerdict === 'PASS_WITH_NOTES')) {
|
|
487
|
+
try {
|
|
488
|
+
const archGate = require('./hooks/core/architect-required-gate');
|
|
489
|
+
archGate.writeArchitectRunMarker({
|
|
490
|
+
taskId,
|
|
491
|
+
model: runCtx.model || null,
|
|
492
|
+
plan: parseResult?.plan ? { sections: sectionsPresent } : null,
|
|
493
|
+
});
|
|
494
|
+
} catch (_err) { /* fail-open */ }
|
|
495
|
+
}
|
|
482
496
|
}
|
|
483
497
|
|
|
484
498
|
// ============================================================
|
|
@@ -34,6 +34,7 @@ const fs = require('node:fs');
|
|
|
34
34
|
const path = require('node:path');
|
|
35
35
|
|
|
36
36
|
const { PATHS, safeJsonParse } = require('./flow-utils');
|
|
37
|
+
const { safeJsonParseString } = require('./flow-io');
|
|
37
38
|
|
|
38
39
|
// ============================================================
|
|
39
40
|
// Score Cap Thresholds
|
|
@@ -641,6 +642,114 @@ function compareTrend(currentResults, previousAudit) {
|
|
|
641
642
|
// Main: Run All Gates
|
|
642
643
|
// ============================================================
|
|
643
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Gate: Feature Output Health (wf-6c58953a)
|
|
647
|
+
*
|
|
648
|
+
* Inspects DATA produced by features, not just CODE that produces it.
|
|
649
|
+
* Catches "silent feature no-op" — feature runs without errors, persists
|
|
650
|
+
* data, but the persisted data has all-null structured fields. This class
|
|
651
|
+
* is invisible to traditional code review/lint/typecheck/tests.
|
|
652
|
+
*
|
|
653
|
+
* Discovered 2026-05-09 when wogiflow-cli investigation found the
|
|
654
|
+
* correction-extractor was capturing user frustration but writing null
|
|
655
|
+
* structured fields. The /wogi-audit ran B+ and missed it because every
|
|
656
|
+
* agent inspects code, not output.
|
|
657
|
+
*
|
|
658
|
+
* Rule registry — explicit per-file checks, NOT a generic walker (per
|
|
659
|
+
* challenge round: blanket "all-null is bug" is false-positive city).
|
|
660
|
+
*
|
|
661
|
+
* @param {string} [projectRoot=PATHS.root] — project to inspect (default: current)
|
|
662
|
+
* @returns {Object} gate result with severity + findings
|
|
663
|
+
*/
|
|
664
|
+
function checkFeatureOutputHealth(projectRoot = PATHS.root) {
|
|
665
|
+
const findings = [];
|
|
666
|
+
const stateDir = path.join(projectRoot, '.workflow', 'state');
|
|
667
|
+
const corrDir = path.join(projectRoot, '.workflow', 'corrections');
|
|
668
|
+
|
|
669
|
+
// ---- Rule 1: pending-corrections.json null-fields ratio ----
|
|
670
|
+
// Note: pending-corrections.json is a top-level ARRAY, so safeJsonParse
|
|
671
|
+
// (which rejects arrays) won't work. Use file-read + safeJsonParseString.
|
|
672
|
+
const pcPath = path.join(stateDir, 'pending-corrections.json');
|
|
673
|
+
if (fs.existsSync(pcPath)) {
|
|
674
|
+
let records = [];
|
|
675
|
+
try {
|
|
676
|
+
const content = fs.readFileSync(pcPath, 'utf-8');
|
|
677
|
+
records = safeJsonParseString(content, []);
|
|
678
|
+
} catch (_err) { /* fail-open */ }
|
|
679
|
+
const arr = Array.isArray(records) ? records : [];
|
|
680
|
+
if (arr.length > 0) {
|
|
681
|
+
const nullCount = arr.filter(r =>
|
|
682
|
+
r && typeof r === 'object' &&
|
|
683
|
+
(r.whatWasWrong == null) &&
|
|
684
|
+
(r.whatUserWants == null)
|
|
685
|
+
).length;
|
|
686
|
+
const ratio = nullCount / arr.length;
|
|
687
|
+
if (ratio >= 0.5) {
|
|
688
|
+
findings.push({
|
|
689
|
+
rule: 'pending-corrections-null-fields',
|
|
690
|
+
severity: ratio === 1 ? 'high' : 'medium',
|
|
691
|
+
message: `${nullCount}/${arr.length} (${Math.round(ratio * 100)}%) pending-corrections records have null structured fields. Likely correction-detector extraction failure. Run \`flow-correction-backfill\` or restore via Layer 2 enrichment.`,
|
|
692
|
+
evidence: `${path.relative(projectRoot, pcPath)}: ${arr.length} records analyzed; ${nullCount} fully null`
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---- Rule 2: prompt-history × corrections cross-reference ----
|
|
699
|
+
// prompt-history.json is also typically a top-level array.
|
|
700
|
+
const phPath = path.join(stateDir, 'prompt-history.json');
|
|
701
|
+
if (fs.existsSync(phPath)) {
|
|
702
|
+
let ph = [];
|
|
703
|
+
try {
|
|
704
|
+
const content = fs.readFileSync(phPath, 'utf-8');
|
|
705
|
+
ph = safeJsonParseString(content, []);
|
|
706
|
+
} catch (_err) { /* fail-open */ }
|
|
707
|
+
const phArr = Array.isArray(ph) ? ph : (ph && Array.isArray(ph.prompts) ? ph.prompts : []);
|
|
708
|
+
|
|
709
|
+
// Frustration markers (regex per known-pattern set)
|
|
710
|
+
const frustrationRe = /\b(don'?t|stop|wait|actually|why did|why is|you keep|you always|fucking|seriously)\b/i;
|
|
711
|
+
let frustrationCount = 0;
|
|
712
|
+
for (const entry of phArr) {
|
|
713
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
714
|
+
const text = entry.prompt || entry.text || entry.userMessage || '';
|
|
715
|
+
if (typeof text === 'string' && frustrationRe.test(text)) frustrationCount++;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let corrCount = 0;
|
|
719
|
+
if (fs.existsSync(corrDir)) {
|
|
720
|
+
try {
|
|
721
|
+
corrCount = fs.readdirSync(corrDir).filter(f => f.endsWith('.md')).length;
|
|
722
|
+
} catch (_err) { /* fail-open */ }
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (frustrationCount >= 3 && corrCount === 0) {
|
|
726
|
+
findings.push({
|
|
727
|
+
rule: 'prompt-history-vs-corrections-mismatch',
|
|
728
|
+
severity: 'high',
|
|
729
|
+
message: `prompt-history.json has ${frustrationCount} frustration markers but corrections/ is empty. Correction-extractor pipeline appears non-functional (captures input, fails to materialize records).`,
|
|
730
|
+
evidence: `prompt-history: ${frustrationCount} matches across ${phArr.length} entries; corrections/: ${corrCount} files`
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Determine overall gate severity
|
|
736
|
+
const hasHigh = findings.some(f => f.severity === 'high');
|
|
737
|
+
const hasMed = findings.some(f => f.severity === 'medium');
|
|
738
|
+
const severity = hasHigh ? 'high' : hasMed ? 'medium' : 'pass';
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
gate: 'feature-output-health',
|
|
742
|
+
exists: true,
|
|
743
|
+
passed: findings.length === 0,
|
|
744
|
+
findings,
|
|
745
|
+
severity,
|
|
746
|
+
scoreCap: 100, // doesn't cap score directly; surfaces as audit findings
|
|
747
|
+
message: findings.length === 0
|
|
748
|
+
? 'Feature output health: no issues detected'
|
|
749
|
+
: `Feature output health: ${findings.length} finding(s) — ${findings.map(f => f.rule).join(', ')}`
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
644
753
|
/**
|
|
645
754
|
* Run all Gate 0 checks and return consolidated results.
|
|
646
755
|
* @returns {Object} gate results with score cap
|
|
@@ -654,6 +763,7 @@ function runAllGates() {
|
|
|
654
763
|
gates.push(checkLintConfigIntegrity());
|
|
655
764
|
gates.push(checkTests());
|
|
656
765
|
gates.push(checkScriptCompleteness());
|
|
766
|
+
gates.push(checkFeatureOutputHealth());
|
|
657
767
|
|
|
658
768
|
const cap = calculateScoreCap(gates);
|
|
659
769
|
const framework = detectFramework();
|
|
@@ -716,6 +826,14 @@ function main() {
|
|
|
716
826
|
console.log(JSON.stringify(checkScriptCompleteness(), null, 2));
|
|
717
827
|
break;
|
|
718
828
|
|
|
829
|
+
case 'feature-output-health': {
|
|
830
|
+
// Optional --project=<path> argument for cross-project audit
|
|
831
|
+
const projArg = process.argv.find(a => a.startsWith('--project='));
|
|
832
|
+
const projectRoot = projArg ? projArg.slice('--project='.length) : PATHS.root;
|
|
833
|
+
console.log(JSON.stringify(checkFeatureOutputHealth(projectRoot), null, 2));
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
|
|
719
837
|
case 'eslint-disable':
|
|
720
838
|
console.log(JSON.stringify(countEslintDisables(), null, 2));
|
|
721
839
|
break;
|
|
@@ -827,6 +945,7 @@ module.exports = {
|
|
|
827
945
|
checkTests,
|
|
828
946
|
parseTestErrorCount, // wf-e111d850: exposed for unit testing
|
|
829
947
|
checkScriptCompleteness,
|
|
948
|
+
checkFeatureOutputHealth, // wf-6c58953a: feature output health gate
|
|
830
949
|
|
|
831
950
|
// Extended checks
|
|
832
951
|
countEslintDisables,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow — Pending-Corrections Backfill (wf-6c58953a)
|
|
5
|
+
*
|
|
6
|
+
* Backfills records in `.workflow/state/pending-corrections.json` that have
|
|
7
|
+
* null `whatWasWrong` / `whatUserWants` fields. The fix lands at code level
|
|
8
|
+
* (flow-correction-detector.js Layer 1+2 reconciliation), but historical
|
|
9
|
+
* records persisted before the fix already have null fields. This tool
|
|
10
|
+
* applies the same deterministic-fallback extraction retroactively.
|
|
11
|
+
*
|
|
12
|
+
* Strategy:
|
|
13
|
+
* - Read pending-corrections.json
|
|
14
|
+
* - For each record where userMessage is populated AND
|
|
15
|
+
* (whatWasWrong is null OR whatUserWants is null)
|
|
16
|
+
* - Apply deterministic extraction: whatWasWrong = first 200 chars of
|
|
17
|
+
* userMessage; whatUserWants stays null (intent inference is an LLM job
|
|
18
|
+
* — honest null > wrong guess; live extractor will populate going forward)
|
|
19
|
+
* - Mark `enrichmentSource: "backfill-<date>"` so consumers can distinguish
|
|
20
|
+
* backfilled from live extractions
|
|
21
|
+
* - Atomic write: write-temp + rename
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* node scripts/flow-correction-backfill.js # current project
|
|
25
|
+
* node scripts/flow-correction-backfill.js --project=<path> # explicit project
|
|
26
|
+
* node scripts/flow-correction-backfill.js --dry-run # report only
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const { PATHS } = require('./flow-utils');
|
|
35
|
+
const { safeJsonParseString } = require('./flow-io');
|
|
36
|
+
const { deterministicWhatWasWrong } = require('./flow-correction-detector');
|
|
37
|
+
|
|
38
|
+
const BACKFILL_DATE = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Backfill a single project's pending-corrections.json.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} projectRoot — project directory containing .workflow/
|
|
44
|
+
* @param {Object} [opts]
|
|
45
|
+
* @param {boolean} [opts.dryRun=false] — if true, return what WOULD change without writing
|
|
46
|
+
* @returns {{ found: number, backfilled: number, alreadyPopulated: number, written: boolean, path: string|null, dryRun: boolean }}
|
|
47
|
+
*/
|
|
48
|
+
function backfillPendingCorrections(projectRoot, opts = {}) {
|
|
49
|
+
const { dryRun = false } = opts;
|
|
50
|
+
const pcPath = path.join(projectRoot, '.workflow', 'state', 'pending-corrections.json');
|
|
51
|
+
|
|
52
|
+
const result = {
|
|
53
|
+
found: 0,
|
|
54
|
+
backfilled: 0,
|
|
55
|
+
alreadyPopulated: 0,
|
|
56
|
+
written: false,
|
|
57
|
+
path: null,
|
|
58
|
+
dryRun
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (!fs.existsSync(pcPath)) {
|
|
62
|
+
result.path = pcPath;
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = fs.readFileSync(pcPath, 'utf-8');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw new Error(`Cannot read pending-corrections at ${pcPath}: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const records = safeJsonParseString(content, []);
|
|
74
|
+
if (!Array.isArray(records)) {
|
|
75
|
+
throw new Error(`Expected array at ${pcPath}; got ${typeof records}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
result.found = records.length;
|
|
79
|
+
result.path = pcPath;
|
|
80
|
+
|
|
81
|
+
let changed = false;
|
|
82
|
+
for (const r of records) {
|
|
83
|
+
if (!r || typeof r !== 'object') continue;
|
|
84
|
+
const userMsg = r.userMessage;
|
|
85
|
+
if (typeof userMsg !== 'string' || !userMsg.trim()) continue;
|
|
86
|
+
|
|
87
|
+
const needsFill = (r.whatWasWrong == null) && (r.whatUserWants == null);
|
|
88
|
+
if (!needsFill) {
|
|
89
|
+
result.alreadyPopulated += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Apply deterministic extraction (whatWasWrong only — whatUserWants
|
|
94
|
+
// stays null; intent inference is the live extractor's job going forward)
|
|
95
|
+
r.whatWasWrong = deterministicWhatWasWrong(userMsg);
|
|
96
|
+
r.enrichmentSource = `backfill-${BACKFILL_DATE}`;
|
|
97
|
+
result.backfilled += 1;
|
|
98
|
+
changed = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (changed && !dryRun) {
|
|
102
|
+
// Atomic write: write-temp + rename
|
|
103
|
+
const tmpPath = `${pcPath}.tmp-${process.pid}`;
|
|
104
|
+
fs.writeFileSync(tmpPath, JSON.stringify(records, null, 2) + '\n');
|
|
105
|
+
fs.renameSync(tmpPath, pcPath);
|
|
106
|
+
result.written = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============================================================
|
|
113
|
+
// CLI
|
|
114
|
+
// ============================================================
|
|
115
|
+
|
|
116
|
+
function main() {
|
|
117
|
+
const argv = process.argv.slice(2);
|
|
118
|
+
const projArg = argv.find(a => a.startsWith('--project='));
|
|
119
|
+
const dryRun = argv.includes('--dry-run');
|
|
120
|
+
|
|
121
|
+
const projectRoot = projArg ? projArg.slice('--project='.length) : PATHS.root;
|
|
122
|
+
|
|
123
|
+
let result;
|
|
124
|
+
try {
|
|
125
|
+
result = backfillPendingCorrections(projectRoot, { dryRun });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(`Error: ${err.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log(JSON.stringify({
|
|
132
|
+
project: projectRoot,
|
|
133
|
+
pendingCorrectionsPath: result.path,
|
|
134
|
+
found: result.found,
|
|
135
|
+
backfilled: result.backfilled,
|
|
136
|
+
alreadyPopulated: result.alreadyPopulated,
|
|
137
|
+
written: result.written,
|
|
138
|
+
dryRun: result.dryRun
|
|
139
|
+
}, null, 2));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
backfillPendingCorrections
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (require.main === module) {
|
|
147
|
+
main();
|
|
148
|
+
}
|
|
@@ -329,14 +329,106 @@ function recordHybridTelemetry(verdict, runCtx = {}) {
|
|
|
329
329
|
}
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
// ============================================================================
|
|
333
|
+
// Layer 1 + Layer 2 Reconciliation (wf-6c58953a)
|
|
334
|
+
// ============================================================================
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Deterministic fallback for `whatWasWrong` — preserves the user's literal
|
|
338
|
+
* frustration text when LLM extraction is unavailable or fails. Better than
|
|
339
|
+
* null: a 200-char excerpt is honest signal; null is data loss.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} message — user message
|
|
342
|
+
* @returns {string|null}
|
|
343
|
+
*/
|
|
344
|
+
function deterministicWhatWasWrong(message) {
|
|
345
|
+
if (typeof message !== 'string') return null;
|
|
346
|
+
const trimmed = message.trim();
|
|
347
|
+
if (!trimmed) return null;
|
|
348
|
+
return trimmed.slice(0, 200);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Reconcile Layer 1 (keyword classifier) + Layer 2 (Haiku LLM) results.
|
|
353
|
+
*
|
|
354
|
+
* Pre-fix bug: Layer 1 returned `{whatWasWrong: null, whatUserWants: null}`
|
|
355
|
+
* when keyword matched, never calling Layer 2. The user's actual frustration
|
|
356
|
+
* was captured but structured fields stayed null — silent feature no-op.
|
|
357
|
+
*
|
|
358
|
+
* Post-fix design:
|
|
359
|
+
* - Layer 1 hit + Layer 2 success: trust Layer 1's classification (high-
|
|
360
|
+
* precision keyword match), use Layer 2's strings if non-null else
|
|
361
|
+
* deterministic fallback. Record `llmDisagreed` if Layer 2 said
|
|
362
|
+
* `isCorrection: false` (e.g., user said "I'm just asking a question").
|
|
363
|
+
* - Layer 1 hit + Layer 2 fail/skip: deterministic fallback for `whatWasWrong`
|
|
364
|
+
* (first 200 chars). `whatUserWants` stays null (intent inference is an
|
|
365
|
+
* LLM job; honest null > wrong guess).
|
|
366
|
+
* - Layer 1 miss + Layer 2 success: Layer 2 is primary classifier (existing path).
|
|
367
|
+
* - Both miss: not a correction.
|
|
368
|
+
*
|
|
369
|
+
* Pure function — testable in isolation, no LLM mock needed.
|
|
370
|
+
*
|
|
371
|
+
* @param {Object|null} layer1 — Layer 1 result {isCorrection, confidence, correctionType, method, matchedPattern}
|
|
372
|
+
* @param {Object|null} layer2 — Layer 2 (LLM) result {isCorrection, confidence, correctionType, whatWasWrong, whatUserWants}
|
|
373
|
+
* @param {string} trimmed — trimmed user message (for deterministic fallback)
|
|
374
|
+
* @returns {Object|null} reconciled record OR null if not a correction
|
|
375
|
+
*/
|
|
376
|
+
function reconcileExtraction(layer1, layer2, trimmed) {
|
|
377
|
+
// Both layers ran
|
|
378
|
+
if (layer1 && layer2) {
|
|
379
|
+
const what = layer2.whatWasWrong || deterministicWhatWasWrong(trimmed);
|
|
380
|
+
const wants = layer2.whatUserWants || null;
|
|
381
|
+
return {
|
|
382
|
+
isCorrection: true, // Layer 1 high-precision keyword match wins binary
|
|
383
|
+
confidence: layer1.confidence,
|
|
384
|
+
correctionType: layer1.correctionType || layer2.correctionType || 'behavior',
|
|
385
|
+
whatWasWrong: what,
|
|
386
|
+
whatUserWants: wants,
|
|
387
|
+
method: 'keyword+ai',
|
|
388
|
+
matchedPattern: layer1.matchedPattern,
|
|
389
|
+
enrichmentSource: layer2.whatWasWrong ? 'haiku' : 'deterministic-fallback',
|
|
390
|
+
llmDisagreed: layer2.isCorrection === false,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// Layer 1 only (Layer 2 unavailable: no API key, network error, etc.)
|
|
394
|
+
if (layer1) {
|
|
395
|
+
return {
|
|
396
|
+
isCorrection: true,
|
|
397
|
+
confidence: layer1.confidence,
|
|
398
|
+
correctionType: layer1.correctionType || 'behavior',
|
|
399
|
+
whatWasWrong: deterministicWhatWasWrong(trimmed),
|
|
400
|
+
whatUserWants: null,
|
|
401
|
+
method: layer1.method,
|
|
402
|
+
matchedPattern: layer1.matchedPattern,
|
|
403
|
+
enrichmentSource: 'deterministic-fallback',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
// Layer 2 only (Layer 1 missed)
|
|
407
|
+
if (layer2 && layer2.isCorrection) {
|
|
408
|
+
return {
|
|
409
|
+
isCorrection: true,
|
|
410
|
+
confidence: layer2.confidence,
|
|
411
|
+
correctionType: layer2.correctionType || null,
|
|
412
|
+
whatWasWrong: layer2.whatWasWrong || null,
|
|
413
|
+
whatUserWants: layer2.whatUserWants || null,
|
|
414
|
+
method: 'ai',
|
|
415
|
+
enrichmentSource: 'haiku',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// Both missed → not a correction
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
332
422
|
// ============================================================================
|
|
333
423
|
// AI-Based Detection (Haiku — language-agnostic)
|
|
334
424
|
// ============================================================================
|
|
335
425
|
|
|
336
426
|
/**
|
|
337
427
|
* Detect if a message is a correction using Claude Haiku.
|
|
338
|
-
*
|
|
339
|
-
*
|
|
428
|
+
* Hybrid: Layer 1 keyword classifier (fast) + Layer 2 Haiku enrichment.
|
|
429
|
+
*
|
|
430
|
+
* wf-6c58953a (2026-05-09): Layer 1 hit no longer short-circuits structured
|
|
431
|
+
* extraction. See reconcileExtraction() for the post-fix design rationale.
|
|
340
432
|
*
|
|
341
433
|
* @param {string} userMessage - The user's message
|
|
342
434
|
* @param {string} previousContext - Summary of what the AI was doing
|
|
@@ -354,8 +446,12 @@ async function detectCorrection(userMessage, previousContext = '') {
|
|
|
354
446
|
return { isCorrection: false, confidence: 0, method: 'skipped', reason: 'length-filter' };
|
|
355
447
|
}
|
|
356
448
|
|
|
357
|
-
// Layer 1 (wf-e6d65edf) — keyword pre-classifier.
|
|
449
|
+
// Layer 1 (wf-e6d65edf) — keyword pre-classifier.
|
|
450
|
+
// wf-6c58953a: NO longer short-circuits structured extraction. Layer 1's
|
|
451
|
+
// classification is captured; reconcile with Layer 2 (or deterministic
|
|
452
|
+
// fallback when Layer 2 unavailable) at end.
|
|
358
453
|
const hybridCfg = getHybridConfig();
|
|
454
|
+
let layer1Result = null;
|
|
359
455
|
if (hybridCfg.hybridEnabled) {
|
|
360
456
|
const matched = findKeywordMatch(trimmed);
|
|
361
457
|
if (matched) {
|
|
@@ -368,12 +464,10 @@ async function detectCorrection(userMessage, previousContext = '') {
|
|
|
368
464
|
confidence: conf,
|
|
369
465
|
durationMs: Date.now() - start,
|
|
370
466
|
});
|
|
371
|
-
|
|
467
|
+
layer1Result = {
|
|
372
468
|
isCorrection: true,
|
|
373
469
|
confidence: conf,
|
|
374
470
|
correctionType: 'behavior',
|
|
375
|
-
whatWasWrong: null,
|
|
376
|
-
whatUserWants: null,
|
|
377
471
|
method: 'keyword',
|
|
378
472
|
matchedPattern: matched.phrase,
|
|
379
473
|
};
|
|
@@ -383,6 +477,10 @@ async function detectCorrection(userMessage, previousContext = '') {
|
|
|
383
477
|
// Check if API key is available
|
|
384
478
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
385
479
|
if (!apiKey) {
|
|
480
|
+
// wf-6c58953a: Layer 1 hit + no API key → deterministic fallback (not null)
|
|
481
|
+
if (layer1Result) {
|
|
482
|
+
return reconcileExtraction(layer1Result, null, trimmed);
|
|
483
|
+
}
|
|
386
484
|
return { isCorrection: false, confidence: 0, method: 'skipped', reason: 'no-api-key' };
|
|
387
485
|
}
|
|
388
486
|
|
|
@@ -492,11 +590,19 @@ Respond with JSON only (no markdown, no explanation):
|
|
|
492
590
|
durationMs: Date.now() - start,
|
|
493
591
|
});
|
|
494
592
|
|
|
593
|
+
// wf-6c58953a: reconcile Layer 1 + Layer 2 (or just Layer 2 if Layer 1 missed)
|
|
594
|
+
const reconciled = reconcileExtraction(layer1Result, aiResult, trimmed);
|
|
595
|
+
if (reconciled) return reconciled;
|
|
596
|
+
// Both layers say no-correction
|
|
495
597
|
return aiResult;
|
|
496
598
|
} catch (err) {
|
|
497
599
|
if (process.env.DEBUG) {
|
|
498
600
|
console.error(`[DEBUG] AI correction detection failed: ${err.message}`);
|
|
499
601
|
}
|
|
602
|
+
// wf-6c58953a: Layer 2 failure with Layer 1 hit → deterministic fallback
|
|
603
|
+
if (layer1Result) {
|
|
604
|
+
return reconcileExtraction(layer1Result, null, trimmed);
|
|
605
|
+
}
|
|
500
606
|
return { isCorrection: false, confidence: 0, method: 'ai', reason: err.message };
|
|
501
607
|
}
|
|
502
608
|
}
|
|
@@ -1295,11 +1401,15 @@ function correlateWithPriorGates(correction) {
|
|
|
1295
1401
|
// ============================================================================
|
|
1296
1402
|
|
|
1297
1403
|
module.exports = {
|
|
1298
|
-
// Detection (
|
|
1404
|
+
// Detection (hybrid Layer 1 + Layer 2)
|
|
1299
1405
|
detectCorrection,
|
|
1300
1406
|
batchAnalyzePrompts,
|
|
1301
1407
|
spawnBackgroundDetection,
|
|
1302
1408
|
|
|
1409
|
+
// wf-6c58953a: reconciliation helpers exposed for unit testing + backfill
|
|
1410
|
+
reconcileExtraction,
|
|
1411
|
+
deterministicWhatWasWrong,
|
|
1412
|
+
|
|
1303
1413
|
// Queue management
|
|
1304
1414
|
loadPendingCorrections,
|
|
1305
1415
|
queuePendingCorrection,
|
|
@@ -77,23 +77,23 @@ const MATCH_LEVEL_SEVERITY = {
|
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
// Task type to check type mapping for smart scoping
|
|
80
|
-
// wf-00c5067b: 'hook-three-layer' added
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
80
|
+
// wf-00c5067b: 'hook-three-layer' added — entry-file LOC + import-count rule.
|
|
81
|
+
// wf-037f8d66: 'forbidden-patterns' added — declarative project-specific
|
|
82
|
+
// patterns (agnosticism rules, no-hardcoding rules, etc.) loaded from
|
|
83
|
+
// .workflow/state/forbidden-patterns.json. Empty file = no-op.
|
|
84
84
|
const TASK_CHECK_MAP = {
|
|
85
|
-
'component': ['naming', 'components', 'security', 'hook-three-layer'],
|
|
86
|
-
'utility': ['naming', 'functions', 'security', 'hook-three-layer'],
|
|
87
|
-
'api': ['naming', 'api', 'security', 'hook-three-layer'],
|
|
88
|
-
'feature': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'],
|
|
89
|
-
'bugfix': ['naming', 'security', 'hook-three-layer'],
|
|
90
|
-
'refactor': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'],
|
|
91
|
-
'story': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'],
|
|
92
|
-
'default': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer']
|
|
85
|
+
'component': ['naming', 'components', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
86
|
+
'utility': ['naming', 'functions', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
87
|
+
'api': ['naming', 'api', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
88
|
+
'feature': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
89
|
+
'bugfix': ['naming', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
90
|
+
'refactor': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
91
|
+
'story': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'],
|
|
92
|
+
'default': ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns']
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
// All available check types
|
|
96
|
-
const ALL_CHECK_TYPES = ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer'];
|
|
96
|
+
const ALL_CHECK_TYPES = ['naming', 'components', 'functions', 'api', 'schemas', 'services', 'security', 'hook-three-layer', 'forbidden-patterns'];
|
|
97
97
|
|
|
98
98
|
// ============================================================================
|
|
99
99
|
// Parse Standards Files
|
|
@@ -639,6 +639,140 @@ function checkHookThreeLayer(file, hookThreeLayerConfig = {}) {
|
|
|
639
639
|
return violations;
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
+
/**
|
|
643
|
+
* wf-037f8d66 — Check forbidden patterns from project's declarative rule pack.
|
|
644
|
+
*
|
|
645
|
+
* Projects declare their forbidden patterns in `.workflow/state/forbidden-patterns.json`.
|
|
646
|
+
* This is the GENERIC, DATA-DRIVEN counterpart to checkSecurityPatterns (which is
|
|
647
|
+
* source-coded). Projects can encode "agnosticism" rules, "no-hardcoding" rules,
|
|
648
|
+
* "no-claude-code-literal-outside-docs" rules, etc., without modifying the checker.
|
|
649
|
+
*
|
|
650
|
+
* Format (forbidden-patterns.json):
|
|
651
|
+
* [
|
|
652
|
+
* {
|
|
653
|
+
* "id": "no-claude-code-literal",
|
|
654
|
+
* "pattern": "['\"]claude-code['\"]", // RegExp source string
|
|
655
|
+
* "flags": "g", // optional, defaults to 'g'
|
|
656
|
+
* "exemptions": ["docs/**", "*.md", "reference/**"],
|
|
657
|
+
* "severity": "must-fix", // 'must-fix' | 'warning'
|
|
658
|
+
* "message": "wogiflow-cli is agnostic; ..."
|
|
659
|
+
* }
|
|
660
|
+
* ]
|
|
661
|
+
*
|
|
662
|
+
* @param {Object} file — { path, content }
|
|
663
|
+
* @param {Object[]} patterns — array of pattern entries
|
|
664
|
+
* @returns {Object[]} violations
|
|
665
|
+
*/
|
|
666
|
+
function checkForbiddenPatterns(file, patterns) {
|
|
667
|
+
const violations = [];
|
|
668
|
+
if (!Array.isArray(patterns) || patterns.length === 0) return violations;
|
|
669
|
+
|
|
670
|
+
const content = file.content || '';
|
|
671
|
+
const relPath = file.path.startsWith('/')
|
|
672
|
+
? path.relative(PATHS.root, file.path)
|
|
673
|
+
: file.path;
|
|
674
|
+
|
|
675
|
+
for (const entry of patterns) {
|
|
676
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
677
|
+
if (!entry.pattern || typeof entry.pattern !== 'string') continue;
|
|
678
|
+
|
|
679
|
+
// Exemption check (glob match against relative path)
|
|
680
|
+
const exemptions = Array.isArray(entry.exemptions) ? entry.exemptions : [];
|
|
681
|
+
const exempted = exemptions.some(glob => globMatch(relPath, glob));
|
|
682
|
+
if (exempted) continue;
|
|
683
|
+
|
|
684
|
+
// Compile regex
|
|
685
|
+
let re;
|
|
686
|
+
try {
|
|
687
|
+
const flags = entry.flags && typeof entry.flags === 'string' ? entry.flags : 'g';
|
|
688
|
+
re = new RegExp(entry.pattern, flags);
|
|
689
|
+
} catch (_err) {
|
|
690
|
+
// Invalid regex — skip (don't crash check)
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Match
|
|
695
|
+
let match;
|
|
696
|
+
re.lastIndex = 0;
|
|
697
|
+
while ((match = re.exec(content)) !== null) {
|
|
698
|
+
const before = content.substring(0, match.index);
|
|
699
|
+
const lineNumber = (before.match(/\n/g) || []).length + 1;
|
|
700
|
+
violations.push({
|
|
701
|
+
type: 'forbidden-pattern',
|
|
702
|
+
severity: entry.severity === 'warning' ? 'warning' : 'must-fix',
|
|
703
|
+
file: file.path,
|
|
704
|
+
line: lineNumber,
|
|
705
|
+
message: entry.message || `Forbidden pattern matched: ${entry.id || entry.pattern}`,
|
|
706
|
+
rule: `forbidden-patterns.json: ${entry.id || '(unnamed)'}`
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// Avoid infinite loop on zero-length matches
|
|
710
|
+
if (match.index === re.lastIndex) re.lastIndex++;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return violations;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Minimal glob-to-regex matcher for forbidden-pattern exemptions.
|
|
719
|
+
* Supports: `**` (any depth), `*` (no path separator), exact match.
|
|
720
|
+
* Per security-patterns.md §4: use `[^/]*` not `.*` to prevent path
|
|
721
|
+
* separator matching in `*` (only `**` should cross directories).
|
|
722
|
+
*/
|
|
723
|
+
function globMatch(filePath, glob) {
|
|
724
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
725
|
+
// Build regex from glob
|
|
726
|
+
let re = '^';
|
|
727
|
+
let i = 0;
|
|
728
|
+
while (i < glob.length) {
|
|
729
|
+
const c = glob[i];
|
|
730
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
731
|
+
// ** matches anything including separators
|
|
732
|
+
re += '.*';
|
|
733
|
+
i += 2;
|
|
734
|
+
// Skip a following / so '**/foo' matches both 'foo' and 'a/b/foo'
|
|
735
|
+
if (glob[i] === '/') i++;
|
|
736
|
+
} else if (c === '*') {
|
|
737
|
+
re += '[^/]*';
|
|
738
|
+
i++;
|
|
739
|
+
} else if (c === '?') {
|
|
740
|
+
re += '[^/]';
|
|
741
|
+
i++;
|
|
742
|
+
} else if ('.^$+(){}[]|\\'.includes(c)) {
|
|
743
|
+
re += '\\' + c;
|
|
744
|
+
i++;
|
|
745
|
+
} else {
|
|
746
|
+
re += c;
|
|
747
|
+
i++;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
re += '$';
|
|
751
|
+
try {
|
|
752
|
+
return new RegExp(re).test(normalized);
|
|
753
|
+
} catch (_err) {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Load forbidden-patterns.json from project state dir.
|
|
760
|
+
* @returns {Object[]} pattern entries (empty array if missing/invalid)
|
|
761
|
+
*/
|
|
762
|
+
function loadForbiddenPatterns(projectRoot) {
|
|
763
|
+
const root = projectRoot || PATHS.root;
|
|
764
|
+
const filePath = path.join(root, '.workflow', 'state', 'forbidden-patterns.json');
|
|
765
|
+
if (!fs.existsSync(filePath)) return [];
|
|
766
|
+
try {
|
|
767
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
768
|
+
const parsed = JSON.parse(raw);
|
|
769
|
+
if (!Array.isArray(parsed)) return [];
|
|
770
|
+
return parsed;
|
|
771
|
+
} catch (_err) {
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
642
776
|
function checkApiDuplication(file, existingEndpoints, matchConfig) {
|
|
643
777
|
const violations = [];
|
|
644
778
|
const content = file.content || '';
|
|
@@ -1090,6 +1224,8 @@ function runStandardsCheck(files, options = {}) {
|
|
|
1090
1224
|
const functions = checksToRun.includes('functions') ? parseFunctionMap() : [];
|
|
1091
1225
|
const endpoints = checksToRun.includes('api') ? parseApiMap() : [];
|
|
1092
1226
|
const rulesFiles = checksToRun.includes('security') ? loadRulesDir() : [];
|
|
1227
|
+
// wf-037f8d66: load forbidden-patterns.json (empty array if missing)
|
|
1228
|
+
const forbiddenPatterns = checksToRun.includes('forbidden-patterns') ? loadForbiddenPatterns() : [];
|
|
1093
1229
|
|
|
1094
1230
|
// Load schema/service registries if needed
|
|
1095
1231
|
const schemas = checksToRun.includes('schemas') ? parseSchemaMap() : [];
|
|
@@ -1115,7 +1251,8 @@ function runStandardsCheck(files, options = {}) {
|
|
|
1115
1251
|
'service-map.md': { checked: checksToRun.includes('services') && services.length > 0, violations: 0 },
|
|
1116
1252
|
'naming-conventions': { checked: checksToRun.includes('naming'), violations: 0 },
|
|
1117
1253
|
'security-patterns': { checked: checksToRun.includes('security'), violations: 0 },
|
|
1118
|
-
'hook-three-layer': { checked: checksToRun.includes('hook-three-layer'), violations: 0 }
|
|
1254
|
+
'hook-three-layer': { checked: checksToRun.includes('hook-three-layer'), violations: 0 },
|
|
1255
|
+
'forbidden-patterns': { checked: checksToRun.includes('forbidden-patterns') && forbiddenPatterns.length > 0, violations: 0 }
|
|
1119
1256
|
};
|
|
1120
1257
|
|
|
1121
1258
|
for (const file of files) {
|
|
@@ -1181,6 +1318,13 @@ function runStandardsCheck(files, options = {}) {
|
|
|
1181
1318
|
allViolations.push(...hookViolations);
|
|
1182
1319
|
checksSummary['hook-three-layer'].violations += hookViolations.length;
|
|
1183
1320
|
}
|
|
1321
|
+
|
|
1322
|
+
// Forbidden patterns from project rule pack (wf-037f8d66)
|
|
1323
|
+
if (checksToRun.includes('forbidden-patterns') && forbiddenPatterns.length > 0) {
|
|
1324
|
+
const fpViolations = checkForbiddenPatterns(file, forbiddenPatterns);
|
|
1325
|
+
allViolations.push(...fpViolations);
|
|
1326
|
+
checksSummary['forbidden-patterns'].violations += fpViolations.length;
|
|
1327
|
+
}
|
|
1184
1328
|
}
|
|
1185
1329
|
|
|
1186
1330
|
// Count must-fix violations
|
|
@@ -1415,6 +1559,9 @@ module.exports = {
|
|
|
1415
1559
|
checkRegistryDuplication,
|
|
1416
1560
|
checkSecurityPatterns,
|
|
1417
1561
|
checkHookThreeLayer,
|
|
1562
|
+
checkForbiddenPatterns, // wf-037f8d66: declarative project rule pack
|
|
1563
|
+
loadForbiddenPatterns, // wf-037f8d66: exposed for tests + external use
|
|
1564
|
+
globMatch, // wf-037f8d66: exposed for unit testing
|
|
1418
1565
|
extractDeclaredNames,
|
|
1419
1566
|
discoverAllRegistries,
|
|
1420
1567
|
collectReuseCandidates,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Architect-Required Gate (wf-037f8d66)
|
|
5
|
+
*
|
|
6
|
+
* Closes the methodology gap where the IGR Architect/Adversary pass at
|
|
7
|
+
* spec_review IS specced (per .claude/docs/phases/02-spec.md) but enforcement
|
|
8
|
+
* is prompt-only — the agent can skip Architect and go straight to coding.
|
|
9
|
+
*
|
|
10
|
+
* This gate fires on Edit/Write during the `coding` phase for L1+ tasks when
|
|
11
|
+
* config.intentGroundedReasoning.enabled is true and no evidence of an
|
|
12
|
+
* Architect run exists for the current task.
|
|
13
|
+
*
|
|
14
|
+
* Evidence marker: `.workflow/state/architect-runs/<task-id>.json` written
|
|
15
|
+
* by flow-architect-pass.js on successful completion.
|
|
16
|
+
*
|
|
17
|
+
* Scope:
|
|
18
|
+
* - L0 (epic) / L1 (story) tasks: Architect required → gate enforces
|
|
19
|
+
* - L2 / L3 tasks: skip spec_review entirely (correctly) → gate is a no-op
|
|
20
|
+
*
|
|
21
|
+
* Fail-open: any error reading state/config → allow tool call. Same pattern
|
|
22
|
+
* as research-evidence-gate.js.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const { PATHS } = require('../../flow-utils');
|
|
28
|
+
|
|
29
|
+
const ARCHITECT_RUNS_DIR = path.join(PATHS.state, 'architect-runs');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute path to the Architect-run evidence marker for a task.
|
|
33
|
+
*/
|
|
34
|
+
function getArchitectRunPath(taskId) {
|
|
35
|
+
if (!taskId || typeof taskId !== 'string') return null;
|
|
36
|
+
return path.join(ARCHITECT_RUNS_DIR, `${taskId}.json`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write an Architect-run evidence marker. Called by flow-architect-pass.js
|
|
41
|
+
* on successful completion. Atomic write-temp + rename.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} payload — { taskId, completedAt, model, plan }
|
|
44
|
+
* @returns {{ written: boolean, path: string|null }}
|
|
45
|
+
*/
|
|
46
|
+
function writeArchitectRunMarker(payload) {
|
|
47
|
+
if (!payload || !payload.taskId) {
|
|
48
|
+
return { written: false, path: null };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.existsSync(ARCHITECT_RUNS_DIR)) {
|
|
52
|
+
fs.mkdirSync(ARCHITECT_RUNS_DIR, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
const filePath = getArchitectRunPath(payload.taskId);
|
|
55
|
+
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
56
|
+
fs.writeFileSync(tmpPath, JSON.stringify({
|
|
57
|
+
taskId: payload.taskId,
|
|
58
|
+
completedAt: payload.completedAt || new Date().toISOString(),
|
|
59
|
+
model: payload.model || null,
|
|
60
|
+
plan: payload.plan || null
|
|
61
|
+
}, null, 2));
|
|
62
|
+
fs.renameSync(tmpPath, filePath);
|
|
63
|
+
return { written: true, path: filePath };
|
|
64
|
+
} catch (_err) {
|
|
65
|
+
return { written: false, path: null };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check whether an Architect run is recorded for a given task.
|
|
71
|
+
* @param {string} taskId
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function hasArchitectRun(taskId) {
|
|
75
|
+
const p = getArchitectRunPath(taskId);
|
|
76
|
+
if (!p) return false;
|
|
77
|
+
try {
|
|
78
|
+
return fs.existsSync(p);
|
|
79
|
+
} catch (_err) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Determine whether the current task requires Architect (L1+ only).
|
|
86
|
+
* @param {Object} taskMeta — task record from ready.json (has level, type)
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
function requiresArchitect(taskMeta) {
|
|
90
|
+
if (!taskMeta || typeof taskMeta !== 'object') return false;
|
|
91
|
+
const level = (taskMeta.level || '').toUpperCase();
|
|
92
|
+
// L0 (epic) and L1 (story) require Architect.
|
|
93
|
+
// L2 (task) / L3 (subtask) bypass spec_review correctly.
|
|
94
|
+
return level === 'L0' || level === 'L1';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read whether the gate is enabled from config.
|
|
99
|
+
* Default: enabled when IGR is enabled.
|
|
100
|
+
*/
|
|
101
|
+
function isGateEnabled(config) {
|
|
102
|
+
const igr = config?.intentGroundedReasoning;
|
|
103
|
+
if (!igr || igr.enabled === false) return false;
|
|
104
|
+
// Explicit toggle on the gate itself overrides
|
|
105
|
+
if (config?.architectRequiredGate?.enabled === false) return false;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Main gate check.
|
|
111
|
+
*
|
|
112
|
+
* @param {Object} ctx — { phase, taskId, taskMeta, config, toolName }
|
|
113
|
+
* @returns {{ blocked: boolean, reason?: string, message?: string }}
|
|
114
|
+
*/
|
|
115
|
+
function checkArchitectRequired(ctx) {
|
|
116
|
+
const { phase, taskId, taskMeta, config, toolName } = ctx || {};
|
|
117
|
+
|
|
118
|
+
// Only fires on tools that mutate state (Edit/Write/Bash/TodoWrite)
|
|
119
|
+
const mutationTools = new Set(['Edit', 'Write', 'TodoWrite', 'Bash']);
|
|
120
|
+
if (!toolName || !mutationTools.has(toolName)) {
|
|
121
|
+
return { blocked: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Only fires during coding phase
|
|
125
|
+
if (phase !== 'coding') {
|
|
126
|
+
return { blocked: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Gate disabled (or IGR off)
|
|
130
|
+
if (!isGateEnabled(config)) {
|
|
131
|
+
return { blocked: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// No active task → not in scope (gate doesn't apply)
|
|
135
|
+
if (!taskId) {
|
|
136
|
+
return { blocked: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// L2/L3 tasks bypass spec_review correctly — gate is a no-op
|
|
140
|
+
if (!requiresArchitect(taskMeta)) {
|
|
141
|
+
return { blocked: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check for evidence marker
|
|
145
|
+
if (hasArchitectRun(taskId)) {
|
|
146
|
+
return { blocked: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Block: Architect required but no evidence
|
|
150
|
+
return {
|
|
151
|
+
blocked: true,
|
|
152
|
+
reason: 'architect-required',
|
|
153
|
+
message: [
|
|
154
|
+
`ARCHITECT-REQUIRED GATE: task ${taskId} is L1+ in coding phase but no `,
|
|
155
|
+
`Architect run is recorded at ${getArchitectRunPath(taskId)}.\n\n`,
|
|
156
|
+
`Per .claude/docs/phases/02-spec.md Step 1.55, L1+ tasks must run an `,
|
|
157
|
+
`Architect pass before coding. Invoke:\n\n`,
|
|
158
|
+
` node scripts/flow-architect-pass.js run --task=${taskId}\n\n`,
|
|
159
|
+
`Then retry your edit. To opt out for this task only, set `,
|
|
160
|
+
`config.architectRequiredGate.enabled = false (project-level), or use `,
|
|
161
|
+
`\`flow architect-skip --task=${taskId} --reason="..."\` (single-task escape; `,
|
|
162
|
+
`not yet implemented — opens follow-up wf if needed).`
|
|
163
|
+
].join('')
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
checkArchitectRequired,
|
|
169
|
+
writeArchitectRunMarker,
|
|
170
|
+
hasArchitectRun,
|
|
171
|
+
requiresArchitect,
|
|
172
|
+
getArchitectRunPath,
|
|
173
|
+
isGateEnabled,
|
|
174
|
+
ARCHITECT_RUNS_DIR
|
|
175
|
+
};
|
|
@@ -58,6 +58,15 @@ function loadGateDeps() {
|
|
|
58
58
|
if (process.env.DEBUG) console.error(`[Hook] Phase-read gate not loaded: ${_err.message}`);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// wf-037f8d66: Architect-required gate (mechanical Layer 2 enforcement)
|
|
62
|
+
let checkArchitectRequired = () => ({ blocked: false });
|
|
63
|
+
try {
|
|
64
|
+
const arg = require('./architect-required-gate');
|
|
65
|
+
checkArchitectRequired = arg.checkArchitectRequired;
|
|
66
|
+
} catch (_err) {
|
|
67
|
+
if (process.env.DEBUG) console.error(`[Hook] Architect-required gate not loaded: ${_err.message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
let recordEvidenceRead = () => {};
|
|
62
71
|
let checkSpecWriteGate = _phaseNoop;
|
|
63
72
|
let clearResearchEvidence = () => {};
|
|
@@ -168,6 +177,7 @@ function loadGateDeps() {
|
|
|
168
177
|
checkRoutingGate, clearRoutingPending, hasActiveTask,
|
|
169
178
|
checkPhaseGate, checkCommitLogGate,
|
|
170
179
|
recordPhaseRead, checkPhaseReadGate, clearPhaseReads,
|
|
180
|
+
checkArchitectRequired, // wf-037f8d66
|
|
171
181
|
recordEvidenceRead, checkSpecWriteGate, clearResearchEvidence,
|
|
172
182
|
checkDeployGate, checkWriteBlock,
|
|
173
183
|
checkStrikeGate, checkBugfixScope, checkScopeMutation,
|
|
@@ -118,6 +118,51 @@ function runPreToolGates(ctx, deps) {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Architect-required gate (wf-037f8d66)
|
|
122
|
+
// L1+ tasks in coding phase must have run Architect/Adversary before any Edit/Write/Bash.
|
|
123
|
+
// L2/L3 tasks bypass spec_review entirely (correctly), so the gate is a no-op there.
|
|
124
|
+
// Fail-open on any error.
|
|
125
|
+
if (typeof deps.checkArchitectRequired === 'function' &&
|
|
126
|
+
(toolName === 'Edit' || toolName === 'Write' || toolName === 'Bash' || toolName === 'TodoWrite')) {
|
|
127
|
+
try {
|
|
128
|
+
// Resolve current phase + task meta from active state
|
|
129
|
+
const flowUtils = require('../../flow-utils');
|
|
130
|
+
const flowIo = require('../../flow-io');
|
|
131
|
+
let phase = 'idle';
|
|
132
|
+
let taskId = null;
|
|
133
|
+
let taskMeta = null;
|
|
134
|
+
try {
|
|
135
|
+
const phaseStatePath = path.join(flowUtils.PATHS.state, 'workflow-phase.json');
|
|
136
|
+
const ps = flowIo.safeJsonParse(phaseStatePath, null);
|
|
137
|
+
if (ps) {
|
|
138
|
+
phase = ps.phase || 'idle';
|
|
139
|
+
taskId = ps.taskId || null;
|
|
140
|
+
}
|
|
141
|
+
} catch (_err) { /* fail-open */ }
|
|
142
|
+
if (taskId) {
|
|
143
|
+
try {
|
|
144
|
+
const ready = flowUtils.getReadyData ? flowUtils.getReadyData() : null;
|
|
145
|
+
const inProgress = (ready && Array.isArray(ready.inProgress)) ? ready.inProgress : [];
|
|
146
|
+
taskMeta = inProgress.find(t => t && t.id === taskId) || null;
|
|
147
|
+
} catch (_err) { /* fail-open */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const archResult = deps.checkArchitectRequired({
|
|
151
|
+
phase, taskId, taskMeta, config, toolName
|
|
152
|
+
});
|
|
153
|
+
if (archResult.blocked) {
|
|
154
|
+
return {
|
|
155
|
+
allowed: false,
|
|
156
|
+
blocked: true,
|
|
157
|
+
reason: archResult.reason || 'architect-required',
|
|
158
|
+
message: archResult.message,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
} catch (_err) {
|
|
162
|
+
if (process.env.DEBUG) console.error(`[Hook] Architect-required gate error (fail-open): ${_err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
121
166
|
// wf-f9912af6: Deferral-authorization gate. Blocks Write/Edit to
|
|
122
167
|
// last-review.json / last-audit.json when the new content introduces
|
|
123
168
|
// `status: deferred*` on findings without explicit user authorization.
|