@turri-dev/cli 0.1.8-next.e553476 → 0.1.9-next.73bfddf
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 +1 -3
- package/dist/cli.js +6 -2
- package/dist/config-edit.d.ts +1 -1
- package/dist/config-edit.js +4 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +28 -1
- package/dist/extension.js +75 -9
- package/dist/genome.js +2 -0
- package/dist/host.d.ts +3 -1
- package/dist/host.js +4 -1
- package/dist/init-config.d.ts +3 -0
- package/dist/init-config.js +15 -0
- package/dist/ledger-cli.d.ts +1 -0
- package/dist/ledger-cli.js +50 -0
- package/dist/ledger-tools.d.ts +10 -0
- package/dist/ledger-tools.js +64 -0
- package/dist/loop-cli.js +4 -0
- package/dist/pi.d.ts +2 -0
- package/dist/pi.js +24 -4
- package/dist/regenerate/activity.d.ts +9 -0
- package/dist/regenerate/activity.js +11 -0
- package/dist/regenerate/agent-env.d.ts +7 -0
- package/dist/regenerate/agent-env.js +126 -5
- package/dist/regenerate/agent-shell.js +5 -0
- package/dist/regenerate/chunk-plan.d.ts +28 -9
- package/dist/regenerate/chunk-plan.js +141 -17
- package/dist/regenerate/conformance-agents.js +2 -2
- package/dist/regenerate/conformance.js +6 -3
- package/dist/regenerate/debug-log.d.ts +3 -1
- package/dist/regenerate/debug-log.js +10 -2
- package/dist/regenerate/diagnose.js +12 -2
- package/dist/regenerate/environment-error.d.ts +6 -0
- package/dist/regenerate/environment-error.js +19 -1
- package/dist/regenerate/job-log.d.ts +3 -1
- package/dist/regenerate/job-log.js +8 -5
- package/dist/regenerate/jobs-tree.d.ts +7 -3
- package/dist/regenerate/jobs-tree.js +21 -6
- package/dist/regenerate/jobs.d.ts +9 -0
- package/dist/regenerate/jobs.js +51 -5
- package/dist/regenerate/ledger-view.d.ts +92 -0
- package/dist/regenerate/ledger-view.js +345 -0
- package/dist/regenerate/ledger.d.ts +275 -0
- package/dist/regenerate/ledger.js +349 -0
- package/dist/regenerate/lineage.d.ts +9 -1
- package/dist/regenerate/lineage.js +64 -12
- package/dist/regenerate/loop.js +12 -3
- package/dist/regenerate/pulse-files.d.ts +14 -0
- package/dist/regenerate/pulse-files.js +63 -0
- package/dist/regenerate/regenerate.js +7 -5
- package/dist/regenerate/regression.js +4 -2
- package/dist/regenerate/services.js +3 -0
- package/dist/regenerate/side-panel.d.ts +2 -0
- package/dist/regenerate/side-panel.js +4 -3
- package/dist/regenerate/test-scripts.js +18 -5
- package/dist/regenerate/testing.js +3 -0
- package/dist/regenerate/unit-review.js +3 -0
- package/dist/run-report.js +9 -5
- package/docs/config.md +23 -2
- package/docs/development.md +3 -1
- package/docs/headless.md +5 -4
- package/docs/hosting.md +29 -0
- package/docs/installation.md +1 -0
- package/docs/regenerating.md +11 -9
- package/docs/sandbox.md +5 -5
- package/docs/using.md +36 -5
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { DEFAULT_CONCEPTS, extractConceptsFromDefinition, extractConceptsFromSpe
|
|
|
4
4
|
import { ACCEPTANCE_TESTS, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS, TEST_REQUIREMENTS } from "../plain/spec.js";
|
|
5
5
|
import { ask, BEGIN, createExecutor } from "../pi.js";
|
|
6
6
|
import { reportActivity, reportStage } from "./activity.js";
|
|
7
|
+
import { firstLine, record } from "./ledger.js";
|
|
7
8
|
import { steeringBlock } from "./steer.js";
|
|
8
9
|
import { marksRegenerateAgain } from "./records.js";
|
|
9
10
|
import { validateConcepts } from "../plain/concepts.js";
|
|
@@ -70,6 +71,27 @@ export function stepsOf(input, draft) {
|
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
73
|
export const MAX_SPECS_PER_JOB = 3;
|
|
74
|
+
/** The most a pulse's title is - in words, and in characters - however it was made: a host shows it as its heading. */
|
|
75
|
+
export const TITLE_WORDS = 10;
|
|
76
|
+
export const TITLE_CHARS = 70;
|
|
77
|
+
/** Whether a title is within `TITLE_WORDS` and `TITLE_CHARS`. */
|
|
78
|
+
function fitsTitle(title) {
|
|
79
|
+
return title.trim().split(/\s+/).length <= TITLE_WORDS && title.trim().length <= TITLE_CHARS;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A title held to `TITLE_WORDS` and `TITLE_CHARS` - or less, for room beside it - cut at a word, with an ellipsis. Text
|
|
83
|
+
* cut already is cut again without stacking a second: several first lines joined, each cut, end on one ellipsis.
|
|
84
|
+
*/
|
|
85
|
+
export function shortTitle(text, chars = TITLE_CHARS, words = TITLE_WORDS) {
|
|
86
|
+
const all = text.trim().split(/\s+/).filter((word) => word !== "");
|
|
87
|
+
let cut = all.slice(0, words).join(" ");
|
|
88
|
+
if (all.length <= words && cut.length <= chars)
|
|
89
|
+
return cut;
|
|
90
|
+
cut = cut.replace(/[\s,.;:…-]+$/, "");
|
|
91
|
+
if (cut.length > chars - 1)
|
|
92
|
+
cut = cut.slice(0, chars - 1).replace(/\s+\S*$/, "") || cut.slice(0, chars - 1);
|
|
93
|
+
return `${cut.replace(/[\s,.;:…-]+$/, "")}…`;
|
|
94
|
+
}
|
|
73
95
|
export const MAX_LINES_PER_JOB = 120;
|
|
74
96
|
/** How often an answer that does not hold goes back to its agent before the plan falls back. */
|
|
75
97
|
const RETRIES = 2;
|
|
@@ -123,6 +145,31 @@ export function groupProblems(specs, answer, maxSpecs = MAX_SPECS_PER_JOB) {
|
|
|
123
145
|
}
|
|
124
146
|
if (group.title.trim() === "")
|
|
125
147
|
problems.push("Every group needs a title.");
|
|
148
|
+
else if (!fitsTitle(group.title))
|
|
149
|
+
problems.push(`"${group.title}" is too long for a title: at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters.`);
|
|
150
|
+
}
|
|
151
|
+
return problems;
|
|
152
|
+
}
|
|
153
|
+
/** What is wrong with a titling: every group titled once, by its number, none empty, none too long. */
|
|
154
|
+
export function titleProblems(groups, answer) {
|
|
155
|
+
const problems = [];
|
|
156
|
+
const numbers = groups.map((group) => group.number);
|
|
157
|
+
const seen = new Map();
|
|
158
|
+
for (const entry of answer.titles) {
|
|
159
|
+
if (!numbers.includes(entry.group))
|
|
160
|
+
problems.push(`There is no group ${entry.group}.`);
|
|
161
|
+
seen.set(entry.group, (seen.get(entry.group) ?? 0) + 1);
|
|
162
|
+
if (entry.title.trim() === "")
|
|
163
|
+
problems.push(`Group ${entry.group} needs a title.`);
|
|
164
|
+
else if (!fitsTitle(entry.title))
|
|
165
|
+
problems.push(`"${entry.title}" is too long for a title: at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters.`);
|
|
166
|
+
}
|
|
167
|
+
for (const number of numbers) {
|
|
168
|
+
const times = seen.get(number) ?? 0;
|
|
169
|
+
if (times === 0)
|
|
170
|
+
problems.push(`Group ${number} has no title: every group is titled.`);
|
|
171
|
+
if (times > 1)
|
|
172
|
+
problems.push(`Group ${number} is titled ${times} times: once.`);
|
|
126
173
|
}
|
|
127
174
|
return problems;
|
|
128
175
|
}
|
|
@@ -226,7 +273,8 @@ function draftOf(chunks, net) {
|
|
|
226
273
|
})
|
|
227
274
|
.filter((entry) => entry.changes.length > 0)
|
|
228
275
|
.map((entry) => ({
|
|
229
|
-
title
|
|
276
|
+
// However the title was made - an agent's, a spec's first line, several joined - it is held to the limit.
|
|
277
|
+
title: shortTitle(entry.chunk.title),
|
|
230
278
|
reason: entry.chunk.built !== undefined
|
|
231
279
|
? "what is built already, changed"
|
|
232
280
|
: entry.chunk.specs.length > 0 && entry.chunk.specs.every(regeneratesAgain)
|
|
@@ -237,7 +285,7 @@ function draftOf(chunks, net) {
|
|
|
237
285
|
}
|
|
238
286
|
/** A spec's first line, as a group's title when no agent gave one. */
|
|
239
287
|
function titleOf(change) {
|
|
240
|
-
return textOf(change).split("\n")[0].replace(/^-\s*/, "")
|
|
288
|
+
return shortTitle(textOf(change).split("\n")[0].replace(/^-\s*/, ""));
|
|
241
289
|
}
|
|
242
290
|
/**
|
|
243
291
|
* Cut the queue's work - from `input.base` to `input.current` - into jobs, as the three steps say. Returns the draft
|
|
@@ -245,11 +293,16 @@ function titleOf(change) {
|
|
|
245
293
|
* the plan did not do as asked - the person's wishes an agent did not follow and why, an answer that did not hold, a
|
|
246
294
|
* group split for its size - for the conversation to say, rather than guess.
|
|
247
295
|
*/
|
|
296
|
+
/** A planner agent that failed: the plan falls back, and the ledger says why. */
|
|
297
|
+
function plannerFailed(step, error) {
|
|
298
|
+
record({ kind: "planner.failed", step, error: firstLine(error instanceof Error ? error.message : String(error), 300) });
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
248
301
|
export async function planChunks(input, agents, notes) {
|
|
249
302
|
if (input.track !== "conformance")
|
|
250
303
|
return cut(input, agents, notes);
|
|
251
|
-
// The conformance tests are grouped by the harness, as one job as far as the guardrail lets:
|
|
252
|
-
return anchored(await cut(input), netChanges(input.base, input.current));
|
|
304
|
+
// The conformance tests are grouped by the harness, as one job as far as the guardrail lets: an agent only titles them.
|
|
305
|
+
return anchored(await cut(input, agents, notes), netChanges(input.base, input.current));
|
|
253
306
|
}
|
|
254
307
|
/**
|
|
255
308
|
* A conformance plan, every job of it testing a functional spec: a job that touches none - reqs or definitions alone -
|
|
@@ -370,8 +423,29 @@ async function cut(input, agents, notes) {
|
|
|
370
423
|
...list.map((group) => ({ title: group.title, specs: group.specs.map((number) => bySpec.get(number)), definitions: [], reqs: [] })),
|
|
371
424
|
];
|
|
372
425
|
const conformance = input.track === "conformance";
|
|
373
|
-
|
|
374
|
-
const
|
|
426
|
+
// The conformance tests are grouped and their reqs placed by the harness: their agents are only asked for titles.
|
|
427
|
+
const planners = conformance ? undefined : agents;
|
|
428
|
+
/**
|
|
429
|
+
* Groups titled for what their pulses do, by the title agent: those no agent titled - the conformance tests', a
|
|
430
|
+
* grouping that fell back. When it fails, or its answer does not hold, they keep their first lines.
|
|
431
|
+
*/
|
|
432
|
+
const titled = async (list) => {
|
|
433
|
+
if (agents?.title === undefined || list.length === 0)
|
|
434
|
+
return list;
|
|
435
|
+
const track = conformance ? "conformance" : "code";
|
|
436
|
+
reportStage(`planning the queue: a title for each of the ${list.length} pulse${list.length === 1 ? "" : "s"}`);
|
|
437
|
+
const toTitle = list.map((group, index) => ({ number: index + 1, specs: group.specs.map((number) => textOf(bySpec.get(number))) }));
|
|
438
|
+
const answer = (await agents.title(toTitle, (given) => titleProblems(toTitle, given), track).catch((error) => plannerFailed("title", error))) ?? undefined;
|
|
439
|
+
const problems = answer === undefined ? [] : titleProblems(toTitle, answer);
|
|
440
|
+
if (answer !== undefined && problems.length === 0)
|
|
441
|
+
return list.map((group, index) => ({ ...group, title: answer.titles.find((entry) => entry.group === index + 1).title.trim() }));
|
|
442
|
+
record({ kind: "planner.fellBack", step: "title", why: answer === undefined ? "no titles were given" : firstLine(problems.join(" "), 300) });
|
|
443
|
+
say(answer === undefined ? "the titles were not given - each pulse titled by its functional specs' first lines" : `the titles did not hold (${problems.join(" ")}) - each pulse titled by its functional specs' first lines`);
|
|
444
|
+
return list;
|
|
445
|
+
};
|
|
446
|
+
const grouped = conformance ? undefined : ((await agents?.group(numbered, (answer) => groupProblems(numbered, answer, maxSpecs), input.steering, maxSpecs).catch((error) => plannerFailed("group", error))) ?? undefined);
|
|
447
|
+
const holds = grouped !== undefined && groupProblems(numbered, grouped, maxSpecs).length === 0;
|
|
448
|
+
const made = conformance
|
|
375
449
|
? conformanceGroups(numbered, input.separately === true, maxLines, (list) => {
|
|
376
450
|
const trial = chunksFor(list);
|
|
377
451
|
placeDefinitions(trial, definitions);
|
|
@@ -380,17 +454,23 @@ async function cut(input, agents, notes) {
|
|
|
380
454
|
const frid = fridOf(bySpec.get(spec.number));
|
|
381
455
|
return frid === undefined ? -1 : (input.groups ?? []).findIndex((group) => group.includes(frid));
|
|
382
456
|
})
|
|
383
|
-
: grouped !== undefined &&
|
|
457
|
+
: grouped !== undefined && holds
|
|
384
458
|
? grouped.groups
|
|
385
459
|
: numbered.map((spec, index) => ({ specs: [spec.number], title: titleOf(specs[index]) }));
|
|
386
460
|
if (grouped?.notes !== undefined && grouped.notes.trim() !== "")
|
|
387
461
|
say(`the grouping: ${grouped.notes.trim()}`);
|
|
388
462
|
if (!conformance && agents !== undefined) {
|
|
389
|
-
if (grouped === undefined)
|
|
463
|
+
if (grouped === undefined) {
|
|
464
|
+
record({ kind: "planner.fellBack", step: "group", why: "no grouping was given" });
|
|
390
465
|
say("the grouping was not given - a functional spec a pulse");
|
|
391
|
-
|
|
466
|
+
}
|
|
467
|
+
else if (groupProblems(numbered, grouped, maxSpecs).length > 0) {
|
|
468
|
+
record({ kind: "planner.fellBack", step: "group", why: firstLine(groupProblems(numbered, grouped, maxSpecs).join(" "), 300) });
|
|
392
469
|
say(`the grouping did not hold (${groupProblems(numbered, grouped, maxSpecs).join(" ")}) - a functional spec a pulse`);
|
|
470
|
+
}
|
|
393
471
|
}
|
|
472
|
+
// Titled before the chunks are made of them: a group split for its size keeps its title, numbered.
|
|
473
|
+
const groups = holds ? made : await titled(made);
|
|
394
474
|
let chunks = chunksFor(groups);
|
|
395
475
|
// 2. The definitions each group uses.
|
|
396
476
|
placeDefinitions(chunks, definitions);
|
|
@@ -407,11 +487,13 @@ async function cut(input, agents, notes) {
|
|
|
407
487
|
implementationReqs: implementationReqs.map((change, index) => ({ number: index + 1, text: textOf(change) })),
|
|
408
488
|
testReqs: testReqs.map((change, index) => ({ number: index + 1, text: textOf(change) })),
|
|
409
489
|
};
|
|
410
|
-
const answer = (await
|
|
490
|
+
const answer = (await planners?.place(placeInput, (given) => placeProblems(placeInput, given)).catch((error) => plannerFailed("place", error))) ?? undefined;
|
|
411
491
|
if (answer?.notes !== undefined && answer.notes.trim() !== "")
|
|
412
492
|
say(`the placement: ${answer.notes.trim()}`);
|
|
413
493
|
placement = answer !== undefined && placeProblems(placeInput, answer).length === 0 ? answer : undefined;
|
|
414
|
-
if (placement === undefined &&
|
|
494
|
+
if (placement === undefined && planners !== undefined)
|
|
495
|
+
record({ kind: "planner.fellBack", step: "place", why: answer === undefined ? "no placement was given" : firstLine(placeProblems(placeInput, answer).join(" "), 300) });
|
|
496
|
+
if (placement === undefined && planners !== undefined)
|
|
415
497
|
say("the placement did not hold - every implementation req with the first pulse, every test req with the first conformance pulse");
|
|
416
498
|
}
|
|
417
499
|
/** A req's chunk: its group, the built job, or the first job of the new work for "all". */
|
|
@@ -458,7 +540,10 @@ async function cut(input, agents, notes) {
|
|
|
458
540
|
}
|
|
459
541
|
runs.push(run);
|
|
460
542
|
say(`"${group.title}" is past ${maxLines} lines with its definitions and reqs - split into ${runs.length} pulses`);
|
|
461
|
-
runs.forEach((specs, index) => split.push({
|
|
543
|
+
runs.forEach((specs, index) => split.push({
|
|
544
|
+
specs,
|
|
545
|
+
title: `${shortTitle(group.title, TITLE_CHARS - 9, TITLE_WORDS - 3)} (${index + 1} of ${runs.length})`,
|
|
546
|
+
}));
|
|
462
547
|
});
|
|
463
548
|
const before = placement;
|
|
464
549
|
// A req placed with a group that split goes with the first functional spec of it.
|
|
@@ -485,7 +570,7 @@ async function cut(input, agents, notes) {
|
|
|
485
570
|
// It does not hold together: a functional spec a job, every req with the first.
|
|
486
571
|
say(`the plan did not hold together (${checkDraft(input, draft)[0]?.slice(0, 120)}) - a functional spec a pulse`);
|
|
487
572
|
placement = undefined;
|
|
488
|
-
groups.splice(0, groups.length, ...numbered.map((spec, index) => ({ specs: [spec.number], title: titleOf(specs[index]) })));
|
|
573
|
+
groups.splice(0, groups.length, ...(await titled(numbered.map((spec, index) => ({ specs: [spec.number], title: titleOf(specs[index]) })))));
|
|
489
574
|
chunks = chunksFor(groups);
|
|
490
575
|
place();
|
|
491
576
|
const simple = draftOf(chunks, net);
|
|
@@ -508,7 +593,8 @@ export function groupPrompt(specs, steering, maxSpecs = MAX_SPECS_PER_JOB) {
|
|
|
508
593
|
"Never group specs only because they are of the same kind.",
|
|
509
594
|
"",
|
|
510
595
|
"Only functional specs next to each other in the same module go together, and they stay in the order given: the",
|
|
511
|
-
|
|
596
|
+
`order is what each is built on. Give each group a title that says what it builds - at most ${TITLE_WORDS} words and ${TITLE_CHARS}`,
|
|
597
|
+
"characters: it heads the group's pulse where the person watches the work, and a longer one is cut short there.",
|
|
512
598
|
"",
|
|
513
599
|
"The specification is split into modules - a ***plain matter, not how the code is laid out. Each numbers its",
|
|
514
600
|
"functional specs from 1: each below is given with its module and its id there, and a number of its own to answer with.",
|
|
@@ -553,6 +639,22 @@ export function placePrompt(input) {
|
|
|
553
639
|
"Call submit_placement once, with every one of them placed once.",
|
|
554
640
|
].join("\n");
|
|
555
641
|
}
|
|
642
|
+
export function titlePrompt(groups, track) {
|
|
643
|
+
return [
|
|
644
|
+
track === "conformance"
|
|
645
|
+
? "You title the pulses that test a codebase. Each group below is a pulse: it writes and runs the conformance tests for its functional specs - black-box tests of the code as built."
|
|
646
|
+
: "You title the pulses that build a codebase. Each group below is a pulse: a coding agent builds its functional specs into the code.",
|
|
647
|
+
"",
|
|
648
|
+
track === "conformance"
|
|
649
|
+
? "Give each group a title that says what its pulse tests - the behaviour, not the spec's wording."
|
|
650
|
+
: "Give each group a title that says what its pulse builds - the feature, not the spec's wording.",
|
|
651
|
+
`At most ${TITLE_WORDS} words and ${TITLE_CHARS} characters: it heads the pulse where the person watches the work, and a longer one is cut short there.`,
|
|
652
|
+
"",
|
|
653
|
+
...groups.map((group) => [`<group number="${group.number}">`, ...group.specs.map((spec) => `<functional spec>\n${spec.trim()}\n</functional spec>`), "</group>"].join("\n")),
|
|
654
|
+
"",
|
|
655
|
+
"Call submit_titles once, with a title for every group, by its number.",
|
|
656
|
+
].join("\n");
|
|
657
|
+
}
|
|
556
658
|
const TargetSchema = Type.Union([Type.Number(), Type.Literal("all"), Type.Literal("built")]);
|
|
557
659
|
const NOTES = "Each of the person's wishes for how the queue is planned that you did not follow, and why - a limit it would break, say. Leave it out when you followed them all, or there are none.";
|
|
558
660
|
/** Ask one step of an agent, and ask again with what was wrong, until it holds or the retries are spent. */
|
|
@@ -575,7 +677,7 @@ async function askStep(options) {
|
|
|
575
677
|
session.dispose();
|
|
576
678
|
}
|
|
577
679
|
}
|
|
578
|
-
/** The chunking's
|
|
680
|
+
/** The chunking's agents, on a model - fresh for every plan. */
|
|
579
681
|
export function llmChunkAgents(options) {
|
|
580
682
|
const label = options.label ?? "queue planner";
|
|
581
683
|
return {
|
|
@@ -588,9 +690,9 @@ export function llmChunkAgents(options) {
|
|
|
588
690
|
tool: (take) => defineTool({
|
|
589
691
|
name: "submit_groups",
|
|
590
692
|
label: "Submit the groups",
|
|
591
|
-
description:
|
|
693
|
+
description: `The new functional specs in groups, in order: each group's spec numbers and a title of at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters.`,
|
|
592
694
|
parameters: Type.Object({
|
|
593
|
-
groups: Type.Array(Type.Object({ specs: Type.Array(Type.Number()), title: Type.String() })),
|
|
695
|
+
groups: Type.Array(Type.Object({ specs: Type.Array(Type.Number()), title: Type.String({ description: `What the group builds: at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters.` }) })),
|
|
594
696
|
notes: Type.Optional(Type.String({ description: NOTES })),
|
|
595
697
|
}),
|
|
596
698
|
async execute(_id, params) {
|
|
@@ -621,5 +723,27 @@ export function llmChunkAgents(options) {
|
|
|
621
723
|
},
|
|
622
724
|
}),
|
|
623
725
|
}),
|
|
726
|
+
title: (groups, check, track) => askStep({
|
|
727
|
+
cwd: options.cwd,
|
|
728
|
+
model: options.model,
|
|
729
|
+
label: `${label} titles`,
|
|
730
|
+
task: titlePrompt(groups, track),
|
|
731
|
+
check,
|
|
732
|
+
tool: (take) => defineTool({
|
|
733
|
+
name: "submit_titles",
|
|
734
|
+
label: "Submit the titles",
|
|
735
|
+
description: `A title for every group, by its number: at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters each.`,
|
|
736
|
+
parameters: Type.Object({
|
|
737
|
+
titles: Type.Array(Type.Object({
|
|
738
|
+
group: Type.Number(),
|
|
739
|
+
title: Type.String({ description: `What the group's pulse ${track === "conformance" ? "tests" : "builds"}: at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters.` }),
|
|
740
|
+
})),
|
|
741
|
+
}),
|
|
742
|
+
async execute(_id, params) {
|
|
743
|
+
take(params);
|
|
744
|
+
return { content: [{ type: "text", text: "Recorded." }], details: {}, terminate: true };
|
|
745
|
+
},
|
|
746
|
+
}),
|
|
747
|
+
}),
|
|
624
748
|
};
|
|
625
749
|
}
|
|
@@ -316,9 +316,9 @@ export function createAskExplorerTool(options) {
|
|
|
316
316
|
return defineTool({
|
|
317
317
|
name: "ask_explorer",
|
|
318
318
|
label: "Ask the explorer",
|
|
319
|
-
description: "Ask the explorer - who read
|
|
319
|
+
description: "Ask the explorer - who read the code, and wrote the account of it you were given - something the account " +
|
|
320
320
|
"does not say: how a functionality is reached, what a field is called, what the software needs around it. It reads " +
|
|
321
|
-
`
|
|
321
|
+
`the code to answer - it runs nothing, so what only running the software shows is yours to try. ${options.questions ?? EXPLORER_QUESTIONS} questions a run: ask what reading the code yourself would take long to find.`,
|
|
322
322
|
promptSnippet: "ask_explorer: a question about how the software is reached, answered by the explorer from the code",
|
|
323
323
|
parameters: Type.Object({ question: Type.String({ description: "What you need to know, exactly" }) }),
|
|
324
324
|
async execute(_id, params) {
|
|
@@ -5,7 +5,8 @@ import { PLAIN_SOURCE_FILE_EXTENSION, plainFileParser } from "../plain/parser.js
|
|
|
5
5
|
import { ACCEPTANCE_TESTS, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, getFrids, getInheritedFunctionalSpecs, getSpecificationsForFrid, TEST_REQUIREMENTS, } from "../plain/spec.js";
|
|
6
6
|
import { reportActivity, reportLogNote, reportSpec, reportStage, unitOf } from "./activity.js";
|
|
7
7
|
import { confinedTools } from "./confine.js";
|
|
8
|
-
import { abandonWork, diffSinceSnapshot,
|
|
8
|
+
import { abandonWork, diffSinceSnapshot, landWork, moduleDirOf, startWork, workDiff, workSnapshot } from "./lineage.js";
|
|
9
|
+
import { firstLine, record } from "./ledger.js";
|
|
9
10
|
import { createWorkLedger, describeAccounts, SUBMIT_WORK_NOTE, unanswered, unansweredPrompt } from "./submit-work.js";
|
|
10
11
|
import { CONFORMANCE_COMPACTION } from "./compaction.js";
|
|
11
12
|
import { NOTES_TURN, notesForSession, passedOutcome, readNotes, reflectOnReview, withNotes } from "./notes.js";
|
|
@@ -417,6 +418,7 @@ export async function renderConformanceTests(options) {
|
|
|
417
418
|
// every functionality's tests - and the code's unit testing script.
|
|
418
419
|
stage(`running the conformance testing script on the whole suite (attempt ${rounds + 1} of ${maxRounds})`);
|
|
419
420
|
rounds += 1;
|
|
421
|
+
record({ kind: "gate.round", gate: "conformance", round: rounds, of: maxRounds });
|
|
420
422
|
let recheck = await runConformanceScript(buildDir, [conformanceDir], scripts);
|
|
421
423
|
runs.push({ command: recheck.command, exitCode: recheck.exitCode });
|
|
422
424
|
options.onRun?.(recheck.command, recheck.exitCode);
|
|
@@ -537,6 +539,7 @@ export async function renderConformanceTests(options) {
|
|
|
537
539
|
onDelta: options.onDelta,
|
|
538
540
|
});
|
|
539
541
|
const reason = verdict.reason.split("\n")[0].slice(0, 160);
|
|
542
|
+
record({ kind: "judge.verdict", culprit: verdict.culprit, round: rounds, reason: firstLine(verdict.reason, 300) });
|
|
540
543
|
// Stopped by the time limit while it was still getting on: no agent can give it more time. The pulse stops now -
|
|
541
544
|
// rolled back, no retry - and the person is told how to raise the limit.
|
|
542
545
|
if (verdict.culprit === "environment" && recheck.timedOut === true) {
|
|
@@ -797,9 +800,9 @@ export async function renderConformanceTests(options) {
|
|
|
797
800
|
// The tests, and the code when it changed: one commit if they share a module folder.
|
|
798
801
|
const subject = `conformance ${moduleName} ${options.diff !== undefined ? "for a spec change" : `fs ${frid}`}${changed ? " (code changed)" : ""}`;
|
|
799
802
|
stage(`committing the conformance tests${changed ? " and the code changes" : ""}`);
|
|
800
|
-
|
|
803
|
+
landWork(conformanceDir, subject);
|
|
801
804
|
if (moduleDirOf(buildDir) !== moduleDirOf(conformanceDir))
|
|
802
|
-
|
|
805
|
+
landWork(buildDir, subject);
|
|
803
806
|
}
|
|
804
807
|
else if (start !== undefined) {
|
|
805
808
|
const why = notLandedWhy(options.diff !== undefined ? "for the spec change" : `of functionality ${frid}`, unlabelled);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
/**
|
|
3
|
-
* Every render agent's whole conversation, streamed to a file of its own as it happens
|
|
3
|
+
* Every render agent's whole conversation, streamed to a file of its own as it happens: an agent of a pulse's for as
|
|
4
|
+
* long as the pulse runs - what the diagnosis of a failure reads - and kept after it in debug mode alone (`debug:`,
|
|
5
|
+
* pulse-files.ts); an agent outside a pulse only in debug mode.
|
|
4
6
|
*
|
|
5
7
|
* A job's transcript keeps a tool call's arguments to 600 characters and its result to 2000, and only while pi is
|
|
6
8
|
* running. When an agent goes wrong, what it was actually
|
|
@@ -2,9 +2,13 @@ import { appendFileSync, existsSync } from "node:fs";
|
|
|
2
2
|
import { maskSecrets } from "./masking.js";
|
|
3
3
|
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import { currentJob, reportActivity, reportTranscript } from "./activity.js";
|
|
5
|
+
import { debugMode } from "../config.js";
|
|
6
|
+
import { pulseWrote } from "./pulse-files.js";
|
|
5
7
|
import { ensureInScratch, MODULES_DIR, projectRootOf, scratchDir } from "./paths.js";
|
|
6
8
|
/**
|
|
7
|
-
* Every render agent's whole conversation, streamed to a file of its own as it happens
|
|
9
|
+
* Every render agent's whole conversation, streamed to a file of its own as it happens: an agent of a pulse's for as
|
|
10
|
+
* long as the pulse runs - what the diagnosis of a failure reads - and kept after it in debug mode alone (`debug:`,
|
|
11
|
+
* pulse-files.ts); an agent outside a pulse only in debug mode.
|
|
8
12
|
*
|
|
9
13
|
* A job's transcript keeps a tool call's arguments to 600 characters and its result to 2000, and only while pi is
|
|
10
14
|
* running. When an agent goes wrong, what it was actually
|
|
@@ -47,8 +51,11 @@ export function debugLogOf(session) {
|
|
|
47
51
|
*/
|
|
48
52
|
export function startDebugLog(session, options) {
|
|
49
53
|
let file;
|
|
54
|
+
const dir = debugDirFor(options.cwd);
|
|
55
|
+
// Outside a pulse nothing reads it - no diagnosis, no person but one debugging turri.
|
|
56
|
+
if (currentJob() === undefined && !debugMode(dirname(dirname(dir))))
|
|
57
|
+
return undefined;
|
|
50
58
|
try {
|
|
51
|
-
const dir = debugDirFor(options.cwd);
|
|
52
59
|
ensureInScratch(dirname(dir), dir);
|
|
53
60
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
54
61
|
const name = options.label.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "agent";
|
|
@@ -56,6 +63,7 @@ export function startDebugLog(session, options) {
|
|
|
56
63
|
for (let n = 2; existsSync(file); n += 1)
|
|
57
64
|
file = join(dir, `${stamp}-${name}-${n}.log`);
|
|
58
65
|
appendFileSync(file, maskSecrets(header(session, options)), { flag: "wx" });
|
|
66
|
+
pulseWrote(file);
|
|
59
67
|
}
|
|
60
68
|
catch (error) {
|
|
61
69
|
reportActivity(`debug log not started: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { ask, BEGIN, createExecutor } from "../pi.js";
|
|
4
|
+
import { debugMode } from "../config.js";
|
|
4
5
|
import { confinedTools } from "./confine.js";
|
|
5
6
|
/** How many tool calls the diagnosis may make before it is told to decide. */
|
|
6
7
|
export const DIAGNOSIS_CALLS = 40;
|
|
@@ -49,8 +50,10 @@ export async function diagnoseFailure(options) {
|
|
|
49
50
|
if (diagnosis === undefined)
|
|
50
51
|
await ask(session, "You have not called submit_diagnosis. Call it now, with what you know.");
|
|
51
52
|
}
|
|
52
|
-
catch {
|
|
53
|
-
// Without a diagnosis the person still gets both failures, as they were.
|
|
53
|
+
catch (error) {
|
|
54
|
+
// Without a diagnosis the person still gets both failures, as they were - and the ledger why there is none.
|
|
55
|
+
if (diagnosis === undefined)
|
|
56
|
+
throw error;
|
|
54
57
|
}
|
|
55
58
|
finally {
|
|
56
59
|
session.dispose();
|
|
@@ -75,6 +78,13 @@ export function diagnosisPrompt(options) {
|
|
|
75
78
|
"Find the root cause, not the last symptom: read the failing run's output and the agents' logs around where it",
|
|
76
79
|
"failed, and follow it to the spec line, the code, the test, the environment or the harness it comes from. Then",
|
|
77
80
|
"call submit_diagnosis: what happened, why, whose it is to fix, and exactly what to do.",
|
|
81
|
+
...(debugMode(options.projectDir)
|
|
82
|
+
? []
|
|
83
|
+
: [
|
|
84
|
+
"",
|
|
85
|
+
"The logs and the full outputs are removed as the job ends - the project is not in debug mode - so never send",
|
|
86
|
+
"the person to one of them: quote what in them shows the cause.",
|
|
87
|
+
]),
|
|
78
88
|
"",
|
|
79
89
|
...options.failures.flatMap((failure, index) => [`<attempt ${index + 1}>`, failure.trim().slice(-12_000), `</attempt ${index + 1}>`, ""]),
|
|
80
90
|
].join("\n");
|
|
@@ -128,6 +128,12 @@ export declare function missingToolchainError(run: {
|
|
|
128
128
|
script: string;
|
|
129
129
|
kind: "unit" | "conformance";
|
|
130
130
|
}): EnvironmentError | undefined;
|
|
131
|
+
/**
|
|
132
|
+
* Work that passed could not be committed into the module's history: the next pulse, putting the module back to its last
|
|
133
|
+
* commit, would undo it while turri took it as built - so the pulse does not land, and is rolled back like any other that
|
|
134
|
+
* did not, and the person is told why. `said` is what git said.
|
|
135
|
+
*/
|
|
136
|
+
export declare function historyError(moduleDir: string, said: string): EnvironmentError;
|
|
131
137
|
/**
|
|
132
138
|
* Before a pulse in `moduleDir` starts any agent: a credential `agent-env:` names that is not there stops it - every
|
|
133
139
|
* command that needs it would fail, and no agent may make one up. Nothing when all are there, or the folder is no
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { basename, dirname, isAbsolute, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, sep } from "node:path";
|
|
4
4
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { passedCredentials } from "./agent-env.js";
|
|
@@ -246,6 +246,24 @@ export function missingToolchainError(run, details) {
|
|
|
246
246
|
const missing = missingToolchain(run);
|
|
247
247
|
return missing !== undefined ? toolchainError(missing, details) : undefined;
|
|
248
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Work that passed could not be committed into the module's history: the next pulse, putting the module back to its last
|
|
251
|
+
* commit, would undo it while turri took it as built - so the pulse does not land, and is rolled back like any other that
|
|
252
|
+
* did not, and the person is told why. `said` is what git said.
|
|
253
|
+
*/
|
|
254
|
+
export function historyError(moduleDir, said) {
|
|
255
|
+
const lock = /index\.lock/.test(said);
|
|
256
|
+
return new EnvironmentError({
|
|
257
|
+
moduleName: moduleNameOf(moduleDir),
|
|
258
|
+
missing: `a commit into the module's history, \`${join(moduleDir, ".plaingit")}\``,
|
|
259
|
+
neededBy: "the work that passed - without the commit, the next pulse would undo it",
|
|
260
|
+
shownBy: `git said: ${said.replace(/\s+/g, " ").trim().slice(0, 600)}`,
|
|
261
|
+
fix: lock
|
|
262
|
+
? `if no git is still running on it, remove \`${join(moduleDir, ".plaingit", "index.lock")}\`, which a git stopped mid-commit left behind.`
|
|
263
|
+
: "fix what git said - a full disk, files it cannot read - so a commit can be made there.",
|
|
264
|
+
source: "harness",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
249
267
|
/** The module a folder is part of, by name: `plain_modules/<module>/...`. */
|
|
250
268
|
function moduleNameOf(moduleDir) {
|
|
251
269
|
return basename(moduleDir);
|
|
@@ -17,7 +17,9 @@ export interface JobLog {
|
|
|
17
17
|
/** `agents`: a line per agent - its turns, compactions and cost. */
|
|
18
18
|
end(status: string, report: string | undefined, agents?: string[]): void;
|
|
19
19
|
}
|
|
20
|
-
/** The
|
|
20
|
+
/** The project a job works in: the one of the first module it writes, from its lanes. */
|
|
21
|
+
export declare function projectOfLanes(lanes: string[]): string | undefined;
|
|
22
|
+
/** The folder a job's log goes in: `.turri/jobs` in its project. */
|
|
21
23
|
export declare function jobLogDir(lanes: string[]): string | undefined;
|
|
22
24
|
export declare function startJobLog(job: {
|
|
23
25
|
id: string;
|
|
@@ -2,12 +2,15 @@ import { appendFileSync, existsSync } from "node:fs";
|
|
|
2
2
|
import { maskSecrets } from "./masking.js";
|
|
3
3
|
import { dirname, isAbsolute, join, sep } from "node:path";
|
|
4
4
|
import { ensureInScratch, MODULES_DIR, projectRootOf, scratchDir } from "./paths.js";
|
|
5
|
-
/** The
|
|
6
|
-
export function
|
|
5
|
+
/** The project a job works in: the one of the first module it writes, from its lanes. */
|
|
6
|
+
export function projectOfLanes(lanes) {
|
|
7
7
|
const lane = lanes.find((candidate) => isAbsolute(candidate) && candidate.split(sep).includes(MODULES_DIR));
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
return lane !== undefined ? projectRootOf(lane) : undefined;
|
|
9
|
+
}
|
|
10
|
+
/** The folder a job's log goes in: `.turri/jobs` in its project. */
|
|
11
|
+
export function jobLogDir(lanes) {
|
|
12
|
+
const project = projectOfLanes(lanes);
|
|
13
|
+
return project !== undefined ? join(scratchDir(join(project, MODULES_DIR)), "jobs") : undefined;
|
|
11
14
|
}
|
|
12
15
|
export function startJobLog(job, lanes) {
|
|
13
16
|
const dir = jobLogDir(lanes);
|
|
@@ -3,15 +3,17 @@ import type { TranscriptStyle } from "./transcript-view.ts";
|
|
|
3
3
|
/**
|
|
4
4
|
* The side panel's Jobs view: the queue as a tree, read one job at a time.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Four sections - running, queued, finished, and earlier sessions' from the ledger - each opened and closed on its own,
|
|
7
|
+
* and under them one line per job:
|
|
7
8
|
* its module, its time and its cost. A job opened shows all of what it builds - the diff, or the spec - and, running,
|
|
8
|
-
* what it is doing; finished, what went on in it. So what is planned can be read
|
|
9
|
+
* what it is doing; finished, what went on in it; earlier, what the ledger says of it. So what is planned can be read
|
|
10
|
+
* without every diff on the screen at once.
|
|
9
11
|
*
|
|
10
12
|
* A cursor moves over the sections and the jobs; it stays on its row, whatever moves under it - a job that finishes
|
|
11
13
|
* leaves its row to the one after it. A job opened stays open while it stays where it is: moving to another section
|
|
12
14
|
* it closes, and turns up there collapsed. The state is the conversation's, kept while the panel is closed.
|
|
13
15
|
*/
|
|
14
|
-
export type JobSection = "running" | "queued" | "finished";
|
|
16
|
+
export type JobSection = "running" | "queued" | "finished" | "earlier";
|
|
15
17
|
export declare const SECTIONS: JobSection[];
|
|
16
18
|
export interface JobsTreeState {
|
|
17
19
|
/** Which sections are open. */
|
|
@@ -24,6 +26,8 @@ export interface JobsTreeState {
|
|
|
24
26
|
cursor: number;
|
|
25
27
|
/** The first line shown. */
|
|
26
28
|
top: number;
|
|
29
|
+
/** How many runs the ledger has beyond the earlier sessions shown: said under them. */
|
|
30
|
+
earlierMore?: number;
|
|
27
31
|
}
|
|
28
32
|
export declare function createJobsTreeState(): JobsTreeState;
|
|
29
33
|
export declare function sectionOf(job: Job): JobSection;
|
|
@@ -3,11 +3,15 @@ import { describeAgents } from "./activity.js";
|
|
|
3
3
|
import { didNotPass, finishedMark, pulseCost, spinner } from "./job-panel.js";
|
|
4
4
|
import { formatDuration, pulseName } from "./jobs.js";
|
|
5
5
|
import { diffFiles, fileHeader, PLAIN_PAINT } from "./diff-view.js";
|
|
6
|
-
export const SECTIONS = ["running", "queued", "finished"];
|
|
6
|
+
export const SECTIONS = ["running", "queued", "finished", "earlier"];
|
|
7
|
+
/** A section as its header says it. */
|
|
8
|
+
const SECTION_NAMES = { running: "running", queued: "queued", finished: "finished", earlier: "earlier sessions" };
|
|
7
9
|
export function createJobsTreeState() {
|
|
8
|
-
return { open: { running: true, queued: true, finished: false }, openJobs: new Map(), openFiles: new Map(), cursor: 0, top: 0 };
|
|
10
|
+
return { open: { running: true, queued: true, finished: false, earlier: false }, openJobs: new Map(), openFiles: new Map(), cursor: 0, top: 0 };
|
|
9
11
|
}
|
|
10
12
|
export function sectionOf(job) {
|
|
13
|
+
if (job.earlier !== undefined)
|
|
14
|
+
return "earlier";
|
|
11
15
|
return job.status === "running" ? "running" : job.status === "queued" ? "queued" : "finished";
|
|
12
16
|
}
|
|
13
17
|
/** The files of a job's diff, in the order they are shown - read once for each diff, not on every frame. */
|
|
@@ -47,7 +51,7 @@ export function selectJob(jobs, state, id) {
|
|
|
47
51
|
const job = jobs.find((entry) => entry.id === id);
|
|
48
52
|
if (job === undefined)
|
|
49
53
|
return false;
|
|
50
|
-
const section = job
|
|
54
|
+
const section = sectionOf(job);
|
|
51
55
|
state.open[section] = true;
|
|
52
56
|
state.openJobs.set(id, section);
|
|
53
57
|
const at = jobsTreeRows(jobs, state).findIndex((row) => row.kind === "job" && row.job.id === id);
|
|
@@ -60,13 +64,16 @@ export function jobsTreeRows(jobs, state) {
|
|
|
60
64
|
const bySequence = jobs.filter((job) => job.replacedBy === undefined).sort((a, b) => a.sequence - b.sequence);
|
|
61
65
|
const of = (section) => {
|
|
62
66
|
const inSection = bySequence.filter((job) => sectionOf(job) === section);
|
|
63
|
-
return section === "finished" ? inSection.sort((a, b) => (b.finishedAt ?? b.sequence) - (a.finishedAt ?? a.sequence) || b.sequence - a.sequence) : inSection;
|
|
67
|
+
return section === "finished" || section === "earlier" ? inSection.sort((a, b) => (b.finishedAt ?? b.sequence) - (a.finishedAt ?? a.sequence) || b.sequence - a.sequence) : inSection;
|
|
64
68
|
};
|
|
65
69
|
// Forget what is open for jobs that moved on - they close as they leave.
|
|
66
70
|
for (const job of jobs)
|
|
67
71
|
isOpen(state, job);
|
|
68
72
|
return SECTIONS.flatMap((section) => {
|
|
69
73
|
const inSection = of(section);
|
|
74
|
+
// Earlier sessions are there only when the ledger has some.
|
|
75
|
+
if (section === "earlier" && inSection.length === 0)
|
|
76
|
+
return [];
|
|
70
77
|
const jobRows = (job) => [
|
|
71
78
|
{ kind: "job", section, job },
|
|
72
79
|
// An open job's diff, a row a file: each opens and closes on its own.
|
|
@@ -82,7 +89,8 @@ export function jobsTreeRows(jobs, state) {
|
|
|
82
89
|
const MARK = { running: spinner, queued: () => "·", succeeded: () => "✓", failed: () => "✗", stopped: () => "■" };
|
|
83
90
|
/** A pulse's mark: running or queued as it is; finished, in the colour of how it ended - green it passed, red it failed or did not pass, stopped amber. */
|
|
84
91
|
function markOf(job, style) {
|
|
85
|
-
|
|
92
|
+
const section = sectionOf(job);
|
|
93
|
+
if (section !== "finished" && section !== "earlier")
|
|
86
94
|
return MARK[job.status]?.(Date.now()) ?? "·";
|
|
87
95
|
return finishedMark(job, { success: style.success ?? style.added, error: style.error, warning: style.warning ?? style.accent });
|
|
88
96
|
}
|
|
@@ -106,6 +114,9 @@ function named(jobs, id) {
|
|
|
106
114
|
/** All of a job, opened: what it builds, and - running - what it is doing; waiting, what it waits on; done, how it ended. */
|
|
107
115
|
function detailLines(job, jobs, width, style) {
|
|
108
116
|
const indent = " ";
|
|
117
|
+
// An earlier session's: what the ledger says of it - its transcript is its log's alone.
|
|
118
|
+
if (job.earlier !== undefined)
|
|
119
|
+
return job.earlier.lines.flatMap((line) => wrap(line, width, indent, /FAILED|ended with/.test(line) ? style.error : style.muted));
|
|
109
120
|
const lines = [];
|
|
110
121
|
// A diff is shown a file at a time, in rows of their own after these.
|
|
111
122
|
if ((job.diff === undefined || job.diff.trim() === "") && job.building !== undefined) {
|
|
@@ -170,7 +181,7 @@ export function jobsTreeLines(jobs, state, width, style) {
|
|
|
170
181
|
lines.push("");
|
|
171
182
|
rowStarts[index] = lines.length;
|
|
172
183
|
}
|
|
173
|
-
const header = `${pointer} ${state.open[row.section] ? "▾" : "▸"} ${row.section} (${row.count})`;
|
|
184
|
+
const header = `${pointer} ${state.open[row.section] ? "▾" : "▸"} ${SECTION_NAMES[row.section]} (${row.count})`;
|
|
174
185
|
lines.push(here ? style.heading(header) : style.muted(header));
|
|
175
186
|
return;
|
|
176
187
|
}
|
|
@@ -197,6 +208,10 @@ export function jobsTreeLines(jobs, state, width, style) {
|
|
|
197
208
|
if (open)
|
|
198
209
|
lines.push(...detailLines(job, jobs, width, style));
|
|
199
210
|
});
|
|
211
|
+
// The ledger has more than the runs shown: where to find them.
|
|
212
|
+
if (state.open.earlier && (state.earlierMore ?? 0) > 0) {
|
|
213
|
+
lines.push(style.muted(` ${state.earlierMore} earlier run${state.earlierMore === 1 ? "" : "s"} in the ledger - turri ledger lists them`));
|
|
214
|
+
}
|
|
200
215
|
return { lines, rowStarts, rows };
|
|
201
216
|
}
|
|
202
217
|
/** How far a job's files, and their lines, are indented under it. */
|
|
@@ -84,6 +84,15 @@ export interface Job {
|
|
|
84
84
|
announce?: string;
|
|
85
85
|
error?: string;
|
|
86
86
|
details?: unknown;
|
|
87
|
+
/**
|
|
88
|
+
* A pulse of an earlier session, as the ledger recorded it - not this session's registry's: shown, never run, halted or
|
|
89
|
+
* waited for. Its run, how and when that run was started, and what the ledger says of it, a line each.
|
|
90
|
+
*/
|
|
91
|
+
earlier?: {
|
|
92
|
+
run: string;
|
|
93
|
+
runMode?: string;
|
|
94
|
+
lines: string[];
|
|
95
|
+
};
|
|
87
96
|
}
|
|
88
97
|
export interface JobOutcome {
|
|
89
98
|
report: string;
|