pi-plans 0.1.1 → 0.1.2
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 +15 -9
- package/index.ts +115 -42
- package/package.json +1 -1
- package/src/exec.ts +812 -33
- package/src/execution-panel.ts +155 -21
- package/src/plan.ts +106 -0
- package/src/state.ts +36 -0
- package/tests/exec.test.ts +837 -40
- package/tests/execution-panel.test.ts +44 -15
- package/tests/plan.test.ts +84 -1
- package/tests/plans.test.ts +36 -0
- package/tests/state.test.ts +60 -0
- package/tools/execute-plan.ts +46 -6
- package/tools/plans.ts +34 -1
package/src/execution-panel.ts
CHANGED
|
@@ -2,7 +2,15 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
extractCoverage,
|
|
7
|
+
resolveImplStatuses,
|
|
8
|
+
shortImplDescription,
|
|
9
|
+
type CheckItem,
|
|
10
|
+
type ImplDisplayState,
|
|
11
|
+
type ImplItem,
|
|
12
|
+
type ImplMarkerState,
|
|
13
|
+
} from "./plan.ts";
|
|
6
14
|
|
|
7
15
|
/** SGR runs plus OSC hyperlinks: escaped bytes, no display columns. */
|
|
8
16
|
const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
@@ -125,6 +133,8 @@ export interface ExecutionPanelState {
|
|
|
125
133
|
export interface ExecutionPanelExecutionLike {
|
|
126
134
|
planPath: string;
|
|
127
135
|
items: CheckItem[];
|
|
136
|
+
implItems?: ImplItem[];
|
|
137
|
+
implStatus?: Record<string, ImplMarkerState>;
|
|
128
138
|
panel?: ExecutionPanelState;
|
|
129
139
|
}
|
|
130
140
|
|
|
@@ -395,21 +405,124 @@ function renderItemLines(item: CheckItem, summary: ItemDiffSummary | undefined,
|
|
|
395
405
|
return lines;
|
|
396
406
|
}
|
|
397
407
|
|
|
408
|
+
function formatElapsed(startedAt: string): string {
|
|
409
|
+
const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
|
|
410
|
+
const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
|
411
|
+
const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
|
|
412
|
+
const sec = String(total % 60).padStart(2, "0");
|
|
413
|
+
return `${h}:${m}:${sec}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function formatToks(tokens: number): string {
|
|
417
|
+
const n = Math.max(0, Math.round(tokens));
|
|
418
|
+
return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Single source for the ⌛ progress count (I-based with zero-coverage
|
|
423
|
+
* exclusion, VC fallback) — shared by the footer line and the panel header.
|
|
424
|
+
*/
|
|
425
|
+
export function computeExecutionProgress(execution: ExecutionPanelExecutionLike & {
|
|
426
|
+
usage?: { inToks: number; outToks: number };
|
|
427
|
+
implStatus?: Record<string, ImplMarkerState>;
|
|
428
|
+
}): { done: number; total: number } {
|
|
429
|
+
const implItems = execution.implItems ?? [];
|
|
430
|
+
if (implItems.length) {
|
|
431
|
+
const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
|
|
432
|
+
const counted = implItems.filter((impl) =>
|
|
433
|
+
execution.items.some((item) => extractCoverage(item.text).includes(impl.id)),
|
|
434
|
+
);
|
|
435
|
+
if (counted.length > 0) {
|
|
436
|
+
return {
|
|
437
|
+
done: counted.filter((impl) => statuses[impl.id] === "vc-passed").length,
|
|
438
|
+
total: counted.length,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
done: execution.items.filter((item) => item.done).length,
|
|
444
|
+
total: execution.items.length,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The `⌛ plans x/y: spent ...` line. Plain text — callers apply theme color.
|
|
450
|
+
* Shared by the footer line (collapsed) and the panel header (expanded).
|
|
451
|
+
*/
|
|
452
|
+
export function formatExecutionStatusLine(
|
|
453
|
+
execution: ExecutionPanelExecutionLike & {
|
|
454
|
+
startedAt: string;
|
|
455
|
+
usage: { inToks: number; outToks: number };
|
|
456
|
+
implStatus?: Record<string, ImplMarkerState>;
|
|
457
|
+
},
|
|
458
|
+
): string {
|
|
459
|
+
const progress = computeExecutionProgress(execution);
|
|
460
|
+
return `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
|
|
461
|
+
}
|
|
462
|
+
|
|
398
463
|
function renderPanelLines(execution: ExecutionPanelExecutionLike, theme: ThemeLike, width: number): string[] {
|
|
399
|
-
// Expanded detail view only: the count
|
|
400
|
-
//
|
|
464
|
+
// Expanded detail view only: the collapsed count lives in the footer, and
|
|
465
|
+
// the expanded header re-renders the same line above the item list.
|
|
401
466
|
const panel = ensurePanelState(execution);
|
|
402
467
|
if (!panel.expanded) return [""];
|
|
403
468
|
const lines: string[] = [];
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
469
|
+
const header = formatExecutionStatusLine(
|
|
470
|
+
execution as ExecutionPanelExecutionLike & {
|
|
471
|
+
startedAt: string;
|
|
472
|
+
usage: { inToks: number; outToks: number };
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
lines.push(truncateAnsi(theme.fg("accent", header), width));
|
|
476
|
+
if (execution.implItems?.length) {
|
|
477
|
+
lines.push(...renderImplGroupedLines(execution, theme, width));
|
|
478
|
+
} else {
|
|
479
|
+
// Legacy fallback: plans without a parsable Implementation Items section.
|
|
480
|
+
for (const item of execution.items) {
|
|
481
|
+
const summary = panel.itemSummaries[item.id]?.summary;
|
|
482
|
+
lines.push(...renderItemLines(item, summary, theme, width));
|
|
483
|
+
}
|
|
407
484
|
}
|
|
408
485
|
return lines.length ? lines : [""];
|
|
409
486
|
}
|
|
410
487
|
|
|
488
|
+
const IMPL_LABELS: Record<ImplDisplayState, string> = {
|
|
489
|
+
pending: "[Pending]",
|
|
490
|
+
implementing: "[Implementing]",
|
|
491
|
+
implemented: "[Implemented]",
|
|
492
|
+
validating: "[Validating]",
|
|
493
|
+
"vc-passed": "[VC passed]",
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const IMPL_COLORS: Record<ImplDisplayState, string> = {
|
|
497
|
+
pending: "muted",
|
|
498
|
+
implementing: "accent",
|
|
499
|
+
implemented: "accent",
|
|
500
|
+
validating: "warning",
|
|
501
|
+
"vc-passed": "success",
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
function renderImplGroupedLines(execution: ExecutionPanelExecutionLike, theme: ThemeLike, width: number): string[] {
|
|
505
|
+
const implItems = execution.implItems ?? [];
|
|
506
|
+
const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
|
|
507
|
+
const lines: string[] = [];
|
|
508
|
+
|
|
509
|
+
for (const impl of implItems) {
|
|
510
|
+
const status = statuses[impl.id] ?? "pending";
|
|
511
|
+
let line = `${IMPL_LABELS[status]} ${impl.id}: ${shortImplDescription(impl.text)}`;
|
|
512
|
+
if (status === "vc-passed") {
|
|
513
|
+
// Final state: strike through the entire line.
|
|
514
|
+
line = theme.strikethrough(theme.fg(IMPL_COLORS[status], line));
|
|
515
|
+
} else {
|
|
516
|
+
line = theme.fg(IMPL_COLORS[status], line);
|
|
517
|
+
}
|
|
518
|
+
lines.push(truncateAnsi(line, width));
|
|
519
|
+
}
|
|
520
|
+
return lines;
|
|
521
|
+
}
|
|
522
|
+
|
|
411
523
|
interface WidgetThemeSource {
|
|
412
524
|
theme: ThemeLike | null;
|
|
525
|
+
requestRender: (() => void) | null;
|
|
413
526
|
}
|
|
414
527
|
|
|
415
528
|
let renderCacheWidth: number | null = null;
|
|
@@ -446,7 +559,7 @@ class ExecutionPanelWidget implements WidgetLike {
|
|
|
446
559
|
// a full TUI relayout mid-stream). Registration is tied to the host `ctx.ui`
|
|
447
560
|
// instance so replacement hosts (extension reload, tests) register afresh.
|
|
448
561
|
let panelRef: { current: ExecutionPanelExecutionLike | null } = { current: null };
|
|
449
|
-
const themeSource: WidgetThemeSource = { theme: null };
|
|
562
|
+
const themeSource: WidgetThemeSource = { theme: null, requestRender: null };
|
|
450
563
|
let registeredUi: unknown = null;
|
|
451
564
|
|
|
452
565
|
export function refreshExecutionPanel(ctx: ExtensionContext, execution: ExecutionPanelExecutionLike | null): void {
|
|
@@ -455,30 +568,51 @@ export function refreshExecutionPanel(ctx: ExtensionContext, execution: Executio
|
|
|
455
568
|
return;
|
|
456
569
|
}
|
|
457
570
|
if (!execution.panel?.expanded) {
|
|
458
|
-
// Collapsed:
|
|
459
|
-
|
|
571
|
+
// Collapsed: footer carries the ⌛ line; no panel widget is needed. Clear
|
|
572
|
+
// only when a live expanded widget exists, so repeated progress refreshes
|
|
573
|
+
// do not re-register or tear down the widget slot.
|
|
574
|
+
const hadWidget = panelRef.current !== null || registeredUi !== null;
|
|
575
|
+
panelRef.current = null;
|
|
576
|
+
registeredUi = null;
|
|
577
|
+
themeSource.requestRender = null;
|
|
578
|
+
invalidateRenderCache();
|
|
579
|
+
if (hadWidget) ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
580
|
+
ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", formatExecutionStatusLine(
|
|
581
|
+
execution as ExecutionPanelExecutionLike & {
|
|
582
|
+
startedAt: string;
|
|
583
|
+
usage: { inToks: number; outToks: number };
|
|
584
|
+
},
|
|
585
|
+
)));
|
|
460
586
|
return;
|
|
461
587
|
}
|
|
462
588
|
panelRef.current = execution;
|
|
463
589
|
invalidateRenderCache(); // next render always reflects the latest state
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
590
|
+
const sameHost = registeredUi === ctx.ui;
|
|
591
|
+
if (!sameHost) {
|
|
592
|
+
registeredUi = ctx.ui;
|
|
593
|
+
themeSource.requestRender = null;
|
|
594
|
+
ctx.ui.setWidget(
|
|
595
|
+
WIDGET_ID,
|
|
596
|
+
(tui, theme) => {
|
|
597
|
+
themeSource.theme = theme as ThemeLike;
|
|
598
|
+
const render = (tui as { requestRender?: unknown }).requestRender;
|
|
599
|
+
themeSource.requestRender = typeof render === "function" ? () => (render as () => void)() : null;
|
|
600
|
+
return new ExecutionPanelWidget(themeSource);
|
|
601
|
+
},
|
|
602
|
+
{ placement: "belowEditor" },
|
|
603
|
+
);
|
|
467
604
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
return new ExecutionPanelWidget(themeSource);
|
|
474
|
-
},
|
|
475
|
-
{ placement: "belowEditor" },
|
|
476
|
-
);
|
|
605
|
+
// Expanded: the panel renders the ⌛ header itself. Clear the footer copy
|
|
606
|
+
// only when the view is first mounted or the host changes, then request a
|
|
607
|
+
// render for in-place progress updates.
|
|
608
|
+
if (!sameHost) ctx.ui.setStatus("pi-plans", undefined);
|
|
609
|
+
themeSource.requestRender?.();
|
|
477
610
|
}
|
|
478
611
|
|
|
479
612
|
export function clearExecutionPanel(ctx: ExtensionContext): void {
|
|
480
613
|
panelRef.current = null;
|
|
481
614
|
registeredUi = null;
|
|
615
|
+
themeSource.requestRender = null;
|
|
482
616
|
invalidateRenderCache();
|
|
483
617
|
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
484
618
|
}
|
package/src/plan.ts
CHANGED
|
@@ -35,6 +35,112 @@ export function scanDoneMarkers(text: string): string[] {
|
|
|
35
35
|
return [...text.matchAll(/\[DONE:(VC-\d+)\]/g)].map((match) => match[1]);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export type ImplMarkerState = "implemented" | "validating";
|
|
39
|
+
|
|
40
|
+
export interface ImplMarker {
|
|
41
|
+
id: string;
|
|
42
|
+
state: ImplMarkerState;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extract every [I-xxx:implemented|validating] marker from an assistant message. */
|
|
46
|
+
export function scanImplMarkers(text: string): ImplMarker[] {
|
|
47
|
+
return [...text.matchAll(/\[(I-\d+):(implemented|validating)\]/g)].map((match) => ({
|
|
48
|
+
id: match[1],
|
|
49
|
+
state: match[2] as ImplMarkerState,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ImplItem {
|
|
54
|
+
id: string;
|
|
55
|
+
text: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse the `## Implementation Items` section of a PLAN_vN.md. Strict grammar:
|
|
60
|
+
* top-level `- `I-001`: text` single lines only; multi-line bodies and nested
|
|
61
|
+
* sub-bullets are ignored (text stops at end of the first line).
|
|
62
|
+
*/
|
|
63
|
+
export function parseImplItems(planText: string): ImplItem[] {
|
|
64
|
+
const lines = planText.split("\n");
|
|
65
|
+
const headerIndex = lines.findIndex((line) => /^##\s+Implementation Items\s*$/.test(line.trim()));
|
|
66
|
+
if (headerIndex < 0) return [];
|
|
67
|
+
const items: ImplItem[] = [];
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
for (let i = headerIndex + 1; i < lines.length; i++) {
|
|
70
|
+
const line = lines[i];
|
|
71
|
+
if (/^##\s/.test(line.trim())) break; // next section ends the items
|
|
72
|
+
const match = line.match(/^\s*-\s+`(I-\d+)`\s*:\s+(.*)$/);
|
|
73
|
+
if (!match) continue;
|
|
74
|
+
const id = match[1];
|
|
75
|
+
if (seen.has(id)) continue;
|
|
76
|
+
seen.add(id);
|
|
77
|
+
items.push({ id, text: match[2].trim() });
|
|
78
|
+
}
|
|
79
|
+
return items;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One-sentence description: cut at the first sentence boundary (。/.) or
|
|
84
|
+
* semicolon (;/;), then cap at 80 characters with an ellipsis.
|
|
85
|
+
*/
|
|
86
|
+
export function shortImplDescription(text: string): string {
|
|
87
|
+
const clipped = text.split(/[。;;]/)[0] ?? text;
|
|
88
|
+
const sentence = clipped.split(/(?<=[.])\s/)[0] ?? clipped;
|
|
89
|
+
const trimmed = sentence.trim();
|
|
90
|
+
if (trimmed.length <= 80) return trimmed;
|
|
91
|
+
return `${trimmed.slice(0, 79)}…`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract the covered I-ids from a VC checklist line's coverage clause
|
|
96
|
+
* ("`VC-001` covers `I-002` and `I-003`; pass condition: ..." → ["I-002",
|
|
97
|
+
* "I-003"]). Only references before the first ";" count.
|
|
98
|
+
*/
|
|
99
|
+
export function extractCoverage(vcText: string): string[] {
|
|
100
|
+
const clause = vcText.split(";")[0] ?? "";
|
|
101
|
+
return [...clause.matchAll(/\bI-\d+\b/g)].map((match) => match[0]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ImplDisplayState = "pending" | "implementing" | "implemented" | "validating" | "vc-passed";
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the display state for every I-item. Precedence: vc-passed (all
|
|
108
|
+
* covering VCs done, final) > explicit marker (validating / implemented) >
|
|
109
|
+
* derivation (some covering VC done → validating; first I with no covering
|
|
110
|
+
* VC done → implementing; rest → pending).
|
|
111
|
+
*/
|
|
112
|
+
export function resolveImplStatuses(
|
|
113
|
+
implItems: ImplItem[],
|
|
114
|
+
items: CheckItem[],
|
|
115
|
+
implStatus: Record<string, ImplMarkerState> | undefined,
|
|
116
|
+
): Record<string, ImplDisplayState> {
|
|
117
|
+
const result: Record<string, ImplDisplayState> = {};
|
|
118
|
+
let frontierAssigned = false;
|
|
119
|
+
for (const impl of implItems) {
|
|
120
|
+
const coverage = items.filter((item) => extractCoverage(item.text).includes(impl.id));
|
|
121
|
+
if (coverage.length > 0 && coverage.every((item) => item.done)) {
|
|
122
|
+
result[impl.id] = "vc-passed";
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const marker = implStatus?.[impl.id];
|
|
126
|
+
if (marker === "validating" || marker === "implemented") {
|
|
127
|
+
result[impl.id] = marker;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (coverage.some((item) => item.done)) {
|
|
131
|
+
result[impl.id] = "validating";
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!frontierAssigned) {
|
|
135
|
+
result[impl.id] = "implementing";
|
|
136
|
+
frontierAssigned = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
result[impl.id] = "pending";
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
38
144
|
export interface PlanVersionFile {
|
|
39
145
|
path: string;
|
|
40
146
|
version: number;
|
package/src/state.ts
CHANGED
|
@@ -34,11 +34,18 @@ export interface RoleConfig {
|
|
|
34
34
|
confirmed_at: string | null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export interface ExecutionConfig {
|
|
38
|
+
model_selector: string | null;
|
|
39
|
+
source: SettingSource;
|
|
40
|
+
updated_at: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
export interface PlansConfig {
|
|
38
44
|
schema: number;
|
|
39
45
|
language: LanguageConfig;
|
|
40
46
|
reviewer: RoleConfig;
|
|
41
47
|
criticizer: RoleConfig;
|
|
48
|
+
execution: ExecutionConfig;
|
|
42
49
|
artifact_root: string;
|
|
43
50
|
artifact_root_source: SettingSource;
|
|
44
51
|
artifact_root_updated_at: string | null;
|
|
@@ -69,6 +76,11 @@ export const DEFAULT_CONFIG: PlansConfig = {
|
|
|
69
76
|
name_prefix: "pi-plans-criticizer",
|
|
70
77
|
confirmed_at: null,
|
|
71
78
|
},
|
|
79
|
+
execution: {
|
|
80
|
+
model_selector: null,
|
|
81
|
+
source: "unset",
|
|
82
|
+
updated_at: null,
|
|
83
|
+
},
|
|
72
84
|
artifact_root: DEFAULT_ARTIFACT_ROOT,
|
|
73
85
|
artifact_root_source: "unset",
|
|
74
86
|
artifact_root_updated_at: null,
|
|
@@ -316,6 +328,22 @@ export function setArtifactRoot(workdir: string, artifactRoot: string, source: "
|
|
|
316
328
|
return { config, stateRoot, notices };
|
|
317
329
|
}
|
|
318
330
|
|
|
331
|
+
export interface SetExecutionModelOptions {
|
|
332
|
+
modelSelector?: string;
|
|
333
|
+
source: "user" | "auto";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function setExecutionModel(workdir: string, options: SetExecutionModelOptions): EnsureResult {
|
|
337
|
+
const { config, stateRoot, notices } = ensureState(workdir);
|
|
338
|
+
config.execution = {
|
|
339
|
+
model_selector: options.modelSelector === undefined || options.modelSelector === "inherit" ? null : options.modelSelector,
|
|
340
|
+
source: options.source,
|
|
341
|
+
updated_at: utcNow(),
|
|
342
|
+
};
|
|
343
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
344
|
+
return { config, stateRoot, notices };
|
|
345
|
+
}
|
|
346
|
+
|
|
319
347
|
export interface SetRoleOptions {
|
|
320
348
|
role: "reviewer" | "criticizer";
|
|
321
349
|
mode?: string;
|
|
@@ -359,6 +387,7 @@ export interface StartRunOptions {
|
|
|
359
387
|
topic: string;
|
|
360
388
|
skill: string;
|
|
361
389
|
requestText: string;
|
|
390
|
+
onStart?: (run: RunInfo) => void;
|
|
362
391
|
}
|
|
363
392
|
|
|
364
393
|
export interface StartRunResult {
|
|
@@ -408,6 +437,13 @@ export function startRun(workdir: string, options: StartRunOptions): StartRunRes
|
|
|
408
437
|
run_dir: runDir,
|
|
409
438
|
artifact_dir: artifactDir,
|
|
410
439
|
} satisfies ActiveInfo);
|
|
440
|
+
if (options.onStart) {
|
|
441
|
+
try {
|
|
442
|
+
options.onStart(run);
|
|
443
|
+
} catch {
|
|
444
|
+
/* marker failure is non-fatal: planning start should not abort when the entry appender rejects */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
411
447
|
return { run, notices };
|
|
412
448
|
}
|
|
413
449
|
|