@magnusekdahl/parallix 1.3.3 → 1.3.4
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/docs/agents.md +1 -1
- package/docs/use-cases.md +1 -1
- package/lib/agents/agents.js +20 -3
- package/lib/agents/agents.ts +14 -3
- package/lib/agents/mistral.js +128 -14
- package/lib/agents/mistral.ts +145 -5
- package/lib/commands/active.js +69 -39
- package/lib/commands/active.ts +97 -60
- package/lib/commands/config.ts +3 -3
- package/lib/commands/coverage-gate.ts +1 -1
- package/lib/commands/draft.js +1 -1
- package/lib/commands/draft.ts +1 -1
- package/lib/commands/handoff.js +13 -8
- package/lib/commands/handoff.ts +24 -18
- package/lib/commands/rebase.js +1 -1
- package/lib/commands/rebase.ts +2 -2
- package/lib/commands/repair-handoff.js +141 -20
- package/lib/commands/repair-handoff.ts +185 -45
- package/lib/commands/resolve-conflict.js +1 -1
- package/lib/commands/resolve-conflict.ts +2 -2
- package/lib/commands/stats-backfill.ts +10 -10
- package/lib/commands/stats.js +38 -11
- package/lib/commands/stats.ts +40 -96
- package/lib/core/fmt.ts +2 -2
- package/lib/core/git.ts +2 -2
- package/lib/core/gitignore.ts +2 -2
- package/lib/core/mission-utils.js +2 -2
- package/lib/core/mission-utils.ts +2 -2
- package/lib/core/persistent-data-migration.ts +2 -2
- package/lib/core/spawn-tee.ts +1 -1
- package/lib/core/state-map.ts +2 -2
- package/lib/core/storage.ts +1 -1
- package/lib/core/verification.ts +1 -1
- package/lib/review/rebase.ts +12 -12
- package/lib/review/review-artifacts.ts +35 -35
- package/lib/review/review-commands.ts +40 -40
- package/lib/review/review-events.ts +12 -12
- package/lib/review/review-loop.js +236 -7
- package/lib/review/review-loop.ts +338 -22
- package/lib/review/review-polling.ts +6 -6
- package/lib/review/review-prompts.js +8 -4
- package/lib/review/review-prompts.ts +12 -8
- package/lib/review/review-state.js +1 -1
- package/lib/review/review-state.ts +2 -2
- package/package.json +3 -2
- package/prompts/review-verbose.md +1 -1
- package/prompts/review.md +1 -1
|
@@ -3,19 +3,155 @@ import * as missionUtils from '../core/mission-utils.js';
|
|
|
3
3
|
import rebase from './rebase.js';
|
|
4
4
|
import * as fmt from '../core/fmt.js';
|
|
5
5
|
|
|
6
|
+
// ── FailureClass: 8 classes from ADR 0048 ────────────────────────────────────
|
|
7
|
+
const FailureClass = {
|
|
8
|
+
UnverifiableClaims: 'UnverifiableClaims',
|
|
9
|
+
MalformedGates: 'MalformedGates',
|
|
10
|
+
MissingArtifacts: 'MissingArtifacts',
|
|
11
|
+
IncompleteEvidence: 'IncompleteEvidence',
|
|
12
|
+
GitBlockers: 'GitBlockers',
|
|
13
|
+
GateFailure: 'GateFailure',
|
|
14
|
+
InfraBlocker: 'InfraBlocker',
|
|
15
|
+
StateMachineViolation: 'StateMachineViolation',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
type FailureClassType = (typeof FailureClass)[keyof typeof FailureClass];
|
|
19
|
+
|
|
20
|
+
// ── DispatchAction: 3 actions from ADR 0048 ──────────────────────────────────
|
|
21
|
+
const DispatchAction = {
|
|
22
|
+
AutoRepair: 'AutoRepair',
|
|
23
|
+
AutoSendBack: 'AutoSendBack',
|
|
24
|
+
HumanOnly: 'HumanOnly',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
type DispatchActionType = (typeof DispatchAction)[keyof typeof DispatchAction];
|
|
28
|
+
|
|
29
|
+
// ── Dispatch table: maps each failure class to its prescribed action (ADR 0048) ─
|
|
30
|
+
const DISPATCH_TABLE: Record<FailureClassType, DispatchActionType> = {
|
|
31
|
+
[FailureClass.UnverifiableClaims]: DispatchAction.AutoSendBack,
|
|
32
|
+
[FailureClass.MalformedGates]: DispatchAction.AutoRepair,
|
|
33
|
+
[FailureClass.MissingArtifacts]: DispatchAction.AutoSendBack,
|
|
34
|
+
[FailureClass.IncompleteEvidence]: DispatchAction.AutoSendBack,
|
|
35
|
+
[FailureClass.GitBlockers]: DispatchAction.AutoRepair,
|
|
36
|
+
[FailureClass.GateFailure]: DispatchAction.AutoSendBack,
|
|
37
|
+
[FailureClass.InfraBlocker]: DispatchAction.HumanOnly,
|
|
38
|
+
[FailureClass.StateMachineViolation]: DispatchAction.HumanOnly,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Look up the dispatch action for a given failure class.
|
|
43
|
+
*
|
|
44
|
+
* @param failureClass - One of the 8 failure class values from ADR 0048
|
|
45
|
+
* @returns The prescribed dispatch action, or null if unknown
|
|
46
|
+
*/
|
|
47
|
+
export function getDispatchAction(failureClass: FailureClassType): DispatchActionType | null {
|
|
48
|
+
return DISPATCH_TABLE[failureClass] ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sub-reason for GitBlockers classification: distinguishes dirty-artifact from behind-branch errors.
|
|
53
|
+
* Used by repairHandoff() to decide whether to auto-rebase (behind) vs auto-commit only (dirty).
|
|
54
|
+
*/
|
|
55
|
+
type GitBlockerReason = 'dirty' | 'behind' | 'other';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Classify an error message into one of the 8 failure classes from ADR 0048
|
|
59
|
+
* and return the associated dispatch action.
|
|
60
|
+
*
|
|
61
|
+
* Patterns are checked in order of specificity to avoid collisions.
|
|
62
|
+
* Returns a `reason` field for GitBlockers to distinguish dirty-artifact from behind-branch errors,
|
|
63
|
+
* allowing callers to derive repair-strategy flags without duplicating pattern matching.
|
|
64
|
+
*
|
|
65
|
+
* @param errorMsg - The error message to classify
|
|
66
|
+
* @returns Object with failureClass, dispatchAction, and (for GitBlockers) reason
|
|
67
|
+
*/
|
|
68
|
+
export function classifyError(errorMsg: string): { failureClass: FailureClassType; dispatchAction: DispatchActionType; reason?: GitBlockerReason } {
|
|
69
|
+
if (!errorMsg || typeof errorMsg !== 'string') {
|
|
70
|
+
return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 1. IncompleteEvidence: goal-check table missing evidence rows (most specific — checked before generic gate patterns)
|
|
74
|
+
if (errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
|
|
75
|
+
errorMsg.includes('A goal-check table with real evidence is required before handoff')) {
|
|
76
|
+
return { failureClass: FailureClass.IncompleteEvidence, dispatchAction: DispatchAction.AutoSendBack };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. GitBlockers: dirty/uncommitted mission artifacts (mechanical git blocker — auto-repairable)
|
|
80
|
+
if (errorMsg.includes('is modified but uncommitted') ||
|
|
81
|
+
errorMsg.includes('Commit the mission contract before handoff') ||
|
|
82
|
+
errorMsg.includes('Commit the implementation evidence before handoff')) {
|
|
83
|
+
return { failureClass: FailureClass.GitBlockers, dispatchAction: DispatchAction.AutoRepair, reason: 'dirty' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. GitBlockers: branch behind primary / push rejected (mechanical git blocker — auto-repairable via rebase)
|
|
87
|
+
if (errorMsg.includes('Updates were rejected') ||
|
|
88
|
+
errorMsg.includes('fetch first') ||
|
|
89
|
+
errorMsg.includes('non-fast-forward') ||
|
|
90
|
+
errorMsg.includes('behind its remote') ||
|
|
91
|
+
(errorMsg.includes('git push failed') && (
|
|
92
|
+
errorMsg.includes('rejected') ||
|
|
93
|
+
errorMsg.includes('remote contains work')
|
|
94
|
+
))) {
|
|
95
|
+
return { failureClass: FailureClass.GitBlockers, dispatchAction: DispatchAction.AutoRepair, reason: 'behind' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 4. GateFailure: verification gate failed
|
|
99
|
+
if (/verification gate failed/i.test(errorMsg)) {
|
|
100
|
+
return { failureClass: FailureClass.GateFailure, dispatchAction: DispatchAction.AutoSendBack };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 5. GateFailure: declared gate failed
|
|
104
|
+
if (/\bdeclared gate\b/i.test(errorMsg) && /\bfailed\b/i.test(errorMsg)) {
|
|
105
|
+
return { failureClass: FailureClass.GateFailure, dispatchAction: DispatchAction.AutoSendBack };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 6. UnverifiableClaims: test claims that cannot be verified
|
|
109
|
+
if (/test(s?\s+)?passed/i.test(errorMsg) && /cannot\s+verify|unverifiable|proof\s+(not\s+)?found|stale\s+proof/i.test(errorMsg)) {
|
|
110
|
+
return { failureClass: FailureClass.UnverifiableClaims, dispatchAction: DispatchAction.AutoSendBack };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 7. MalformedGates: malformed or non-runnable declared gates
|
|
114
|
+
if (/malformed\s+gate|invalid\s+gate\s+config|gate\s+command\s+(not\s+found|syntax\s+error|not\s+runnable)/i.test(errorMsg) ||
|
|
115
|
+
(/gate/i.test(errorMsg) && /syntax\s+error|not\s+found|missing\s+(file|command)/i.test(errorMsg))) {
|
|
116
|
+
return { failureClass: FailureClass.MalformedGates, dispatchAction: DispatchAction.AutoRepair };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 8. MissingArtifacts: mandatory mission artifacts missing
|
|
120
|
+
if (/mandatory\s+(artifact|file|document)|missing\s+(mission\s+)?(artifact|file|document)|required\s+(artifact|file|document)\s+(not\s+)?found/i.test(errorMsg) ||
|
|
121
|
+
(/gatekeeper/i.test(errorMsg) && /missing\s+(artifact|file|document)/i.test(errorMsg))) {
|
|
122
|
+
return { failureClass: FailureClass.MissingArtifacts, dispatchAction: DispatchAction.AutoSendBack };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 9. StateMachineViolation: task state machine violations
|
|
126
|
+
if (/state\s+violation|invalid\s+state|transition\s+not\s+allowed|cannot\s+(move|transition)\s+(from|to)\s+\w+\s+(to|from)/i.test(errorMsg) ||
|
|
127
|
+
(/task\s+state/i.test(errorMsg) && /invalid|violation|incorrect/i.test(errorMsg))) {
|
|
128
|
+
return { failureClass: FailureClass.StateMachineViolation, dispatchAction: DispatchAction.HumanOnly };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 10. InfraBlocker: forgejo/infrastructure blockers
|
|
132
|
+
if (/forgejo|infrastructure|authentication\s+failed|token\s+(expired|invalid|missing)|forbidden|unauthorized\s+(access|request)|rate\s+limit|connection\s+(refused|timed?\s*out)|network\s+error/i.test(errorMsg)) {
|
|
133
|
+
return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Default: human-only for unrecognized errors
|
|
137
|
+
return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
|
|
138
|
+
}
|
|
139
|
+
|
|
6
140
|
/**
|
|
7
141
|
* Check if an error message indicates a relaunchable content error (missing/empty goal-check table).
|
|
142
|
+
* Delegates to classifyError for backward-compatible classification.
|
|
8
143
|
*
|
|
9
|
-
* @param
|
|
10
|
-
* @returns
|
|
144
|
+
* @param errorMsg - The error message to check
|
|
145
|
+
* @returns True if the error is relaunchable (IncompleteEvidence or GateFailure)
|
|
11
146
|
*/
|
|
12
|
-
function isRelaunchableError(errorMsg: string) {
|
|
147
|
+
function isRelaunchableError(errorMsg: string): boolean {
|
|
13
148
|
if (!errorMsg || typeof errorMsg !== 'string') {
|
|
14
149
|
return false;
|
|
15
150
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
151
|
+
const { failureClass } = classifyError(errorMsg);
|
|
152
|
+
// IncompleteEvidence and GateFailure are the only classes that were relaunchable under the old logic
|
|
153
|
+
return failureClass === FailureClass.IncompleteEvidence
|
|
154
|
+
|| failureClass === FailureClass.GateFailure;
|
|
19
155
|
}
|
|
20
156
|
|
|
21
157
|
/**
|
|
@@ -26,45 +162,57 @@ function isRelaunchableError(errorMsg: string) {
|
|
|
26
162
|
* @param {string} worktree - Path to the mission worktree
|
|
27
163
|
* @returns {string} The relaunch prompt
|
|
28
164
|
*/
|
|
29
|
-
function buildRelaunchPrompt(errorMsg: string, slug: string, worktree: string) {
|
|
165
|
+
function buildRelaunchPrompt(errorMsg: string, slug: string, worktree: string, gateOutput?: { stdout: string; stderr: string }) {
|
|
30
166
|
const year = missionUtils.getMissionYear(slug, worktree);
|
|
31
167
|
const missionDir = missionUtils.findMissionDir(slug, worktree) || missionUtils.missionDirForSlug(worktree, slug);
|
|
32
168
|
|
|
33
|
-
|
|
169
|
+
let prompt = `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
|
|
34
170
|
|
|
35
171
|
` +
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
172
|
+
`Please fix the final checkpoint document in ${missionDir} by adding a Goal Check table ` +
|
|
173
|
+
`with real evidence rows (file:line references, test names). The Goal Check table must have ` +
|
|
174
|
+
`a header row and at least one evidence row using pipe syntax (|).
|
|
39
175
|
|
|
40
176
|
` +
|
|
41
|
-
|
|
177
|
+
`Steps:
|
|
42
178
|
` +
|
|
43
|
-
|
|
179
|
+
`1. Open the final checkpoint document (CP-N.md) in ${missionDir}
|
|
44
180
|
` +
|
|
45
|
-
|
|
181
|
+
`2. Add or update the "## Goal Check" section
|
|
46
182
|
` +
|
|
47
|
-
|
|
183
|
+
`3. Create a markdown table with columns for: Goal Check description | Evidence | Status
|
|
48
184
|
` +
|
|
49
|
-
|
|
185
|
+
`4. Add at least one evidence row with real file:line or test name references
|
|
50
186
|
` +
|
|
51
|
-
|
|
187
|
+
`5. Commit the updated checkpoint with a descriptive commit message
|
|
52
188
|
` +
|
|
53
|
-
|
|
189
|
+
`6. Re-run: node parallix review ${slug} --submit
|
|
54
190
|
|
|
55
191
|
` +
|
|
56
|
-
|
|
192
|
+
`Example Goal Check table:
|
|
57
193
|
` +
|
|
58
|
-
|
|
194
|
+
`| Goal Check | Evidence | Status |
|
|
59
195
|
` +
|
|
60
|
-
|
|
196
|
+
`|---|---|---|
|
|
61
197
|
` +
|
|
62
|
-
|
|
198
|
+
`| Final checkpoint has Goal Check section | docs/missions/${year}/${slug}/CP-1.md:15 | PASS |
|
|
63
199
|
` +
|
|
64
|
-
|
|
200
|
+
`| Tests pass | npm test -- parallix/test/repair-handoff.test.js | PASS |
|
|
65
201
|
|
|
66
202
|
` +
|
|
67
|
-
|
|
203
|
+
`Do NOT add placeholder or generic evidence. Each row must cite real, verifiable artifacts.`;
|
|
204
|
+
|
|
205
|
+
// Append captured gate output if available (task-1387)
|
|
206
|
+
if (gateOutput && (gateOutput.stdout || gateOutput.stderr)) {
|
|
207
|
+
const totalOutput = (gateOutput.stdout || '') + (gateOutput.stderr || '');
|
|
208
|
+
// Truncate if total output exceeds 16000 chars; keep last 8000 chars
|
|
209
|
+
const truncated = totalOutput.length > 16000
|
|
210
|
+
? `[truncated — total ${totalOutput.length} chars, showing last 8000]\n` + totalOutput.slice(-8000)
|
|
211
|
+
: totalOutput;
|
|
212
|
+
prompt += `\n\n--- Captured Gate Output ---\n${truncated}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return prompt;
|
|
68
216
|
}
|
|
69
217
|
|
|
70
218
|
/**
|
|
@@ -100,32 +248,19 @@ async function repairHandoff(slug: string, worktree: string, errorMsg: string, o
|
|
|
100
248
|
return { xy, file: cleanPath };
|
|
101
249
|
}
|
|
102
250
|
|
|
103
|
-
// 0. Check if error is repairable
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const isBehind = errorMsg && (
|
|
111
|
-
errorMsg.includes('Updates were rejected') ||
|
|
112
|
-
errorMsg.includes('fetch first') ||
|
|
113
|
-
errorMsg.includes('non-fast-forward') ||
|
|
114
|
-
errorMsg.includes('behind its remote') ||
|
|
115
|
-
// Ensure we don't match generic git push failed unless it has non-fast-forward hints
|
|
116
|
-
(errorMsg.includes('git push failed') && (
|
|
117
|
-
errorMsg.includes('rejected') ||
|
|
118
|
-
errorMsg.includes('remote contains work')
|
|
119
|
-
))
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
if (!isDirtyError && !isBehind) {
|
|
251
|
+
// 0. Check if error is repairable via classifyError
|
|
252
|
+
const classification = classifyError(errorMsg);
|
|
253
|
+
const isGitBlocker = classification.failureClass === FailureClass.GitBlockers;
|
|
254
|
+
// Derive isBehind from classification result (avoids duplicating classifyError's behind-branch patterns)
|
|
255
|
+
const isBehind = classification.reason === 'behind';
|
|
256
|
+
|
|
257
|
+
if (!isGitBlocker) {
|
|
123
258
|
log(`Handoff error is not automatically repairable: ${errorMsg}`);
|
|
124
259
|
return { repaired: false, blocker: null };
|
|
125
260
|
}
|
|
126
261
|
|
|
127
262
|
// 1. Auto-commit mission artifacts if uncommitted
|
|
128
|
-
if (
|
|
263
|
+
if (isGitBlocker) {
|
|
129
264
|
const statusResult = gitFn(['-C', rootDir, 'status', '--porcelain']);
|
|
130
265
|
if (statusResult.status === 0 && statusResult.stdout) {
|
|
131
266
|
const dirtyLines = statusResult.stdout.split('\n')
|
|
@@ -224,9 +359,14 @@ async function repairHandoff(slug: string, worktree: string, errorMsg: string, o
|
|
|
224
359
|
|
|
225
360
|
(repairHandoff as any).isRelaunchableError = isRelaunchableError;
|
|
226
361
|
(repairHandoff as any).buildRelaunchPrompt = buildRelaunchPrompt;
|
|
362
|
+
(repairHandoff as any).classifyError = classifyError;
|
|
363
|
+
(repairHandoff as any).getDispatchAction = getDispatchAction;
|
|
364
|
+
(repairHandoff as any).FailureClass = FailureClass;
|
|
365
|
+
(repairHandoff as any).DispatchAction = DispatchAction;
|
|
227
366
|
|
|
228
367
|
export default repairHandoff;
|
|
229
368
|
export { repairHandoff, isRelaunchableError, buildRelaunchPrompt };
|
|
369
|
+
export { FailureClass, DispatchAction };
|
|
230
370
|
|
|
231
371
|
// CJS compat: ensure require() returns the function directly
|
|
232
372
|
declare const module: { exports: any } | undefined;
|
|
@@ -80,7 +80,7 @@ function buildAgentResolutionPrompt({ slug, area, worktreePath, missionSpecificF
|
|
|
80
80
|
].join('\n');
|
|
81
81
|
}
|
|
82
82
|
/** @param {string[]} args @param {{resolveConflictsFn?: Function, startAgentFn?: Function, exitFn?: Function}} opts */
|
|
83
|
-
async function resolveConflict(args, { resolveConflictsFn = integrate_js_1.default.resolveConflictsForMission, startAgentFn = agents_js_1.startAgent, exitFn = ((
|
|
83
|
+
async function resolveConflict(args, { resolveConflictsFn = integrate_js_1.default.resolveConflictsForMission, startAgentFn = agents_js_1.startAgent, exitFn = ((_code) => process.exit(_code)), } = {}) {
|
|
84
84
|
const explicitSlug = args[0];
|
|
85
85
|
const slug = (0, mission_utils_js_1.inferSlug)(explicitSlug);
|
|
86
86
|
if (!slug) {
|
|
@@ -47,8 +47,8 @@ function buildAgentResolutionPrompt({ slug, area, worktreePath, missionSpecificF
|
|
|
47
47
|
async function resolveConflict(args: string[], {
|
|
48
48
|
resolveConflictsFn = (integrate as any).resolveConflictsForMission,
|
|
49
49
|
startAgentFn = startAgent,
|
|
50
|
-
exitFn = ((
|
|
51
|
-
}: {resolveConflictsFn?: Function, startAgentFn?: Function, exitFn?: (
|
|
50
|
+
exitFn = ((_code: number) => process.exit(_code)) as (_code: number) => void,
|
|
51
|
+
}: {resolveConflictsFn?: Function, startAgentFn?: Function, exitFn?: (_code: number) => void} = {}) {
|
|
52
52
|
const explicitSlug = args[0];
|
|
53
53
|
const slug = inferSlug(explicitSlug);
|
|
54
54
|
if (!slug) {
|
|
@@ -12,13 +12,13 @@ import {
|
|
|
12
12
|
import { findMissionDir } from '../core/mission-utils.js';
|
|
13
13
|
|
|
14
14
|
interface StatsAugmented {
|
|
15
|
-
resolveMissionClassification: (
|
|
16
|
-
_internals: Record<string, (...
|
|
17
|
-
resolveStatsPath: (
|
|
18
|
-
resolveStatsRepoName: (
|
|
19
|
-
loadStatsCsv: (
|
|
20
|
-
deriveImplementerAndFixRounds: (
|
|
21
|
-
upsertStatsRow: (
|
|
15
|
+
resolveMissionClassification: (_slug: string, _rootDir?: string) => { classification?: string; source?: string };
|
|
16
|
+
_internals: Record<string, (..._args: unknown[]) => unknown>;
|
|
17
|
+
resolveStatsPath: (_options?: { ensureDir?: boolean }) => string;
|
|
18
|
+
resolveStatsRepoName: (_rootDir: string) => string;
|
|
19
|
+
loadStatsCsv: (_filePath: string, _options?: { rootDir?: string }) => { rows: Record<string, string>[] };
|
|
20
|
+
deriveImplementerAndFixRounds: (_slug: string, _rootDir?: string) => { implementer: string; prFixRounds: number; source: string };
|
|
21
|
+
upsertStatsRow: (_row: Record<string, string>, _options: { filePath: string; rootDir?: string }) => { changed: boolean };
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function getStats(): StatsAugmented {
|
|
@@ -175,7 +175,7 @@ function resolveHistoricalClassification(slug: string, taskFile: string, rootDir
|
|
|
175
175
|
}
|
|
176
176
|
// Classification missing or invalid — fall through to fallbacks.
|
|
177
177
|
const classificationValue = getTaskFrontmatterValue(taskFile, 'classification');
|
|
178
|
-
const legacy = (s._internals as Record<string, (
|
|
178
|
+
const legacy = (s._internals as Record<string, (_v: string) => string | null>).normalizeClassification(classificationValue ?? '');
|
|
179
179
|
if (legacy) {
|
|
180
180
|
return { value: legacy, source: 'backlog-classification' };
|
|
181
181
|
}
|
|
@@ -347,8 +347,8 @@ Notes:
|
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
interface BackfillOptions {
|
|
350
|
-
log?: (
|
|
351
|
-
error?: (
|
|
350
|
+
log?: (_msg: string) => string | null;
|
|
351
|
+
error?: (_msg: string) => string | null;
|
|
352
352
|
rootDir?: string;
|
|
353
353
|
}
|
|
354
354
|
|
package/lib/commands/stats.js
CHANGED
|
@@ -76,16 +76,19 @@ const storage = __importStar(require("../core/storage.js"));
|
|
|
76
76
|
// and one-time header migration of legacy stats files (task-1251).
|
|
77
77
|
const LEGACY_HEADERS = ['date', 'mission', 'classification', 'implementer', 'pr_fix_rounds'];
|
|
78
78
|
exports.LEGACY_HEADERS = LEGACY_HEADERS;
|
|
79
|
-
// Extended
|
|
80
|
-
// migrated in-memory on load: the legacy columns are preserved
|
|
81
|
-
// columns default to '' (text) or '0' (numeric). On the next write the
|
|
82
|
-
// header is upgraded and existing rows gain the new columns.
|
|
79
|
+
// Extended 22-column telemetry schema (task-1314 + task-1251 + task-1380). Legacy
|
|
80
|
+
// 5-column rows are migrated in-memory on load: the legacy columns are preserved
|
|
81
|
+
// and the new columns default to '' (text) or '0' (numeric). On the next write the
|
|
82
|
+
// file header is upgraded and existing rows gain the new columns. The `closed`
|
|
83
|
+
// column (task-1380) stores 'yes' for closed/integrated missions and is empty for
|
|
84
|
+
// in-progress stage rows; filtering by `closed === 'yes'` excludes in-progress
|
|
85
|
+
// missions from weekly and range mission counts.
|
|
83
86
|
const STATS_HEADERS = [
|
|
84
87
|
'date', 'repo', 'mission', 'classification', 'implementer', 'pr_fix_rounds',
|
|
85
88
|
'provider', 'model', 'implementer_agent', 'reviewer_agent', 'stage',
|
|
86
89
|
'input_tokens', 'output_tokens', 'cached_tokens', 'context_tokens',
|
|
87
90
|
'tool_calls', 'openai_usage_before', 'openai_usage_after',
|
|
88
|
-
'openai_usage_delta', 'duration_minutes', 'cost_usd'
|
|
91
|
+
'openai_usage_delta', 'duration_minutes', 'cost_usd', 'closed'
|
|
89
92
|
];
|
|
90
93
|
exports.STATS_HEADERS = STATS_HEADERS;
|
|
91
94
|
// Columns coerced to non-negative integers on canonicalization.
|
|
@@ -271,16 +274,30 @@ function loadStatsCsv(filePath = null, options = {}) {
|
|
|
271
274
|
if (data.headers.length === 0) {
|
|
272
275
|
return { headers: [...STATS_HEADERS], rows: [] };
|
|
273
276
|
}
|
|
277
|
+
// Detect whether the loaded CSV already has the `closed` column (task-1380).
|
|
278
|
+
// Legacy CSVs (pre-closed) lack the column; their rows represent completed
|
|
279
|
+
// missions written at integration time, so default `closed` to 'yes' for
|
|
280
|
+
// backward compatibility. Modern CSVs already have the column set per-row.
|
|
281
|
+
const hasClosedColumn = data.headers.includes('closed');
|
|
282
|
+
const migratedRows = data.rows.map((row) => {
|
|
283
|
+
const normalized = normalizeStatsRow(row, { rootDir: options.rootDir });
|
|
284
|
+
if (!hasClosedColumn) {
|
|
285
|
+
// Legacy CSV: all rows are from integration time, treat as closed.
|
|
286
|
+
return { ...normalized, closed: 'yes' };
|
|
287
|
+
}
|
|
288
|
+
return { ...normalized, closed: row.closed || '' };
|
|
289
|
+
});
|
|
274
290
|
return {
|
|
275
291
|
headers: [...STATS_HEADERS],
|
|
276
|
-
rows:
|
|
292
|
+
rows: migratedRows,
|
|
277
293
|
};
|
|
278
294
|
}
|
|
279
295
|
/**
|
|
280
296
|
* Map any row (legacy 5-column or full 21-column) to the full schema, defaulting
|
|
281
297
|
* missing text columns to '' and numeric columns to '0'. `stage` defaults to
|
|
282
298
|
* 'default' so legacy rows and integration rows share the (repo, mission, stage)
|
|
283
|
-
* upsert key.
|
|
299
|
+
* upsert key. `closed` defaults to '' (unset) — the backward-compat default of
|
|
300
|
+
* 'yes' for legacy CSV rows is applied exclusively in `loadStatsCsv`.
|
|
284
301
|
*/
|
|
285
302
|
function normalizeStatsRow(row = {}, options = {}) {
|
|
286
303
|
const repo = String(row.repo || options.repo || resolveStatsRepoName(options.rootDir)).trim();
|
|
@@ -306,6 +323,7 @@ function normalizeStatsRow(row = {}, options = {}) {
|
|
|
306
323
|
openai_usage_delta: row.openai_usage_delta || '0',
|
|
307
324
|
duration_minutes: row.duration_minutes || '0',
|
|
308
325
|
cost_usd: row.cost_usd || '0',
|
|
326
|
+
closed: row.closed || '',
|
|
309
327
|
};
|
|
310
328
|
}
|
|
311
329
|
/**
|
|
@@ -695,11 +713,14 @@ function rowInWindow(row, window) {
|
|
|
695
713
|
*/
|
|
696
714
|
function summarizeMissionWindow(rows, window) {
|
|
697
715
|
const windowRows = rows.filter(row => rowInWindow(row, window));
|
|
716
|
+
// Filter to only closed missions (task-1380): rows without closed:'yes' are
|
|
717
|
+
// in-progress stage rows and should not inflate mission counts.
|
|
718
|
+
const closedRows = windowRows.filter(row => row.closed === 'yes');
|
|
698
719
|
// Deduplicate by mission so multi-stage telemetry rows don't inflate counts.
|
|
699
720
|
// One row per unique repo+mission pair is kept (first occurrence is sufficient
|
|
700
721
|
// since classification is stable across stages for the same mission in a repo).
|
|
701
722
|
const seenMissions = new Set();
|
|
702
|
-
const uniqueMissions =
|
|
723
|
+
const uniqueMissions = closedRows.filter(row => {
|
|
703
724
|
const key = statsMissionKey(row);
|
|
704
725
|
if (seenMissions.has(key)) {
|
|
705
726
|
return false;
|
|
@@ -712,7 +733,7 @@ function summarizeMissionWindow(rows, window) {
|
|
|
712
733
|
const unknown = uniqueMissions.filter(row => normalizeClassification(row.classification) === 'unknown').length;
|
|
713
734
|
const validMissions = uniqueMissions.filter(row => normalizeClassification(row.classification) !== null);
|
|
714
735
|
return {
|
|
715
|
-
rows:
|
|
736
|
+
rows: closedRows,
|
|
716
737
|
total: validMissions.length,
|
|
717
738
|
userValue,
|
|
718
739
|
aiSdlc,
|
|
@@ -760,10 +781,12 @@ function summarizeAgentWindow(rows, window, options = {}) {
|
|
|
760
781
|
const opts = options;
|
|
761
782
|
const { rootDir = null, deriveFixRoundsFn = deriveFixRoundsLocalAuthoritative } = opts;
|
|
762
783
|
const windowRows = rows.filter(row => rowInWindow(row, window));
|
|
784
|
+
// Filter to only closed missions (task-1380).
|
|
785
|
+
const closedWindowRows = windowRows.filter(row => row.closed === 'yes');
|
|
763
786
|
// Only count missions with a valid classification so the agent table totals
|
|
764
787
|
// align with the mission-count table (which also excludes null/invalid
|
|
765
788
|
// classifications via summarizeMissionWindow → validMissions).
|
|
766
|
-
const validWindowRows =
|
|
789
|
+
const validWindowRows = closedWindowRows.filter(row => normalizeClassification(row.classification) !== null);
|
|
767
790
|
// Deduplicate globally by (repo, mission) first so each mission is counted
|
|
768
791
|
// exactly once across all agent groups — matching the mission-count table.
|
|
769
792
|
// Prefer the row where model === implementer (the implementer's own model),
|
|
@@ -988,7 +1011,8 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
|
|
|
988
1011
|
const opts = options;
|
|
989
1012
|
const wantedRepo = String(opts.repo || resolveStatsRepoName(opts.rootDir)).trim();
|
|
990
1013
|
const missionRows = (rows || []).filter(row => String(row.mission || '').trim().toLowerCase() === wanted &&
|
|
991
|
-
String(row.repo || '').trim() === wantedRepo
|
|
1014
|
+
String(row.repo || '').trim() === wantedRepo &&
|
|
1015
|
+
row.closed === 'yes');
|
|
992
1016
|
const byStage = new Map();
|
|
993
1017
|
for (const row of missionRows) {
|
|
994
1018
|
const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
|
|
@@ -1438,6 +1462,7 @@ function canonicalizeStatsRow(row, options = {}) {
|
|
|
1438
1462
|
classification: /** @type{string|number|boolean|undefined} */ (normalizeClassification(row.classification)),
|
|
1439
1463
|
implementer: /** @type{string|number|boolean|undefined} */ (normalizeImplementer(row.implementer)),
|
|
1440
1464
|
stage: String(row.stage || '').trim().toLowerCase() || 'default',
|
|
1465
|
+
closed: row.closed || '',
|
|
1441
1466
|
};
|
|
1442
1467
|
for (const key of USAGE_NUMBERS) {
|
|
1443
1468
|
canonical[key] = String(Math.max(0, Number.parseInt(String(/** @type{any} */ (normalized)[key]), 10) || 0));
|
|
@@ -1516,6 +1541,7 @@ function recordIntegrationStats(options = {}) {
|
|
|
1516
1541
|
classification,
|
|
1517
1542
|
implementer: implementerInfo.implementer,
|
|
1518
1543
|
pr_fix_rounds: implementerInfo.prFixRounds,
|
|
1544
|
+
closed: 'yes',
|
|
1519
1545
|
}, { filePath, rootDir });
|
|
1520
1546
|
return {
|
|
1521
1547
|
...result,
|
|
@@ -1982,6 +2008,7 @@ stats._internals = {
|
|
|
1982
2008
|
deriveFinalImplementerFromBranchHistory,
|
|
1983
2009
|
deriveImplementerAndFixRoundsFromPrComments,
|
|
1984
2010
|
deriveImplementerAndFixRounds,
|
|
2011
|
+
summarizeMissionWindow,
|
|
1985
2012
|
summarizeAgentWindow,
|
|
1986
2013
|
colorAverageFixRounds,
|
|
1987
2014
|
colorMissionCounts,
|