@ai-content-space/loopx 0.2.4 → 0.2.8
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/README.md +108 -12
- package/README.zh-CN.md +109 -13
- package/docs/loopx/design/finish/345/255/246/344/271/240/345/256/241/350/256/241/351/234/200/346/261/202/350/256/276/350/256/241/346/226/207/346/241/243.md +707 -0
- package/docs/loopx/design/loopx-skill-suite-v1-design.md +4 -4
- package/docs/loopx/memory/2026-06-09-stale-archive-hook-guidance.md +15 -0
- package/docs/loopx/memory/README.md +25 -0
- package/docs/loopx/plans/2026-06-08-finish-audit-change-window.md +933 -0
- package/docs/loopx/plans/2026-06-08-finish-learning-audit.md +410 -0
- package/docs/loopx/plans/2026-06-09-cli-onboarding-install-surface.md +1277 -0
- package/docs/loopx/plans/loopx-skill-suite-v1-implementation.md +1 -1
- package/docs/loopx/specs/installation.md +33 -0
- package/package.json +18 -2
- package/plugins/loopx/.codex-plugin/plugin.json +1 -1
- package/plugins/loopx/skills/clarify/SKILL.md +3 -3
- package/plugins/loopx/skills/debug/SKILL.md +1 -1
- package/plugins/loopx/skills/doc-readability/SKILL.md +1 -1
- package/plugins/loopx/skills/exec/SKILL.md +12 -2
- package/plugins/loopx/skills/final-review/SKILL.md +1 -1
- package/plugins/loopx/skills/finish/SKILL.md +39 -7
- package/plugins/loopx/skills/fix-review/SKILL.md +1 -1
- package/plugins/loopx/skills/go-style/SKILL.md +1 -1
- package/plugins/loopx/skills/kratos/SKILL.md +1 -1
- package/plugins/loopx/skills/{plan → plan-to-exec}/SKILL.md +5 -5
- package/plugins/loopx/skills/refactor-plan/SKILL.md +1 -1
- package/plugins/loopx/skills/review/SKILL.md +1 -1
- package/plugins/loopx/skills/spec/DESIGN_SPEC_TEMPLATE.md +2 -2
- package/plugins/loopx/skills/spec/SKILL.md +4 -4
- package/plugins/loopx/skills/subagent-exec/SKILL.md +14 -2
- package/plugins/loopx/skills/tdd/SKILL.md +1 -1
- package/plugins/loopx/skills/verify/SKILL.md +1 -1
- package/scripts/claude-workflow-hook.mjs +52 -3
- package/scripts/codex-workflow-hook.mjs +36 -15
- package/scripts/install-skills.mjs +58 -3
- package/scripts/verify-skills.mjs +83 -7
- package/skills/RESOLVER.md +4 -4
- package/skills/clarify/SKILL.md +3 -3
- package/skills/debug/SKILL.md +1 -1
- package/skills/doc-readability/SKILL.md +1 -1
- package/skills/exec/SKILL.md +12 -2
- package/skills/final-review/SKILL.md +1 -1
- package/skills/finish/SKILL.md +39 -7
- package/skills/fix-review/SKILL.md +1 -1
- package/skills/go-style/SKILL.md +1 -1
- package/skills/kratos/SKILL.md +1 -1
- package/skills/{plan → plan-to-exec}/SKILL.md +5 -5
- package/skills/refactor-plan/SKILL.md +1 -1
- package/skills/review/SKILL.md +1 -1
- package/skills/spec/DESIGN_SPEC_TEMPLATE.md +2 -2
- package/skills/spec/SKILL.md +4 -4
- package/skills/subagent-exec/SKILL.md +14 -2
- package/skills/tdd/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/src/cli.mjs +473 -86
- package/src/finish-runtime.mjs +1184 -0
- package/src/install-discovery.mjs +38 -1
- package/src/next-skill.mjs +10 -12
- package/src/workflow.mjs +21 -28
- package/skills/deepsearch/SKILL.md +0 -38
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const FINISH_SCHEMA_VERSION = 1;
|
|
8
|
+
const DEFAULT_NO_CANDIDATES_REASON = 'No accepted or rejected candidates were recorded at audit start.';
|
|
9
|
+
const MAX_AUDIT_ID_COLLISIONS = 1000;
|
|
10
|
+
const FINISH_RECORD_STATE_STATUSES = ['needs-agent-audit', 'audited', 'choice-recorded', 'completed', 'failed'];
|
|
11
|
+
const EXTRACTION_SURFACE_PREFIXES = [
|
|
12
|
+
'src',
|
|
13
|
+
'skills',
|
|
14
|
+
'plugins/loopx/skills',
|
|
15
|
+
'scripts',
|
|
16
|
+
'templates',
|
|
17
|
+
'docs',
|
|
18
|
+
'test',
|
|
19
|
+
'README.md',
|
|
20
|
+
'README.zh-CN.md',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function normalizeSlug(raw) {
|
|
24
|
+
return String(raw || '')
|
|
25
|
+
.trim()
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
28
|
+
.replace(/^-+|-+$/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function finishStamp(date = new Date()) {
|
|
32
|
+
return date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function finishAuditId(slug, date = new Date()) {
|
|
36
|
+
return `${finishStamp(date)}-${normalizeSlug(slug) || 'finish-audit'}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveFinishAuditRoot(cwd) {
|
|
40
|
+
return join(resolve(cwd), '.loopx', 'finish');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveFinishAuditPath(cwd, auditId) {
|
|
44
|
+
return join(resolveFinishAuditRoot(cwd), auditId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveFinishBaselineRoot(cwd) {
|
|
48
|
+
return join(resolveFinishAuditRoot(cwd), 'baselines');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveFinishBaselinePath(cwd, slug) {
|
|
52
|
+
const normalizedSlug = normalizeSlug(slug) || 'finish-audit';
|
|
53
|
+
const filename = normalizedSlug === 'latest' ? 'latest-baseline' : normalizedSlug;
|
|
54
|
+
return join(resolveFinishBaselineRoot(cwd), `${filename}.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveLatestFinishBaselinePath(cwd) {
|
|
58
|
+
return join(resolveFinishBaselineRoot(cwd), 'latest.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function gitOutput(cwd, args) {
|
|
62
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
63
|
+
cwd,
|
|
64
|
+
maxBuffer: 1024 * 1024 * 8,
|
|
65
|
+
});
|
|
66
|
+
return stdout.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function gitOutputAllowFailure(cwd, args) {
|
|
70
|
+
try {
|
|
71
|
+
return await gitOutput(cwd, args);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return `${error?.stdout || ''}${error?.stderr || ''}`.trim();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function gitOutputOrUnknown(cwd, args) {
|
|
78
|
+
try {
|
|
79
|
+
const value = await gitOutput(cwd, args);
|
|
80
|
+
return value || 'unknown';
|
|
81
|
+
} catch {
|
|
82
|
+
return 'unknown';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readGitField(cwd, args) {
|
|
87
|
+
return gitOutputOrUnknown(cwd, args);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function resolveFullHead(cwd) {
|
|
91
|
+
return readGitField(cwd, ['rev-parse', 'HEAD']);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolveRequiredHead(cwd) {
|
|
95
|
+
const head = await resolveCommitRef(cwd, 'HEAD');
|
|
96
|
+
if (!head) {
|
|
97
|
+
throw new Error('finish_start_no_valid_head');
|
|
98
|
+
}
|
|
99
|
+
return head;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeBranchRef(raw) {
|
|
103
|
+
const value = String(raw || '').trim();
|
|
104
|
+
if (!value) {
|
|
105
|
+
return 'unknown';
|
|
106
|
+
}
|
|
107
|
+
return value
|
|
108
|
+
.replace(/^refs\/heads\//, '')
|
|
109
|
+
.replace(/^refs\/remotes\/[^/]+\//, '')
|
|
110
|
+
|| 'unknown';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeUpstreamRef(raw) {
|
|
114
|
+
const value = String(raw || '').trim();
|
|
115
|
+
if (!value) {
|
|
116
|
+
return 'unknown';
|
|
117
|
+
}
|
|
118
|
+
return value
|
|
119
|
+
.replace(/^refs\/remotes\/[^/]+\//, '')
|
|
120
|
+
.replace(/^[^/]+\//, '')
|
|
121
|
+
|| 'unknown';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function resolveGitEvidence(cwd) {
|
|
125
|
+
const isWorktree = await gitOutputAllowFailure(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
126
|
+
if (isWorktree !== 'true') {
|
|
127
|
+
return {
|
|
128
|
+
branch: 'unknown',
|
|
129
|
+
base_branch: 'unknown',
|
|
130
|
+
base_ref: 'unknown',
|
|
131
|
+
head: 'unknown',
|
|
132
|
+
worktree: 'unknown',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const branch = normalizeBranchRef(await readGitField(cwd, ['branch', '--show-current']));
|
|
137
|
+
const head = await readGitField(cwd, ['rev-parse', '--short', 'HEAD']);
|
|
138
|
+
const remoteName = branch !== 'unknown'
|
|
139
|
+
? await gitOutputOrUnknown(cwd, ['config', '--get', `branch.${branch}.remote`])
|
|
140
|
+
: 'unknown';
|
|
141
|
+
const mergeTarget = branch !== 'unknown'
|
|
142
|
+
? normalizeBranchRef(await gitOutputOrUnknown(cwd, ['config', '--get', `branch.${branch}.merge`]))
|
|
143
|
+
: 'unknown';
|
|
144
|
+
const rawUpstreamRef = branch !== 'unknown'
|
|
145
|
+
? await gitOutputOrUnknown(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
|
|
146
|
+
: 'unknown';
|
|
147
|
+
const upstreamRef = normalizeUpstreamRef(rawUpstreamRef);
|
|
148
|
+
const baseBranch = mergeTarget !== 'unknown' ? mergeTarget : upstreamRef;
|
|
149
|
+
const configuredBaseRef = remoteName !== 'unknown' && mergeTarget !== 'unknown'
|
|
150
|
+
? `${remoteName}/${mergeTarget}`
|
|
151
|
+
: rawUpstreamRef !== 'unknown'
|
|
152
|
+
? rawUpstreamRef
|
|
153
|
+
: 'unknown';
|
|
154
|
+
const worktree = await readGitField(cwd, ['rev-parse', '--show-toplevel']);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
branch,
|
|
158
|
+
base_branch: baseBranch || 'unknown',
|
|
159
|
+
base_ref: configuredBaseRef,
|
|
160
|
+
head,
|
|
161
|
+
worktree,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function finishChoices() {
|
|
166
|
+
return {
|
|
167
|
+
accepted: [
|
|
168
|
+
{
|
|
169
|
+
id: 'audit-evidence',
|
|
170
|
+
summary: 'Finish audit evidence collected from the current worktree.',
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
rejected: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function changedPathStartsWith(path, prefixes) {
|
|
178
|
+
const normalizedPath = String(path || '').replaceAll('\\', '/');
|
|
179
|
+
return prefixes.some((prefix) => normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function summarizeChangedPaths(changedFiles, limit = 5) {
|
|
183
|
+
return changedFiles
|
|
184
|
+
.map((item) => item.path)
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
.slice(0, limit)
|
|
187
|
+
.map((path) => `file: ${path}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function summarizeCommitSubjects(commits, limit = 5) {
|
|
191
|
+
return commits
|
|
192
|
+
.map((item) => item.subject)
|
|
193
|
+
.filter(Boolean)
|
|
194
|
+
.slice(0, limit)
|
|
195
|
+
.map((subject) => `commit: ${subject}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function extractionEvidenceForChangeWindow(changeWindow) {
|
|
199
|
+
const evidence = [
|
|
200
|
+
`change_window.source=${changeWindow.source}`,
|
|
201
|
+
`change_window.range=${changeWindow.range ?? 'none'}`,
|
|
202
|
+
`change_window.commit_count=${changeWindow.commit_count}`,
|
|
203
|
+
...summarizeCommitSubjects(changeWindow.commits || []),
|
|
204
|
+
...summarizeChangedPaths(changeWindow.changed_files || []),
|
|
205
|
+
];
|
|
206
|
+
return evidence.filter((item) => nonEmptyText(item));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function changeWindowTouchesDurableSurface(changeWindow) {
|
|
210
|
+
const changedFiles = Array.isArray(changeWindow.changed_files) ? changeWindow.changed_files : [];
|
|
211
|
+
return changedFiles.some((item) => changedPathStartsWith(item.path, EXTRACTION_SURFACE_PREFIXES));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function changeWindowTouchesTeamRuleSurface(changeWindow) {
|
|
215
|
+
const changedFiles = Array.isArray(changeWindow.changed_files) ? changeWindow.changed_files : [];
|
|
216
|
+
return changedFiles.some((item) => changedPathStartsWith(item.path, EXTRACTION_SURFACE_PREFIXES));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function createExtractionCandidates(changeWindow) {
|
|
220
|
+
if (!plainObject(changeWindow) || Number(changeWindow.commit_count || 0) <= 0) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const evidence = extractionEvidenceForChangeWindow(changeWindow);
|
|
225
|
+
const candidates = [];
|
|
226
|
+
if (changeWindowTouchesDurableSurface(changeWindow)) {
|
|
227
|
+
candidates.push({
|
|
228
|
+
id: 'memory-local-review-change-window',
|
|
229
|
+
kind: 'memory',
|
|
230
|
+
scope: 'local',
|
|
231
|
+
status: 'pending-review',
|
|
232
|
+
target: '.loopx/memory/entries/',
|
|
233
|
+
summary: 'Review the committed finish change window for local agent memory worth preserving.',
|
|
234
|
+
reason: 'Committed code, docs, tests, or workflow files may encode a reusable decision, constraint, pitfall, or handoff that future agents should know.',
|
|
235
|
+
evidence,
|
|
236
|
+
});
|
|
237
|
+
candidates.push({
|
|
238
|
+
id: 'memory-shared-review-change-window',
|
|
239
|
+
kind: 'memory',
|
|
240
|
+
scope: 'shared',
|
|
241
|
+
status: 'pending-review',
|
|
242
|
+
target: 'docs/loopx/memory/',
|
|
243
|
+
summary: 'Review the committed finish change window for git-tracked shared memory worth preserving across machines.',
|
|
244
|
+
reason: 'A user may need lightweight project memory across multiple machines before it becomes stable enough to promote to a spec.',
|
|
245
|
+
evidence,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (changeWindowTouchesTeamRuleSurface(changeWindow)) {
|
|
249
|
+
candidates.push({
|
|
250
|
+
id: 'spec-review-change-window',
|
|
251
|
+
kind: 'spec',
|
|
252
|
+
status: 'pending-review',
|
|
253
|
+
target: 'docs/loopx/specs/inbox.md',
|
|
254
|
+
summary: 'Review the committed finish change window for a repo-tracked spec candidate.',
|
|
255
|
+
reason: 'Committed workflow, skill, runtime, documentation, or test changes may define a stable team rule that belongs in specs.',
|
|
256
|
+
evidence,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return candidates;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function createFinishAuditDirectory(cwd, slug, date) {
|
|
263
|
+
await mkdir(resolveFinishAuditRoot(cwd), { recursive: true });
|
|
264
|
+
const baseAuditId = finishAuditId(slug, date);
|
|
265
|
+
for (let attempt = 0; attempt < MAX_AUDIT_ID_COLLISIONS; attempt += 1) {
|
|
266
|
+
const auditId = attempt === 0 ? baseAuditId : `${baseAuditId}-${attempt + 1}`;
|
|
267
|
+
const root = resolveFinishAuditPath(cwd, auditId);
|
|
268
|
+
try {
|
|
269
|
+
await mkdir(root);
|
|
270
|
+
return { auditId, root };
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error?.code === 'EEXIST') {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
throw new Error('finish_audit_id_collision');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function createChoiceRecord({
|
|
282
|
+
action = null,
|
|
283
|
+
status = null,
|
|
284
|
+
summary = null,
|
|
285
|
+
url = null,
|
|
286
|
+
recorded_at = null,
|
|
287
|
+
updated_at = null,
|
|
288
|
+
} = {}) {
|
|
289
|
+
return {
|
|
290
|
+
action,
|
|
291
|
+
status,
|
|
292
|
+
summary,
|
|
293
|
+
url,
|
|
294
|
+
recorded_at,
|
|
295
|
+
updated_at,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function pathExists(path) {
|
|
300
|
+
try {
|
|
301
|
+
await access(path);
|
|
302
|
+
return true;
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function selectReportCandidates(primary) {
|
|
309
|
+
if (Array.isArray(primary) && primary.length > 0) {
|
|
310
|
+
return primary;
|
|
311
|
+
}
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function readJsonIfExists(path) {
|
|
316
|
+
if (!await pathExists(path)) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function resolveCommitRef(cwd, ref) {
|
|
327
|
+
const value = String(ref || '').trim();
|
|
328
|
+
if (!value) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
return await gitOutput(cwd, ['rev-parse', '--verify', '--end-of-options', `${value}^{commit}`]);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function validatedFinishBaseline(cwd, baseline, { slug = null, evidence = null } = {}) {
|
|
339
|
+
const state = plainObject(baseline);
|
|
340
|
+
if (!state) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const normalizedBaselineSlug = normalizeSlug(state.slug);
|
|
345
|
+
if (!normalizedBaselineSlug || state.schema_version !== FINISH_SCHEMA_VERSION) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
if (slug !== null && normalizedBaselineSlug !== normalizeSlug(slug)) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (!nonEmptyText(state.created_at) || Number.isNaN(Date.parse(state.created_at))) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
if (evidence?.worktree && evidence.worktree !== 'unknown' && state.worktree !== evidence.worktree) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
if (evidence?.branch && state.branch !== evidence.branch) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
if (!nonEmptyText(state.branch) || !nonEmptyText(state.head) || !nonEmptyText(state.head_short)) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
if (state.source !== null && state.source !== undefined && typeof state.source !== 'string') {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const fullHead = await resolveCommitRef(cwd, state.head);
|
|
368
|
+
if (!fullHead) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
schema_version: state.schema_version,
|
|
374
|
+
slug: normalizedBaselineSlug,
|
|
375
|
+
created_at: state.created_at,
|
|
376
|
+
worktree: state.worktree,
|
|
377
|
+
branch: state.branch,
|
|
378
|
+
head: fullHead,
|
|
379
|
+
head_short: state.head_short,
|
|
380
|
+
source: state.source ?? null,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function readValidFinishBaseline(cwd, path, options) {
|
|
385
|
+
return validatedFinishBaseline(cwd, await readJsonIfExists(path), options);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function latestBaselineMatchesRequest(baseline, slug, evidence) {
|
|
389
|
+
if (!plainObject(baseline)) {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
return normalizeSlug(baseline.slug) === normalizeSlug(slug)
|
|
393
|
+
&& baseline.branch === evidence.branch
|
|
394
|
+
&& baseline.worktree === evidence.worktree;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function readFinishBaseline(cwd, slug, evidence) {
|
|
398
|
+
const normalizedSlug = normalizeSlug(slug) || 'finish-audit';
|
|
399
|
+
const slugWasOmitted = String(slug ?? '').trim() === '';
|
|
400
|
+
const latestBaseline = await readValidFinishBaseline(cwd, resolveLatestFinishBaselinePath(cwd), {
|
|
401
|
+
evidence,
|
|
402
|
+
});
|
|
403
|
+
if (slugWasOmitted && latestBaseline) {
|
|
404
|
+
return latestBaseline;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const directBaseline = await readValidFinishBaseline(cwd, resolveFinishBaselinePath(cwd, normalizedSlug), {
|
|
408
|
+
slug: normalizedSlug,
|
|
409
|
+
evidence,
|
|
410
|
+
});
|
|
411
|
+
if (directBaseline) {
|
|
412
|
+
return directBaseline;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!latestBaseline) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (latestBaselineMatchesRequest(latestBaseline, normalizedSlug, evidence)) {
|
|
420
|
+
return latestBaseline;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function parseNameStatus(text) {
|
|
427
|
+
return String(text || '')
|
|
428
|
+
.split('\n')
|
|
429
|
+
.map((line) => line.trim())
|
|
430
|
+
.filter(Boolean)
|
|
431
|
+
.map((line) => {
|
|
432
|
+
const [status, firstPath, secondPath] = line.split('\t');
|
|
433
|
+
return {
|
|
434
|
+
status,
|
|
435
|
+
path: secondPath || firstPath,
|
|
436
|
+
};
|
|
437
|
+
})
|
|
438
|
+
.filter((item) => item.status && item.path);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function parseCommitLog(text) {
|
|
442
|
+
return String(text || '')
|
|
443
|
+
.split('\n')
|
|
444
|
+
.map((line) => line.trim())
|
|
445
|
+
.filter(Boolean)
|
|
446
|
+
.map((line) => {
|
|
447
|
+
const [sha, subject = ''] = line.split('\t');
|
|
448
|
+
return { sha, subject };
|
|
449
|
+
})
|
|
450
|
+
.filter((item) => /^[0-9a-f]{7,40}$/.test(item.sha));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function parseStatusShort(text) {
|
|
454
|
+
return String(text || '')
|
|
455
|
+
.split('\n')
|
|
456
|
+
.map((line) => line.trimEnd())
|
|
457
|
+
.filter(Boolean)
|
|
458
|
+
.filter((line) => !isLoopxRuntimeStatusLine(line));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function statusLinePath(line) {
|
|
462
|
+
const value = String(line || '').slice(3).trim();
|
|
463
|
+
const parts = value.split(' -> ');
|
|
464
|
+
return parts.at(-1) || value;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isLoopxRuntimeStatusLine(line) {
|
|
468
|
+
const path = statusLinePath(line).replaceAll('\\', '/').replace(/^\.\//, '');
|
|
469
|
+
return path.split('/').includes('.loopx');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function resolveMergeBaseRef(cwd, evidence) {
|
|
473
|
+
const normalizedBaseBranch = normalizeBranchRef(evidence.base_branch);
|
|
474
|
+
const namedBase = normalizedBaseBranch !== 'unknown' && normalizedBaseBranch !== evidence.branch
|
|
475
|
+
? normalizedBaseBranch
|
|
476
|
+
: null;
|
|
477
|
+
const configuredRemoteBase = evidence.base_ref !== 'unknown' && normalizeUpstreamRef(evidence.base_ref) === namedBase
|
|
478
|
+
? evidence.base_ref
|
|
479
|
+
: null;
|
|
480
|
+
const baseRemoteRefs = namedBase ? await gitOutputAllowFailure(cwd, [
|
|
481
|
+
'branch',
|
|
482
|
+
'-r',
|
|
483
|
+
'--list',
|
|
484
|
+
`*/${namedBase}`,
|
|
485
|
+
'--format=%(refname:short)',
|
|
486
|
+
]) : '';
|
|
487
|
+
const originHead = await gitOutputAllowFailure(cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
|
|
488
|
+
const mainRemoteRefs = await gitOutputAllowFailure(cwd, [
|
|
489
|
+
'branch',
|
|
490
|
+
'-r',
|
|
491
|
+
'--list',
|
|
492
|
+
'*/main',
|
|
493
|
+
'--format=%(refname:short)',
|
|
494
|
+
]);
|
|
495
|
+
const masterRemoteRefs = await gitOutputAllowFailure(cwd, [
|
|
496
|
+
'branch',
|
|
497
|
+
'-r',
|
|
498
|
+
'--list',
|
|
499
|
+
'*/master',
|
|
500
|
+
'--format=%(refname:short)',
|
|
501
|
+
]);
|
|
502
|
+
const localConfiguredCandidates = [
|
|
503
|
+
namedBase,
|
|
504
|
+
].map((item) => String(item || '').trim()).filter(Boolean);
|
|
505
|
+
const exactRemoteConfiguredCandidates = [
|
|
506
|
+
configuredRemoteBase,
|
|
507
|
+
].map((item) => String(item || '').trim()).filter(Boolean);
|
|
508
|
+
const remoteConfiguredCandidates = [
|
|
509
|
+
namedBase ? `origin/${namedBase}` : null,
|
|
510
|
+
namedBase ? `refs/remotes/origin/${namedBase}` : null,
|
|
511
|
+
...baseRemoteRefs.split('\n'),
|
|
512
|
+
].map((item) => String(item || '').trim()).filter(Boolean);
|
|
513
|
+
const fallbackCandidates = [
|
|
514
|
+
originHead,
|
|
515
|
+
'main',
|
|
516
|
+
'master',
|
|
517
|
+
'origin/main',
|
|
518
|
+
'origin/master',
|
|
519
|
+
'refs/remotes/origin/main',
|
|
520
|
+
'refs/remotes/origin/master',
|
|
521
|
+
...mainRemoteRefs.split('\n'),
|
|
522
|
+
...masterRemoteRefs.split('\n'),
|
|
523
|
+
].map((item) => String(item || '').trim()).filter(Boolean);
|
|
524
|
+
const fullHead = await resolveFullHead(cwd);
|
|
525
|
+
const tryCandidates = async (candidates) => {
|
|
526
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
527
|
+
const resolvedCandidate = await resolveCommitRef(cwd, candidate);
|
|
528
|
+
if (!resolvedCandidate) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const value = await gitOutputAllowFailure(cwd, ['merge-base', 'HEAD', candidate]);
|
|
532
|
+
if (!/^[0-9a-f]{7,40}$/.test(value)) {
|
|
533
|
+
return { value: null, terminal: true };
|
|
534
|
+
}
|
|
535
|
+
if (value === fullHead) {
|
|
536
|
+
return { value: null, terminal: true };
|
|
537
|
+
}
|
|
538
|
+
return { value, terminal: true };
|
|
539
|
+
}
|
|
540
|
+
return { value: null, terminal: false };
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const localConfiguredResult = await tryCandidates(localConfiguredCandidates);
|
|
544
|
+
if (localConfiguredResult.value || (namedBase && localConfiguredResult.terminal)) {
|
|
545
|
+
return localConfiguredResult.value;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const exactRemoteConfiguredResult = await tryCandidates(exactRemoteConfiguredCandidates);
|
|
549
|
+
if (exactRemoteConfiguredResult.value || (configuredRemoteBase && exactRemoteConfiguredResult.terminal)) {
|
|
550
|
+
return exactRemoteConfiguredResult.value;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const remoteConfiguredResult = await tryCandidates(remoteConfiguredCandidates);
|
|
554
|
+
if (remoteConfiguredResult.value || (namedBase && remoteConfiguredResult.terminal)) {
|
|
555
|
+
return remoteConfiguredResult.value;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const fallbackResult = await tryCandidates(fallbackCandidates);
|
|
559
|
+
if (fallbackResult.value || fallbackResult.terminal) {
|
|
560
|
+
return fallbackResult.value;
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function resolveManualBaselineRef(cwd, ref) {
|
|
566
|
+
const value = String(ref || '').trim();
|
|
567
|
+
if (!value) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
return await gitOutput(cwd, ['rev-parse', '--verify', `${value}^{commit}`]);
|
|
572
|
+
} catch {
|
|
573
|
+
throw new Error(`finish_audit_invalid_baseline_ref:${value}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function resolveChangeWindow(cwd, slug, evidence, { baselineRef = null } = {}) {
|
|
578
|
+
const rootCwd = evidence.worktree === 'unknown' ? cwd : evidence.worktree;
|
|
579
|
+
const manualBaselineHead = await resolveManualBaselineRef(cwd, baselineRef);
|
|
580
|
+
const baseline = manualBaselineHead
|
|
581
|
+
? { head: manualBaselineHead, head_short: manualBaselineHead.slice(0, 7), source: null }
|
|
582
|
+
: await readFinishBaseline(rootCwd, slug, evidence);
|
|
583
|
+
const fallbackMergeBase = baseline ? null : await resolveMergeBaseRef(cwd, evidence);
|
|
584
|
+
const ref = baseline?.head || fallbackMergeBase;
|
|
585
|
+
const source = baseline?.head ? 'baseline' : fallbackMergeBase ? 'merge-base' : 'none';
|
|
586
|
+
const statusText = evidence.worktree === 'unknown'
|
|
587
|
+
? ''
|
|
588
|
+
: await gitOutputAllowFailure(cwd, ['status', '--short']);
|
|
589
|
+
const uncommittedStatus = parseStatusShort(statusText);
|
|
590
|
+
|
|
591
|
+
if (!ref || ref === 'unknown') {
|
|
592
|
+
return {
|
|
593
|
+
source,
|
|
594
|
+
baseline_ref: null,
|
|
595
|
+
baseline_ref_short: null,
|
|
596
|
+
range: null,
|
|
597
|
+
commit_count: 0,
|
|
598
|
+
commits: [],
|
|
599
|
+
changed_files: [],
|
|
600
|
+
diff_stat: '',
|
|
601
|
+
uncommitted_status: uncommittedStatus,
|
|
602
|
+
source_artifacts: baseline?.source ? [baseline.source] : [],
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const range = `${ref}..HEAD`;
|
|
607
|
+
const commits = parseCommitLog(await gitOutputAllowFailure(cwd, ['log', '--pretty=format:%H%x09%s', range]));
|
|
608
|
+
const changedFiles = parseNameStatus(await gitOutputAllowFailure(cwd, ['diff', '--name-status', range]));
|
|
609
|
+
const diffStat = await gitOutputAllowFailure(cwd, ['diff', '--stat', range]);
|
|
610
|
+
return {
|
|
611
|
+
source,
|
|
612
|
+
baseline_ref: ref,
|
|
613
|
+
baseline_ref_short: baseline?.head_short || ref.slice(0, 7),
|
|
614
|
+
range: `${ref.slice(0, 7)}..HEAD`,
|
|
615
|
+
commit_count: commits.length,
|
|
616
|
+
commits,
|
|
617
|
+
changed_files: changedFiles,
|
|
618
|
+
diff_stat: diffStat,
|
|
619
|
+
uncommitted_status: uncommittedStatus,
|
|
620
|
+
source_artifacts: baseline?.source ? [baseline.source] : [],
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function singleLineText(value) {
|
|
625
|
+
return String(value ?? 'null').replace(/\s*\r?\n+\s*/g, ' ').trim() || 'null';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function nonEmptyText(value) {
|
|
629
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function nonEmptyTextArray(value) {
|
|
633
|
+
return Array.isArray(value) && value.some((item) => nonEmptyText(item));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function plainObject(item) {
|
|
637
|
+
return item && typeof item === 'object' && !Array.isArray(item) ? item : null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function candidateObject(item) {
|
|
641
|
+
return plainObject(item);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function candidateIdentifier(item) {
|
|
645
|
+
const candidate = candidateObject(item);
|
|
646
|
+
return candidate?.id ?? candidate?.kind ?? 'candidate';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function formatCandidateValue(value) {
|
|
650
|
+
if (Array.isArray(value)) {
|
|
651
|
+
const parts = value.map((item) => singleLineText(item)).filter((item) => item !== 'null');
|
|
652
|
+
return parts.length > 0 ? parts.join('; ') : 'null';
|
|
653
|
+
}
|
|
654
|
+
return singleLineText(value);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function candidateDetailLine(item, key) {
|
|
658
|
+
const candidate = candidateObject(item);
|
|
659
|
+
if (!candidate || !Object.hasOwn(candidate, key)) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
const value = candidate[key];
|
|
663
|
+
const hasValue = Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && String(value).trim() !== '';
|
|
664
|
+
return hasValue ? ` - ${key}: ${formatCandidateValue(value)}` : null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function formatCandidate(item, detailKeys = []) {
|
|
668
|
+
const candidate = candidateObject(item);
|
|
669
|
+
const summary = candidate
|
|
670
|
+
? candidate.summary ?? candidate.rejection_reason ?? candidate.reason ?? 'null'
|
|
671
|
+
: item;
|
|
672
|
+
const lines = [`- ${singleLineText(candidateIdentifier(item))}: ${singleLineText(summary)}`];
|
|
673
|
+
for (const key of detailKeys) {
|
|
674
|
+
const line = candidateDetailLine(item, key);
|
|
675
|
+
if (line) {
|
|
676
|
+
lines.push(line);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return lines.join('\n');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function formatChoiceHistoryEntry(item, index) {
|
|
683
|
+
const recordedAt = item.recorded_at ?? item.updated_at;
|
|
684
|
+
const parts = [
|
|
685
|
+
`- ${index + 1}. ${singleLineText(item.action)}`,
|
|
686
|
+
singleLineText(item.status),
|
|
687
|
+
singleLineText(item.summary),
|
|
688
|
+
`url=${singleLineText(item.url)}`,
|
|
689
|
+
`recorded_at=${singleLineText(recordedAt)}`,
|
|
690
|
+
`superseded_at=${singleLineText(item.superseded_at)}`,
|
|
691
|
+
];
|
|
692
|
+
return parts.join(' / ');
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function buildFinishReport({ state, evidence, scannedInputs }) {
|
|
696
|
+
const auditId = state.audit_id;
|
|
697
|
+
const slug = state.slug;
|
|
698
|
+
const auditChoices = state.audit || {};
|
|
699
|
+
const changeWindow = auditChoices.change_window || {};
|
|
700
|
+
const noCandidatesReason = auditChoices.no_candidates_reason ?? null;
|
|
701
|
+
const acceptedSource = selectReportCandidates(auditChoices.accepted_candidates);
|
|
702
|
+
const rejectedSource = selectReportCandidates(auditChoices.rejected_candidates);
|
|
703
|
+
const extractionSource = selectReportCandidates(auditChoices.extraction_candidates);
|
|
704
|
+
const accepted = acceptedSource.length > 0
|
|
705
|
+
? acceptedSource.map((item) => formatCandidate(item, ['evidence', 'target', 'confidence', 'status'])).join('\n')
|
|
706
|
+
: '- none';
|
|
707
|
+
const rejected = rejectedSource.length > 0
|
|
708
|
+
? rejectedSource.map((item) => formatCandidate(item, ['rejection_reason', 'reason', 'evidence', 'target', 'confidence', 'status'])).join('\n')
|
|
709
|
+
: '- none';
|
|
710
|
+
const extraction = extractionSource.length > 0
|
|
711
|
+
? extractionSource.map((item) => formatCandidate(item, ['kind', 'scope', 'status', 'target', 'reason', 'evidence'])).join('\n')
|
|
712
|
+
: '- none';
|
|
713
|
+
const choice = state.choice || createChoiceRecord();
|
|
714
|
+
const choiceLine = [
|
|
715
|
+
`- action: ${singleLineText(choice.action)}`,
|
|
716
|
+
`- status: ${singleLineText(choice.status)}`,
|
|
717
|
+
`- summary: ${singleLineText(choice.summary)}`,
|
|
718
|
+
`- url: ${singleLineText(choice.url)}`,
|
|
719
|
+
].join('\n');
|
|
720
|
+
const noCandidatesLine = noCandidatesReason
|
|
721
|
+
? `- ${singleLineText(noCandidatesReason)}`
|
|
722
|
+
: '- none';
|
|
723
|
+
const history = Array.isArray(state.choice_history) && state.choice_history.length > 0
|
|
724
|
+
? state.choice_history.map((item, index) => formatChoiceHistoryEntry(item, index)).join('\n')
|
|
725
|
+
: '- none';
|
|
726
|
+
const scanned = scannedInputs.length > 0
|
|
727
|
+
? scannedInputs.map((item) => `- ${item}`).join('\n')
|
|
728
|
+
: '- none';
|
|
729
|
+
const commits = Array.isArray(changeWindow.commits) && changeWindow.commits.length > 0
|
|
730
|
+
? changeWindow.commits.map((item) => `- ${singleLineText(item.sha)} ${singleLineText(item.subject)}`).join('\n')
|
|
731
|
+
: '- none';
|
|
732
|
+
const changedFiles = Array.isArray(changeWindow.changed_files) && changeWindow.changed_files.length > 0
|
|
733
|
+
? changeWindow.changed_files.map((item) => `- ${singleLineText(item.status)} ${singleLineText(item.path)}`).join('\n')
|
|
734
|
+
: '- none';
|
|
735
|
+
const uncommitted = Array.isArray(changeWindow.uncommitted_status) && changeWindow.uncommitted_status.length > 0
|
|
736
|
+
? changeWindow.uncommitted_status.map((item) => `- ${singleLineText(item)}`).join('\n')
|
|
737
|
+
: '- none';
|
|
738
|
+
const sourceArtifacts = Array.isArray(changeWindow.source_artifacts) && changeWindow.source_artifacts.length > 0
|
|
739
|
+
? changeWindow.source_artifacts.map((item) => `- ${singleLineText(item)}`).join('\n')
|
|
740
|
+
: '- none';
|
|
741
|
+
const diffStat = nonEmptyText(changeWindow.diff_stat)
|
|
742
|
+
? String(changeWindow.diff_stat).split('\n').map((line) => `- ${singleLineText(line)}`).join('\n')
|
|
743
|
+
: '- none';
|
|
744
|
+
|
|
745
|
+
return [
|
|
746
|
+
'# Finish Audit',
|
|
747
|
+
'',
|
|
748
|
+
'## Summary',
|
|
749
|
+
'',
|
|
750
|
+
`- audit_id: ${auditId}`,
|
|
751
|
+
`- slug: ${slug}`,
|
|
752
|
+
`- status: ${state.status}`,
|
|
753
|
+
`- updated_at: ${state.updated_at ?? 'null'}`,
|
|
754
|
+
`- branch: ${evidence.branch}`,
|
|
755
|
+
`- base branch: ${evidence.base_branch}`,
|
|
756
|
+
`- worktree: ${evidence.worktree}`,
|
|
757
|
+
'',
|
|
758
|
+
'## Scanned Inputs',
|
|
759
|
+
'',
|
|
760
|
+
scanned,
|
|
761
|
+
'',
|
|
762
|
+
'## Change Window',
|
|
763
|
+
'',
|
|
764
|
+
`- source: ${singleLineText(changeWindow.source)}`,
|
|
765
|
+
`- baseline_ref: ${singleLineText(changeWindow.baseline_ref_short ?? changeWindow.baseline_ref)}`,
|
|
766
|
+
`- range: ${singleLineText(changeWindow.range)}`,
|
|
767
|
+
`- committed_change_count: ${singleLineText(changeWindow.commit_count)}`,
|
|
768
|
+
'',
|
|
769
|
+
'### Commits',
|
|
770
|
+
'',
|
|
771
|
+
commits,
|
|
772
|
+
'',
|
|
773
|
+
'### Changed Files',
|
|
774
|
+
'',
|
|
775
|
+
changedFiles,
|
|
776
|
+
'',
|
|
777
|
+
'### Uncommitted Status',
|
|
778
|
+
'',
|
|
779
|
+
uncommitted,
|
|
780
|
+
'',
|
|
781
|
+
'### Source Artifacts',
|
|
782
|
+
'',
|
|
783
|
+
sourceArtifacts,
|
|
784
|
+
'',
|
|
785
|
+
'### Diff Stat',
|
|
786
|
+
'',
|
|
787
|
+
diffStat,
|
|
788
|
+
'',
|
|
789
|
+
'## Extraction Candidates',
|
|
790
|
+
'',
|
|
791
|
+
extraction,
|
|
792
|
+
'',
|
|
793
|
+
'## Accepted Candidates',
|
|
794
|
+
'',
|
|
795
|
+
accepted,
|
|
796
|
+
'',
|
|
797
|
+
'## Rejected Candidates',
|
|
798
|
+
'',
|
|
799
|
+
rejected,
|
|
800
|
+
'',
|
|
801
|
+
'## No Candidates Reason',
|
|
802
|
+
'',
|
|
803
|
+
noCandidatesLine,
|
|
804
|
+
'',
|
|
805
|
+
'## Choice',
|
|
806
|
+
'',
|
|
807
|
+
choiceLine,
|
|
808
|
+
'',
|
|
809
|
+
'## Choice History',
|
|
810
|
+
'',
|
|
811
|
+
history,
|
|
812
|
+
'',
|
|
813
|
+
'## Next Steps',
|
|
814
|
+
'',
|
|
815
|
+
'- Agent review the audit evidence and decide whether the finish state can advance.',
|
|
816
|
+
'- Record the final audit decision once the audit is complete.',
|
|
817
|
+
'',
|
|
818
|
+
].join('\n');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function resolveFinishAuditDir(cwd, auditIdOrPath) {
|
|
822
|
+
const raw = String(auditIdOrPath || '').trim();
|
|
823
|
+
if (!raw) {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const directPath = resolve(cwd, raw);
|
|
828
|
+
if (await pathExists(join(directPath, 'finish-state.json'))) {
|
|
829
|
+
return directPath;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const evidence = await resolveGitEvidence(cwd);
|
|
833
|
+
const rootCwd = evidence.worktree === 'unknown' ? cwd : evidence.worktree;
|
|
834
|
+
const rootIdPath = resolveFinishAuditPath(rootCwd, raw);
|
|
835
|
+
if (await pathExists(join(rootIdPath, 'finish-state.json'))) {
|
|
836
|
+
return rootIdPath;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const cwdIdPath = resolveFinishAuditPath(cwd, raw);
|
|
840
|
+
if (cwdIdPath !== rootIdPath && await pathExists(join(cwdIdPath, 'finish-state.json'))) {
|
|
841
|
+
return cwdIdPath;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function readFinishState(statePath) {
|
|
848
|
+
try {
|
|
849
|
+
return JSON.parse(await readFile(statePath, 'utf8'));
|
|
850
|
+
} catch {
|
|
851
|
+
throw new Error('finish_record_invalid_state');
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function throwInvalidFinishState() {
|
|
856
|
+
throw new Error('finish_record_invalid_state');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function stringArray(value) {
|
|
860
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function validChoiceHistory(value) {
|
|
864
|
+
return Array.isArray(value) && value.every((item) => Boolean(plainObject(item)));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function validateFinishRecordState(state, root) {
|
|
868
|
+
if (!plainObject(state)) {
|
|
869
|
+
throwInvalidFinishState();
|
|
870
|
+
}
|
|
871
|
+
if (state.schema_version !== FINISH_SCHEMA_VERSION) {
|
|
872
|
+
throwInvalidFinishState();
|
|
873
|
+
}
|
|
874
|
+
if (!nonEmptyText(state.audit_id) || !nonEmptyText(state.slug)) {
|
|
875
|
+
throwInvalidFinishState();
|
|
876
|
+
}
|
|
877
|
+
if (state.audit_id !== basename(root)) {
|
|
878
|
+
throwInvalidFinishState();
|
|
879
|
+
}
|
|
880
|
+
if (!FINISH_RECORD_STATE_STATUSES.includes(state.status)) {
|
|
881
|
+
throwInvalidFinishState();
|
|
882
|
+
}
|
|
883
|
+
if (!plainObject(state.inputs) || !stringArray(state.inputs.scanned)) {
|
|
884
|
+
throwInvalidFinishState();
|
|
885
|
+
}
|
|
886
|
+
if (!plainObject(state.audit)) {
|
|
887
|
+
throwInvalidFinishState();
|
|
888
|
+
}
|
|
889
|
+
for (const key of ['branch', 'base_branch', 'worktree', 'head']) {
|
|
890
|
+
if (typeof state.audit[key] !== 'string') {
|
|
891
|
+
throwInvalidFinishState();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (!Array.isArray(state.audit.accepted_candidates) || !Array.isArray(state.audit.rejected_candidates)) {
|
|
895
|
+
throwInvalidFinishState();
|
|
896
|
+
}
|
|
897
|
+
if (state.audit.extraction_candidates !== undefined && !Array.isArray(state.audit.extraction_candidates)) {
|
|
898
|
+
throwInvalidFinishState();
|
|
899
|
+
}
|
|
900
|
+
if (state.audit.change_window !== undefined && !plainObject(state.audit.change_window)) {
|
|
901
|
+
throwInvalidFinishState();
|
|
902
|
+
}
|
|
903
|
+
if (state.audit.no_candidates_reason !== null && typeof state.audit.no_candidates_reason !== 'string') {
|
|
904
|
+
throwInvalidFinishState();
|
|
905
|
+
}
|
|
906
|
+
if (state.choice !== null && state.choice !== undefined && !plainObject(state.choice)) {
|
|
907
|
+
throwInvalidFinishState();
|
|
908
|
+
}
|
|
909
|
+
if (!validChoiceHistory(state.choice_history)) {
|
|
910
|
+
throwInvalidFinishState();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function hasSpecificNoCandidatesReason(reason) {
|
|
915
|
+
return nonEmptyText(reason) && reason.trim() !== DEFAULT_NO_CANDIDATES_REASON;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function acceptedCandidateIsComplete(candidate) {
|
|
919
|
+
return Boolean(candidate)
|
|
920
|
+
&& nonEmptyText(candidate.summary)
|
|
921
|
+
&& nonEmptyTextArray(candidate.evidence);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function rejectedCandidateIsComplete(candidate) {
|
|
925
|
+
return Boolean(candidate)
|
|
926
|
+
&& (nonEmptyText(candidate.rejection_reason) || nonEmptyText(candidate.reason));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function candidateIdSet(candidates) {
|
|
930
|
+
return new Set(
|
|
931
|
+
(Array.isArray(candidates) ? candidates : [])
|
|
932
|
+
.map((candidate) => candidateObject(candidate)?.id)
|
|
933
|
+
.filter((id) => nonEmptyText(id)),
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function extractionCandidatesAreReviewed(state) {
|
|
938
|
+
const extractionCandidates = Array.isArray(state?.audit?.extraction_candidates)
|
|
939
|
+
? state.audit.extraction_candidates
|
|
940
|
+
: [];
|
|
941
|
+
if (extractionCandidates.length === 0) {
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const acceptedIds = candidateIdSet(state?.audit?.accepted_candidates);
|
|
946
|
+
const rejectedIds = candidateIdSet(state?.audit?.rejected_candidates);
|
|
947
|
+
return extractionCandidates.every((candidate) => {
|
|
948
|
+
const id = candidateObject(candidate)?.id;
|
|
949
|
+
return nonEmptyText(id) && (acceptedIds.has(id) || rejectedIds.has(id));
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function isFinishAuditReadyForDone(state) {
|
|
954
|
+
if (!['audited', 'choice-recorded', 'completed', 'failed'].includes(state?.status)) {
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const acceptedCandidates = Array.isArray(state?.audit?.accepted_candidates)
|
|
959
|
+
? state.audit.accepted_candidates
|
|
960
|
+
: [];
|
|
961
|
+
const rejectedCandidates = Array.isArray(state?.audit?.rejected_candidates)
|
|
962
|
+
? state.audit.rejected_candidates
|
|
963
|
+
: [];
|
|
964
|
+
if (rejectedCandidates.some((candidate) => !rejectedCandidateIsComplete(candidate))) {
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
if (!extractionCandidatesAreReviewed(state)) {
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
if (acceptedCandidates.length > 0) {
|
|
971
|
+
return acceptedCandidates.every((candidate) => acceptedCandidateIsComplete(candidate));
|
|
972
|
+
}
|
|
973
|
+
if (Array.isArray(state?.audit?.extraction_candidates) && state.audit.extraction_candidates.length > 0) {
|
|
974
|
+
return true;
|
|
975
|
+
}
|
|
976
|
+
return hasSpecificNoCandidatesReason(state?.audit?.no_candidates_reason);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function hasChoiceActionChange(previousChoice, nextChoice) {
|
|
980
|
+
if (!previousChoice || typeof previousChoice !== 'object') {
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
return (previousChoice.action ?? null) !== (nextChoice.action ?? null);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function isRecordedChoice(choice) {
|
|
987
|
+
return Boolean(choice && (choice.recorded_at || choice.updated_at));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function nextChoiceHistory(state, choice, updatedAt) {
|
|
991
|
+
const history = Array.isArray(state.choice_history) ? state.choice_history.slice() : [];
|
|
992
|
+
if (isRecordedChoice(state.choice) && hasChoiceActionChange(state.choice, choice)) {
|
|
993
|
+
history.push({
|
|
994
|
+
...state.choice,
|
|
995
|
+
superseded_at: updatedAt,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
return history;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function evidenceFromState(state, fallback = {}) {
|
|
1002
|
+
const audit = state.audit || {};
|
|
1003
|
+
return {
|
|
1004
|
+
branch: audit.branch ?? fallback.branch ?? 'unknown',
|
|
1005
|
+
base_branch: audit.base_branch ?? fallback.base_branch ?? 'unknown',
|
|
1006
|
+
head: audit.head ?? fallback.head ?? 'unknown',
|
|
1007
|
+
worktree: audit.worktree ?? fallback.worktree ?? 'unknown',
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export async function finishAuditStage(cwd, slug, { env = process.env, date = new Date(), baselineRef = null } = {}) {
|
|
1012
|
+
const auditDate = date instanceof Date ? date : new Date(date);
|
|
1013
|
+
const evidence = await resolveGitEvidence(cwd);
|
|
1014
|
+
const rootCwd = evidence.worktree === 'unknown' ? cwd : evidence.worktree;
|
|
1015
|
+
const { auditId, root } = await createFinishAuditDirectory(rootCwd, slug, auditDate);
|
|
1016
|
+
const normalizedSlug = normalizeSlug(slug) || 'finish-audit';
|
|
1017
|
+
const changeWindow = await resolveChangeWindow(cwd, slug, evidence, { baselineRef });
|
|
1018
|
+
const scannedInputs = [
|
|
1019
|
+
`slug=${normalizedSlug}`,
|
|
1020
|
+
`worktree=${evidence.worktree}`,
|
|
1021
|
+
`branch=${evidence.branch}`,
|
|
1022
|
+
`base_branch=${evidence.base_branch}`,
|
|
1023
|
+
`head=${evidence.head}`,
|
|
1024
|
+
`change_window_source=${changeWindow.source}`,
|
|
1025
|
+
`change_range=${changeWindow.range ?? 'none'}`,
|
|
1026
|
+
`committed_change_count=${changeWindow.commit_count}`,
|
|
1027
|
+
`changed_files_count=${changeWindow.changed_files.length}`,
|
|
1028
|
+
`uncommitted_change_count=${changeWindow.uncommitted_status.length}`,
|
|
1029
|
+
`cwd=${resolve(cwd)}`,
|
|
1030
|
+
`env.LOOPX_DEVELOPER=${String(env.LOOPX_DEVELOPER || 'unknown')}`,
|
|
1031
|
+
];
|
|
1032
|
+
const choices = finishChoices();
|
|
1033
|
+
const extractionCandidates = createExtractionCandidates(changeWindow);
|
|
1034
|
+
const state = {
|
|
1035
|
+
schema_version: FINISH_SCHEMA_VERSION,
|
|
1036
|
+
audit_id: auditId,
|
|
1037
|
+
slug: normalizedSlug,
|
|
1038
|
+
status: 'needs-agent-audit',
|
|
1039
|
+
updated_at: auditDate.toISOString(),
|
|
1040
|
+
inputs: {
|
|
1041
|
+
scanned: scannedInputs,
|
|
1042
|
+
},
|
|
1043
|
+
audit: {
|
|
1044
|
+
branch: evidence.branch,
|
|
1045
|
+
base_branch: evidence.base_branch,
|
|
1046
|
+
worktree: evidence.worktree,
|
|
1047
|
+
head: evidence.head,
|
|
1048
|
+
accepted_candidates: [],
|
|
1049
|
+
rejected_candidates: [],
|
|
1050
|
+
extraction_candidates: extractionCandidates,
|
|
1051
|
+
no_candidates_reason: DEFAULT_NO_CANDIDATES_REASON,
|
|
1052
|
+
change_window: changeWindow,
|
|
1053
|
+
report_candidates: {
|
|
1054
|
+
accepted: choices.accepted.map((item) => ({ ...item })),
|
|
1055
|
+
rejected: choices.rejected.map((item) => ({ ...item })),
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
choice: createChoiceRecord(),
|
|
1059
|
+
choice_history: [],
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const reportText = buildFinishReport({
|
|
1063
|
+
state,
|
|
1064
|
+
evidence,
|
|
1065
|
+
scannedInputs,
|
|
1066
|
+
});
|
|
1067
|
+
await writeFile(join(root, 'finish-state.json'), `${JSON.stringify(state, null, 2)}\n`);
|
|
1068
|
+
await writeFile(join(root, 'finish-report.md'), reportText);
|
|
1069
|
+
|
|
1070
|
+
return {
|
|
1071
|
+
auditId,
|
|
1072
|
+
root,
|
|
1073
|
+
state,
|
|
1074
|
+
reportPath: join(root, 'finish-report.md'),
|
|
1075
|
+
statePath: join(root, 'finish-state.json'),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
export async function finishStartStage(cwd, slug, { source = null, date = new Date() } = {}) {
|
|
1080
|
+
const baselineDate = date instanceof Date ? date : new Date(date);
|
|
1081
|
+
const normalizedSlug = normalizeSlug(slug) || 'finish-audit';
|
|
1082
|
+
|
|
1083
|
+
const evidence = await resolveGitEvidence(cwd);
|
|
1084
|
+
if (evidence.worktree === 'unknown') {
|
|
1085
|
+
throw new Error('finish_start_no_valid_head');
|
|
1086
|
+
}
|
|
1087
|
+
const fullHead = await resolveRequiredHead(cwd);
|
|
1088
|
+
const rootCwd = evidence.worktree === 'unknown' ? cwd : evidence.worktree;
|
|
1089
|
+
await mkdir(resolveFinishBaselineRoot(rootCwd), { recursive: true });
|
|
1090
|
+
const path = resolveFinishBaselinePath(rootCwd, normalizedSlug);
|
|
1091
|
+
const latestPath = resolveLatestFinishBaselinePath(rootCwd);
|
|
1092
|
+
const existingState = await readValidFinishBaseline(rootCwd, path, {
|
|
1093
|
+
slug: normalizedSlug,
|
|
1094
|
+
evidence,
|
|
1095
|
+
});
|
|
1096
|
+
if (existingState) {
|
|
1097
|
+
await writeFile(latestPath, `${JSON.stringify(existingState, null, 2)}\n`);
|
|
1098
|
+
return { path, latestPath, state: existingState };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const state = {
|
|
1102
|
+
schema_version: FINISH_SCHEMA_VERSION,
|
|
1103
|
+
slug: normalizedSlug,
|
|
1104
|
+
created_at: baselineDate.toISOString(),
|
|
1105
|
+
worktree: evidence.worktree,
|
|
1106
|
+
branch: evidence.branch,
|
|
1107
|
+
head: fullHead,
|
|
1108
|
+
head_short: fullHead === 'unknown' ? evidence.head : fullHead.slice(0, 7),
|
|
1109
|
+
source: source ? String(source) : null,
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
1113
|
+
await writeFile(latestPath, `${JSON.stringify(state, null, 2)}\n`);
|
|
1114
|
+
return { path, latestPath, state };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export async function finishRecordStage(cwd, auditIdOrPath, {
|
|
1118
|
+
action,
|
|
1119
|
+
status,
|
|
1120
|
+
summary = null,
|
|
1121
|
+
url = null,
|
|
1122
|
+
env = process.env,
|
|
1123
|
+
} = {}) {
|
|
1124
|
+
const normalizedAction = String(action || '').trim().toLowerCase();
|
|
1125
|
+
if (!['merge', 'pr', 'keep', 'discard'].includes(normalizedAction)) {
|
|
1126
|
+
throw new Error('finish_record_invalid_action');
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const normalizedStatus = String(status || '').trim().toLowerCase();
|
|
1130
|
+
if (!['pending', 'done', 'failed', 'aborted'].includes(normalizedStatus)) {
|
|
1131
|
+
throw new Error('finish_record_invalid_status');
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const root = await resolveFinishAuditDir(cwd, auditIdOrPath);
|
|
1135
|
+
if (!root) {
|
|
1136
|
+
throw new Error('finish_record_audit_not_found');
|
|
1137
|
+
}
|
|
1138
|
+
const statePath = join(root, 'finish-state.json');
|
|
1139
|
+
const state = await readFinishState(statePath);
|
|
1140
|
+
validateFinishRecordState(state, root);
|
|
1141
|
+
if (normalizedStatus === 'done' && !isFinishAuditReadyForDone(state)) {
|
|
1142
|
+
throw new Error('finish_record_audit_incomplete');
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const updatedAt = new Date().toISOString();
|
|
1146
|
+
const choice = {
|
|
1147
|
+
...createChoiceRecord({
|
|
1148
|
+
action: normalizedAction,
|
|
1149
|
+
status: normalizedStatus,
|
|
1150
|
+
summary,
|
|
1151
|
+
url,
|
|
1152
|
+
recorded_at: updatedAt,
|
|
1153
|
+
updated_at: updatedAt,
|
|
1154
|
+
}),
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
state.choice_history = nextChoiceHistory(state, choice, updatedAt);
|
|
1158
|
+
state.choice = choice;
|
|
1159
|
+
state.updated_at = updatedAt;
|
|
1160
|
+
state.status = normalizedStatus === 'done'
|
|
1161
|
+
? 'completed'
|
|
1162
|
+
: normalizedStatus === 'failed' || normalizedStatus === 'aborted'
|
|
1163
|
+
? 'failed'
|
|
1164
|
+
: 'choice-recorded';
|
|
1165
|
+
|
|
1166
|
+
const fallbackEvidence = await resolveGitEvidence(cwd);
|
|
1167
|
+
const evidence = evidenceFromState(state, fallbackEvidence);
|
|
1168
|
+
const scannedInputs = Array.isArray(state.inputs?.scanned) ? state.inputs.scanned : [];
|
|
1169
|
+
const reportText = buildFinishReport({
|
|
1170
|
+
state,
|
|
1171
|
+
evidence,
|
|
1172
|
+
scannedInputs,
|
|
1173
|
+
});
|
|
1174
|
+
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
1175
|
+
await writeFile(join(root, 'finish-report.md'), reportText);
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
auditId: state.audit_id,
|
|
1179
|
+
root,
|
|
1180
|
+
state,
|
|
1181
|
+
reportPath: join(root, 'finish-report.md'),
|
|
1182
|
+
statePath,
|
|
1183
|
+
};
|
|
1184
|
+
}
|