pi-eda 0.2.0 → 0.2.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/LICENSE +21 -0
- package/README.md +7 -1
- package/extensions/eda-workflow-tracker.ts +18 -13
- package/extensions/lib/eda-workflow-tracker-core.ts +29 -6
- package/extensions/lib/show-core.ts +24 -0
- package/extensions/show.ts +4 -16
- package/package.json +2 -1
- package/skills/exploring-data/SKILL.md +2 -3
- package/skills/exploring-data/directory-structure.md +5 -0
- package/skills/exploring-data/plotting.md +5 -6
- package/skills/exploring-data/workflow.graph.json +3 -5
- package/skills/exploring-data/workflow.md +15 -22
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Shivam V Verma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -13,10 +13,16 @@ The [`pi-subagents`](https://www.npmjs.com/package/pi-subagents) package, for
|
|
|
13
13
|
custom-agent support and the `/skill:pi-subagents` dispatch pattern the skills
|
|
14
14
|
reference.
|
|
15
15
|
|
|
16
|
+
For plot review, use Pi interactively in a terminal with inline-image support.
|
|
17
|
+
Leave Pi's default `terminal.showImages` setting enabled so `show` can render
|
|
18
|
+
figures to the user without adding their pixels to model context.
|
|
19
|
+
|
|
16
20
|
## Install
|
|
17
21
|
|
|
22
|
+
Install dependencies & this package:
|
|
23
|
+
|
|
18
24
|
```bash
|
|
19
|
-
pi install
|
|
25
|
+
pi install npm:pi-subagents
|
|
20
26
|
pi install npm:pi-eda
|
|
21
27
|
```
|
|
22
28
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { readFile } from "node:fs/promises";
|
|
20
20
|
import { resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
21
22
|
import type {
|
|
22
23
|
ExtensionAPI,
|
|
23
24
|
ExtensionContext,
|
|
@@ -35,9 +36,14 @@ import {
|
|
|
35
36
|
handleStatus,
|
|
36
37
|
handleClear,
|
|
37
38
|
formatWidgetText,
|
|
39
|
+
resolveGraphPath,
|
|
38
40
|
reconstructFromBranch,
|
|
39
41
|
} from "./lib/eda-workflow-tracker-core.js";
|
|
40
42
|
|
|
43
|
+
const bundledGraphPath = fileURLToPath(
|
|
44
|
+
new URL("../skills/exploring-data/workflow.graph.json", import.meta.url),
|
|
45
|
+
);
|
|
46
|
+
|
|
41
47
|
const WorkflowTrackerParams = Type.Object({
|
|
42
48
|
action: Type.Union(
|
|
43
49
|
[
|
|
@@ -51,7 +57,7 @@ const WorkflowTrackerParams = Type.Object({
|
|
|
51
57
|
graphPath: Type.Optional(
|
|
52
58
|
Type.String({
|
|
53
59
|
description:
|
|
54
|
-
"Path to a workflow.graph.json ({ start, edges }) (for init)",
|
|
60
|
+
"Path to a workflow.graph.json ({ start, edges }); defaults to the bundled EDA graph (for init)",
|
|
55
61
|
}),
|
|
56
62
|
),
|
|
57
63
|
to: Type.Optional(
|
|
@@ -134,17 +140,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
134
140
|
|
|
135
141
|
switch (params.action) {
|
|
136
142
|
case "init": {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
next: [],
|
|
142
|
-
error: "graphPath required for init",
|
|
143
|
-
};
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
143
|
+
const selectedGraphPath = resolveGraphPath(
|
|
144
|
+
params.graphPath,
|
|
145
|
+
bundledGraphPath,
|
|
146
|
+
);
|
|
146
147
|
try {
|
|
147
|
-
const parsed = await loadGraphFile(ctx.cwd,
|
|
148
|
+
const parsed = await loadGraphFile(ctx.cwd, selectedGraphPath);
|
|
148
149
|
if (!isValidGraph(parsed)) {
|
|
149
150
|
throw new Error("expected { start: string, edges: object }");
|
|
150
151
|
}
|
|
@@ -154,11 +155,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
154
155
|
graphPath,
|
|
155
156
|
current,
|
|
156
157
|
next: [],
|
|
157
|
-
error: `failed to load graph at "${
|
|
158
|
+
error: `failed to load graph at "${selectedGraphPath}": ${(err as Error).message}`,
|
|
158
159
|
};
|
|
159
160
|
break;
|
|
160
161
|
}
|
|
161
|
-
result = handleInit(graph,
|
|
162
|
+
result = handleInit(graph, selectedGraphPath);
|
|
162
163
|
graphPath = result.graphPath;
|
|
163
164
|
current = result.current;
|
|
164
165
|
updateWidget(ctx);
|
|
@@ -196,6 +197,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
196
197
|
}
|
|
197
198
|
case "status": {
|
|
198
199
|
result = handleStatus(graph, current, graphPath);
|
|
200
|
+
if (!graph && current) {
|
|
201
|
+
result.error =
|
|
202
|
+
"graph not loaded -- current state was reconstructed but the graph file is unavailable; call init to reload";
|
|
203
|
+
}
|
|
199
204
|
break;
|
|
200
205
|
}
|
|
201
206
|
case "clear": {
|
|
@@ -11,15 +11,38 @@ export interface WorkflowGraph {
|
|
|
11
11
|
edges: Record<string, string[]>;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/** True if `value` has
|
|
14
|
+
/** True if `value` has a known start node and string-array edges. */
|
|
15
15
|
export function isValidGraph(value: unknown): value is WorkflowGraph {
|
|
16
16
|
if (typeof value !== "object" || value === null) return false;
|
|
17
17
|
const candidate = value as Record<string, unknown>;
|
|
18
|
-
|
|
19
|
-
typeof candidate.start
|
|
20
|
-
typeof candidate.edges
|
|
21
|
-
candidate.edges
|
|
22
|
-
|
|
18
|
+
if (
|
|
19
|
+
typeof candidate.start !== "string" ||
|
|
20
|
+
typeof candidate.edges !== "object" ||
|
|
21
|
+
candidate.edges === null ||
|
|
22
|
+
Array.isArray(candidate.edges)
|
|
23
|
+
) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const edges = candidate.edges as Record<string, unknown>;
|
|
28
|
+
if (
|
|
29
|
+
!Object.values(edges).every(
|
|
30
|
+
(targets) =>
|
|
31
|
+
Array.isArray(targets) &&
|
|
32
|
+
targets.every((target) => typeof target === "string"),
|
|
33
|
+
)
|
|
34
|
+
) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return allNodes(edges as Record<string, string[]>).has(candidate.start);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveGraphPath(
|
|
42
|
+
graphPath: string | undefined,
|
|
43
|
+
bundledGraphPath: string,
|
|
44
|
+
): string {
|
|
45
|
+
return graphPath ?? bundledGraphPath;
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
export interface WorkflowTrackerDetails {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for the `show` extension, split out for unit testing without
|
|
3
|
+
* needing a live ExtensionAPI/TUI (same pattern as eda-workflow-tracker-core.ts).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { extname, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
const MEDIA_TYPES: Record<string, string> = {
|
|
9
|
+
".png": "image/png",
|
|
10
|
+
".jpg": "image/jpeg",
|
|
11
|
+
".jpeg": "image/jpeg",
|
|
12
|
+
".gif": "image/gif",
|
|
13
|
+
".webp": "image/webp",
|
|
14
|
+
".bmp": "image/bmp",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function resolveMediaType(path: string): string | undefined {
|
|
18
|
+
return MEDIA_TYPES[extname(path).toLowerCase()];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveImagePath(rawPath: string, cwd: string): string {
|
|
22
|
+
const path = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
|
|
23
|
+
return resolve(cwd, path);
|
|
24
|
+
}
|
package/extensions/show.ts
CHANGED
|
@@ -9,19 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { readFile, stat } from "node:fs/promises";
|
|
12
|
-
import { extname, resolve } from "node:path";
|
|
13
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
13
|
import { Image, Text } from "@earendil-works/pi-tui";
|
|
15
14
|
import { Type } from "typebox";
|
|
16
|
-
|
|
17
|
-
const MEDIA_TYPES: Record<string, string> = {
|
|
18
|
-
".png": "image/png",
|
|
19
|
-
".jpg": "image/jpeg",
|
|
20
|
-
".jpeg": "image/jpeg",
|
|
21
|
-
".gif": "image/gif",
|
|
22
|
-
".webp": "image/webp",
|
|
23
|
-
".bmp": "image/bmp",
|
|
24
|
-
};
|
|
15
|
+
import { resolveImagePath, resolveMediaType } from "./lib/show-core.js";
|
|
25
16
|
|
|
26
17
|
interface ShowDetails {
|
|
27
18
|
path: string;
|
|
@@ -48,12 +39,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
48
39
|
}),
|
|
49
40
|
|
|
50
41
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
51
|
-
const
|
|
52
|
-
? params.path.slice(1)
|
|
53
|
-
: params.path;
|
|
54
|
-
const absolutePath = resolve(ctx.cwd, rawPath);
|
|
42
|
+
const absolutePath = resolveImagePath(params.path, ctx.cwd);
|
|
55
43
|
|
|
56
|
-
const mediaType =
|
|
44
|
+
const mediaType = resolveMediaType(absolutePath);
|
|
57
45
|
if (!mediaType) {
|
|
58
46
|
return {
|
|
59
47
|
content: [
|
|
@@ -107,7 +95,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
107
95
|
}
|
|
108
96
|
return new Image(details.base64, details.mediaType, theme, {
|
|
109
97
|
maxWidthCells: 80,
|
|
110
|
-
maxHeightCells:
|
|
98
|
+
maxHeightCells: 36,
|
|
111
99
|
});
|
|
112
100
|
},
|
|
113
101
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-eda",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Pi tooling for interactive exploratory data analysis.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shivam V Verma",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
"README.md",
|
|
19
|
+
"LICENSE",
|
|
19
20
|
"agents",
|
|
20
21
|
"eda.mplstyle.example",
|
|
21
22
|
"extensions",
|
|
@@ -25,9 +25,8 @@ what the figure shows, not a conclusion dressed as a finding.
|
|
|
25
25
|
|
|
26
26
|
If using this skill, you **must** first:
|
|
27
27
|
|
|
28
|
-
- Run `init` on `eda-workflow-tracker`
|
|
29
|
-
|
|
30
|
-
should be working on.
|
|
28
|
+
- Run `init` on `eda-workflow-tracker` for guidance on what stage you should be
|
|
29
|
+
working on.
|
|
31
30
|
- Read `workflow.md` for instructions for each stage.
|
|
32
31
|
|
|
33
32
|
**Always call `advance` on the `eda-workflow-tracker` when done with a
|
|
@@ -7,8 +7,10 @@ wanted).
|
|
|
7
7
|
|
|
8
8
|
```text
|
|
9
9
|
eda/
|
|
10
|
+
.gitignore # containing `outputs/`
|
|
10
11
|
index.md # landing pad for readers -- what's been done,
|
|
11
12
|
# links to each report below
|
|
13
|
+
outputs/ # gitignored data/caches, can be shared across analyses
|
|
12
14
|
customer_churn.md # a report -- lives directly under eda/
|
|
13
15
|
customer_churn/ # paired folder, same stem as the report file
|
|
14
16
|
scripts/
|
|
@@ -35,6 +37,9 @@ eda/
|
|
|
35
37
|
- Shared code (data loading, plotting helpers, constants) goes in an
|
|
36
38
|
unnumbered `common_<topic>.py` and gets imported from there instead --
|
|
37
39
|
never import one numbered script from another.
|
|
40
|
+
- `eda/outputs/` is a top-level, shared cache. Any script can read from or
|
|
41
|
+
write to it. No substructure required inside it. Scripts that produce
|
|
42
|
+
data caches should check-or-recompute so it can be repopulated if needed.
|
|
38
43
|
|
|
39
44
|
## Common Mistakes
|
|
40
45
|
|
|
@@ -10,8 +10,9 @@ per-type rule that hasn't been stated.
|
|
|
10
10
|
|
|
11
11
|
- Generate figures with matplotlib, saved as PNG, following the conventions
|
|
12
12
|
below.
|
|
13
|
-
-
|
|
14
|
-
(`plt.style.use("eda")`) and respect user defaults.
|
|
13
|
+
- If `~/.config/matplotlib/stylelib/eda.mplstyle` exists, apply it at the top
|
|
14
|
+
of the script (`plt.style.use("eda")`) and respect user defaults. Otherwise
|
|
15
|
+
skip `plt.style.use` and fall back to Defaults below.
|
|
15
16
|
- Visually inspect every figure with the `read` tool before treating it as a
|
|
16
17
|
result -- never make hypotheses from a figure you haven't looked at.
|
|
17
18
|
- `read` is for your own inspection. When you're ready to show the figure to
|
|
@@ -30,10 +31,8 @@ per-type rule that hasn't been stated.
|
|
|
30
31
|
If the user has not set up `eda.mplstyle` or has not offered opinions
|
|
31
32
|
on plot style, use these defaults:
|
|
32
33
|
|
|
33
|
-
- Colors: `
|
|
34
|
-
|
|
35
|
-
for a colormap -- pick something else only if needed to convey a point, e.g.
|
|
36
|
-
categorical comparison.
|
|
34
|
+
- Colors: `lightsteelblue` for one color and `Blues` for a colormap -- pick
|
|
35
|
+
something else only if needed to convey a point, e.g. categorical comparison.
|
|
37
36
|
- Gridlines: none. Spines / ticks: matplotlib defaults -- don't add or strip
|
|
38
37
|
either.
|
|
39
38
|
- Titles: Be brief -- words must earn their space. Detailed descriptions belong
|
|
@@ -5,13 +5,11 @@
|
|
|
5
5
|
"Peek at the data": ["Propose/ask a question"],
|
|
6
6
|
"Propose/ask a question": ["Build script/plot"],
|
|
7
7
|
"Build script/plot": ["Light or heavy?"],
|
|
8
|
-
"Light or heavy?": ["Reread script", "Dispatch script-checker"],
|
|
9
|
-
"Reread script": ["Build script/plot", "
|
|
8
|
+
"Light or heavy?": ["Reread and run script", "Dispatch script-checker"],
|
|
9
|
+
"Reread and run script": ["Build script/plot", "Summarize the answer"],
|
|
10
10
|
"Dispatch script-checker": ["Build script/plot", "Dispatch job-runner"],
|
|
11
|
-
"Run now, this session": ["Summarize the answer"],
|
|
12
11
|
"Dispatch job-runner": ["Summarize the answer"],
|
|
13
|
-
"Summarize the answer": [
|
|
14
|
-
"Continue analysis or lock in?": [
|
|
12
|
+
"Summarize the answer": [
|
|
15
13
|
"Propose/ask a question",
|
|
16
14
|
"/skill:finishing-exploratory-data-analysis"
|
|
17
15
|
]
|
|
@@ -7,7 +7,7 @@ exactly that stage of work; and then call `advance` before starting the next
|
|
|
7
7
|
stage. Call `status` whenever you need to know where you are instead of holding
|
|
8
8
|
it in your head.
|
|
9
9
|
|
|
10
|
-
At the end of every turn, ask yourself if you have accomplished the exit
|
|
10
|
+
At the end of every turn, ask yourself if you have accomplished the exit
|
|
11
11
|
condition to `advance` to the next stage.
|
|
12
12
|
|
|
13
13
|
- If yes, `advance` should **always** be your first tool call.
|
|
@@ -28,10 +28,10 @@ condition to `advance` to the next stage.
|
|
|
28
28
|
- Expect the scope to narrow or shift as the data yields a story during
|
|
29
29
|
this analysis. This step is purely for you to ground yourself.
|
|
30
30
|
- You should **never** be running code at this stage. If the user has
|
|
31
|
-
already offered specific
|
|
32
|
-
|
|
31
|
+
already offered a specific question/approach, it establishes the scope but
|
|
32
|
+
you must confirm a report name before advancing.
|
|
33
33
|
- `advance` condition: the user has confirmed the scope of the analysis and has
|
|
34
|
-
given a report name
|
|
34
|
+
given a report name.
|
|
35
35
|
|
|
36
36
|
### `Peek at the data`
|
|
37
37
|
|
|
@@ -94,9 +94,9 @@ We want to increase iteration speed and not waste user time.
|
|
|
94
94
|
treating as heavy."
|
|
95
95
|
- `advance` condition: you understand the computational cost of the script.
|
|
96
96
|
|
|
97
|
-
### `Reread script`
|
|
97
|
+
### `Reread and run script`
|
|
98
98
|
|
|
99
|
-
Light path:
|
|
99
|
+
Light path: reread the script as a sanity check, then run it in this session.
|
|
100
100
|
|
|
101
101
|
- Does it match the user's stated intent/framing, does each nontrivial
|
|
102
102
|
transform do what it should?
|
|
@@ -105,13 +105,10 @@ Light path: Reread the script as a sanity check.
|
|
|
105
105
|
- This is a deliberately lower bar than the heavy path. A subtle mistake here
|
|
106
106
|
can also be caught downstream in `/skill:finishing-exploratory-data-analysis`.
|
|
107
107
|
We just want to sanity check here.
|
|
108
|
-
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Light path continuation: run the smoke-tested script/plot in this session.
|
|
113
|
-
|
|
114
|
-
- `advance` condition: you have run the script successfully on the data.
|
|
108
|
+
- Once the reread finds no issues, run the smoke-tested script/plot in this
|
|
109
|
+
session.
|
|
110
|
+
- `advance` condition: you have reread the script, found no issues, and run
|
|
111
|
+
it successfully on the data.
|
|
115
112
|
|
|
116
113
|
### `Dispatch script-checker`
|
|
117
114
|
|
|
@@ -149,14 +146,10 @@ Heavy path continuation: launch the `job-runner` only once the
|
|
|
149
146
|
- `show` the figure(s) to the user, then summarize briefly with bullets.
|
|
150
147
|
- Core principle: the user decides the interpretation. Continue interrogating
|
|
151
148
|
the user until it makes sense to you and is consistent with the dataset.
|
|
152
|
-
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
- Ask the user whether to continue with another question or lock in the
|
|
157
|
-
analysis; keep interrogating until they pick one. "Next question" loops back
|
|
158
|
-
to `Propose/ask a question`, "finish analysis" exits to the finishing skill.
|
|
159
|
-
- `advance` condition: the user has decided to continue the session or finish it.
|
|
149
|
+
- Once the user makes a clear interpretation, ask whether to continue with another
|
|
150
|
+
question or lock in the analysis. Keep interrogating until they pick one.
|
|
151
|
+
- `advance` condition: you understand the user's interpretation of the
|
|
152
|
+
figure, and the user has decided to continue the session or finish it.
|
|
160
153
|
|
|
161
154
|
### `/skill:finishing-exploratory-data-analysis`
|
|
162
155
|
|
|
@@ -178,7 +171,7 @@ Figure/analysis seems meaningful, ready to write up or commit.
|
|
|
178
171
|
| Silently skipping the smoke test on a "just read cache and plot" script | Only skip for zero-computation cached-read-and-plot, and state that justification explicitly |
|
|
179
172
|
| Trusting a sanity check that only ran on an easy/symmetric case | Pick a case that could actually have failed, especially for matching/ordering/canonicalization logic |
|
|
180
173
|
| Checking that a smoke test ran fast/clean without looking at what it produced | Glance at the actual output values -- a clean run on garbage isn't a pass |
|
|
181
|
-
| Running a light-path script without rereading it first because it's "just quick" | Reread against the stated intent/framing before running -- speed doesn't waive the check, it just changes who does it |
|
|
174
|
+
| Running a light-path script without rereading it first because it's "just quick" | Reread against the stated intent/framing before running -- speed doesn't waive the check, it just changes who does it. Both happen in the same `Reread and run script` stage, but the reread must come first |
|
|
182
175
|
| Launching a heavy scale-up "while the `script-checker` runs in the background" instead of waiting for it | Confirm the `script-checker` came back clean before launching -- don't launch and plan to fold feedback in later |
|
|
183
176
|
| Letting 2+ small edits pile up on a heavy-path script since the last `script-checker` dispatch because none felt big enough alone | Dispatch again once 2+ edits have landed |
|
|
184
177
|
| Treating a mid-work `script-checker` pass as covering the finishing skill's lock-in checkpoint | Different gate, different artifact -- a pending or completed `script-checker` pass never substitutes for it |
|