@magnusekdahl/parallix 1.0.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/CHANGELOG.md +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- package/tools/setup-forgejo-docker.sh +84 -0
|
@@ -0,0 +1,1007 @@
|
|
|
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
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const { git } = require('../core/git');
|
|
17
|
+
const { findMissionDir, missionBranchName, resolveWorktree } = require('../core/mission-utils');
|
|
18
|
+
const { readReviewState } = require('./review-state');
|
|
19
|
+
const fmt = require('../core/fmt');
|
|
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
|
+
* Reviewer findings, reviewer outcome, implementer round summary, implementer disposition,
|
|
53
|
+
* and round-affecting human notes are mirrored.
|
|
54
|
+
* Raw draft artifacts, retry bookkeeping, classification metadata, and local diagnostics
|
|
55
|
+
* remain workflow-local by default.
|
|
56
|
+
*/
|
|
57
|
+
const MIRRORED_EVENT_TYPES = Object.freeze(new Set([
|
|
58
|
+
VALID_EVENT_TYPES.REVIEWER_FINDINGS,
|
|
59
|
+
VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
60
|
+
VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY,
|
|
61
|
+
VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION,
|
|
62
|
+
VALID_EVENT_TYPES.HUMAN_NOTE
|
|
63
|
+
]));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Valid disposition values for implementer disposition events.
|
|
67
|
+
*/
|
|
68
|
+
const VALID_DISPOSITIONS = Object.freeze([
|
|
69
|
+
'CHANGES_MADE',
|
|
70
|
+
'PUSHBACK_ALL',
|
|
71
|
+
'PARKED',
|
|
72
|
+
'BLOCKED'
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Valid review verdict values for reviewer outcome events.
|
|
77
|
+
*/
|
|
78
|
+
const VALID_VERDICTS = Object.freeze([
|
|
79
|
+
'approve',
|
|
80
|
+
'request-changes',
|
|
81
|
+
'comment'
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// -------- Path Resolution --------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns the absolute path to the mission-local review-events directory.
|
|
88
|
+
* Creates the directory if it does not exist.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} slug - Mission slug
|
|
91
|
+
* @param {string} [rootDir] - Root directory to resolve from (defaults to process.cwd())
|
|
92
|
+
* @returns {string|null} - Absolute path to review-events dir, or null if mission dir not found
|
|
93
|
+
*/
|
|
94
|
+
function reviewEventsDir(slug, rootDir = process.cwd()) {
|
|
95
|
+
const missionDir = findMissionDir(slug, rootDir);
|
|
96
|
+
if (!missionDir) return null;
|
|
97
|
+
const dir = path.join(missionDir, 'review-events');
|
|
98
|
+
if (!fs.existsSync(dir)) {
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
return dir;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Returns the path for a specific event file in the mission-local store.
|
|
106
|
+
*
|
|
107
|
+
* Filename pattern: <timestamp>-<classification>-<round>-<actor>.md
|
|
108
|
+
* timestamp: ISO 8601 date-time without colons (e.g., 2026-05-25T143022)
|
|
109
|
+
* classification: event type from VALID_EVENT_TYPES
|
|
110
|
+
* round: review round number
|
|
111
|
+
* actor: agent family name or 'human'
|
|
112
|
+
*
|
|
113
|
+
* @param {string} slug - Mission slug
|
|
114
|
+
* @param {string} eventType - One of VALID_EVENT_TYPES
|
|
115
|
+
* @param {number} round - Review round number
|
|
116
|
+
* @param {string} actor - Agent family or 'human'
|
|
117
|
+
* @param {string} [timestamp] - Optional ISO timestamp; defaults to now
|
|
118
|
+
* @param {string} [rootDir] - Root directory to resolve from
|
|
119
|
+
* @returns {string|null} - Absolute file path, or null if mission dir not found
|
|
120
|
+
*/
|
|
121
|
+
function eventFilePath(slug, eventType, round, actor, timestamp = null, rootDir = process.cwd()) {
|
|
122
|
+
const eventsDir = reviewEventsDir(slug, rootDir);
|
|
123
|
+
if (!eventsDir) return null;
|
|
124
|
+
|
|
125
|
+
const ts = timestamp || generateEventTimestamp();
|
|
126
|
+
const sanitizedActor = sanitizeFilename(actor);
|
|
127
|
+
const filename = `${ts}-${eventType}-${round}-${sanitizedActor}.md`;
|
|
128
|
+
return path.join(eventsDir, filename);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Returns the legacy /tmp/ artifact path for compatibility.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} slug - Mission slug
|
|
135
|
+
* @param {string} artifactName - e.g., 'review-findings.md'
|
|
136
|
+
* @param {string} [tmpDir] - Temp directory, defaults to os.tmpdir()
|
|
137
|
+
* @returns {string} - Full path to the /tmp/ artifact
|
|
138
|
+
*/
|
|
139
|
+
function legacyArtifactPath(slug, artifactName, tmpDir = os.tmpdir()) {
|
|
140
|
+
return path.join(tmpDir, `${slug}-${artifactName}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// -------- Timestamp & Sanitization --------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Generate a filesystem-safe ISO timestamp without colons.
|
|
147
|
+
* @returns {string} - e.g., '2026-05-25T143022'
|
|
148
|
+
*/
|
|
149
|
+
function generateEventTimestamp() {
|
|
150
|
+
const now = new Date();
|
|
151
|
+
const datePart = now.toISOString().split('T')[0];
|
|
152
|
+
const timePart = now.toISOString().split('T')[1].split('.')[0];
|
|
153
|
+
// Replace colons with nothing for filesystem safety
|
|
154
|
+
return `${datePart}T${timePart.replace(/:/g, '')}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Sanitize a string for use in a filename.
|
|
159
|
+
* Removes all non-alphanumeric characters except hyphens and underscores.
|
|
160
|
+
* @param {string} str - Input string
|
|
161
|
+
* @returns {string} - Filesystem-safe string
|
|
162
|
+
*/
|
|
163
|
+
function sanitizeFilename(str) {
|
|
164
|
+
if (!str) return 'unknown';
|
|
165
|
+
return String(str)
|
|
166
|
+
.toLowerCase()
|
|
167
|
+
// Replace @ and . with empty string first (common in emails/agent names)
|
|
168
|
+
.replace(/[@.]/g, '')
|
|
169
|
+
// Then replace any remaining non-alphanumeric/underscore/hyphen with hyphen
|
|
170
|
+
.replace(/[^a-z0-9_-]/g, '-')
|
|
171
|
+
.replace(/-+/g, '-')
|
|
172
|
+
.replace(/^-|-$/g, '');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// -------- Event Validation --------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate an event type against the taxonomy.
|
|
179
|
+
* @param {string} eventType - The type to validate
|
|
180
|
+
* @returns {boolean} - True if valid
|
|
181
|
+
*/
|
|
182
|
+
function isValidEventType(eventType) {
|
|
183
|
+
return ALL_EVENT_TYPES.includes(eventType);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Validate a disposition value.
|
|
188
|
+
* @param {string} disposition - The disposition to validate
|
|
189
|
+
* @returns {boolean} - True if valid
|
|
190
|
+
*/
|
|
191
|
+
function isValidDisposition(disposition) {
|
|
192
|
+
return VALID_DISPOSITIONS.includes(disposition);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Validate a verdict value.
|
|
197
|
+
* @param {string} verdict - The verdict to validate
|
|
198
|
+
* @returns {boolean} - True if valid
|
|
199
|
+
*/
|
|
200
|
+
function isValidVerdict(verdict) {
|
|
201
|
+
return VALID_VERDICTS.includes(verdict);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check if an event type should be mirrored to the review provider.
|
|
206
|
+
* @param {string} eventType - The event type
|
|
207
|
+
* @returns {boolean} - True if mirrored
|
|
208
|
+
*/
|
|
209
|
+
function shouldMirrorToProvider(eventType) {
|
|
210
|
+
return MIRRORED_EVENT_TYPES.has(eventType);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// -------- Event Structure --------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Normalize an event payload into a structured format.
|
|
217
|
+
* Extracts metadata from content and ensures required fields are present.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} content - The raw event content (markdown)
|
|
220
|
+
* @param {string} eventType - The event classification
|
|
221
|
+
* @param {Object} [metadata] - Additional metadata (round, phase, actor, etc.)
|
|
222
|
+
* @returns {Object} - Normalized event structure
|
|
223
|
+
*/
|
|
224
|
+
function normalizeEventContent(content, eventType, metadata = {}) {
|
|
225
|
+
const payload = { ...metadata };
|
|
226
|
+
|
|
227
|
+
// Ensure we have the base metadata
|
|
228
|
+
payload.eventType = eventType;
|
|
229
|
+
payload.timestamp = payload.timestamp || new Date().toISOString();
|
|
230
|
+
|
|
231
|
+
// Add content
|
|
232
|
+
payload.content = content;
|
|
233
|
+
|
|
234
|
+
return payload;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Build the frontmatter for an event file.
|
|
239
|
+
* Uses YAML-style frontmatter for structured metadata.
|
|
240
|
+
*
|
|
241
|
+
* @param {Object} event - Normalized event structure
|
|
242
|
+
* @returns {string} - Frontmatter block
|
|
243
|
+
*/
|
|
244
|
+
function buildEventFrontmatter(event) {
|
|
245
|
+
const lines = [
|
|
246
|
+
'---',
|
|
247
|
+
`event_type: ${event.eventType}`,
|
|
248
|
+
`timestamp: ${event.timestamp}`,
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
if (event.round !== undefined) {
|
|
252
|
+
lines.push(`round: ${event.round}`);
|
|
253
|
+
}
|
|
254
|
+
if (event.phase !== undefined) {
|
|
255
|
+
lines.push(`phase: ${event.phase}`);
|
|
256
|
+
}
|
|
257
|
+
if (event.actor !== undefined) {
|
|
258
|
+
lines.push(`actor: ${event.actor}`);
|
|
259
|
+
}
|
|
260
|
+
if (event.slug !== undefined) {
|
|
261
|
+
lines.push(`slug: ${event.slug}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Add event-specific fields
|
|
265
|
+
if (event.disposition !== undefined) {
|
|
266
|
+
lines.push(`disposition: ${event.disposition}`);
|
|
267
|
+
}
|
|
268
|
+
if (event.verdict !== undefined) {
|
|
269
|
+
lines.push(`verdict: ${event.verdict}`);
|
|
270
|
+
}
|
|
271
|
+
if (event.fixedItems !== undefined) {
|
|
272
|
+
lines.push(`fixed_items: ${JSON.stringify(event.fixedItems)}`);
|
|
273
|
+
}
|
|
274
|
+
if (event.pushedBackItems !== undefined) {
|
|
275
|
+
lines.push(`pushed_back_items: ${JSON.stringify(event.pushedBackItems)}`);
|
|
276
|
+
}
|
|
277
|
+
if (event.parkedItems !== undefined) {
|
|
278
|
+
lines.push(`parked_items: ${JSON.stringify(event.parkedItems)}`);
|
|
279
|
+
}
|
|
280
|
+
if (event.blockedReason !== undefined) {
|
|
281
|
+
lines.push(`blocked_reason: "${event.blockedReason.replace(/"/g, '\\"')}"`);
|
|
282
|
+
}
|
|
283
|
+
if (event.followUpReference !== undefined) {
|
|
284
|
+
lines.push(`followup_reference: ${event.followUpReference}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
lines.push('---');
|
|
288
|
+
return lines.join('\n');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Build the workflow metadata footer for an event.
|
|
293
|
+
* Matches the existing pattern in review.js:793-797
|
|
294
|
+
*
|
|
295
|
+
* @param {string} slug - Mission slug
|
|
296
|
+
* @param {number} round - Review round
|
|
297
|
+
* @param {string} phase - Current phase
|
|
298
|
+
* @returns {string} - Metadata footer
|
|
299
|
+
*/
|
|
300
|
+
function buildEventFooter(slug, round, phase) {
|
|
301
|
+
return `\n\n---\n\`[workflow-round:${round}, workflow-phase:${phase}]\``;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Check if a comment body contains the workflow metadata footer.
|
|
306
|
+
* Comments with this footer are workflow-generated; comments without are human.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} body - Comment body text
|
|
309
|
+
* @returns {boolean} - true if comment has workflow footer
|
|
310
|
+
*/
|
|
311
|
+
function hasWorkflowFooter(body) {
|
|
312
|
+
return /\`\[workflow-round:\d+, workflow-phase:[^\]]+\]\`/.test(body);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Classify a comment as either workflow-generated or human.
|
|
317
|
+
* Workflow-generated comments have the metadata footer; human comments do not.
|
|
318
|
+
*
|
|
319
|
+
* @param {Object} comment - Comment object with body field
|
|
320
|
+
* @returns {string|null} - 'human_note' if human, null if workflow-generated
|
|
321
|
+
*/
|
|
322
|
+
function classifyComment(comment) {
|
|
323
|
+
const body = comment.body || '';
|
|
324
|
+
// Workflow-generated comments have the metadata footer
|
|
325
|
+
if (hasWorkflowFooter(body)) {
|
|
326
|
+
return null; // Workflow-generated, not a human note
|
|
327
|
+
}
|
|
328
|
+
// Human comments (no workflow footer) are classified as human_note
|
|
329
|
+
return VALID_EVENT_TYPES.HUMAN_NOTE;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Consume human notes from provider PR comments.
|
|
334
|
+
* Reads PR comments, classifies human (non-workflow) comments, and creates
|
|
335
|
+
* human_note events for them. Human comments do not have the workflow metadata footer.
|
|
336
|
+
*
|
|
337
|
+
* This function satisfies SC 6: Human provider comments are classified as human_note
|
|
338
|
+
* or ignored as non-round-affecting input by an explicit workflow rule; neither path
|
|
339
|
+
* can mutate review-state.json without a workflow command that records a repo-owned event.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} slug - Mission slug
|
|
342
|
+
* @param {string} actor - Actor family (e.g., 'codex', 'mistral') or 'human'
|
|
343
|
+
* @param {Object} [options] - Options
|
|
344
|
+
* @param {Function} [options.getCommentsFn] - Function to get provider comments
|
|
345
|
+
* @param {Function} [options.createEventFn] - Function to create events (default: createEvent)
|
|
346
|
+
* @param {Function} [options.readTokenFn] - Function to read provider token
|
|
347
|
+
* @param {string} [options.worktree] - Working tree directory
|
|
348
|
+
* @param {Function} [options.log] - Log function
|
|
349
|
+
* @param {Function} [options.error] - Error function
|
|
350
|
+
* @returns {Object} - { ok: boolean, created: Array, skipped: Array }
|
|
351
|
+
*/
|
|
352
|
+
async function consumeHumanNotes(slug, actor, options = {}) {
|
|
353
|
+
const {
|
|
354
|
+
getCommentsFn,
|
|
355
|
+
createEventFn = createEvent,
|
|
356
|
+
readTokenFn,
|
|
357
|
+
reviewIdentity = null,
|
|
358
|
+
forgejoUser = null,
|
|
359
|
+
worktree,
|
|
360
|
+
log = fmt.log.plain,
|
|
361
|
+
error = fmt.log.plainError
|
|
362
|
+
} = options;
|
|
363
|
+
|
|
364
|
+
const actorIdentity = reviewIdentity || forgejoUser;
|
|
365
|
+
if (!actorIdentity) {
|
|
366
|
+
error(fmt.status('FAIL', 'Review identity is required for consumeHumanNotes.'));
|
|
367
|
+
return { ok: false, created: [], skipped: [], error: 'reviewIdentity required' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// In sandbox/Codex environments, the provider may be unreachable from Node subprocesses.
|
|
371
|
+
// This is acceptable - human note classification is best-effort
|
|
372
|
+
if (!getCommentsFn) {
|
|
373
|
+
log(fmt.status('INFO', 'consumeHumanNotes: no getCommentsFn provided; skipping human note classification.'));
|
|
374
|
+
return { ok: true, created: [], skipped: [] };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const token = readTokenFn ? readTokenFn(actorIdentity) : null;
|
|
378
|
+
if (!token) {
|
|
379
|
+
log(fmt.status('INFO', 'consumeHumanNotes: no provider token found; skipping human note classification.'));
|
|
380
|
+
return { ok: true, created: [], skipped: [] };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const branch = missionBranchName(slug, worktree || process.cwd());
|
|
384
|
+
const comments = await getCommentsFn(branch, token);
|
|
385
|
+
|
|
386
|
+
if (!comments || !Array.isArray(comments)) {
|
|
387
|
+
log(fmt.status('INFO', 'consumeHumanNotes: no comments retrieved; skipping classification.'));
|
|
388
|
+
return { ok: true, created: [], skipped: [] };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const created = [];
|
|
392
|
+
const skipped = [];
|
|
393
|
+
|
|
394
|
+
// Get current review state for round/phase metadata
|
|
395
|
+
const currentState = readReviewState(slug, worktree || process.cwd());
|
|
396
|
+
const round = currentState ? currentState.round : 1;
|
|
397
|
+
const phase = currentState ? currentState.phase : 'reviewing';
|
|
398
|
+
|
|
399
|
+
for (const comment of comments) {
|
|
400
|
+
// Skip workflow-generated comments (have the metadata footer)
|
|
401
|
+
if (hasWorkflowFooter(comment.body || '')) {
|
|
402
|
+
skipped.push({ user: comment.user, created: comment.created, reason: 'workflow-generated' });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Classify as human_note
|
|
407
|
+
const classification = classifyComment(comment);
|
|
408
|
+
if (!classification) {
|
|
409
|
+
skipped.push({ user: comment.user, created: comment.created, reason: 'already-classified' });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Create human_note event
|
|
414
|
+
const result = createEventFn(slug, classification, {
|
|
415
|
+
content: comment.body || '',
|
|
416
|
+
round,
|
|
417
|
+
phase,
|
|
418
|
+
actor: actor || comment.user || 'human'
|
|
419
|
+
}, {
|
|
420
|
+
worktree: worktree || process.cwd(),
|
|
421
|
+
skipGit: true,
|
|
422
|
+
log,
|
|
423
|
+
error
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
if (result.ok) {
|
|
427
|
+
created.push({ path: result.path, user: comment.user, created: comment.created });
|
|
428
|
+
} else {
|
|
429
|
+
error(fmt.status('WARN', `Failed to create human_note event for comment by ${comment.user}: ${result.error}`));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
log(fmt.status('INFO', `consumeHumanNotes: created ${created.length} human_note events, skipped ${skipped.length} workflow comments`));
|
|
434
|
+
return { ok: true, created, skipped };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Render a complete event file content.
|
|
439
|
+
*
|
|
440
|
+
* @param {Object} event - Normalized event structure
|
|
441
|
+
* @returns {string} - Complete file content
|
|
442
|
+
*/
|
|
443
|
+
function renderEventFile(event) {
|
|
444
|
+
const frontmatter = buildEventFrontmatter(event);
|
|
445
|
+
const content = event.content || '';
|
|
446
|
+
|
|
447
|
+
// If the content already has a metadata footer, don't add another
|
|
448
|
+
const hasFooter = /\n\n---\n`\[workflow-round:\d+, workflow-phase:[^\]]+\]`/.test(content);
|
|
449
|
+
|
|
450
|
+
if (hasFooter) {
|
|
451
|
+
return `${frontmatter}\n\n${content}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Add footer if we have round and phase
|
|
455
|
+
const footer = event.round !== undefined && event.phase !== undefined
|
|
456
|
+
? buildEventFooter(event.slug, event.round, event.phase)
|
|
457
|
+
: '';
|
|
458
|
+
|
|
459
|
+
return `${frontmatter}\n\n${content}${footer}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// -------- Event Creation --------
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Create and persist a classified review event.
|
|
466
|
+
*
|
|
467
|
+
* @param {string} slug - Mission slug
|
|
468
|
+
* @param {string} eventType - One of VALID_EVENT_TYPES
|
|
469
|
+
* @param {Object} params - Event parameters
|
|
470
|
+
* @param {string} params.content - The event content (markdown)
|
|
471
|
+
* @param {number} [params.round] - Review round number (from state)
|
|
472
|
+
* @param {string} [params.phase] - Current phase (from state)
|
|
473
|
+
* @param {string} [params.actor] - Agent family or 'human'
|
|
474
|
+
* @param {string} [params.disposition] - For implementer disposition events
|
|
475
|
+
* @param {string} [params.verdict] - For reviewer outcome events
|
|
476
|
+
* @param {Array} [params.fixedItems] - For implementer round summary
|
|
477
|
+
* @param {Array} [params.pushedBackItems] - For implementer round summary
|
|
478
|
+
* @param {Array} [params.parkedItems] - For implementer round summary
|
|
479
|
+
* @param {string} [params.blockedReason] - For BLOCKED events
|
|
480
|
+
* @param {string} [params.followUpReference] - For PARKED_FOLLOWUP events
|
|
481
|
+
* @param {string} [params.timestamp] - Custom timestamp
|
|
482
|
+
* @param {string} [rootDir] - Root directory
|
|
483
|
+
* @param {Object} [options] - Additional options
|
|
484
|
+
* @param {boolean} [options.skipGit] - Skip git add/commit
|
|
485
|
+
* @param {Function} [options.gitFn] - Git function override for testing
|
|
486
|
+
* @returns {Object} - { ok: boolean, path: string|null, error: string|null }
|
|
487
|
+
*/
|
|
488
|
+
function createEvent(slug, eventType, params, options = {}) {
|
|
489
|
+
const {
|
|
490
|
+
skipGit = false,
|
|
491
|
+
gitFn = git,
|
|
492
|
+
log = fmt.log.plain,
|
|
493
|
+
error = fmt.log.plainError,
|
|
494
|
+
worktree,
|
|
495
|
+
allowMissingRequiredFields = false
|
|
496
|
+
} = options;
|
|
497
|
+
|
|
498
|
+
const rootDir = worktree || resolveWorktree(slug) || process.cwd();
|
|
499
|
+
|
|
500
|
+
// Validate event type
|
|
501
|
+
if (!isValidEventType(eventType)) {
|
|
502
|
+
error(fmt.status('FAIL', `Invalid event type "${eventType}". Valid types: ${ALL_EVENT_TYPES.join(', ')}`));
|
|
503
|
+
return { ok: false, path: null, error: `Invalid event type: ${eventType}` };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Validate disposition if provided
|
|
507
|
+
if (params.disposition !== undefined && !isValidDisposition(params.disposition)) {
|
|
508
|
+
error(fmt.status('FAIL', `Invalid disposition "${params.disposition}". Valid: ${VALID_DISPOSITIONS.join(', ')}`));
|
|
509
|
+
return { ok: false, path: null, error: `Invalid disposition: ${params.disposition}` };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Validate verdict if provided
|
|
513
|
+
if (params.verdict !== undefined && !isValidVerdict(params.verdict)) {
|
|
514
|
+
error(fmt.status('FAIL', `Invalid verdict "${params.verdict}". Valid: ${VALID_VERDICTS.join(', ')}`));
|
|
515
|
+
return { ok: false, path: null, error: `Invalid verdict: ${params.verdict}` };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Validate required fields for specific event types
|
|
519
|
+
// Skip validation for legacy imports which may not have all required fields
|
|
520
|
+
if (!allowMissingRequiredFields) {
|
|
521
|
+
if (eventType === VALID_EVENT_TYPES.REVIEWER_OUTCOME && !params.verdict) {
|
|
522
|
+
error(fmt.status('FAIL', `reviewer_outcome event requires --verdict`));
|
|
523
|
+
return { ok: false, path: null, error: 'reviewer_outcome event requires verdict' };
|
|
524
|
+
}
|
|
525
|
+
if (eventType === VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION && !params.disposition) {
|
|
526
|
+
error(fmt.status('FAIL', `implementer_disposition event requires --disposition`));
|
|
527
|
+
return { ok: false, path: null, error: 'implementer_disposition event requires disposition' };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Get current review state for round/phase if not provided
|
|
532
|
+
let state;
|
|
533
|
+
if (params.round === undefined || params.phase === undefined) {
|
|
534
|
+
state = readReviewState(slug, rootDir);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const round = params.round !== undefined ? params.round : (state ? state.round : 1);
|
|
538
|
+
const phase = params.phase !== undefined ? params.phase : (state ? state.phase : 'reviewing');
|
|
539
|
+
const actor = params.actor || 'unknown';
|
|
540
|
+
|
|
541
|
+
// Build event structure
|
|
542
|
+
const event = normalizeEventContent(params.content || '', eventType, {
|
|
543
|
+
slug,
|
|
544
|
+
round,
|
|
545
|
+
phase,
|
|
546
|
+
actor,
|
|
547
|
+
eventType,
|
|
548
|
+
timestamp: params.timestamp,
|
|
549
|
+
disposition: params.disposition,
|
|
550
|
+
verdict: params.verdict,
|
|
551
|
+
fixedItems: params.fixedItems,
|
|
552
|
+
pushedBackItems: params.pushedBackItems,
|
|
553
|
+
parkedItems: params.parkedItems,
|
|
554
|
+
blockedReason: params.blockedReason,
|
|
555
|
+
followUpReference: params.followUpReference
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Determine file path
|
|
559
|
+
const filePath = eventFilePath(slug, eventType, round, actor, params.timestamp, rootDir);
|
|
560
|
+
if (!filePath) {
|
|
561
|
+
error(fmt.status('FAIL', `Cannot resolve mission directory for slug "${slug}"`));
|
|
562
|
+
return { ok: false, path: null, error: `Mission directory not found` };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Ensure parent directory exists
|
|
566
|
+
const dir = path.dirname(filePath);
|
|
567
|
+
if (!fs.existsSync(dir)) {
|
|
568
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Render and write
|
|
572
|
+
const content = renderEventFile(event);
|
|
573
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
574
|
+
|
|
575
|
+
log(fmt.status('PASS', `Created review event: ${path.basename(filePath)}`));
|
|
576
|
+
|
|
577
|
+
// Git add and commit
|
|
578
|
+
if (!skipGit) {
|
|
579
|
+
const relPath = path.relative(rootDir, filePath);
|
|
580
|
+
const result = gitFn(['-C', rootDir, 'add', relPath]);
|
|
581
|
+
if (result.status !== 0) {
|
|
582
|
+
error(fmt.status('WARN', `Failed to git add event file: ${result.stderr}`));
|
|
583
|
+
} else {
|
|
584
|
+
const commitMsg = `review-event(${slug}): ${eventType} round ${round} (${phase}) [${actor}]`;
|
|
585
|
+
const commitResult = gitFn(['-C', rootDir, 'commit', '-m', commitMsg, '--allow-empty']);
|
|
586
|
+
if (commitResult.status !== 0) {
|
|
587
|
+
error(fmt.status('WARN', `Failed to commit event file: ${commitResult.stderr}`));
|
|
588
|
+
} else {
|
|
589
|
+
log(fmt.status('PASS', `Committed review event to git`));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return { ok: true, path: filePath, event };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Import an event from a legacy /tmp/ artifact file.
|
|
599
|
+
* Normalizes the content and stores it in the repo-owned mission store.
|
|
600
|
+
*
|
|
601
|
+
* @param {string} slug - Mission slug
|
|
602
|
+
* @param {string} artifactName - e.g., 'review-findings.md', 'round-resolution.md'
|
|
603
|
+
* @param {string} eventType - The classification to assign
|
|
604
|
+
* @param {Object} [params] - Additional parameters (round, phase, actor, etc.)
|
|
605
|
+
* @param {Object} [options] - Options (same as createEvent)
|
|
606
|
+
* @returns {Object} - { ok: boolean, path: string|null, importedFrom: string|null, error: string|null }
|
|
607
|
+
*/
|
|
608
|
+
function importLegacyArtifact(slug, artifactName, eventType, params = {}, options = {}) {
|
|
609
|
+
const {
|
|
610
|
+
tmpDir = os.tmpdir(),
|
|
611
|
+
log = fmt.log.plain,
|
|
612
|
+
error = fmt.log.plainError,
|
|
613
|
+
worktree
|
|
614
|
+
} = options;
|
|
615
|
+
|
|
616
|
+
const rootDir = worktree || resolveWorktree(slug) || process.cwd();
|
|
617
|
+
const legacyPath = legacyArtifactPath(slug, artifactName, tmpDir);
|
|
618
|
+
|
|
619
|
+
// Check if legacy file exists
|
|
620
|
+
if (!fs.existsSync(legacyPath)) {
|
|
621
|
+
log(fmt.status('INFO', `Legacy artifact not found: ${legacyPath}`));
|
|
622
|
+
return { ok: true, path: null, importedFrom: legacyPath, skipped: true };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Read legacy content
|
|
626
|
+
let content;
|
|
627
|
+
try {
|
|
628
|
+
content = fs.readFileSync(legacyPath, 'utf8');
|
|
629
|
+
} catch (err) {
|
|
630
|
+
error(fmt.status('FAIL', `Failed to read legacy artifact: ${err.message}`));
|
|
631
|
+
return { ok: false, path: null, importedFrom: legacyPath, error: err.message };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Create the normalized event
|
|
635
|
+
// Allow missing required fields for legacy imports (they may not have verdict/disposition)
|
|
636
|
+
const result = createEvent(slug, eventType, {
|
|
637
|
+
content,
|
|
638
|
+
...params,
|
|
639
|
+
// Default actor from artifact name
|
|
640
|
+
actor: params.actor || inferActorFromArtifact(artifactName)
|
|
641
|
+
}, { ...options, worktree: rootDir, allowMissingRequiredFields: true });
|
|
642
|
+
|
|
643
|
+
if (!result.ok) {
|
|
644
|
+
return { ok: false, path: null, importedFrom: legacyPath, error: result.error };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
log(fmt.status('PASS', `Imported legacy artifact ${artifactName} to ${path.basename(result.path)}`));
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
ok: true,
|
|
651
|
+
path: result.path,
|
|
652
|
+
importedFrom: legacyPath,
|
|
653
|
+
event: result.event,
|
|
654
|
+
legacyPath
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Infer actor from legacy artifact name (best guess).
|
|
660
|
+
* In practice, the actor should be passed explicitly from the workflow context.
|
|
661
|
+
*/
|
|
662
|
+
function inferActorFromArtifact(artifactName) {
|
|
663
|
+
// Can't reliably infer from artifact name alone
|
|
664
|
+
return 'unknown';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Map legacy artifact names to event types.
|
|
669
|
+
*/
|
|
670
|
+
const LEGACY_ARTIFACT_TO_EVENT_TYPE = Object.freeze({
|
|
671
|
+
'review-findings.md': VALID_EVENT_TYPES.REVIEWER_FINDINGS,
|
|
672
|
+
'review-outcome.md': VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
673
|
+
'review-verdict.txt': VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
674
|
+
'round-resolution.md': VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY,
|
|
675
|
+
'review-disposition.txt': VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Import all legacy /tmp/ artifacts for a mission.
|
|
680
|
+
* Used during the transition to repo-owned storage.
|
|
681
|
+
*
|
|
682
|
+
* Special handling: review-verdict.txt is treated as metadata (verdict field)
|
|
683
|
+
* for review-outcome.md, not as a standalone reviewer_outcome event.
|
|
684
|
+
* This prevents duplicate reviewer_outcome events.
|
|
685
|
+
*
|
|
686
|
+
* @param {string} slug - Mission slug
|
|
687
|
+
* @param {Object} [options] - Options
|
|
688
|
+
* @returns {Object} - { ok: boolean, imported: Array, errors: Array }
|
|
689
|
+
*/
|
|
690
|
+
function importAllLegacyArtifacts(slug, options = {}) {
|
|
691
|
+
const {
|
|
692
|
+
tmpDir = os.tmpdir(),
|
|
693
|
+
log = fmt.log.plain,
|
|
694
|
+
error = fmt.log.plainError,
|
|
695
|
+
worktree
|
|
696
|
+
} = options;
|
|
697
|
+
|
|
698
|
+
const rootDir = worktree || resolveWorktree(slug) || process.cwd();
|
|
699
|
+
const imported = [];
|
|
700
|
+
const errors = [];
|
|
701
|
+
|
|
702
|
+
// Get current review state for round/phase
|
|
703
|
+
const state = readReviewState(slug, rootDir);
|
|
704
|
+
const round = state ? state.round : 1;
|
|
705
|
+
const phase = state ? state.phase : 'reviewing';
|
|
706
|
+
|
|
707
|
+
// Track which legacy files exist
|
|
708
|
+
const legacyFiles = {};
|
|
709
|
+
for (const artifactName of Object.keys(LEGACY_ARTIFACT_TO_EVENT_TYPE)) {
|
|
710
|
+
const legacyPath = legacyArtifactPath(slug, artifactName, tmpDir);
|
|
711
|
+
legacyFiles[artifactName] = fs.existsSync(legacyPath) ? legacyPath : null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Special case: handle review-outcome.md and review-verdict.txt together
|
|
715
|
+
// review-verdict.txt contains the verdict metadata for review-outcome.md
|
|
716
|
+
const outcomePath = legacyFiles['review-outcome.md'];
|
|
717
|
+
const verdictPath = legacyFiles['review-verdict.txt'];
|
|
718
|
+
|
|
719
|
+
if (outcomePath && fs.existsSync(outcomePath)) {
|
|
720
|
+
// Read verdict content if it exists
|
|
721
|
+
let verdictContent = null;
|
|
722
|
+
if (verdictPath) {
|
|
723
|
+
try {
|
|
724
|
+
if (fs.existsSync(verdictPath)) {
|
|
725
|
+
verdictContent = fs.readFileSync(verdictPath, 'utf8').trim();
|
|
726
|
+
}
|
|
727
|
+
} catch (err) {
|
|
728
|
+
error(fmt.status('WARN', `Failed to read verdict file: ${err.message}`));
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Import review-outcome.md with verdict as metadata (only include verdict if valid)
|
|
733
|
+
const outcomeParams = { round, phase, actor: 'legacy' };
|
|
734
|
+
if (verdictContent && isValidVerdict(verdictContent)) {
|
|
735
|
+
outcomeParams.verdict = verdictContent;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const result = importLegacyArtifact(slug, 'review-outcome.md', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
739
|
+
outcomeParams,
|
|
740
|
+
{ ...options, worktree: rootDir });
|
|
741
|
+
|
|
742
|
+
if (result.ok) {
|
|
743
|
+
if (result.path) {
|
|
744
|
+
imported.push({ artifactName: 'review-outcome.md', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: result.legacyPath });
|
|
745
|
+
// Mark review-verdict.txt as imported (as metadata) even though it wasn't a separate event
|
|
746
|
+
if (verdictPath && fs.existsSync(verdictPath)) {
|
|
747
|
+
imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: verdictPath, asMetadata: true });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
errors.push({ artifactName: 'review-outcome.md', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: result.error });
|
|
752
|
+
// If outcome import fails and verdict exists separately, still try to import it
|
|
753
|
+
if (verdictPath && fs.existsSync(verdictPath)) {
|
|
754
|
+
const verdictResult = importLegacyArtifact(slug, 'review-verdict.txt', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
755
|
+
{ round, phase, actor: 'legacy' },
|
|
756
|
+
{ ...options, worktree: rootDir });
|
|
757
|
+
if (verdictResult.ok && verdictResult.path) {
|
|
758
|
+
imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: verdictResult.path, legacyPath: verdictResult.legacyPath });
|
|
759
|
+
} else if (!verdictResult.ok) {
|
|
760
|
+
errors.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: verdictResult.error });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
} else if (verdictPath && fs.existsSync(verdictPath)) {
|
|
765
|
+
// Only review-verdict.txt exists (no review-outcome.md) - import it as a standalone event
|
|
766
|
+
// This is a fallback for incomplete artifact sets
|
|
767
|
+
// Read the verdict content from the file to use as the verdict param
|
|
768
|
+
let verdictContent = null;
|
|
769
|
+
try {
|
|
770
|
+
verdictContent = fs.readFileSync(verdictPath, 'utf8').trim();
|
|
771
|
+
} catch (err) {
|
|
772
|
+
error(fmt.status('WARN', `Failed to read verdict file: ${err.message}`));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const outcomeParams = { round, phase, actor: 'legacy' };
|
|
776
|
+
if (verdictContent && isValidVerdict(verdictContent)) {
|
|
777
|
+
outcomeParams.verdict = verdictContent;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const result = importLegacyArtifact(slug, 'review-verdict.txt', VALID_EVENT_TYPES.REVIEWER_OUTCOME,
|
|
781
|
+
outcomeParams,
|
|
782
|
+
{ ...options, worktree: rootDir });
|
|
783
|
+
|
|
784
|
+
if (result.ok) {
|
|
785
|
+
if (result.path) {
|
|
786
|
+
imported.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, path: result.path, legacyPath: result.legacyPath });
|
|
787
|
+
}
|
|
788
|
+
} else {
|
|
789
|
+
errors.push({ artifactName: 'review-verdict.txt', eventType: VALID_EVENT_TYPES.REVIEWER_OUTCOME, error: result.error });
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Import all other artifacts normally
|
|
794
|
+
for (const artifactName of Object.keys(LEGACY_ARTIFACT_TO_EVENT_TYPE)) {
|
|
795
|
+
// Skip review-outcome.md and review-verdict.txt as they were handled above
|
|
796
|
+
if (artifactName === 'review-outcome.md' || artifactName === 'review-verdict.txt') {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const result = importLegacyArtifact(slug, artifactName, LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName],
|
|
801
|
+
{ round, phase, actor: 'legacy' },
|
|
802
|
+
{ ...options, worktree: rootDir });
|
|
803
|
+
|
|
804
|
+
if (result.ok) {
|
|
805
|
+
if (result.path) {
|
|
806
|
+
imported.push({ artifactName, eventType: LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName], path: result.path, legacyPath: result.legacyPath });
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
errors.push({ artifactName, eventType: LEGACY_ARTIFACT_TO_EVENT_TYPE[artifactName], error: result.error });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return { ok: errors.length === 0, imported, errors };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// -------- Event Reading --------
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Read all review events for a mission.
|
|
820
|
+
*
|
|
821
|
+
* @param {string} slug - Mission slug
|
|
822
|
+
* @param {Object} [options] - Options
|
|
823
|
+
* @param {string} [options.rootDir] - Root directory
|
|
824
|
+
* @param {Function} [options.readdirSync] - readdirSync override for testing
|
|
825
|
+
* @param {Function} [options.readFileSync] - readFileSync override for testing
|
|
826
|
+
* @returns {Array} - Array of parsed event objects
|
|
827
|
+
*/
|
|
828
|
+
function readAllEvents(slug, options = {}) {
|
|
829
|
+
const {
|
|
830
|
+
rootDir = process.cwd(),
|
|
831
|
+
readdirSync = fs.readdirSync,
|
|
832
|
+
readFileSync = fs.readFileSync,
|
|
833
|
+
log = fmt.log.plain,
|
|
834
|
+
error = fmt.log.plainError
|
|
835
|
+
} = options;
|
|
836
|
+
|
|
837
|
+
const eventsDir = reviewEventsDir(slug, rootDir);
|
|
838
|
+
if (!eventsDir || !fs.existsSync(eventsDir)) {
|
|
839
|
+
return [];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const events = [];
|
|
843
|
+
let files;
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
files = readdirSync(eventsDir);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
error(fmt.status('WARN', `Failed to read review-events directory: ${err.message}`));
|
|
849
|
+
return [];
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
for (const filename of files) {
|
|
853
|
+
if (!filename.endsWith('.md')) continue;
|
|
854
|
+
|
|
855
|
+
const filePath = path.join(eventsDir, filename);
|
|
856
|
+
let content;
|
|
857
|
+
|
|
858
|
+
try {
|
|
859
|
+
content = readFileSync(filePath, 'utf8');
|
|
860
|
+
} catch (err) {
|
|
861
|
+
error(fmt.status('WARN', `Failed to read event file ${filename}: ${err.message}`));
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const event = parseEventFile(content, filePath);
|
|
866
|
+
if (event) {
|
|
867
|
+
events.push(event);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Sort by timestamp descending (newest first)
|
|
872
|
+
events.sort((a, b) => {
|
|
873
|
+
const aTime = a.timestamp || a.fileCreated || '';
|
|
874
|
+
const bTime = b.timestamp || b.fileCreated || '';
|
|
875
|
+
return bTime.localeCompare(aTime);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
return events;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Parse an event file into a structured object.
|
|
883
|
+
* Extracts frontmatter metadata and content body.
|
|
884
|
+
*
|
|
885
|
+
* @param {string} content - File content
|
|
886
|
+
* @param {string} filePath - File path (for fallback metadata)
|
|
887
|
+
* @returns {Object|null} - Parsed event or null
|
|
888
|
+
*/
|
|
889
|
+
function parseEventFile(content, filePath) {
|
|
890
|
+
// Try to parse frontmatter
|
|
891
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
892
|
+
|
|
893
|
+
const metadata = {};
|
|
894
|
+
let body = content;
|
|
895
|
+
|
|
896
|
+
if (frontmatterMatch) {
|
|
897
|
+
const frontmatterText = frontmatterMatch[1];
|
|
898
|
+
body = content.slice(frontmatterMatch[0].length);
|
|
899
|
+
|
|
900
|
+
// Parse YAML-like frontmatter
|
|
901
|
+
const lines = frontmatterText.split('\n');
|
|
902
|
+
for (const line of lines) {
|
|
903
|
+
if (!line.trim()) continue;
|
|
904
|
+
const colonIdx = line.indexOf(':');
|
|
905
|
+
if (colonIdx === -1) continue;
|
|
906
|
+
|
|
907
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
908
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
909
|
+
|
|
910
|
+
// Handle quoted strings
|
|
911
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
912
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
913
|
+
metadata[key] = value.slice(1, -1);
|
|
914
|
+
}
|
|
915
|
+
// Handle JSON arrays
|
|
916
|
+
else if (value.startsWith('[') && value.endsWith(']')) {
|
|
917
|
+
try {
|
|
918
|
+
metadata[key] = JSON.parse(value);
|
|
919
|
+
} catch (_) {
|
|
920
|
+
metadata[key] = value;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// Handle booleans and numbers
|
|
924
|
+
else if (value === 'true') metadata[key] = true;
|
|
925
|
+
else if (value === 'false') metadata[key] = false;
|
|
926
|
+
else if (!isNaN(value)) metadata[key] = Number(value);
|
|
927
|
+
else metadata[key] = value;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Extract from filename if frontmatter missing
|
|
932
|
+
if (!metadata.event_type && filePath) {
|
|
933
|
+
const basename = path.basename(filePath, '.md');
|
|
934
|
+
// Pattern: <timestamp>-<eventType>-<round>-<actor>
|
|
935
|
+
const parts = basename.split('-');
|
|
936
|
+
if (parts.length >= 4) {
|
|
937
|
+
// eventType is typically the second part after timestamp
|
|
938
|
+
const possibleType = parts.slice(1, -2).join('-');
|
|
939
|
+
if (isValidEventType(possibleType)) {
|
|
940
|
+
metadata.event_type = possibleType;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Add file metadata
|
|
946
|
+
const stat = fs.statSync(filePath);
|
|
947
|
+
|
|
948
|
+
return {
|
|
949
|
+
...metadata,
|
|
950
|
+
content: body.trim(),
|
|
951
|
+
filePath,
|
|
952
|
+
fileCreated: stat.birthtime.toISOString(),
|
|
953
|
+
fileModified: stat.mtime.toISOString()
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const shouldMirrorToForgejo = shouldMirrorToProvider;
|
|
958
|
+
|
|
959
|
+
// -------- Module Exports --------
|
|
960
|
+
|
|
961
|
+
module.exports = {
|
|
962
|
+
// Taxonomy constants
|
|
963
|
+
VALID_EVENT_TYPES,
|
|
964
|
+
ALL_EVENT_TYPES,
|
|
965
|
+
MIRRORED_EVENT_TYPES,
|
|
966
|
+
VALID_DISPOSITIONS,
|
|
967
|
+
VALID_VERDICTS,
|
|
968
|
+
|
|
969
|
+
// Validation
|
|
970
|
+
isValidEventType,
|
|
971
|
+
isValidDisposition,
|
|
972
|
+
isValidVerdict,
|
|
973
|
+
shouldMirrorToProvider,
|
|
974
|
+
shouldMirrorToForgejo,
|
|
975
|
+
|
|
976
|
+
// Path resolution
|
|
977
|
+
reviewEventsDir,
|
|
978
|
+
eventFilePath,
|
|
979
|
+
legacyArtifactPath,
|
|
980
|
+
|
|
981
|
+
// Event creation
|
|
982
|
+
createEvent,
|
|
983
|
+
normalizeEventContent,
|
|
984
|
+
|
|
985
|
+
// Legacy compatibility
|
|
986
|
+
importLegacyArtifact,
|
|
987
|
+
importAllLegacyArtifacts,
|
|
988
|
+
LEGACY_ARTIFACT_TO_EVENT_TYPE,
|
|
989
|
+
|
|
990
|
+
// Event reading
|
|
991
|
+
readAllEvents,
|
|
992
|
+
parseEventFile,
|
|
993
|
+
|
|
994
|
+
// Rendering
|
|
995
|
+
buildEventFrontmatter,
|
|
996
|
+
buildEventFooter,
|
|
997
|
+
renderEventFile,
|
|
998
|
+
|
|
999
|
+
// Classification
|
|
1000
|
+
hasWorkflowFooter,
|
|
1001
|
+
classifyComment,
|
|
1002
|
+
consumeHumanNotes,
|
|
1003
|
+
|
|
1004
|
+
// Utilities
|
|
1005
|
+
generateEventTimestamp,
|
|
1006
|
+
sanitizeFilename,
|
|
1007
|
+
};
|