@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.
Files changed (47) hide show
  1. package/docs/agents.md +1 -1
  2. package/docs/use-cases.md +1 -1
  3. package/lib/agents/agents.js +20 -3
  4. package/lib/agents/agents.ts +14 -3
  5. package/lib/agents/mistral.js +128 -14
  6. package/lib/agents/mistral.ts +145 -5
  7. package/lib/commands/active.js +69 -39
  8. package/lib/commands/active.ts +97 -60
  9. package/lib/commands/config.ts +3 -3
  10. package/lib/commands/coverage-gate.ts +1 -1
  11. package/lib/commands/draft.js +1 -1
  12. package/lib/commands/draft.ts +1 -1
  13. package/lib/commands/handoff.js +13 -8
  14. package/lib/commands/handoff.ts +24 -18
  15. package/lib/commands/rebase.js +1 -1
  16. package/lib/commands/rebase.ts +2 -2
  17. package/lib/commands/repair-handoff.js +141 -20
  18. package/lib/commands/repair-handoff.ts +185 -45
  19. package/lib/commands/resolve-conflict.js +1 -1
  20. package/lib/commands/resolve-conflict.ts +2 -2
  21. package/lib/commands/stats-backfill.ts +10 -10
  22. package/lib/commands/stats.js +38 -11
  23. package/lib/commands/stats.ts +40 -96
  24. package/lib/core/fmt.ts +2 -2
  25. package/lib/core/git.ts +2 -2
  26. package/lib/core/gitignore.ts +2 -2
  27. package/lib/core/mission-utils.js +2 -2
  28. package/lib/core/mission-utils.ts +2 -2
  29. package/lib/core/persistent-data-migration.ts +2 -2
  30. package/lib/core/spawn-tee.ts +1 -1
  31. package/lib/core/state-map.ts +2 -2
  32. package/lib/core/storage.ts +1 -1
  33. package/lib/core/verification.ts +1 -1
  34. package/lib/review/rebase.ts +12 -12
  35. package/lib/review/review-artifacts.ts +35 -35
  36. package/lib/review/review-commands.ts +40 -40
  37. package/lib/review/review-events.ts +12 -12
  38. package/lib/review/review-loop.js +236 -7
  39. package/lib/review/review-loop.ts +338 -22
  40. package/lib/review/review-polling.ts +6 -6
  41. package/lib/review/review-prompts.js +8 -4
  42. package/lib/review/review-prompts.ts +12 -8
  43. package/lib/review/review-state.js +1 -1
  44. package/lib/review/review-state.ts +2 -2
  45. package/package.json +3 -2
  46. package/prompts/review-verbose.md +1 -1
  47. 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 {string} errorMsg - The error message to check
10
- * @returns {boolean} True if the error is relaunchable
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
- // Match the exact error message from handoff.js when final checkpoint has no evidence rows
17
- return errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
18
- errorMsg.includes('A goal-check table with real evidence is required before handoff');
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
- return `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
169
+ let prompt = `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
34
170
 
35
171
  ` +
36
- `Please fix the final checkpoint document in ${missionDir} by adding a Goal Check table ` +
37
- `with real evidence rows (file:line references, test names). The Goal Check table must have ` +
38
- `a header row and at least one evidence row using pipe syntax (|).
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
- `Steps:
177
+ `Steps:
42
178
  ` +
43
- `1. Open the final checkpoint document (CP-N.md) in ${missionDir}
179
+ `1. Open the final checkpoint document (CP-N.md) in ${missionDir}
44
180
  ` +
45
- `2. Add or update the "## Goal Check" section
181
+ `2. Add or update the "## Goal Check" section
46
182
  ` +
47
- `3. Create a markdown table with columns for: Goal Check description | Evidence | Status
183
+ `3. Create a markdown table with columns for: Goal Check description | Evidence | Status
48
184
  ` +
49
- `4. Add at least one evidence row with real file:line or test name references
185
+ `4. Add at least one evidence row with real file:line or test name references
50
186
  ` +
51
- `5. Commit the updated checkpoint with a descriptive commit message
187
+ `5. Commit the updated checkpoint with a descriptive commit message
52
188
  ` +
53
- `6. Re-run: node parallix review ${slug} --submit
189
+ `6. Re-run: node parallix review ${slug} --submit
54
190
 
55
191
  ` +
56
- `Example Goal Check table:
192
+ `Example Goal Check table:
57
193
  ` +
58
- `| Goal Check | Evidence | Status |
194
+ `| Goal Check | Evidence | Status |
59
195
  ` +
60
- `|---|---|---|
196
+ `|---|---|---|
61
197
  ` +
62
- `| Final checkpoint has Goal Check section | docs/missions/${year}/${slug}/CP-1.md:15 | PASS |
198
+ `| Final checkpoint has Goal Check section | docs/missions/${year}/${slug}/CP-1.md:15 | PASS |
63
199
  ` +
64
- `| Tests pass | npm test -- parallix/test/repair-handoff.test.js | PASS |
200
+ `| Tests pass | npm test -- parallix/test/repair-handoff.test.js | PASS |
65
201
 
66
202
  ` +
67
- `Do NOT add placeholder or generic evidence. Each row must cite real, verifiable artifacts.`;
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 isDirtyError = errorMsg && (
105
- errorMsg.includes('is modified but uncommitted') ||
106
- errorMsg.includes('Commit the mission contract before handoff') ||
107
- errorMsg.includes('Commit the implementation evidence before handoff')
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 (isDirtyError || isBehind) {
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 = ((code) => process.exit(code)), } = {}) {
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 = ((code: number) => process.exit(code)) as (code: number) => void,
51
- }: {resolveConflictsFn?: Function, startAgentFn?: Function, exitFn?: (code: number) => void} = {}) {
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: (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 };
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, (v: string) => string | null>).normalizeClassification(classificationValue ?? '');
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?: (msg: string) => string | null;
351
- error?: (msg: string) => string | null;
350
+ log?: (_msg: string) => string | null;
351
+ error?: (_msg: string) => string | null;
352
352
  rootDir?: string;
353
353
  }
354
354
 
@@ -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 21-column telemetry schema (task-1314 + task-1251). Legacy 5-column rows are
80
- // migrated in-memory on load: the legacy columns are preserved and the new
81
- // columns default to '' (text) or '0' (numeric). On the next write the file
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: data.rows.map((row) => normalizeStatsRow(row, { rootDir: options.rootDir })),
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 = windowRows.filter(row => {
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: windowRows,
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 = windowRows.filter(row => normalizeClassification(row.classification) !== null);
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,