wogiflow 2.9.0 → 2.10.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-bug.md +49 -4
- package/.claude/commands/wogi-decide.md +25 -0
- package/.claude/commands/wogi-learn.md +12 -5
- package/.claude/commands/wogi-start-continuation.md +84 -0
- package/.claude/commands/wogi-start.md +94 -3
- package/.claude/commands/wogi-statusline-setup.md +18 -5
- package/.claude/docs/claude-code-compatibility.md +77 -1
- package/.claude/docs/explore-agents.md +21 -5
- package/lib/workspace-gates.js +149 -0
- package/package.json +1 -1
- package/scripts/flow-config-defaults.js +7 -1
- package/scripts/flow-context-estimator.js +26 -4
- package/scripts/flow-decision-authority.js +359 -0
- package/scripts/flow-health.js +180 -0
- package/scripts/flow-hypothesis-generator.js +63 -0
- package/scripts/flow-log-manager.js +38 -0
- package/scripts/flow-memory-db.js +53 -2
- package/scripts/flow-section-resolver.js +47 -0
- package/scripts/flow-session-state.js +37 -6
- package/scripts/flow-standards-gate.js +138 -5
- package/scripts/flow-statusline-setup.js +137 -20
- package/scripts/hooks/core/task-completed.js +77 -0
- package/scripts/hooks/entry/claude-code/session-start.js +8 -1
package/lib/workspace-gates.js
CHANGED
|
@@ -69,6 +69,18 @@ const WORKSPACE_GATES = [
|
|
|
69
69
|
description: 'Verify changes are committed and pushed before handoff to downstream workers',
|
|
70
70
|
phase: 'post',
|
|
71
71
|
severity: 'error'
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'crossRepoEnumVerification',
|
|
75
|
+
description: 'Verify type/enum mappings between repos by grepping BOTH repos for actual values',
|
|
76
|
+
phase: 'pre',
|
|
77
|
+
severity: 'error'
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'dispatchVerification',
|
|
81
|
+
description: 'Verify dispatch claims have corresponding tool calls — prevents narrate-without-execute',
|
|
82
|
+
phase: 'post',
|
|
83
|
+
severity: 'error'
|
|
72
84
|
}
|
|
73
85
|
];
|
|
74
86
|
|
|
@@ -538,6 +550,12 @@ function runWorkspaceGate(gateName, workspaceRoot, context, taskMeta = {}) {
|
|
|
538
550
|
case 'deploymentReadiness':
|
|
539
551
|
return gateDeploymentReadiness(workspaceRoot, context, taskMeta);
|
|
540
552
|
|
|
553
|
+
case 'crossRepoEnumVerification':
|
|
554
|
+
return gateCrossRepoEnumVerification(workspaceRoot, context, taskMeta);
|
|
555
|
+
|
|
556
|
+
case 'dispatchVerification':
|
|
557
|
+
return gateDispatchVerification(context, taskMeta);
|
|
558
|
+
|
|
541
559
|
default:
|
|
542
560
|
return { passed: true, message: `Unknown gate: ${gateName}`, severity: 'warning' };
|
|
543
561
|
}
|
|
@@ -800,6 +818,137 @@ function runAllWorkspaceGates(workspaceRoot, context, taskMeta = {}) {
|
|
|
800
818
|
};
|
|
801
819
|
}
|
|
802
820
|
|
|
821
|
+
/**
|
|
822
|
+
* Gate: dispatchVerification
|
|
823
|
+
* After task completion in workspace manager mode, verify that any response
|
|
824
|
+
* claiming to have dispatched/sent/forwarded work to a worker actually
|
|
825
|
+
* contains corresponding tool calls (Bash with dispatch URL, Agent with
|
|
826
|
+
* worker target, etc.).
|
|
827
|
+
*
|
|
828
|
+
* Prevents the 'narrate without execute' failure class where the manager
|
|
829
|
+
* describes sending work but never actually sends it.
|
|
830
|
+
*
|
|
831
|
+
* Source: Manager mistake #1 (~15K tokens wasted on phantom dispatch).
|
|
832
|
+
*/
|
|
833
|
+
function gateDispatchVerification(context, taskMeta) {
|
|
834
|
+
const gate = WORKSPACE_GATES.find(g => g.name === 'dispatchVerification');
|
|
835
|
+
|
|
836
|
+
// Only meaningful in manager mode
|
|
837
|
+
if (!context.currentMember || context.currentMember.role !== 'manager') {
|
|
838
|
+
return { passed: true, message: 'Not in manager mode, dispatch verification skipped', severity: gate.severity };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Check if the task output contains dispatch-claim keywords
|
|
842
|
+
const dispatchKeywords = [
|
|
843
|
+
/\bdispatched\b/i,
|
|
844
|
+
/\bsent to worker\b/i,
|
|
845
|
+
/\bforwarded to\b/i,
|
|
846
|
+
/\bdelegated to\b/i,
|
|
847
|
+
/\brouted to worker\b/i,
|
|
848
|
+
/\bspawned.*agent\b/i
|
|
849
|
+
];
|
|
850
|
+
|
|
851
|
+
const taskOutput = taskMeta.taskOutput ?? '';
|
|
852
|
+
const hasDispatchClaim = dispatchKeywords.some(kw => kw.test(taskOutput));
|
|
853
|
+
|
|
854
|
+
if (!hasDispatchClaim) {
|
|
855
|
+
return { passed: true, message: 'No dispatch claims in task output', severity: gate.severity };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// If dispatch was claimed, verify tool calls exist
|
|
859
|
+
const toolCalls = taskMeta.toolCalls ?? [];
|
|
860
|
+
|
|
861
|
+
// Graceful degradation: if tool call tracking is not wired up yet, skip
|
|
862
|
+
if (toolCalls.length === 0 && !taskMeta.toolCallsTracked) {
|
|
863
|
+
return { passed: true, message: 'Tool call tracking not available — dispatch verification skipped', severity: 'warning' };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const hasDispatchToolCall = toolCalls.some(tc =>
|
|
867
|
+
(tc.tool === 'Bash' && /dispatch|curl.*channel|http.*localhost.*task/i.test(tc.input ?? '')) ||
|
|
868
|
+
(tc.tool === 'Agent' && tc.input) ||
|
|
869
|
+
(tc.tool === 'Bash' && /node.*workspace-routing/i.test(tc.input ?? ''))
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
if (hasDispatchToolCall) {
|
|
873
|
+
return { passed: true, message: 'Dispatch claims verified — tool calls found', severity: gate.severity };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Dispatch claimed but no matching tool call found
|
|
877
|
+
return {
|
|
878
|
+
passed: false,
|
|
879
|
+
message: `Response contains dispatch claims ("dispatched", "sent to worker", etc.) ` +
|
|
880
|
+
`but NO corresponding tool call was found. ` +
|
|
881
|
+
`This is the "narrate without execute" failure — the manager described sending work ` +
|
|
882
|
+
`but never actually sent it. Execute the dispatch before marking complete.`,
|
|
883
|
+
severity: gate.severity,
|
|
884
|
+
action: 'Find the dispatch claim, execute the actual dispatch tool call, then re-verify.'
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Gate: crossRepoEnumVerification
|
|
890
|
+
* When a decision involves type/enum mapping between repos (frontend↔backend),
|
|
891
|
+
* mandate grep/read to BOTH repos for actual values, display side-by-side,
|
|
892
|
+
* THEN present the mapping to the owner.
|
|
893
|
+
*
|
|
894
|
+
* Detects keywords: 'maps to', 'rename to', 'corresponds to', 'enum', 'type mapping'.
|
|
895
|
+
* Source: Manager mistake #5 (DECISION-7 axis mismatch — payment cadence vs compensation model).
|
|
896
|
+
*/
|
|
897
|
+
function gateCrossRepoEnumVerification(workspaceRoot, context, taskMeta) {
|
|
898
|
+
const gate = WORKSPACE_GATES.find(g => g.name === 'crossRepoEnumVerification');
|
|
899
|
+
|
|
900
|
+
// Only activate when workspace is active and has multiple members
|
|
901
|
+
if (!context.config?.members || Object.keys(context.config.members).length < 2) {
|
|
902
|
+
return { passed: true, message: 'Single-repo workspace, enum verification skipped', severity: gate.severity };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Check if the task description or decisions contain cross-repo type mapping keywords
|
|
906
|
+
const mappingKeywords = [
|
|
907
|
+
/\bmaps?\s*to\b/i,
|
|
908
|
+
/\brename[sd]?\s*to\b/i,
|
|
909
|
+
/\bcorresponds?\s*to\b/i,
|
|
910
|
+
/\benum\s+mapping/i,
|
|
911
|
+
/\btype\s+mapping/i,
|
|
912
|
+
/\bfrontend.*backend.*(?:type|enum|interface)/i,
|
|
913
|
+
/\bbackend.*frontend.*(?:type|enum|interface)/i,
|
|
914
|
+
/\bclient.*server.*(?:type|enum|interface)/i
|
|
915
|
+
];
|
|
916
|
+
|
|
917
|
+
const taskText = [
|
|
918
|
+
taskMeta.taskTitle ?? '',
|
|
919
|
+
taskMeta.taskDescription ?? '',
|
|
920
|
+
...(taskMeta.decisions ?? [])
|
|
921
|
+
].join(' ');
|
|
922
|
+
|
|
923
|
+
const hasMappingDecision = mappingKeywords.some(kw => kw.test(taskText));
|
|
924
|
+
|
|
925
|
+
if (!hasMappingDecision) {
|
|
926
|
+
return { passed: true, message: 'No cross-repo type/enum mapping detected', severity: gate.severity };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// If mapping decision detected, check if verification was performed
|
|
930
|
+
if (taskMeta.enumVerificationPerformed) {
|
|
931
|
+
return { passed: true, message: 'Cross-repo enum values verified in both repos', severity: gate.severity };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Build the list of member repos for the error message
|
|
935
|
+
const memberNames = Object.keys(context.config.members);
|
|
936
|
+
|
|
937
|
+
return {
|
|
938
|
+
passed: false,
|
|
939
|
+
message: `Cross-repo type/enum mapping detected but not verified. ` +
|
|
940
|
+
`Before presenting a mapping decision, grep BOTH repos (${memberNames.join(', ')}) ` +
|
|
941
|
+
`for actual enum/type values and display them side-by-side. ` +
|
|
942
|
+
`Never allow 'A maps to B' without verified evidence of both A and B.`,
|
|
943
|
+
severity: gate.severity,
|
|
944
|
+
action: 'Dispatch a grep/read to BOTH repos for actual enum values, display side-by-side, THEN present the mapping.',
|
|
945
|
+
memberRepos: memberNames.map(name => ({
|
|
946
|
+
name,
|
|
947
|
+
path: path.resolve(workspaceRoot, context.config.members[name].path)
|
|
948
|
+
}))
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
803
952
|
// ============================================================
|
|
804
953
|
// Exports
|
|
805
954
|
// ============================================================
|
package/package.json
CHANGED
|
@@ -187,7 +187,13 @@ const CONFIG_DEFAULTS = {
|
|
|
187
187
|
implementationGate: { enabled: true },
|
|
188
188
|
todoWriteGate: { enabled: true, blockImplementationWithoutTask: true },
|
|
189
189
|
routingGate: { enabled: true },
|
|
190
|
-
loopEnforcement: { enabled: true }
|
|
190
|
+
loopEnforcement: { enabled: true },
|
|
191
|
+
hypothesisGate: {
|
|
192
|
+
enabled: true,
|
|
193
|
+
_comment_hypothesisGate: 'Blocks premature "fixed"/"should work" claims during bug investigation until hypothesis is verified. Pattern: hypothesis → verify → confirm → communicate.',
|
|
194
|
+
blockedPhrases: ['fixed', 'should work', 'go try', 'go refresh'],
|
|
195
|
+
requireExplicitVerification: true
|
|
196
|
+
}
|
|
191
197
|
},
|
|
192
198
|
|
|
193
199
|
// --- Execution ---
|
|
@@ -167,10 +167,12 @@ function extractCriteriaCount(specContent) {
|
|
|
167
167
|
|
|
168
168
|
let count = 0;
|
|
169
169
|
|
|
170
|
-
// Count Given/When/Then
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
170
|
+
// Count Given/When/Then by individual keyword minimum (avoids ReDoS from nested [\s\S]*?)
|
|
171
|
+
const givenCount = (specContent.match(/\bGiven\b/gi) || []).length;
|
|
172
|
+
const whenCount = (specContent.match(/\bWhen\b/gi) || []).length;
|
|
173
|
+
const thenCount = (specContent.match(/\bThen\b/gi) || []).length;
|
|
174
|
+
if (givenCount > 0 && whenCount > 0 && thenCount > 0) {
|
|
175
|
+
count += Math.min(givenCount, whenCount, thenCount);
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
// Count numbered acceptance criteria
|
|
@@ -284,6 +286,26 @@ function estimateTaskContextNeeds(task, configOverride = null) {
|
|
|
284
286
|
estimate += est.refactorBuffer;
|
|
285
287
|
}
|
|
286
288
|
|
|
289
|
+
// Blast-radius buffer — factor in consumer impact from explore phase
|
|
290
|
+
// Validate task ID before path construction (security rule #4)
|
|
291
|
+
if (task.id && validateTaskId(task.id).valid) {
|
|
292
|
+
const blastRadiusPath = path.join(PATHS.state, `blast-radius-${task.id}.json`);
|
|
293
|
+
try {
|
|
294
|
+
const blastRadius = safeJsonParse(blastRadiusPath, null);
|
|
295
|
+
if (blastRadius && blastRadius.breakingCount > 0) {
|
|
296
|
+
factors.blastRadius = blastRadius.breakingCount;
|
|
297
|
+
// Each breaking consumer adds ~1% context (for reading + updating)
|
|
298
|
+
estimate += blastRadius.breakingCount * 0.01;
|
|
299
|
+
if (blastRadius.risk === 'HIGH') {
|
|
300
|
+
// HIGH risk (10+ breaking) adds extra buffer for phased migration planning
|
|
301
|
+
estimate += 0.05;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch (_err) {
|
|
305
|
+
// No blast-radius file — that's fine, proceed without it
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
287
309
|
// Parent task: scale by subtask count
|
|
288
310
|
if (task.type === 'parent' && task.subTasks && task.subTasks.length > 0) {
|
|
289
311
|
factors.parentMultiplier = 1 + (task.subTasks.length * 0.3);
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Decision Authority Framework
|
|
5
|
+
*
|
|
6
|
+
* Config-driven classification of decisions into authority levels.
|
|
7
|
+
* Prevents question flooding by classifying engineering vs product decisions
|
|
8
|
+
* and routing them to either agent-decides or owner-decides.
|
|
9
|
+
*
|
|
10
|
+
* Source: Retrospective — manager mistake #4 (12-question flooding),
|
|
11
|
+
* process gap #4 (no framework for engineering vs product authority).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node flow-decision-authority.js classify <decision-text>
|
|
15
|
+
* node flow-decision-authority.js batch <json-array-of-decisions>
|
|
16
|
+
* node flow-decision-authority.js update-category <category> <authority>
|
|
17
|
+
*
|
|
18
|
+
* Programmatic:
|
|
19
|
+
* const { classifyDecision, batchClassify, getAuthorityConfig } = require('./flow-decision-authority');
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const { PATHS, getConfig, safeJsonParse } = require('./flow-utils');
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// Constants
|
|
28
|
+
// ============================================================
|
|
29
|
+
|
|
30
|
+
const AUTHORITY_LEVELS = {
|
|
31
|
+
'agent-decides': 'Agent decides autonomously, reports in completion summary',
|
|
32
|
+
'agent-decides-report-after': 'Agent decides, explicitly reports the decision after',
|
|
33
|
+
'owner-decides': 'Present to user, wait for answer before proceeding',
|
|
34
|
+
'auto-fix-report-after': 'Agent fixes automatically, reports what was fixed'
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const DEFAULT_AUTHORITY_CONFIG = {
|
|
38
|
+
engineering: 'agent-decides',
|
|
39
|
+
infrastructure: 'agent-decides-report-after',
|
|
40
|
+
productBehavior: 'owner-decides',
|
|
41
|
+
security: 'auto-fix-report-after',
|
|
42
|
+
ux: 'owner-decides',
|
|
43
|
+
naming: 'agent-decides',
|
|
44
|
+
performance: 'agent-decides-report-after',
|
|
45
|
+
maxOwnerQuestionsPerBatch: 5
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Keywords and patterns for classifying decisions into categories.
|
|
50
|
+
* Each category has patterns that match against the decision text.
|
|
51
|
+
* IMPORTANT: Do NOT add the /g flag — these are used with .test() in a loop.
|
|
52
|
+
* Patterns use compound terms to avoid false positives from common single words.
|
|
53
|
+
*/
|
|
54
|
+
const CATEGORY_PATTERNS = {
|
|
55
|
+
engineering: [
|
|
56
|
+
/\b(refactor|extract|inline|rename|move file|split|merge|consolidat)\w*/i,
|
|
57
|
+
/\b(import|export|module structure|dependency|circular)\b/i,
|
|
58
|
+
/\b(error handling|try.catch|fallback|retry|timeout)\b/i,
|
|
59
|
+
/\b(lint|format|code style|indentation|semicolons?)\b/i,
|
|
60
|
+
/\b(type system|type annotation|type hierarchy|interface|generic|abstract|class hierarchy)\b/i,
|
|
61
|
+
/\b(cache strategy|memoiz|lazy load|bundle|tree.shak)\w*/i
|
|
62
|
+
],
|
|
63
|
+
infrastructure: [
|
|
64
|
+
/\b(docker|kubernetes|ci\/cd|pipeline|deploy|build)\b/i,
|
|
65
|
+
/\b(database|migration|schema|index|query optimiz)\w*/i,
|
|
66
|
+
/\b(aws|gcp|azure|cloud|server|hosting)\b/i,
|
|
67
|
+
/\b(environment|env var|config file|secret|credential)\b/i,
|
|
68
|
+
/\b(monitoring|logging|alerting|metrics|observability)\b/i
|
|
69
|
+
],
|
|
70
|
+
productBehavior: [
|
|
71
|
+
/\b(user.facing|user experience|product behavior|workflow)\b/i,
|
|
72
|
+
/\b(default value|default behavior|fallback behavior)\b/i,
|
|
73
|
+
/\b(business logic|business rule|domain)\b/i,
|
|
74
|
+
/\b(permission|role|access control|authorization)\b/i,
|
|
75
|
+
/\b(notification|email|alert to user)\b/i,
|
|
76
|
+
/\b(pricing|billing|subscription|plan|tier)\b/i,
|
|
77
|
+
/\b(feature flag|feature toggle|product feature)\b/i
|
|
78
|
+
],
|
|
79
|
+
security: [
|
|
80
|
+
/\b(security|vulnerabilit|exploit|injection|xss|csrf)\w*/i,
|
|
81
|
+
/\b(authentication|auth|token|session|password|hash)\b/i,
|
|
82
|
+
/\b(sanitiz|escap|validat|whitelist|blocklist)\w*/i,
|
|
83
|
+
/\b(cors|csp|header|ssl|tls|certificate)\b/i,
|
|
84
|
+
/\b(rate.limit|brute.force|ddos|firewall)\b/i
|
|
85
|
+
],
|
|
86
|
+
ux: [
|
|
87
|
+
/\b(layout|design|color|font|spacing|margin|padding)\b/i,
|
|
88
|
+
/\b(animation|transition|hover|focus|accessibility)\b/i,
|
|
89
|
+
/\b(copy text|label|placeholder|tooltip|user message)\b/i,
|
|
90
|
+
/\b(navigation|menu|sidebar|header|footer)\b/i,
|
|
91
|
+
/\b(responsive|mobile|breakpoint|viewport)\b/i
|
|
92
|
+
],
|
|
93
|
+
naming: [
|
|
94
|
+
/\b(naming convention|naming pattern|rename)\b/i,
|
|
95
|
+
/\b(variable name|function name|file name|class name)\b/i,
|
|
96
|
+
/\b(camelCase|snake_case|PascalCase|kebab-case)\b/i,
|
|
97
|
+
/\b(prefix|suffix|abbreviat)\w*/i
|
|
98
|
+
],
|
|
99
|
+
performance: [
|
|
100
|
+
/\b(performance|speed|latency|throughput|benchmark)\b/i,
|
|
101
|
+
/\b(memory leak|garbage|allocation|buffer)\b/i,
|
|
102
|
+
/\b(batch|chunk|stream|pagina)\w*/i,
|
|
103
|
+
/\b(optimize|optimis|efficient|bottleneck)\w*/i
|
|
104
|
+
]
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ============================================================
|
|
108
|
+
// Core Functions
|
|
109
|
+
// ============================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the decision authority config, merging defaults with user overrides
|
|
113
|
+
* @returns {Object} Authority config
|
|
114
|
+
*/
|
|
115
|
+
function getAuthorityConfig() {
|
|
116
|
+
const config = getConfig();
|
|
117
|
+
const userConfig = config.decisionAuthority ?? {};
|
|
118
|
+
return {
|
|
119
|
+
...DEFAULT_AUTHORITY_CONFIG,
|
|
120
|
+
...userConfig
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify a decision text into a category
|
|
126
|
+
* @param {string} decisionText - The decision to classify
|
|
127
|
+
* @returns {{ category: string, authority: string, confidence: string }}
|
|
128
|
+
*/
|
|
129
|
+
function classifyDecision(decisionText) {
|
|
130
|
+
const authorityConfig = getAuthorityConfig();
|
|
131
|
+
// Patterns already use /i flag, no need for toLowerCase()
|
|
132
|
+
const text = decisionText;
|
|
133
|
+
|
|
134
|
+
// Score each category
|
|
135
|
+
const scores = {};
|
|
136
|
+
for (const [category, patterns] of Object.entries(CATEGORY_PATTERNS)) {
|
|
137
|
+
scores[category] = 0;
|
|
138
|
+
for (const pattern of patterns) {
|
|
139
|
+
if (pattern.test(text)) {
|
|
140
|
+
scores[category]++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Find highest scoring category
|
|
146
|
+
let bestCategory = 'productBehavior'; // Default to owner-decides (safest)
|
|
147
|
+
let bestScore = 0;
|
|
148
|
+
|
|
149
|
+
for (const [category, score] of Object.entries(scores)) {
|
|
150
|
+
if (score > bestScore) {
|
|
151
|
+
bestScore = score;
|
|
152
|
+
bestCategory = category;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Determine confidence
|
|
157
|
+
const totalMatches = Object.values(scores).reduce((a, b) => a + b, 0);
|
|
158
|
+
let confidence = 'low';
|
|
159
|
+
if (bestScore >= 3) confidence = 'high';
|
|
160
|
+
else if (bestScore >= 2) confidence = 'medium';
|
|
161
|
+
else if (bestScore === 1 && totalMatches <= 2) confidence = 'medium';
|
|
162
|
+
|
|
163
|
+
// Low-confidence decisions default to owner-decides for safety
|
|
164
|
+
const authority = confidence === 'low'
|
|
165
|
+
? 'owner-decides'
|
|
166
|
+
: (authorityConfig[bestCategory] ?? 'owner-decides');
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
category: bestCategory,
|
|
170
|
+
authority,
|
|
171
|
+
confidence,
|
|
172
|
+
score: bestScore,
|
|
173
|
+
description: AUTHORITY_LEVELS[authority] ?? 'Unknown authority level'
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Classify a batch of decisions and enforce maxOwnerQuestionsPerBatch
|
|
179
|
+
* @param {string[]} decisions - Array of decision texts
|
|
180
|
+
* @returns {{ classified: Object[], ownerQuestions: Object[], agentDecisions: Object[], truncated: boolean }}
|
|
181
|
+
*/
|
|
182
|
+
function batchClassify(decisions) {
|
|
183
|
+
const authorityConfig = getAuthorityConfig();
|
|
184
|
+
const maxOwner = authorityConfig.maxOwnerQuestionsPerBatch ?? 5;
|
|
185
|
+
|
|
186
|
+
const classified = decisions.map((text, idx) => ({
|
|
187
|
+
index: idx,
|
|
188
|
+
text,
|
|
189
|
+
...classifyDecision(text)
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
// Separate owner-decides from agent-decides
|
|
193
|
+
const ownerQuestions = classified.filter(d => d.authority === 'owner-decides');
|
|
194
|
+
const agentDecisions = classified.filter(d => d.authority !== 'owner-decides');
|
|
195
|
+
|
|
196
|
+
// Enforce max owner questions — overflow becomes agent-decides-report-after
|
|
197
|
+
let truncated = false;
|
|
198
|
+
if (ownerQuestions.length > maxOwner) {
|
|
199
|
+
truncated = true;
|
|
200
|
+
// Keep the first maxOwner, downgrade the rest
|
|
201
|
+
const overflow = ownerQuestions.splice(maxOwner);
|
|
202
|
+
for (const decision of overflow) {
|
|
203
|
+
decision.authority = 'agent-decides-report-after';
|
|
204
|
+
decision.description = AUTHORITY_LEVELS['agent-decides-report-after'];
|
|
205
|
+
decision.downgraded = true;
|
|
206
|
+
decision.downgradeReason = `Exceeded maxOwnerQuestionsPerBatch (${maxOwner})`;
|
|
207
|
+
agentDecisions.push(decision);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
classified,
|
|
213
|
+
ownerQuestions,
|
|
214
|
+
agentDecisions,
|
|
215
|
+
truncated,
|
|
216
|
+
maxOwner,
|
|
217
|
+
stats: {
|
|
218
|
+
total: decisions.length,
|
|
219
|
+
ownerDecides: ownerQuestions.length,
|
|
220
|
+
agentDecides: agentDecisions.length,
|
|
221
|
+
downgraded: truncated ? agentDecisions.filter(d => d.downgraded).length : 0
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Update a category's authority level in config.json
|
|
228
|
+
* @param {string} category - Category name
|
|
229
|
+
* @param {string} authority - New authority level
|
|
230
|
+
* @returns {{ success: boolean, message: string }}
|
|
231
|
+
*/
|
|
232
|
+
function updateCategoryAuthority(category, authority) {
|
|
233
|
+
if (!CATEGORY_PATTERNS[category] && category !== 'maxOwnerQuestionsPerBatch') {
|
|
234
|
+
return { success: false, message: `Unknown category: ${category}. Valid: ${Object.keys(CATEGORY_PATTERNS).join(', ')}` };
|
|
235
|
+
}
|
|
236
|
+
if (category !== 'maxOwnerQuestionsPerBatch' && !AUTHORITY_LEVELS[authority]) {
|
|
237
|
+
return { success: false, message: `Unknown authority: ${authority}. Valid: ${Object.keys(AUTHORITY_LEVELS).join(', ')}` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const configPath = path.join(PATHS.workflow, 'config.json');
|
|
242
|
+
const config = safeJsonParse(configPath, {});
|
|
243
|
+
|
|
244
|
+
if (!config.decisionAuthority) {
|
|
245
|
+
config.decisionAuthority = {};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (category === 'maxOwnerQuestionsPerBatch') {
|
|
249
|
+
const num = parseInt(authority, 10);
|
|
250
|
+
if (isNaN(num) || num < 1 || num > 20) {
|
|
251
|
+
return { success: false, message: 'maxOwnerQuestionsPerBatch must be a number between 1 and 20' };
|
|
252
|
+
}
|
|
253
|
+
config.decisionAuthority.maxOwnerQuestionsPerBatch = num;
|
|
254
|
+
} else {
|
|
255
|
+
config.decisionAuthority[category] = authority;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
259
|
+
return { success: true, message: `Updated ${category} → ${authority}` };
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return { success: false, message: `Failed to update config: ${err.message}` };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ============================================================
|
|
266
|
+
// CLI Interface
|
|
267
|
+
// ============================================================
|
|
268
|
+
|
|
269
|
+
function main() {
|
|
270
|
+
const [,, command, ...args] = process.argv;
|
|
271
|
+
|
|
272
|
+
switch (command) {
|
|
273
|
+
case 'classify': {
|
|
274
|
+
const text = args.join(' ');
|
|
275
|
+
if (!text) {
|
|
276
|
+
console.error('Usage: flow-decision-authority.js classify <decision-text>');
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
const result = classifyDecision(text);
|
|
280
|
+
console.log(JSON.stringify(result, null, 2));
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case 'batch': {
|
|
285
|
+
const jsonInput = args[0];
|
|
286
|
+
if (!jsonInput) {
|
|
287
|
+
console.error('Usage: flow-decision-authority.js batch <json-array>');
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const decisions = JSON.parse(jsonInput);
|
|
292
|
+
if (!Array.isArray(decisions)) {
|
|
293
|
+
console.error('Input must be a JSON array of decision strings');
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
const result = batchClassify(decisions);
|
|
297
|
+
console.log(JSON.stringify(result, null, 2));
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(`Invalid JSON: ${err.message}`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
case 'update-category': {
|
|
306
|
+
const [category, authority] = args;
|
|
307
|
+
if (!category || !authority) {
|
|
308
|
+
console.error('Usage: flow-decision-authority.js update-category <category> <authority>');
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
const result = updateCategoryAuthority(category, authority);
|
|
312
|
+
if (result.success) {
|
|
313
|
+
console.log(result.message);
|
|
314
|
+
} else {
|
|
315
|
+
console.error(result.message);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case 'config': {
|
|
322
|
+
const config = getAuthorityConfig();
|
|
323
|
+
console.log(JSON.stringify(config, null, 2));
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
default:
|
|
328
|
+
console.log('Wogi Flow - Decision Authority Framework');
|
|
329
|
+
console.log('');
|
|
330
|
+
console.log('Commands:');
|
|
331
|
+
console.log(' classify <text> Classify a decision');
|
|
332
|
+
console.log(' batch <json-array> Classify multiple decisions');
|
|
333
|
+
console.log(' update-category <cat> <authority> Update a category authority');
|
|
334
|
+
console.log(' config Show current config');
|
|
335
|
+
console.log('');
|
|
336
|
+
console.log('Authority levels:');
|
|
337
|
+
for (const [level, desc] of Object.entries(AUTHORITY_LEVELS)) {
|
|
338
|
+
console.log(` ${level}: ${desc}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ============================================================
|
|
344
|
+
// Exports
|
|
345
|
+
// ============================================================
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
classifyDecision,
|
|
349
|
+
batchClassify,
|
|
350
|
+
getAuthorityConfig,
|
|
351
|
+
updateCategoryAuthority,
|
|
352
|
+
AUTHORITY_LEVELS,
|
|
353
|
+
DEFAULT_AUTHORITY_CONFIG,
|
|
354
|
+
CATEGORY_PATTERNS
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
if (require.main === module) {
|
|
358
|
+
main();
|
|
359
|
+
}
|