@magnusekdahl/parallix 1.2.1 → 1.3.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/index.js +190 -185
- package/lib/commands/active.js +502 -579
- package/lib/commands/active.ts +665 -0
- package/lib/commands/checkpoint.js +98 -74
- package/lib/commands/checkpoint.ts +79 -0
- package/lib/commands/config.js +61 -31
- package/lib/commands/config.ts +47 -0
- package/lib/commands/coverage-gate.js +271 -246
- package/lib/commands/coverage-gate.ts +362 -0
- package/lib/commands/diff.js +140 -119
- package/lib/commands/diff.ts +125 -0
- package/lib/commands/draft.js +770 -923
- package/lib/commands/draft.ts +1021 -0
- package/lib/commands/handoff.js +588 -608
- package/lib/commands/handoff.ts +694 -0
- package/lib/commands/integrate.js +1331 -1468
- package/lib/commands/integrate.ts +1688 -0
- package/lib/commands/mission-start.js +279 -237
- package/lib/commands/mission-start.ts +263 -0
- package/lib/commands/rebase.js +606 -582
- package/lib/commands/rebase.ts +635 -0
- package/lib/commands/repair-handoff.js +181 -179
- package/lib/commands/repair-handoff.ts +228 -0
- package/lib/commands/resolve-conflict.js +121 -102
- package/lib/commands/resolve-conflict.ts +112 -0
- package/lib/commands/review.js +3 -4
- package/lib/commands/review.ts +14 -0
- package/lib/commands/setup-review.js +4 -5
- package/lib/commands/setup-review.ts +14 -0
- package/lib/commands/setup.js +3 -3
- package/lib/commands/setup.ts +2 -0
- package/lib/commands/stats-backfill.js +394 -359
- package/lib/commands/stats-backfill.ts +418 -0
- package/lib/commands/stats.js +1424 -1665
- package/lib/commands/stats.ts +2186 -0
- package/lib/commands/status.js +219 -200
- package/lib/commands/status.ts +219 -0
- package/lib/commands/verify.js +3 -1
- package/lib/commands/verify.ts +2 -0
- package/lib/core/nels.js +112 -127
- package/lib/core/nels.ts +189 -0
- package/lib/core/subagent-limit.js +15 -16
- package/lib/core/subagent-limit.ts +25 -0
- package/lib/index.js +142 -59
- package/lib/index.ts +134 -0
- package/lib/review/rebase.js +142 -145
- package/lib/review/rebase.ts +188 -0
- package/lib/review/review-adapter.js +99 -87
- package/lib/review/review-adapter.ts +198 -0
- package/lib/review/review-artifacts.js +486 -629
- package/lib/review/review-artifacts.ts +622 -0
- package/lib/review/review-commands.js +1220 -1373
- package/lib/review/review-commands.ts +1579 -0
- package/lib/review/review-events.js +544 -886
- package/lib/review/review-events.ts +954 -0
- package/lib/review/review-loop.js +945 -1020
- package/lib/review/review-loop.ts +1174 -0
- package/lib/review/review-polling.js +86 -115
- package/lib/review/review-polling.ts +194 -0
- package/lib/review/review-prompts.js +154 -189
- package/lib/review/review-prompts.ts +182 -0
- package/lib/review/review-state.js +246 -225
- package/lib/review/review-state.ts +353 -0
- package/lib/review/review.js +63 -84
- package/lib/review/review.ts +138 -0
- package/lib/tools/backlog.js +694 -680
- package/lib/tools/backlog.ts +835 -0
- package/lib/tools/forgejo.js +1270 -1405
- package/lib/tools/forgejo.ts +1793 -0
- package/lib/tools/gatekeeper.js +106 -96
- package/lib/tools/gatekeeper.ts +124 -0
- package/lib/tools/redgreen.js +200 -181
- package/lib/tools/redgreen.ts +221 -0
- package/lib/tools/sessions.js +58 -52
- package/lib/tools/sessions.ts +81 -0
- package/lib/tools/setup-review.js +902 -975
- package/lib/tools/setup-review.ts +1152 -0
- package/package.json +4 -4
- package/px.js +226 -208
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review event persistence for autonomous review rounds.
|
|
3
|
+
*
|
|
4
|
+
* Stores classified review events under mission-local directories:
|
|
5
|
+
* missions/<slug>/review-events/ by default, or the configured legacy layout
|
|
6
|
+
*
|
|
7
|
+
* Each event is a structured markdown file with bounded fields, not full
|
|
8
|
+
* transient agent context (to avoid noisy diffs per ADR 0039).
|
|
9
|
+
*
|
|
10
|
+
* Owned by the Node workflow harness (task-1145).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import { git } from '../core/git.js';
|
|
17
|
+
import { findMissionDir, missionBranchName, resolveWorktree } from '../core/mission-utils.js';
|
|
18
|
+
import { readReviewState } from './review-state.js';
|
|
19
|
+
import * as fmt from '../core/fmt.js';
|
|
20
|
+
|
|
21
|
+
// -------- Event Taxonomy --------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Valid event classification labels.
|
|
25
|
+
* These are the canonical types that can be stored and (optionally) mirrored to the review provider.
|
|
26
|
+
*/
|
|
27
|
+
const VALID_EVENT_TYPES = Object.freeze({
|
|
28
|
+
// Reviewer-produced events
|
|
29
|
+
REVIEWER_FINDINGS: 'reviewer_findings',
|
|
30
|
+
REVIEWER_OUTCOME: 'reviewer_outcome',
|
|
31
|
+
|
|
32
|
+
// Implementer-produced events
|
|
33
|
+
IMPLEMENTER_ROUND_SUMMARY: 'implementer_round_summary',
|
|
34
|
+
IMPLEMENTER_DISPOSITION: 'implementer_disposition',
|
|
35
|
+
|
|
36
|
+
// Neutral events
|
|
37
|
+
NEUTRAL_DISCUSSION: 'neutral_discussion',
|
|
38
|
+
HUMAN_NOTE: 'human_note',
|
|
39
|
+
|
|
40
|
+
// Workflow-internal events (not mirrored to the review provider by default)
|
|
41
|
+
BLOCKED_PUBLICATION: 'blocked_publication',
|
|
42
|
+
PARKED_FOLLOWUP: 'parked_followup'
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* All valid event type values as an array for validation.
|
|
47
|
+
*/
|
|
48
|
+
const ALL_EVENT_TYPES = Object.freeze(Object.values(VALID_EVENT_TYPES));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Event types that should be mirrored to the review provider.
|
|
52
|
+
*/
|
|
53
|
+
const MIRRORED_EVENT_TYPES = Object.freeze(new Set([
|
|
54
|
+
VALID_EVENT_TYPES.REVIEWER_FINDINGS,
|
|
55
|
+
VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
56
|
+
VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY,
|
|
57
|
+
VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION,
|
|
58
|
+
VALID_EVENT_TYPES.HUMAN_NOTE
|
|
59
|
+
]));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Valid disposition values for implementer disposition events.
|
|
63
|
+
*/
|
|
64
|
+
const VALID_DISPOSITIONS = Object.freeze([
|
|
65
|
+
'CHANGES_MADE',
|
|
66
|
+
'PUSHBACK_ALL',
|
|
67
|
+
'PARKED',
|
|
68
|
+
'BLOCKED'
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Valid review verdict values for reviewer outcome events.
|
|
73
|
+
*/
|
|
74
|
+
const VALID_VERDICTS = Object.freeze([
|
|
75
|
+
'approve',
|
|
76
|
+
'request-changes',
|
|
77
|
+
'comment'
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// -------- Path Resolution --------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns the absolute path to the mission-local review-events directory.
|
|
84
|
+
* Creates the directory if it does not exist.
|
|
85
|
+
*/
|
|
86
|
+
function reviewEventsDir(slug: string, rootDir = process.cwd()): string | null {
|
|
87
|
+
const missionDir = findMissionDir(slug, rootDir);
|
|
88
|
+
if (!missionDir) {return null;}
|
|
89
|
+
const dir = path.join(missionDir, 'review-events');
|
|
90
|
+
if (!fs.existsSync(dir)) {
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns the path for a specific event file in the mission-local store.
|
|
98
|
+
*/
|
|
99
|
+
function eventFilePath(slug: string, eventType: string, round: number, actor: string, timestamp: string | null = null, rootDir = process.cwd()): string | null {
|
|
100
|
+
const eventsDir = reviewEventsDir(slug, rootDir);
|
|
101
|
+
if (!eventsDir) {return null;}
|
|
102
|
+
|
|
103
|
+
const ts = timestamp || generateEventTimestamp();
|
|
104
|
+
const sanitizedActor = sanitizeFilename(actor);
|
|
105
|
+
const filename = `${ts}-${eventType}-${round}-${sanitizedActor}.md`;
|
|
106
|
+
return path.join(eventsDir, filename);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns the legacy /tmp/ artifact path for compatibility.
|
|
111
|
+
*/
|
|
112
|
+
function legacyArtifactPath(slug: string, artifactName: string, tmpDir = os.tmpdir()): string {
|
|
113
|
+
return path.join(tmpDir, `${slug}-${artifactName}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -------- Timestamp & Sanitization --------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generate a filesystem-safe ISO timestamp without colons.
|
|
120
|
+
*/
|
|
121
|
+
function generateEventTimestamp() {
|
|
122
|
+
const now = new Date();
|
|
123
|
+
const datePart = now.toISOString().split('T')[0];
|
|
124
|
+
const timePart = now.toISOString().split('T')[1].split('.')[0];
|
|
125
|
+
return `${datePart}T${timePart.replace(/:/g, '')}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sanitize a string for use in a filename.
|
|
130
|
+
*/
|
|
131
|
+
function sanitizeFilename(str: string): string {
|
|
132
|
+
if (!str) {return 'unknown';}
|
|
133
|
+
return String(str)
|
|
134
|
+
.toLowerCase()
|
|
135
|
+
.replace(/[@.]/g, '')
|
|
136
|
+
.replace(/[^a-z0-9_-]/g, '-')
|
|
137
|
+
.replace(/-+/g, '-')
|
|
138
|
+
.replace(/^-|-$/g, '');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -------- Event Validation --------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Validate an event type against the taxonomy.
|
|
145
|
+
*/
|
|
146
|
+
function isValidEventType(eventType: string): boolean {
|
|
147
|
+
return (ALL_EVENT_TYPES as string[]).includes(eventType);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate a disposition value.
|
|
152
|
+
*/
|
|
153
|
+
function isValidDisposition(disposition: string): boolean {
|
|
154
|
+
return VALID_DISPOSITIONS.includes(disposition);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate a verdict value.
|
|
159
|
+
*/
|
|
160
|
+
function isValidVerdict(verdict: string): boolean {
|
|
161
|
+
return VALID_VERDICTS.includes(verdict);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if an event type should be mirrored to the review provider.
|
|
166
|
+
*/
|
|
167
|
+
function shouldMirrorToProvider(eventType: string): boolean {
|
|
168
|
+
return (MIRRORED_EVENT_TYPES as Set<string>).has(eventType);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// -------- Event Structure --------
|
|
172
|
+
|
|
173
|
+
interface EventMetadata { [key: string]: unknown; }
|
|
174
|
+
|
|
175
|
+
interface NormalizedEvent {
|
|
176
|
+
eventType: string;
|
|
177
|
+
timestamp: string;
|
|
178
|
+
content: string;
|
|
179
|
+
slug?: string;
|
|
180
|
+
round?: number;
|
|
181
|
+
phase?: string;
|
|
182
|
+
actor?: string;
|
|
183
|
+
disposition?: string;
|
|
184
|
+
verdict?: string;
|
|
185
|
+
fixedItems?: unknown[];
|
|
186
|
+
pushedBackItems?: unknown[];
|
|
187
|
+
parkedItems?: unknown[];
|
|
188
|
+
blockedReason?: string;
|
|
189
|
+
followUpReference?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface CreateEventParams {
|
|
193
|
+
content?: string;
|
|
194
|
+
round?: number;
|
|
195
|
+
phase?: string;
|
|
196
|
+
actor?: string;
|
|
197
|
+
disposition?: string;
|
|
198
|
+
verdict?: string;
|
|
199
|
+
fixedItems?: unknown[];
|
|
200
|
+
pushedBackItems?: unknown[];
|
|
201
|
+
parkedItems?: unknown[];
|
|
202
|
+
blockedReason?: string;
|
|
203
|
+
followUpReference?: string;
|
|
204
|
+
timestamp?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface CreateEventOptions {
|
|
208
|
+
skipGit?: boolean;
|
|
209
|
+
gitFn?: typeof git;
|
|
210
|
+
log?: (msg: string) => void;
|
|
211
|
+
error?: (msg: string) => void;
|
|
212
|
+
worktree?: string;
|
|
213
|
+
allowMissingRequiredFields?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface CreateEventResult {
|
|
217
|
+
ok: boolean;
|
|
218
|
+
path: string | null;
|
|
219
|
+
event?: NormalizedEvent;
|
|
220
|
+
error?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface CreateEventResultWithLegacy {
|
|
224
|
+
ok: boolean;
|
|
225
|
+
path: string | null;
|
|
226
|
+
importedFrom?: string;
|
|
227
|
+
event?: NormalizedEvent;
|
|
228
|
+
legacyPath?: string;
|
|
229
|
+
error?: string;
|
|
230
|
+
skipped?: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface ConsumeHumanNotesResult {
|
|
234
|
+
ok: boolean;
|
|
235
|
+
created: unknown[];
|
|
236
|
+
skipped: unknown[];
|
|
237
|
+
error?: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface ImportAllLegacyResult {
|
|
241
|
+
ok: boolean;
|
|
242
|
+
imported: unknown[];
|
|
243
|
+
errors: unknown[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface ReadAllEventsOptions {
|
|
247
|
+
rootDir?: string;
|
|
248
|
+
readdirSync?: typeof fs.readdirSync;
|
|
249
|
+
readFileSync?: typeof fs.readFileSync;
|
|
250
|
+
error?: (msg: string) => void;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface ParseEventResult {
|
|
254
|
+
event_type?: string;
|
|
255
|
+
[key: string]: unknown;
|
|
256
|
+
content: string;
|
|
257
|
+
filePath: string;
|
|
258
|
+
fileCreated: string;
|
|
259
|
+
fileModified: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Normalize an event payload into a structured format.
|
|
264
|
+
*/
|
|
265
|
+
function normalizeEventContent(content: string, eventType: string, metadata: EventMetadata = {}): NormalizedEvent {
|
|
266
|
+
const payload = {
|
|
267
|
+
eventType,
|
|
268
|
+
timestamp: '',
|
|
269
|
+
content,
|
|
270
|
+
...metadata
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
payload.eventType = eventType;
|
|
274
|
+
payload.timestamp = payload.timestamp || new Date().toISOString();
|
|
275
|
+
payload.content = content;
|
|
276
|
+
|
|
277
|
+
return payload;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Build the frontmatter for an event file.
|
|
282
|
+
*/
|
|
283
|
+
function buildEventFrontmatter(event: NormalizedEvent): string {
|
|
284
|
+
const lines = [
|
|
285
|
+
'---',
|
|
286
|
+
`event_type: ${event.eventType}`,
|
|
287
|
+
`timestamp: ${event.timestamp}`,
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
if (event.round !== undefined) {
|
|
291
|
+
lines.push(`round: ${event.round}`);
|
|
292
|
+
}
|
|
293
|
+
if (event.phase !== undefined) {
|
|
294
|
+
lines.push(`phase: ${event.phase}`);
|
|
295
|
+
}
|
|
296
|
+
if (event.actor !== undefined) {
|
|
297
|
+
lines.push(`actor: ${event.actor}`);
|
|
298
|
+
}
|
|
299
|
+
if (event.slug !== undefined) {
|
|
300
|
+
lines.push(`slug: ${event.slug}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (event.disposition !== undefined) {
|
|
304
|
+
lines.push(`disposition: ${event.disposition}`);
|
|
305
|
+
}
|
|
306
|
+
if (event.verdict !== undefined) {
|
|
307
|
+
lines.push(`verdict: ${event.verdict}`);
|
|
308
|
+
}
|
|
309
|
+
if (event.fixedItems !== undefined) {
|
|
310
|
+
lines.push(`fixed_items: ${JSON.stringify(event.fixedItems)}`);
|
|
311
|
+
}
|
|
312
|
+
if (event.pushedBackItems !== undefined) {
|
|
313
|
+
lines.push(`pushed_back_items: ${JSON.stringify(event.pushedBackItems)}`);
|
|
314
|
+
}
|
|
315
|
+
if (event.parkedItems !== undefined) {
|
|
316
|
+
lines.push(`parked_items: ${JSON.stringify(event.parkedItems)}`);
|
|
317
|
+
}
|
|
318
|
+
if (event.blockedReason !== undefined) {
|
|
319
|
+
lines.push(`blocked_reason: "${event.blockedReason.replace(/"/g, '\\"')}"`);
|
|
320
|
+
}
|
|
321
|
+
if (event.followUpReference !== undefined) {
|
|
322
|
+
lines.push(`followup_reference: ${event.followUpReference}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
lines.push('---');
|
|
326
|
+
return lines.join('\n');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build the workflow metadata footer for an event.
|
|
331
|
+
*/
|
|
332
|
+
function buildEventFooter(slug: string, round: number, phase: string): string {
|
|
333
|
+
return `\n\n---\n\`[workflow-round:${round}, workflow-phase:${phase}]\``;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Check if a comment body contains the workflow metadata footer.
|
|
338
|
+
*/
|
|
339
|
+
function hasWorkflowFooter(body: string): boolean {
|
|
340
|
+
return /\`\[workflow-round:\d+, workflow-phase:[^\]]+\]\`/.test(body);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Classify a comment as either workflow-generated or human.
|
|
345
|
+
*/
|
|
346
|
+
function classifyComment(comment: { body?: string }): string | null {
|
|
347
|
+
const body = comment.body || '';
|
|
348
|
+
if (hasWorkflowFooter(body)) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return VALID_EVENT_TYPES.HUMAN_NOTE;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface ConsumeHumanNotesOptions {
|
|
355
|
+
getCommentsFn?: (branch: string, token: string) => Promise<unknown[]>;
|
|
356
|
+
createEventFn?: typeof createEvent;
|
|
357
|
+
readTokenFn?: (user: string) => string | null;
|
|
358
|
+
reviewIdentity?: string | null;
|
|
359
|
+
forgejoUser?: string | null;
|
|
360
|
+
worktree?: string;
|
|
361
|
+
log?: (msg: string) => void;
|
|
362
|
+
error?: (msg: string) => void;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Consume human notes from provider PR comments.
|
|
367
|
+
*/
|
|
368
|
+
async function consumeHumanNotes(slug: string, actor: string, options: ConsumeHumanNotesOptions = {}): Promise<ConsumeHumanNotesResult> {
|
|
369
|
+
const {
|
|
370
|
+
getCommentsFn,
|
|
371
|
+
createEventFn = createEvent,
|
|
372
|
+
readTokenFn,
|
|
373
|
+
reviewIdentity = null,
|
|
374
|
+
forgejoUser = null,
|
|
375
|
+
worktree,
|
|
376
|
+
log: logger = fmt.log.plain,
|
|
377
|
+
error = fmt.log.plainError
|
|
378
|
+
} = options;
|
|
379
|
+
|
|
380
|
+
const actorIdentity = reviewIdentity || forgejoUser;
|
|
381
|
+
if (!actorIdentity) {
|
|
382
|
+
error(fmt.status('FAIL', 'Review identity is required for consumeHumanNotes.'));
|
|
383
|
+
return { ok: false, created: [], skipped: [], error: 'reviewIdentity required' };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!getCommentsFn) {
|
|
387
|
+
logger(fmt.status('INFO', 'consumeHumanNotes: no getCommentsFn provided; skipping human note classification.'));
|
|
388
|
+
return { ok: true, created: [], skipped: [] };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const token = readTokenFn ? readTokenFn(actorIdentity) : null;
|
|
392
|
+
if (!token) {
|
|
393
|
+
logger(fmt.status('INFO', 'consumeHumanNotes: no provider token found; skipping human note classification.'));
|
|
394
|
+
return { ok: true, created: [], skipped: [] };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const branch = missionBranchName(slug, worktree || process.cwd());
|
|
398
|
+
const comments = await getCommentsFn(branch, token);
|
|
399
|
+
|
|
400
|
+
if (!comments || !Array.isArray(comments)) {
|
|
401
|
+
logger(fmt.status('INFO', 'consumeHumanNotes: no comments retrieved; skipping classification.'));
|
|
402
|
+
return { ok: true, created: [], skipped: [] };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const created: unknown[] = [];
|
|
406
|
+
const skipped: unknown[] = [];
|
|
407
|
+
|
|
408
|
+
const currentState = readReviewState(slug, worktree || process.cwd());
|
|
409
|
+
const round = currentState ? currentState.round : 1;
|
|
410
|
+
const phase = currentState ? currentState.phase : 'reviewing';
|
|
411
|
+
|
|
412
|
+
for (const comment of comments) {
|
|
413
|
+
if (hasWorkflowFooter((comment as { body?: string }).body || '')) {
|
|
414
|
+
skipped.push({ user: (comment as { user?: string }).user, created: (comment as { created?: string }).created, reason: 'workflow-generated' });
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const classification = classifyComment(comment as { body?: string });
|
|
419
|
+
if (!classification) {
|
|
420
|
+
skipped.push({ user: (comment as { user?: string }).user, created: (comment as { created?: string }).created, reason: 'already-classified' });
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const result = createEventFn(slug, classification, {
|
|
425
|
+
content: (comment as { body?: string }).body || '',
|
|
426
|
+
round,
|
|
427
|
+
phase,
|
|
428
|
+
actor: actor || (comment as { user?: string }).user || 'human'
|
|
429
|
+
}, {
|
|
430
|
+
worktree: worktree || process.cwd(),
|
|
431
|
+
skipGit: true,
|
|
432
|
+
log: logger,
|
|
433
|
+
error
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
if (result.ok) {
|
|
437
|
+
created.push({ path: result.path, user: (comment as { user?: string }).user, created: (comment as { created?: string }).created });
|
|
438
|
+
} else {
|
|
439
|
+
error(fmt.status('WARN', `Failed to create human_note event for comment by ${(comment as { user?: string }).user}: ${(result as { error?: string }).error}`));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
logger(fmt.status('INFO', `consumeHumanNotes: created ${created.length} human_note events, skipped ${skipped.length} workflow comments`));
|
|
444
|
+
return { ok: true, created, skipped };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Render a complete event file content.
|
|
449
|
+
*/
|
|
450
|
+
function renderEventFile(event: NormalizedEvent): string {
|
|
451
|
+
const frontmatter = buildEventFrontmatter(event);
|
|
452
|
+
const content = event.content || '';
|
|
453
|
+
|
|
454
|
+
const hasFooter = /\n\n---\n`\[workflow-round:\d+, workflow-phase:[^\]]+\]`/.test(content);
|
|
455
|
+
|
|
456
|
+
if (hasFooter) {
|
|
457
|
+
return `${frontmatter}\n\n${content}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const footer = event.round !== undefined && event.phase !== undefined
|
|
461
|
+
? buildEventFooter(event.slug || '', event.round, event.phase)
|
|
462
|
+
: '';
|
|
463
|
+
|
|
464
|
+
return `${frontmatter}\n\n${content}${footer}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// -------- Event Creation --------
|
|
468
|
+
|
|
469
|
+
/** @typedef {{content: string, round?: number, phase?: string, actor?: string, disposition?: string, verdict?: string, fixedItems?: unknown[], pushedBackItems?: unknown[], parkedItems?: unknown[], blockedReason?: string, followUpReference?: string, timestamp?: string}} CreateEventParams */
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Create and persist a classified review event.
|
|
473
|
+
*/
|
|
474
|
+
function createEvent(slug: string, eventType: string, params: CreateEventParams, options: CreateEventOptions = {}): CreateEventResult {
|
|
475
|
+
const {
|
|
476
|
+
skipGit = false,
|
|
477
|
+
gitFn = git,
|
|
478
|
+
log: logger = fmt.log.plain,
|
|
479
|
+
error = fmt.log.plainError,
|
|
480
|
+
worktree,
|
|
481
|
+
allowMissingRequiredFields = false
|
|
482
|
+
} = options;
|
|
483
|
+
|
|
484
|
+
const rootDir = worktree || resolveWorktree(slug) || process.cwd();
|
|
485
|
+
|
|
486
|
+
if (!isValidEventType(eventType)) {
|
|
487
|
+
error(fmt.status('FAIL', `Invalid event type "${eventType}". Valid types: ${ALL_EVENT_TYPES.join(', ')}`));
|
|
488
|
+
return { ok: false, path: null, error: `Invalid event type: ${eventType}` };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (params.disposition !== undefined && !isValidDisposition(params.disposition as string)) {
|
|
492
|
+
error(fmt.status('FAIL', `Invalid disposition "${params.disposition}". Valid: ${VALID_DISPOSITIONS.join(', ')}`));
|
|
493
|
+
return { ok: false, path: null, error: `Invalid disposition: ${params.disposition}` };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (params.verdict !== undefined && !isValidVerdict(params.verdict as string)) {
|
|
497
|
+
error(fmt.status('FAIL', `Invalid verdict "${params.verdict}". Valid: ${VALID_VERDICTS.join(', ')}`));
|
|
498
|
+
return { ok: false, path: null, error: `Invalid verdict: ${params.verdict}` };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (!allowMissingRequiredFields) {
|
|
502
|
+
if (eventType === VALID_EVENT_TYPES.REVIEWER_OUTCOME && !params.verdict) {
|
|
503
|
+
error(fmt.status('FAIL', 'reviewer_outcome event requires --verdict'));
|
|
504
|
+
return { ok: false, path: null, error: 'reviewer_outcome event requires verdict' };
|
|
505
|
+
}
|
|
506
|
+
if (eventType === VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION && !params.disposition) {
|
|
507
|
+
error(fmt.status('FAIL', 'implementer_disposition event requires --disposition'));
|
|
508
|
+
return { ok: false, path: null, error: 'implementer_disposition event requires disposition' };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let state: ReturnType<typeof readReviewState> | null;
|
|
513
|
+
if (params.round === undefined || params.phase === undefined) {
|
|
514
|
+
state = readReviewState(slug, rootDir);
|
|
515
|
+
} else {
|
|
516
|
+
state = null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const round = (params.round !== undefined ? params.round : (state ? state!.round : 1)) as number;
|
|
520
|
+
const phase = (params.phase !== undefined ? params.phase : (state ? state!.phase : 'reviewing')) as string;
|
|
521
|
+
const actor = (params.actor as string) || 'unknown';
|
|
522
|
+
|
|
523
|
+
const event = normalizeEventContent(
|
|
524
|
+
(params.content as string) || '',
|
|
525
|
+
eventType,
|
|
526
|
+
{
|
|
527
|
+
slug,
|
|
528
|
+
round,
|
|
529
|
+
phase,
|
|
530
|
+
actor,
|
|
531
|
+
eventType,
|
|
532
|
+
timestamp: params.timestamp as string | undefined,
|
|
533
|
+
disposition: params.disposition as string | undefined,
|
|
534
|
+
verdict: params.verdict as string | undefined,
|
|
535
|
+
fixedItems: params.fixedItems as unknown[] | undefined,
|
|
536
|
+
pushedBackItems: params.pushedBackItems as unknown[] | undefined,
|
|
537
|
+
parkedItems: params.parkedItems as unknown[] | undefined,
|
|
538
|
+
blockedReason: params.blockedReason as string | undefined,
|
|
539
|
+
followUpReference: params.followUpReference as string | undefined
|
|
540
|
+
}
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const ts = (params.timestamp as string | undefined) ?? null;
|
|
544
|
+
const filePath = eventFilePath(slug, eventType, round, actor, ts, rootDir);
|
|
545
|
+
if (!filePath) {
|
|
546
|
+
error(fmt.status('FAIL', `Cannot resolve mission directory for slug "${slug}"`));
|
|
547
|
+
return { ok: false, path: null, error: `Mission directory not found` };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const dir = path.dirname(filePath);
|
|
551
|
+
if (!fs.existsSync(dir)) {
|
|
552
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const content = renderEventFile(event);
|
|
556
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
557
|
+
|
|
558
|
+
logger(fmt.status('PASS', `Created review event: ${path.basename(filePath)}`));
|
|
559
|
+
|
|
560
|
+
if (!skipGit) {
|
|
561
|
+
const relPath = path.relative(rootDir, filePath);
|
|
562
|
+
const result = gitFn(['-C', rootDir, 'add', relPath]);
|
|
563
|
+
if (result.status !== 0) {
|
|
564
|
+
error(fmt.status('WARN', `Failed to git add event file: ${result.stderr}`));
|
|
565
|
+
} else {
|
|
566
|
+
const commitMsg = `review-event(${slug}): ${eventType} round ${round} (${phase}) [${actor}]`;
|
|
567
|
+
const commitResult = gitFn(['-C', rootDir, 'commit', '-m', commitMsg, '--allow-empty']);
|
|
568
|
+
if (commitResult.status !== 0) {
|
|
569
|
+
error(fmt.status('WARN', `Failed to commit event file: ${commitResult.stderr}`));
|
|
570
|
+
} else {
|
|
571
|
+
logger(fmt.status('PASS', 'Committed review event to git'));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return { ok: true, path: filePath, event };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export interface ImportLegacyArtifactOptions {
|
|
580
|
+
tmpDir?: string;
|
|
581
|
+
log?: (msg: string) => void;
|
|
582
|
+
error?: (msg: string) => void;
|
|
583
|
+
worktree?: string;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Import an event from a legacy /tmp/ artifact file.
|
|
588
|
+
*/
|
|
589
|
+
function importLegacyArtifact(slug: string, artifactName: string, eventType: string, params: CreateEventParams = {}, options: ImportLegacyArtifactOptions = {}): CreateEventResultWithLegacy {
|
|
590
|
+
const {
|
|
591
|
+
tmpDir = os.tmpdir(),
|
|
592
|
+
log: logger = fmt.log.plain,
|
|
593
|
+
error = fmt.log.plainError,
|
|
594
|
+
worktree
|
|
595
|
+
} = options;
|
|
596
|
+
|
|
597
|
+
const legacyPath = legacyArtifactPath(slug, artifactName, tmpDir);
|
|
598
|
+
|
|
599
|
+
if (!fs.existsSync(legacyPath)) {
|
|
600
|
+
logger(fmt.status('INFO', `Legacy artifact not found: ${legacyPath}`));
|
|
601
|
+
return { ok: true, path: null, importedFrom: legacyPath, skipped: true };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let content;
|
|
605
|
+
try {
|
|
606
|
+
content = fs.readFileSync(legacyPath, 'utf8');
|
|
607
|
+
} catch (err) {
|
|
608
|
+
error(fmt.status('FAIL', `Failed to read legacy artifact: ${(err as Error).message}`));
|
|
609
|
+
return { ok: false, path: null, importedFrom: legacyPath, error: (err as Error).message };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const result = createEvent(slug, eventType, {
|
|
613
|
+
...params,
|
|
614
|
+
content,
|
|
615
|
+
actor: params.actor || inferActorFromArtifact()
|
|
616
|
+
}, { ...options, worktree: worktree || undefined, allowMissingRequiredFields: true });
|
|
617
|
+
|
|
618
|
+
if (!result.ok) {
|
|
619
|
+
return { ok: false, path: null, importedFrom: legacyPath, error: result.error ?? undefined };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
logger(fmt.status('PASS', `Imported legacy artifact ${artifactName} to ${path.basename(result.path || '')}`));
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
ok: true,
|
|
626
|
+
path: result.path,
|
|
627
|
+
importedFrom: legacyPath,
|
|
628
|
+
event: result.event,
|
|
629
|
+
legacyPath
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Infer actor from legacy artifact name (best guess).
|
|
635
|
+
*/
|
|
636
|
+
function inferActorFromArtifact() {
|
|
637
|
+
return 'unknown';
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Map legacy artifact names to event types.
|
|
642
|
+
*/
|
|
643
|
+
const LEGACY_ARTIFACT_TO_EVENT_TYPE: Readonly<Record<string, string>> = Object.freeze({
|
|
644
|
+
'review-findings.md': VALID_EVENT_TYPES.REVIEWER_FINDINGS,
|
|
645
|
+
'review-outcome.md': VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
646
|
+
'review-verdict.txt': VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
647
|
+
'round-resolution.md': VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY,
|
|
648
|
+
'review-disposition.txt': VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION,
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
export interface ImportAllLegacyOptions {
|
|
652
|
+
tmpDir?: string;
|
|
653
|
+
error?: (msg: string) => void;
|
|
654
|
+
worktree?: string;
|
|
655
|
+
log?: (msg: string) => void;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Import all legacy /tmp/ artifacts for a mission.
|
|
660
|
+
*/
|
|
661
|
+
function importAllLegacyArtifacts(slug: string, options: ImportAllLegacyOptions = {}): ImportAllLegacyResult {
|
|
662
|
+
const {
|
|
663
|
+
tmpDir = os.tmpdir(),
|
|
664
|
+
error = fmt.log.plainError,
|
|
665
|
+
worktree
|
|
666
|
+
} = options;
|
|
667
|
+
|
|
668
|
+
const rootDir = worktree || resolveWorktree(slug) || process.cwd();
|
|
669
|
+
const imported = [];
|
|
670
|
+
const errors = [];
|
|
671
|
+
|
|
672
|
+
const state = readReviewState(slug, rootDir);
|
|
673
|
+
const round = state ? state.round : 1;
|
|
674
|
+
const phase = state ? state.phase : 'reviewing';
|
|
675
|
+
|
|
676
|
+
const legacyFiles: Record<string, string | null> = {};
|
|
677
|
+
for (const artifactName of Object.keys(LEGACY_ARTIFACT_TO_EVENT_TYPE)) {
|
|
678
|
+
const legacyPath = legacyArtifactPath(slug, artifactName, tmpDir);
|
|
679
|
+
legacyFiles[artifactName] = fs.existsSync(legacyPath) ? legacyPath : null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const outcomePath = legacyFiles['review-outcome.md'];
|
|
683
|
+
const verdictPath = legacyFiles['review-verdict.txt'];
|
|
684
|
+
|
|
685
|
+
if (outcomePath && fs.existsSync(outcomePath)) {
|
|
686
|
+
let verdictContent = null;
|
|
687
|
+
if (verdictPath) {
|
|
688
|
+
try {
|
|
689
|
+
if (fs.existsSync(verdictPath)) {
|
|
690
|
+
verdictContent = fs.readFileSync(verdictPath, 'utf8').trim();
|
|
691
|
+
}
|
|
692
|
+
} catch (err) {
|
|
693
|
+
error(fmt.status('WARN', `Failed to read verdict file: ${(err as Error).message}`));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const outcomeParams: CreateEventParams = { round, phase, actor: 'legacy' };
|
|
698
|
+
if (verdictContent && isValidVerdict(verdictContent)) {
|
|
699
|
+
outcomeParams.verdict = verdictContent;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const result = importLegacyArtifact(slug, 'review-outcome.md', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
703
|
+
outcomeParams,
|
|
704
|
+
{ ...options, worktree: rootDir });
|
|
705
|
+
|
|
706
|
+
if (result.ok) {
|
|
707
|
+
if (result.path) {
|
|
708
|
+
imported.push({ artifactName: 'review-outcome.md', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: result.legacyPath });
|
|
709
|
+
if (verdictPath && fs.existsSync(verdictPath)) {
|
|
710
|
+
imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: verdictPath, asMetadata: true });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
errors.push({ artifactName: 'review-outcome.md', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: result.error });
|
|
715
|
+
if (verdictPath && fs.existsSync(verdictPath)) {
|
|
716
|
+
const verdictResult = importLegacyArtifact(slug, 'review-verdict.txt', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
717
|
+
{ round, phase, actor: 'legacy' },
|
|
718
|
+
{ ...options, worktree: rootDir });
|
|
719
|
+
if (verdictResult.ok && verdictResult.path) {
|
|
720
|
+
imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: verdictResult.path, legacyPath: verdictResult.legacyPath });
|
|
721
|
+
} else if (!verdictResult.ok) {
|
|
722
|
+
errors.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: verdictResult.error });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
} else if (verdictPath && fs.existsSync(verdictPath)) {
|
|
727
|
+
let verdictContent = null;
|
|
728
|
+
try {
|
|
729
|
+
verdictContent = fs.readFileSync(verdictPath, 'utf8').trim();
|
|
730
|
+
} catch (err) {
|
|
731
|
+
error(fmt.status('WARN', `Failed to read verdict file: ${(err as Error).message}`));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const outcomeParams: CreateEventParams = { round, phase, actor: 'legacy' };
|
|
735
|
+
if (verdictContent && isValidVerdict(verdictContent)) {
|
|
736
|
+
outcomeParams.verdict = verdictContent;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const result = importLegacyArtifact(slug, 'review-verdict.txt', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
740
|
+
outcomeParams,
|
|
741
|
+
{ ...options, worktree: rootDir });
|
|
742
|
+
|
|
743
|
+
if (result.ok) {
|
|
744
|
+
if (result.path) {
|
|
745
|
+
imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: result.legacyPath });
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
errors.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: result.error });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
for (const artifactName of Object.keys(LEGACY_ARTIFACT_TO_EVENT_TYPE)) {
|
|
753
|
+
if (artifactName === 'review-outcome.md' || artifactName === 'review-verdict.txt') {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const result = importLegacyArtifact(slug, artifactName, LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName],
|
|
758
|
+
{ round, phase, actor: 'legacy' },
|
|
759
|
+
{ ...options, worktree: rootDir });
|
|
760
|
+
|
|
761
|
+
if (result.ok) {
|
|
762
|
+
if (result.path) {
|
|
763
|
+
imported.push({ artifactName, eventType: LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName], path: result.path, legacyPath: result.legacyPath });
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
errors.push({ artifactName, eventType: LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName], error: result.error });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return { ok: errors.length === 0, imported, errors };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// -------- Event Reading --------
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Read all review events for a mission.
|
|
777
|
+
*/
|
|
778
|
+
function readAllEvents(slug: string, options: ReadAllEventsOptions = {}): unknown[] {
|
|
779
|
+
const {
|
|
780
|
+
rootDir = process.cwd(),
|
|
781
|
+
readdirSync = fs.readdirSync,
|
|
782
|
+
readFileSync = fs.readFileSync,
|
|
783
|
+
error = fmt.log.plainError
|
|
784
|
+
} = options;
|
|
785
|
+
|
|
786
|
+
const eventsDir = reviewEventsDir(slug, rootDir);
|
|
787
|
+
if (!eventsDir || !fs.existsSync(eventsDir)) {
|
|
788
|
+
return [];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const events = [];
|
|
792
|
+
let files;
|
|
793
|
+
|
|
794
|
+
try {
|
|
795
|
+
files = readdirSync(eventsDir);
|
|
796
|
+
} catch (err) {
|
|
797
|
+
error(fmt.status('WARN', `Failed to read review-events directory: ${(err as Error).message}`));
|
|
798
|
+
return [];
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
for (const filename of files) {
|
|
802
|
+
if (!filename.endsWith('.md')) { continue; }
|
|
803
|
+
|
|
804
|
+
const filePath = path.join(eventsDir, filename);
|
|
805
|
+
let content;
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
content = readFileSync(filePath, 'utf8');
|
|
809
|
+
} catch (err) {
|
|
810
|
+
error(fmt.status('WARN', `Failed to read event file ${filename}: ${(err as Error).message}`));
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const event = parseEventFile(content, filePath);
|
|
815
|
+
if (event) {
|
|
816
|
+
events.push(event);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
events.sort((a, b) => {
|
|
821
|
+
const aTime = (a.timestamp as string) || (a.fileCreated as string) || '';
|
|
822
|
+
const bTime = (b.timestamp as string) || (b.fileCreated as string) || '';
|
|
823
|
+
return bTime.localeCompare(aTime);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
return events;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Parse an event file into a structured object.
|
|
831
|
+
*/
|
|
832
|
+
function parseEventFile(content: string, filePath: string): ParseEventResult {
|
|
833
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
834
|
+
|
|
835
|
+
const metadata: Record<string, unknown> = {};
|
|
836
|
+
let body = content;
|
|
837
|
+
|
|
838
|
+
if (frontmatterMatch) {
|
|
839
|
+
const frontmatterText = frontmatterMatch[1];
|
|
840
|
+
body = content.slice(frontmatterMatch[0].length);
|
|
841
|
+
|
|
842
|
+
const lines = frontmatterText.split('\n');
|
|
843
|
+
for (const line of lines) {
|
|
844
|
+
if (!line.trim()) { continue; }
|
|
845
|
+
const colonIdx = line.indexOf(':');
|
|
846
|
+
if (colonIdx === -1) { continue; }
|
|
847
|
+
|
|
848
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
849
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
850
|
+
|
|
851
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
852
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
853
|
+
metadata[key] = value.slice(1, -1);
|
|
854
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
855
|
+
try {
|
|
856
|
+
metadata[key] = JSON.parse(value);
|
|
857
|
+
} catch {
|
|
858
|
+
metadata[key] = value;
|
|
859
|
+
}
|
|
860
|
+
} else if (value === 'true') { metadata[key] = true; }
|
|
861
|
+
else if (value === 'false') { metadata[key] = false; }
|
|
862
|
+
else if (value !== '' && !isNaN(Number(value))) { metadata[key] = Number(value); }
|
|
863
|
+
else { metadata[key] = value; }
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (!metadata.event_type && filePath) {
|
|
868
|
+
const basename = path.basename(filePath, '.md');
|
|
869
|
+
const parts = basename.split('-');
|
|
870
|
+
if (parts.length >= 4) {
|
|
871
|
+
const possibleType = parts.slice(1, -2).join('-');
|
|
872
|
+
if (isValidEventType(possibleType)) {
|
|
873
|
+
metadata.event_type = possibleType;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const stat = fs.statSync(filePath);
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
...metadata,
|
|
882
|
+
content: body.trim(),
|
|
883
|
+
filePath,
|
|
884
|
+
fileCreated: stat.birthtime.toISOString(),
|
|
885
|
+
fileModified: stat.mtime.toISOString()
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export { shouldMirrorToProvider as shouldMirrorToForgejo };
|
|
890
|
+
|
|
891
|
+
// -------- Module Exports --------
|
|
892
|
+
|
|
893
|
+
// Taxonomy constants
|
|
894
|
+
export {
|
|
895
|
+
VALID_EVENT_TYPES,
|
|
896
|
+
ALL_EVENT_TYPES,
|
|
897
|
+
MIRRORED_EVENT_TYPES,
|
|
898
|
+
VALID_DISPOSITIONS,
|
|
899
|
+
VALID_VERDICTS
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// Validation
|
|
903
|
+
export {
|
|
904
|
+
isValidEventType,
|
|
905
|
+
isValidDisposition,
|
|
906
|
+
isValidVerdict,
|
|
907
|
+
shouldMirrorToProvider
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
// Path resolution
|
|
911
|
+
export {
|
|
912
|
+
reviewEventsDir,
|
|
913
|
+
eventFilePath,
|
|
914
|
+
legacyArtifactPath
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// Event creation
|
|
918
|
+
export {
|
|
919
|
+
createEvent,
|
|
920
|
+
normalizeEventContent
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
// Legacy compatibility
|
|
924
|
+
export {
|
|
925
|
+
importLegacyArtifact,
|
|
926
|
+
importAllLegacyArtifacts,
|
|
927
|
+
LEGACY_ARTIFACT_TO_EVENT_TYPE
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// Event reading
|
|
931
|
+
export {
|
|
932
|
+
readAllEvents,
|
|
933
|
+
parseEventFile
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
// Rendering
|
|
937
|
+
export {
|
|
938
|
+
buildEventFrontmatter,
|
|
939
|
+
buildEventFooter,
|
|
940
|
+
renderEventFile
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
// Classification
|
|
944
|
+
export {
|
|
945
|
+
hasWorkflowFooter,
|
|
946
|
+
classifyComment,
|
|
947
|
+
consumeHumanNotes
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
// Utilities
|
|
951
|
+
export {
|
|
952
|
+
generateEventTimestamp,
|
|
953
|
+
sanitizeFilename
|
|
954
|
+
};
|