pi-anchorloop 2.1.1 → 2.2.1
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 +4 -3
- package/README.zh-TW.md +4 -3
- package/package.json +3 -2
- package/pi/anchor-gate.ts +21 -77
- package/pi/book.ts +554 -0
- package/pi/second-thought.ts +141 -172
- package/pi/support/book.ts +791 -0
- package/pi/support/jev.ts +28 -0
- package/pi/support/phases.ts +301 -0
- package/pi/support/snapshot.ts +71 -0
- package/pi/support/subagent.ts +5 -0
- package/skills/anchorloop/SKILL.md +14 -28
- package/skills/coding/MINI-SDD.md +2 -2
- package/skills/coding/SKILL.md +2 -2
- package/skills/coding/playbooks/feature.md +1 -1
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop book: one YAML file per open loop, kept under the agent dir.
|
|
3
|
+
* A header is written once; every later fact is one JSON flow-mapping line under `entries:`, appended
|
|
4
|
+
* with a single O_APPEND write, so parallel writers never interleave and the file stays valid YAML.
|
|
5
|
+
* Each extension reads the book from disk, so no state is shared between extensions in memory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmdirSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const KINDS = ["quick", "inline", "reviewed", "durable"] as const;
|
|
15
|
+
export type Kind = (typeof KINDS)[number];
|
|
16
|
+
|
|
17
|
+
export type Entry = { ts: string; stage: string; [key: string]: unknown };
|
|
18
|
+
export type Book = { path: string; header: Record<string, unknown>; entries: Entry[] };
|
|
19
|
+
|
|
20
|
+
/** What the book needs to know about the calling session. */
|
|
21
|
+
export type Who = {
|
|
22
|
+
cwd: string;
|
|
23
|
+
sessionManager: { getSessionId?(): string; getSessionFile?(): string | undefined; getBranch(): unknown[] };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const STALE_BOOK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
27
|
+
const STALE_UNSAVED_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
export function booksRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
30
|
+
if (env.ANCHORLOOP_BOOKS_DIR) return env.ANCHORLOOP_BOOKS_DIR;
|
|
31
|
+
return join(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "anchorloop", "books");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function real(path: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(path);
|
|
37
|
+
} catch {
|
|
38
|
+
return resolve(path);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function projectSlug(cwd: string): string {
|
|
43
|
+
return real(cwd).replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(-80) || "root";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function childId(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
47
|
+
return env.PI_SUBAGENT_ID || undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safe(id: string): string {
|
|
51
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Runs an event handler so a book I/O failure never blocks the tool or the turn it observes. */
|
|
55
|
+
export function guarded<Args extends unknown[], Result>(handler: (...args: Args) => Result): (...args: Args) => Result | undefined {
|
|
56
|
+
return (...args) => {
|
|
57
|
+
try {
|
|
58
|
+
const result = handler(...args);
|
|
59
|
+
if (result instanceof Promise) return result.catch(() => undefined) as Result;
|
|
60
|
+
return result;
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function bookPath(who: Who, env: NodeJS.ProcessEnv = process.env): string {
|
|
68
|
+
const dir = join(booksRoot(env), projectSlug(who.cwd));
|
|
69
|
+
const child = childId(env);
|
|
70
|
+
if (child) return join(dir, "agents", `${safe(child)}.yml`);
|
|
71
|
+
return join(dir, `${safe(who.sessionManager.getSessionId?.() || `pid-${process.pid}`)}.yml`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseBook(path: string): Book | undefined {
|
|
75
|
+
let text: string;
|
|
76
|
+
try {
|
|
77
|
+
text = readFileSync(path, "utf-8");
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const header: Record<string, unknown> = {};
|
|
82
|
+
const entries: Entry[] = [];
|
|
83
|
+
let inEntries = false;
|
|
84
|
+
for (const line of text.split("\n")) {
|
|
85
|
+
if (!line || line.startsWith("#")) continue;
|
|
86
|
+
if (line === "entries:") {
|
|
87
|
+
inEntries = true;
|
|
88
|
+
} else if (inEntries) {
|
|
89
|
+
if (!line.startsWith("- ")) continue;
|
|
90
|
+
try {
|
|
91
|
+
entries.push(JSON.parse(line.slice(2)) as Entry);
|
|
92
|
+
} catch {
|
|
93
|
+
// A line that is not an entry is skipped; the book stays readable.
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
const split = line.indexOf(": ");
|
|
97
|
+
if (split < 0) continue;
|
|
98
|
+
try {
|
|
99
|
+
header[line.slice(0, split)] = JSON.parse(line.slice(split + 2));
|
|
100
|
+
} catch {
|
|
101
|
+
// A header line that is not JSON is skipped.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { path, header, entries };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function openBook(who: Who, env: NodeJS.ProcessEnv = process.env): Book | undefined {
|
|
109
|
+
return parseBook(bookPath(who, env));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A subagent that closed its loop keeps its book for main; such a book is no longer an open loop. */
|
|
113
|
+
export function isClosed(book: Book): boolean {
|
|
114
|
+
return book.entries.at(-1)?.stage === "close";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The open loop's book, or undefined when there is none or the book was closed. */
|
|
118
|
+
export function openLoop(who: Who, env: NodeJS.ProcessEnv = process.env): Book | undefined {
|
|
119
|
+
const book = openBook(who, env);
|
|
120
|
+
return book && !isClosed(book) ? book : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function openForAppend(path: string): number {
|
|
124
|
+
try {
|
|
125
|
+
return openSync(path, "a");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// Another session's sweep may remove the directory between mkdir and open; recreate it once.
|
|
128
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
130
|
+
return openSync(path, "a");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function appendBytes(path: string, text: string): void {
|
|
135
|
+
const fd = openForAppend(path);
|
|
136
|
+
try {
|
|
137
|
+
const bytes = Buffer.from(text);
|
|
138
|
+
const written = writeSync(fd, bytes);
|
|
139
|
+
if (written !== bytes.length) throw new Error(`short write: ${written} of ${bytes.length} bytes`);
|
|
140
|
+
} finally {
|
|
141
|
+
closeSync(fd);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createBook(who: Who, env: NodeJS.ProcessEnv = process.env): Book {
|
|
146
|
+
const path = bookPath(who, env);
|
|
147
|
+
if (existsSync(path)) deleteBook(path);
|
|
148
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
149
|
+
const child = childId(env);
|
|
150
|
+
const header = {
|
|
151
|
+
// Unique per loop: the file name follows the session, so a reopened loop must not reuse an old id.
|
|
152
|
+
id: `${basename(path, ".yml")}-${randomUUID().slice(0, 8)}`,
|
|
153
|
+
owner: child ? env.PI_SUBAGENT_NAME || child : "main",
|
|
154
|
+
session: (child ? env.PI_SUBAGENT_SESSION : undefined) || who.sessionManager.getSessionFile?.() || null,
|
|
155
|
+
cwd: real(who.cwd),
|
|
156
|
+
opened: new Date().toISOString(),
|
|
157
|
+
};
|
|
158
|
+
const lines = ["# anchorloop loop book", ...Object.entries(header).map(([key, value]) => `${key}: ${JSON.stringify(value)}`), "entries:", ""];
|
|
159
|
+
appendBytes(path, lines.join("\n"));
|
|
160
|
+
return { path, header, entries: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function appendEntry(path: string, stage: string, fields: Record<string, unknown> = {}): Entry {
|
|
164
|
+
const entry: Entry = { ts: new Date().toISOString(), stage, ...fields };
|
|
165
|
+
appendBytes(path, `- ${JSON.stringify(entry)}\n`);
|
|
166
|
+
return entry;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The book of the child `id` among all projects, or undefined. */
|
|
170
|
+
export function findChildBook(id: string, env: NodeJS.ProcessEnv = process.env): Book | undefined {
|
|
171
|
+
const root = booksRoot(env);
|
|
172
|
+
if (!existsSync(root)) return undefined;
|
|
173
|
+
for (const project of readdirSync(root)) {
|
|
174
|
+
const book = parseBook(join(root, project, "agents", `${safe(id)}.yml`));
|
|
175
|
+
if (book) return book;
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function removeIfEmpty(dir: string): boolean {
|
|
181
|
+
try {
|
|
182
|
+
rmdirSync(dir);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Removes the `agents/` and project directories a deleted book leaves empty. */
|
|
190
|
+
function tidy(dir: string): void {
|
|
191
|
+
if (!removeIfEmpty(dir)) return;
|
|
192
|
+
const parent = dirname(dir);
|
|
193
|
+
if (basename(dir) === "agents") removeIfEmpty(parent);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function deleteBook(path: string): void {
|
|
197
|
+
try {
|
|
198
|
+
unlinkSync(path);
|
|
199
|
+
} catch {
|
|
200
|
+
// Already gone.
|
|
201
|
+
}
|
|
202
|
+
tidy(dirname(path));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type Sweep = { deleted: string[]; open: string[] };
|
|
206
|
+
|
|
207
|
+
function sessionGone(book: Book): boolean {
|
|
208
|
+
if (Date.now() - statSync(book.path).mtimeMs > STALE_BOOK_MS) return true;
|
|
209
|
+
const session = book.header.session;
|
|
210
|
+
return typeof session === "string" ? !existsSync(session) : Date.now() - statSync(book.path).mtimeMs > STALE_UNSAVED_MS;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Deletes books whose owner session file is gone, and child books that no open book links and whose own
|
|
215
|
+
* session file is gone too; a running child whose parent has not linked it yet stays. Lists the deletions
|
|
216
|
+
* of every project but only the open books of the project `cwd` belongs to.
|
|
217
|
+
*/
|
|
218
|
+
export function sweep(env: NodeJS.ProcessEnv = process.env, cwd?: string): Sweep {
|
|
219
|
+
const root = booksRoot(env);
|
|
220
|
+
const result: Sweep = { deleted: [], open: [] };
|
|
221
|
+
if (!existsSync(root)) return result;
|
|
222
|
+
const slug = cwd ? projectSlug(cwd) : undefined;
|
|
223
|
+
const linked = new Set<string>();
|
|
224
|
+
const children: Book[] = [];
|
|
225
|
+
// Other sessions create and tidy books concurrently, so a directory or file can vanish mid-walk; skip it.
|
|
226
|
+
for (const project of readdirSync(root)) {
|
|
227
|
+
try {
|
|
228
|
+
const dir = join(root, project);
|
|
229
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
230
|
+
for (const file of readdirSync(dir)) {
|
|
231
|
+
if (!file.endsWith(".yml")) continue;
|
|
232
|
+
const book = parseBook(join(dir, file));
|
|
233
|
+
if (!book) continue;
|
|
234
|
+
if (sessionGone(book)) {
|
|
235
|
+
deleteBook(book.path);
|
|
236
|
+
result.deleted.push(book.path);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!slug || project === slug) result.open.push(`${book.path} (${currentKind(book.entries) ?? "no kind"}: ${currentIntent(book.entries) ?? "no intent"})`);
|
|
240
|
+
for (const entry of book.entries) if (entry.stage === "link" && typeof entry.child === "string") linked.add(safe(entry.child));
|
|
241
|
+
}
|
|
242
|
+
const agents = join(dir, "agents");
|
|
243
|
+
if (existsSync(agents)) {
|
|
244
|
+
for (const file of readdirSync(agents)) {
|
|
245
|
+
const child = file.endsWith(".yml") ? parseBook(join(agents, file)) : undefined;
|
|
246
|
+
if (child) children.push(child);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// Vanished under a concurrent tidy.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const child of children) {
|
|
254
|
+
try {
|
|
255
|
+
if (linked.has(basename(child.path, ".yml")) || !sessionGone(child)) continue;
|
|
256
|
+
deleteBook(child.path);
|
|
257
|
+
result.deleted.push(child.path);
|
|
258
|
+
} catch {
|
|
259
|
+
// Vanished under a concurrent tidy.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const project of readdirSync(root)) {
|
|
263
|
+
const dir = join(root, project);
|
|
264
|
+
removeIfEmpty(join(dir, "agents"));
|
|
265
|
+
removeIfEmpty(dir);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- derived state -------------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
export function currentKind(entries: Entry[]): Kind | undefined {
|
|
273
|
+
const found = [...entries].reverse().find((entry) => entry.stage === "kind" && typeof entry.kind === "string");
|
|
274
|
+
return found?.kind as Kind | undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function currentIntent(entries: Entry[]): string | undefined {
|
|
278
|
+
const found = [...entries].reverse().find((entry) => entry.stage === "kind" && typeof entry.intent === "string");
|
|
279
|
+
return found?.intent as string | undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const NOT_STAGES = new Set(["base", "link", "close", "task", "taskrun", "verification", "skip", "back", "friction", "reflect", "goldfish"]);
|
|
283
|
+
|
|
284
|
+
/** The stages the book shows, in order of first appearance; `edit` entries read as `act`. */
|
|
285
|
+
export function stagesDone(entries: Entry[]): string[] {
|
|
286
|
+
const seen: string[] = [];
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (NOT_STAGES.has(entry.stage)) continue;
|
|
289
|
+
const name = entry.stage === "edit" ? "act" : entry.stage;
|
|
290
|
+
if (!seen.includes(name)) seen.push(name);
|
|
291
|
+
}
|
|
292
|
+
return seen;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function lastIndex(entries: Entry[], ...stages: string[]): number {
|
|
296
|
+
for (let i = entries.length - 1; i >= 0; i--) if (stages.includes(entries[i].stage)) return i;
|
|
297
|
+
return -1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The book's entries with the change entries of its linked children merged in by time, each marked `child`, so a coordinator
|
|
302
|
+
* whose edits are all made by children still shows an act. Indexes into the result differ from indexes into `entries`.
|
|
303
|
+
*/
|
|
304
|
+
export function withChildActs(entries: Entry[], env: NodeJS.ProcessEnv = process.env): Entry[] {
|
|
305
|
+
const acts: Entry[] = [];
|
|
306
|
+
for (const id of childIds(entries)) {
|
|
307
|
+
const child = findChildBook(id, env);
|
|
308
|
+
if (child) acts.push(...child.entries.filter((entry) => entry.stage === "edit").map((entry) => ({ ...entry, child: id })));
|
|
309
|
+
}
|
|
310
|
+
if (acts.length === 0) return entries;
|
|
311
|
+
acts.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
312
|
+
const merged: Entry[] = [];
|
|
313
|
+
for (const entry of entries) {
|
|
314
|
+
while (acts.length && acts[0].ts < entry.ts) merged.push(acts.shift() as Entry);
|
|
315
|
+
merged.push(entry);
|
|
316
|
+
}
|
|
317
|
+
return [...merged, ...acts];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The index of the latest change entry (a production write or edit, a commit), or -1. */
|
|
321
|
+
export function lastActIndex(entries: Entry[]): number {
|
|
322
|
+
return lastIndex(entries, "edit");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function reviews(entries: Entry[]): Entry[] {
|
|
326
|
+
return entries.filter((entry) => entry.stage === "review");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function isBlocking(entry: Entry): boolean {
|
|
330
|
+
return entry.blocking === true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** True when the two latest review rounds both still had blocking findings. */
|
|
334
|
+
export function reviewsStalled(entries: Entry[]): boolean {
|
|
335
|
+
const rounds = reviews(entries);
|
|
336
|
+
return rounds.length >= 2 && isBlocking(rounds[rounds.length - 1]) && isBlocking(rounds[rounds.length - 2]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function reviewTrend(entries: Entry[]): string {
|
|
340
|
+
const rounds = reviews(entries);
|
|
341
|
+
if (rounds.length === 0) return "no review";
|
|
342
|
+
return `${rounds.length} round(s): ${rounds.map((round) => (round.blocking === true ? `blocking${round.p0 !== undefined ? ` P0=${round.p0} P1=${round.p1}` : ""}` : round.blocking === false ? "clear" : "unknown")).join(" → ")}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A review recorded after the last change with no blocking finding on it. */
|
|
346
|
+
export function reviewClearAfterLastAct(raw: Entry[]): boolean {
|
|
347
|
+
const entries = withChildActs(raw);
|
|
348
|
+
const review = lastIndex(entries, "review");
|
|
349
|
+
return review >= 0 && review > lastActIndex(entries) && entries[review].blocking === false;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The latest anchor text, matched against bash commands; an anchor that never occurs in a command is verified by declaration. */
|
|
353
|
+
export function anchorCommand(entries: Entry[]): string | undefined {
|
|
354
|
+
return latestAnchor(entries);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The latest anchor of either kind, for display. */
|
|
358
|
+
export function latestAnchor(entries: Entry[]): string | undefined {
|
|
359
|
+
const found = [...entries].reverse().find((entry) => entry.stage === "anchor" && typeof entry.check === "string");
|
|
360
|
+
return found?.check as string | undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function evidenceLine(entry: Entry): string {
|
|
364
|
+
return `${entry.stage} ${entry.declared ? `declared: ${entry.note ?? entry.result}` : `exit ${entry.exit ?? "unknown"}${entry.last ? `: ${entry.last}` : ""}`}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The latest check or verify entry after the last change, or undefined when none follows it. */
|
|
368
|
+
export function verifyAfterLastAct(raw: Entry[]): Entry | undefined {
|
|
369
|
+
const entries = withChildActs(raw);
|
|
370
|
+
const index = lastIndex(entries, "check", "verify");
|
|
371
|
+
return index >= 0 && index > lastActIndex(entries) ? entries[index] : undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function childIds(entries: Entry[]): string[] {
|
|
375
|
+
return entries.filter((entry) => entry.stage === "link" && typeof entry.child === "string").map((entry) => entry.child as string);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** One line per linked child book that exists. */
|
|
379
|
+
export function childLines(entries: Entry[], env: NodeJS.ProcessEnv = process.env): string[] {
|
|
380
|
+
const lines: string[] = [];
|
|
381
|
+
for (const id of childIds(entries)) {
|
|
382
|
+
const link = entries.find((entry) => entry.stage === "link" && entry.child === id);
|
|
383
|
+
const child = findChildBook(id, env);
|
|
384
|
+
if (!child) continue;
|
|
385
|
+
const lastCheck = [...child.entries].reverse().find((entry) => entry.stage === "check" || entry.stage === "verify");
|
|
386
|
+
const friction = frictionOf(child.entries).length;
|
|
387
|
+
lines.push(
|
|
388
|
+
`${link?.name ?? id}: ${currentKind(child.entries) ?? "no kind"}${isClosed(child) ? " (closed)" : ""}; stages ${stagesDone(child.entries).join(", ") || "none"}; ${lastCheck ? evidenceLine(lastCheck) : "no check"}${friction ? `; ${friction} friction` : ""}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return lines;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// --- friction and reflection ---------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
export function frictionOf(entries: Entry[]): Entry[] {
|
|
397
|
+
return entries.filter((entry) => entry.stage === "friction");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export type FrictionItem = { ref: string; owner: string; tried: string; found: string; led_by: string; reflected: boolean; class?: string; action?: string };
|
|
401
|
+
|
|
402
|
+
function coveringReflect(entries: Entry[], refs: string[]): Entry | undefined {
|
|
403
|
+
return entries.find((entry) => entry.stage === "reflect" && Array.isArray(entry.friction) && entry.friction.some((id: unknown) => refs.includes(String(id))));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function item(owner: string, ref: string, entry: Entry, reflect: Entry | undefined): FrictionItem {
|
|
407
|
+
return {
|
|
408
|
+
ref,
|
|
409
|
+
owner,
|
|
410
|
+
tried: String(entry.tried),
|
|
411
|
+
found: String(entry.found),
|
|
412
|
+
led_by: String(entry.led_by ?? "none"),
|
|
413
|
+
reflected: reflect !== undefined,
|
|
414
|
+
...(reflect ? { class: String(reflect.class), action: String(reflect.action ?? "") } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Every friction entry of the book and of its linked children, each with the reflect that covers it, if any. */
|
|
419
|
+
export function allFriction(entries: Entry[], env: NodeJS.ProcessEnv = process.env): FrictionItem[] {
|
|
420
|
+
const items = frictionOf(entries).map((entry) => item("main", String(entry.id), entry, coveringReflect(entries, [String(entry.id)])));
|
|
421
|
+
for (const id of childIds(entries)) {
|
|
422
|
+
const child = findChildBook(id, env);
|
|
423
|
+
if (!child) continue;
|
|
424
|
+
const link = entries.find((entry) => entry.stage === "link" && entry.child === id);
|
|
425
|
+
const label = String(link?.name ?? id);
|
|
426
|
+
for (const entry of frictionOf(child.entries)) {
|
|
427
|
+
const reflect = coveringReflect(entries, [`${label}#${entry.id}`, `${id}#${entry.id}`]) ?? coveringReflect(child.entries, [String(entry.id)]);
|
|
428
|
+
items.push(item(label, `${label}#${entry.id}`, entry, reflect));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return items;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export type RetryPair = { key: string; fail: number; pass: number };
|
|
435
|
+
|
|
436
|
+
/** Regressions: a run that failed after the same command had passed in this loop, and then passed again. A failure before any pass is red-first work, not a retry. */
|
|
437
|
+
export function retryPairs(entries: Entry[]): RetryPair[] {
|
|
438
|
+
const pairs: RetryPair[] = [];
|
|
439
|
+
const passed = new Set<string>();
|
|
440
|
+
const failed = new Map<string, number>();
|
|
441
|
+
entries.forEach((entry, index) => {
|
|
442
|
+
if (entry.declared === true) return;
|
|
443
|
+
const key = entry.stage === "taskrun" ? `task#${entry.id}` : entry.stage === "verify" || entry.stage === "check" ? `anchor:${String(entry.command ?? "")}` : undefined;
|
|
444
|
+
if (!key || typeof entry.exit !== "number") return;
|
|
445
|
+
if (entry.exit !== 0) {
|
|
446
|
+
if (passed.has(key) && !failed.has(key)) failed.set(key, index);
|
|
447
|
+
} else {
|
|
448
|
+
if (failed.has(key)) {
|
|
449
|
+
pairs.push({ key, fail: failed.get(key) as number, pass: index });
|
|
450
|
+
failed.delete(key);
|
|
451
|
+
}
|
|
452
|
+
passed.add(key);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
return pairs;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** A retry pair that no friction entry follows. */
|
|
459
|
+
export function retryWithoutFriction(entries: Entry[]): boolean {
|
|
460
|
+
const lastFriction = lastIndex(entries, "friction");
|
|
461
|
+
return retryPairs(entries).some((pair) => pair.pass > lastFriction);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --- todos ---------------------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
export type Task = { id: number; subject: string; status: string; metadata?: Record<string, unknown> };
|
|
467
|
+
|
|
468
|
+
const TASK_STATUSES = new Set(["pending", "in_progress", "completed", "deleted"]);
|
|
469
|
+
|
|
470
|
+
export function taskDetails(value: unknown): Task[] | undefined {
|
|
471
|
+
if (!value || typeof value !== "object") return undefined;
|
|
472
|
+
const tasks = (value as { tasks?: unknown }).tasks;
|
|
473
|
+
if (!Array.isArray(tasks)) return undefined;
|
|
474
|
+
const valid = tasks.every((task) => task && typeof task.subject === "string" && TASK_STATUSES.has(task.status));
|
|
475
|
+
return valid ? (tasks as Task[]) : undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The todo list as of the latest `rpiv-todo-state` entry, else the latest `todo` tool result, on the branch. */
|
|
479
|
+
export function replayTodos(branch: unknown[]): Task[] {
|
|
480
|
+
let fromEntries: Task[] | undefined;
|
|
481
|
+
let fromResults: Task[] | undefined;
|
|
482
|
+
for (const raw of branch) {
|
|
483
|
+
const entry = raw as { type?: string; customType?: string; data?: unknown; message?: { role?: string; toolName?: string; details?: unknown } };
|
|
484
|
+
if (entry.type === "custom") {
|
|
485
|
+
if (entry.customType === "rpiv-todo-state") fromEntries = taskDetails(entry.data) ?? fromEntries;
|
|
486
|
+
} else if (entry.type === "message" && entry.message?.role === "toolResult" && entry.message.toolName === "todo") {
|
|
487
|
+
fromResults = taskDetails(entry.message.details) ?? fromResults;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return (fromEntries ?? fromResults ?? []).filter((task) => task.status !== "deleted");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export const isOpenTask = (task: Task): boolean => task.status === "pending" || task.status === "in_progress";
|
|
494
|
+
|
|
495
|
+
const TICKET = /^(#\d+|[A-Za-z][A-Za-z0-9]*-\d+)$/;
|
|
496
|
+
|
|
497
|
+
/** True when `followup` is a ticket id, `memory`, or `drop — <reason>`. */
|
|
498
|
+
export function validFollowup(followup: unknown): boolean {
|
|
499
|
+
if (typeof followup !== "string") return false;
|
|
500
|
+
const value = followup.trim();
|
|
501
|
+
return TICKET.test(value) || value === "memory" || /^drop\s*[—–-]\s*\S/.test(value);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// --- git -----------------------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
function git(cwd: string, args: string[]): string | undefined {
|
|
507
|
+
try {
|
|
508
|
+
return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
509
|
+
} catch {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function existingDir(path: string): string {
|
|
515
|
+
let dir = path;
|
|
516
|
+
while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir);
|
|
517
|
+
return dir;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The repo root and HEAD that contain `path`, or undefined outside a repo or before its first commit. */
|
|
521
|
+
export function repoOf(path: string): { repo: string; head: string } | undefined {
|
|
522
|
+
const dir = existingDir(dirname(resolve(path)));
|
|
523
|
+
const repo = git(dir, ["rev-parse", "--show-toplevel"]);
|
|
524
|
+
const head = repo ? git(repo, ["rev-parse", "HEAD"]) : undefined;
|
|
525
|
+
return repo && head ? { repo: real(repo), head } : undefined;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The root of the git repo containing `path`, or undefined outside a repo. */
|
|
529
|
+
export function repoRootOf(path: string): string | undefined {
|
|
530
|
+
const repo = git(existingDir(dirname(resolve(path))), ["rev-parse", "--show-toplevel"]);
|
|
531
|
+
return repo ? real(repo) : undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function bases(entries: Entry[]): { repo: string; head: string }[] {
|
|
535
|
+
return entries.filter((entry) => entry.stage === "base").map((entry) => ({ repo: String(entry.repo), head: String(entry.head) }));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Adds the repo of `path` to the book with its HEAD when the book has no base for it yet. */
|
|
539
|
+
export function touchRepo(book: Book, path: string): boolean {
|
|
540
|
+
const found = repoOf(path);
|
|
541
|
+
if (!found) return false;
|
|
542
|
+
if (bases(book.entries).some((base) => base.repo === found.repo)) return false;
|
|
543
|
+
const entry = appendEntry(book.path, "base", found);
|
|
544
|
+
book.entries.push(entry);
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export type Diff = { summary: string; files: string[]; paths: string[] };
|
|
549
|
+
|
|
550
|
+
/** The diff of each base's repo against its recorded HEAD, as one summary line per repo and the changed files. */
|
|
551
|
+
export function diffAgainstBases(entries: Entry[]): Diff {
|
|
552
|
+
const lines: string[] = [];
|
|
553
|
+
const files: string[] = [];
|
|
554
|
+
const paths: string[] = [];
|
|
555
|
+
for (const { repo, head } of bases(entries)) {
|
|
556
|
+
const stat = git(repo, ["diff", "--shortstat", head]);
|
|
557
|
+
const names = git(repo, ["diff", "--name-only", head])?.split("\n").filter(Boolean) ?? [];
|
|
558
|
+
const untracked = git(repo, ["ls-files", "--others", "--exclude-standard"])?.split("\n").filter(Boolean) ?? [];
|
|
559
|
+
if (stat === undefined) {
|
|
560
|
+
lines.push(`${basename(repo)}: diff unavailable`);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (stat) lines.push(`${basename(repo)}: ${stat}`);
|
|
564
|
+
files.push(...names.map((name) => `${basename(repo)}/${name}`), ...untracked.map((name) => `${basename(repo)}/${name}`));
|
|
565
|
+
paths.push(...[...names, ...untracked].map((name) => join(repo, name)));
|
|
566
|
+
}
|
|
567
|
+
return { summary: lines.join("\n") || (bases(entries).length ? "no tracked changes" : "no repository"), files, paths };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function diffText(diff: Diff, limit = 1200): string {
|
|
571
|
+
const shown = diff.files.slice(0, 30).join(", ");
|
|
572
|
+
return `${diff.summary}${shown ? `\nfiles: ${shown}${diff.files.length > 30 ? ", …" : ""}` : ""}`.slice(0, limit);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// --- durable artifacts ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
const ARTIFACT = /(?:^|\/)docs\/specs\/[^/]+\/(decision|spec|design|verification)\.md$/;
|
|
578
|
+
|
|
579
|
+
export function artifactKind(path: string): "decision" | "spec" | "design" | "verification" | undefined {
|
|
580
|
+
return ARTIFACT.exec(path.replace(/\\/g, "/"))?.[1] as "decision" | "spec" | "design" | "verification" | undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const hash = (text: string): string => createHash("sha1").update(text).digest("hex").slice(0, 8);
|
|
584
|
+
|
|
585
|
+
function sections(text: string): Record<string, string> {
|
|
586
|
+
const found: Record<string, string> = {};
|
|
587
|
+
let heading: string | undefined;
|
|
588
|
+
let body: string[] = [];
|
|
589
|
+
const flush = () => {
|
|
590
|
+
if (heading === undefined) return;
|
|
591
|
+
let key = heading;
|
|
592
|
+
for (let n = 2; key in found; n++) key = `${heading} #${n}`;
|
|
593
|
+
found[key] = hash(body.join("\n").trim());
|
|
594
|
+
};
|
|
595
|
+
for (const line of text.split("\n")) {
|
|
596
|
+
const match = /^#{1,6}\s+(.*\S)\s*$/.exec(line);
|
|
597
|
+
if (match) {
|
|
598
|
+
flush();
|
|
599
|
+
heading = match[1];
|
|
600
|
+
body = [];
|
|
601
|
+
} else {
|
|
602
|
+
body.push(line);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
flush();
|
|
606
|
+
return found;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function tableRows(text: string): string[][] {
|
|
610
|
+
return text
|
|
611
|
+
.split("\n")
|
|
612
|
+
.filter((line) => /^\s*\|.*\|\s*$/.test(line))
|
|
613
|
+
.map((line) => line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim()))
|
|
614
|
+
.filter((cells) => cells.length >= 2 && !cells.every((cell) => /^:?-{2,}:?$/.test(cell)));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function previous(entries: Entry[], stage: string, path: string): Entry | undefined {
|
|
618
|
+
return [...entries].reverse().find((entry) => entry.stage === stage && entry.auto === true && entry.path === path);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** The entry fields summarising `text` of an artifact, compared with the book's last entry for the same file. */
|
|
622
|
+
export function summarizeArtifact(entries: Entry[], path: string, text: string): { stage: string; fields: Record<string, unknown> } | undefined {
|
|
623
|
+
const kind = artifactKind(path);
|
|
624
|
+
if (!kind) return undefined;
|
|
625
|
+
const before = previous(entries, kind, path);
|
|
626
|
+
const fields: Record<string, unknown> = { auto: true, path };
|
|
627
|
+
if (kind === "decision") {
|
|
628
|
+
const rows = tableRows(text).filter((cells) => ["grounded", "confirmed", "open"].includes(cells[cells.length - 1].toLowerCase()));
|
|
629
|
+
const state: Record<string, string> = {};
|
|
630
|
+
const questions: Record<string, string> = {};
|
|
631
|
+
const counts: Record<string, number> = { grounded: 0, confirmed: 0, open: 0 };
|
|
632
|
+
for (const cells of rows) {
|
|
633
|
+
const status = cells[cells.length - 1].toLowerCase();
|
|
634
|
+
counts[status]++;
|
|
635
|
+
state[hash(cells[0])] = status;
|
|
636
|
+
questions[hash(cells[0])] = cells[0];
|
|
637
|
+
}
|
|
638
|
+
const old = (before?.rowState ?? {}) as Record<string, string>;
|
|
639
|
+
fields.counts = counts;
|
|
640
|
+
fields.changed = Object.keys(state).filter((key) => old[key] !== state[key]).map((key) => questions[key]);
|
|
641
|
+
fields.rowState = state;
|
|
642
|
+
return { stage: kind, fields };
|
|
643
|
+
}
|
|
644
|
+
const now = sections(text);
|
|
645
|
+
const old = (before?.sections ?? {}) as Record<string, string>;
|
|
646
|
+
fields.sections = now;
|
|
647
|
+
fields.changed = Object.keys(now).filter((heading) => old[heading] !== now[heading]);
|
|
648
|
+
if (before) fields.removed = Object.keys(old).filter((heading) => !(heading in now));
|
|
649
|
+
if (kind === "spec") {
|
|
650
|
+
fields.status = /^\s*Status:\s*(\S+)/m.exec(text)?.[1] ?? null;
|
|
651
|
+
fields.approvedFrom = /^\s*Approved from:\s*\S/m.test(text);
|
|
652
|
+
}
|
|
653
|
+
if (kind === "design") fields.approach = approachFilled(text);
|
|
654
|
+
if (kind === "verification") {
|
|
655
|
+
const counts: Record<string, number> = { pass: 0, fail: 0, unknown: 0 };
|
|
656
|
+
for (const cells of tableRows(text)) {
|
|
657
|
+
const result = cells[cells.length - 1].toLowerCase();
|
|
658
|
+
if (result in counts) counts[result]++;
|
|
659
|
+
}
|
|
660
|
+
fields.counts = counts;
|
|
661
|
+
}
|
|
662
|
+
return { stage: kind, fields };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** True when a heading named Approach (or 做法) has text under it, subsections included. */
|
|
666
|
+
export function approachFilled(text: string): boolean {
|
|
667
|
+
const lines = text.split("\n");
|
|
668
|
+
const start = lines.findIndex((line) => /^#{1,6}\s+.*(approach|做法)/i.test(line));
|
|
669
|
+
if (start < 0) return false;
|
|
670
|
+
const depth = /^(#+)/.exec(lines[start])?.[1].length ?? 1;
|
|
671
|
+
for (const line of lines.slice(start + 1)) {
|
|
672
|
+
const heading = /^(#+)\s/.exec(line);
|
|
673
|
+
if (heading && heading[1].length <= depth) break;
|
|
674
|
+
if (line.trim() && !heading) return true;
|
|
675
|
+
}
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const SPEC_FOLDER = /docs\/specs\/[^\s/#"'`)\]]+/;
|
|
680
|
+
|
|
681
|
+
/** The spec folder of the loop: the one a recorded artifact lives in, else one named by the intent or a todo, if it exists. */
|
|
682
|
+
export function specFolder(entries: Entry[], todos: Task[], cwd: string): string | undefined {
|
|
683
|
+
const artifact = [...entries].reverse().find((entry) => typeof entry.path === "string" && artifactKind(entry.path));
|
|
684
|
+
if (artifact) return dirname(artifact.path as string);
|
|
685
|
+
const texts = [
|
|
686
|
+
currentIntent(entries) ?? "",
|
|
687
|
+
...entries.filter((entry) => entry.stage === "task").flatMap((entry) => [String(entry.subject ?? ""), ...((entry.refs ?? []) as { ref: string }[]).map((item) => item.ref)]),
|
|
688
|
+
...todos.flatMap((task) => [task.subject, ...refsOf(task.metadata), String(task.metadata?.anchor ?? "")]),
|
|
689
|
+
];
|
|
690
|
+
for (const text of texts) {
|
|
691
|
+
const found = SPEC_FOLDER.exec(text)?.[0];
|
|
692
|
+
if (found && existsSync(resolve(cwd, found))) return found;
|
|
693
|
+
}
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** The artifact `name` in `folder` as a summary entry read from disk, for a loop that did not write it. */
|
|
698
|
+
export function artifactOnDisk(folder: string, name: string, cwd: string): Entry | undefined {
|
|
699
|
+
const path = join(folder, `${name}.md`);
|
|
700
|
+
try {
|
|
701
|
+
const text = readFileSync(resolve(cwd, path), "utf-8");
|
|
702
|
+
const summary = summarizeArtifact([], path, text);
|
|
703
|
+
return summary ? { ts: "", stage: summary.stage, ...summary.fields } : undefined;
|
|
704
|
+
} catch {
|
|
705
|
+
return undefined;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// --- todo links ----------------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
const MARKDOWN = /\.(md|markdown)$/i;
|
|
712
|
+
|
|
713
|
+
const isMarkdownRef = (ref: string): boolean => MARKDOWN.test(ref.split("#")[0]);
|
|
714
|
+
|
|
715
|
+
/** The `ref` metadata of a task as pointers: `<path>#<heading>` into markdown, or the path of any other file. */
|
|
716
|
+
export function refsOf(metadata: Record<string, unknown> | undefined): string[] {
|
|
717
|
+
const ref = metadata?.ref;
|
|
718
|
+
const list = Array.isArray(ref) ? ref : [ref];
|
|
719
|
+
return list.filter((item): item is string => typeof item === "string" && item.trim() !== "" && (item.includes("#") || !isMarkdownRef(item)));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* The line range of the section `<path>#<heading>` names, such as `L88-L101`, or `missing` when the file or heading is gone.
|
|
724
|
+
* A ref to a file that is not markdown has no headings to resolve and returns "" (shown as given). Relative paths resolve against
|
|
725
|
+
* the cwd, the loop's repos, and the folders of recorded artifacts.
|
|
726
|
+
*/
|
|
727
|
+
export function resolveRef(ref: string, cwd: string, entries: Entry[]): string {
|
|
728
|
+
if (!isMarkdownRef(ref)) return "";
|
|
729
|
+
const split = ref.indexOf("#");
|
|
730
|
+
if (split < 0) return "";
|
|
731
|
+
const path = ref.slice(0, split);
|
|
732
|
+
const heading = ref.slice(split + 1).trim();
|
|
733
|
+
const dirs = [
|
|
734
|
+
cwd,
|
|
735
|
+
...bases(entries).map((base) => base.repo),
|
|
736
|
+
...new Set(entries.filter((entry) => typeof entry.path === "string").map((entry) => dirname(resolve(cwd, entry.path as string)))),
|
|
737
|
+
];
|
|
738
|
+
for (const dir of dirs) {
|
|
739
|
+
let lines: string[];
|
|
740
|
+
try {
|
|
741
|
+
lines = readFileSync(resolve(dir, path), "utf-8").split("\n");
|
|
742
|
+
} catch {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const found = lines.map((line) => /^(#{1,6})\s+(.*\S)\s*$/.exec(line));
|
|
746
|
+
let start = found.findIndex((match) => match?.[2] === heading);
|
|
747
|
+
if (start < 0) start = found.findIndex((match) => match?.[2].toLowerCase() === heading.toLowerCase());
|
|
748
|
+
if (start < 0) continue;
|
|
749
|
+
const depth = found[start]?.[1].length ?? 1;
|
|
750
|
+
let end = lines.length;
|
|
751
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
752
|
+
const match = found[i];
|
|
753
|
+
if (match && match[1].length <= depth) {
|
|
754
|
+
end = i;
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
while (end > start + 1 && !lines[end - 1].trim()) end--;
|
|
759
|
+
return `L${start + 1}-L${end}`;
|
|
760
|
+
}
|
|
761
|
+
return "missing";
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** The latest `task` entry of each task id, in id order. */
|
|
765
|
+
export function latestTasks(entries: Entry[]): Entry[] {
|
|
766
|
+
const byId = new Map<number, Entry>();
|
|
767
|
+
for (const entry of entries) if (entry.stage === "task" && typeof entry.id === "number") byId.set(entry.id, entry);
|
|
768
|
+
return [...byId.values()].sort((a, b) => (a.id as number) - (b.id as number));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export const TASK_LEGEND = "#id status subject — anchor (last exit) — ref → line range; missing = heading gone";
|
|
772
|
+
|
|
773
|
+
/** One line per live task: its own anchor with the last exit code of a matching bash call, and its refs with current line ranges. */
|
|
774
|
+
export function taskLines(entries: Entry[], todos: Task[], cwd: string): string[] {
|
|
775
|
+
const live = new Map(todos.map((task) => [task.id, task]));
|
|
776
|
+
const exits = new Map<number, Entry>();
|
|
777
|
+
for (const entry of entries) if (entry.stage === "taskrun" && typeof entry.id === "number") exits.set(entry.id, entry);
|
|
778
|
+
return latestTasks(entries)
|
|
779
|
+
.filter((entry) => live.has(entry.id as number))
|
|
780
|
+
.map((entry) => {
|
|
781
|
+
const task = live.get(entry.id as number);
|
|
782
|
+
const run = exits.get(entry.id as number);
|
|
783
|
+
const anchor = typeof entry.anchor === "string" ? `${entry.anchor}${run ? ` (exit ${run.exit})` : ""}` : "loop anchor";
|
|
784
|
+
const refs = ((entry.refs ?? []) as { ref: string }[]).map((item) => {
|
|
785
|
+
const range = resolveRef(item.ref, cwd, entries);
|
|
786
|
+
return range ? `${item.ref} → ${range}` : item.ref;
|
|
787
|
+
});
|
|
788
|
+
const followup = task?.metadata?.followup ?? entry.followup;
|
|
789
|
+
return [`#${entry.id} ${task?.status} ${task?.subject}`, anchor, ...(refs.length ? [refs.join(", ")] : []), ...(followup !== undefined ? [`followup: ${String(followup)}`] : [])].join(" — ");
|
|
790
|
+
});
|
|
791
|
+
}
|