skill-harness 0.13.0 → 0.14.0

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 CHANGED
@@ -8,9 +8,12 @@ bar, and open an interactive review UI so you can measure a `SKILL.md` edit.
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- npm i -g skill-harness
11
+ npm i -g skill-harness # CLI
12
+ pi install npm:skill-harness # CLI plus /skill-harness extension in Pi
12
13
  ```
13
14
 
15
+ The Pi package declares its bundled extension directly. After install/update, `/reload` makes it available in an existing supported Pi session; verify the loaded resource rather than treating the package version as proof.
16
+
14
17
  ## The loop
15
18
 
16
19
  1. **list** — discover which skills have a spec.
@@ -0,0 +1,177 @@
1
+ // Client-side scorer for the review UI (assets/report.template.html).
2
+ //
3
+ // This is the SINGLE source of truth for "how does a column get graded in the
4
+ // browser" — it must implement exactly the same rules as
5
+ // packages/core/src/score.ts's `score()`. It is:
6
+ // (a) imported directly, as plain ESM, by
7
+ // packages/core/test/grade-column-parity.test.ts, which asserts parity
8
+ // against score.ts for a set of fixtures (PASS/FAIL/critical/B-series/
9
+ // suspect/override-resolved-suspect). If you change score.ts's rules
10
+ // and forget to mirror them here, that test fails.
11
+ // (b) injected verbatim into report.template.html's inline <script>, at the
12
+ // GRADE placeholder comment near its top (see renderReport in
13
+ // packages/core/src/report.ts).
14
+ //
15
+ // INJECTION NOTE: a bare inline <script> (no type="module") cannot contain an
16
+ // `export` statement. renderReport() strips the leading `export ` keyword off
17
+ // each exported declaration textually before splicing this file's contents
18
+ // into the template. Nothing here relies on import/export semantics at
19
+ // runtime (no imports, no re-exports), so stripping `export ` and leaving
20
+ // plain function declarations behind is safe in both the browser (global
21
+ // script scope) and Node (this file imported as an ES module).
22
+ //
23
+ // No DOM access, no imports — must load in Node (for the parity test) and in
24
+ // a plain <script> in the browser (for the review UI).
25
+
26
+ export function effective(cell) {
27
+ if (cell.override) return cell.override;
28
+ // Mechanical evidence outranks a prose judge. Objective PASS deliberately
29
+ // forces nothing — the checklist judge still decides the behavioral rubric.
30
+ if (cell.objective && cell.objective.status === "ERROR") return "ERROR";
31
+ if (cell.objective && cell.objective.status === "NOT-MEASURED") return "NOT-MEASURED";
32
+ if (cell.objective && cell.objective.status === "FAIL") return "FAIL";
33
+ return cell.judge_verdict;
34
+ }
35
+
36
+ function letterFor(pct) {
37
+ if (pct >= 90) return "A";
38
+ if (pct >= 80) return "B";
39
+ if (pct >= 70) return "C";
40
+ if (pct >= 60) return "D";
41
+ return "F";
42
+ }
43
+
44
+ /**
45
+ * Score one report column against the ship bar — mirrors score.ts's `score()`
46
+ * exactly, over `col.cells` (a scenario-id -> cell map) instead of a flat
47
+ * verdict list. A `suspect` cell without an override is excluded from both
48
+ * `passed` and `total` (untrustworthy: neither pass nor fail) and blocks ship.
49
+ */
50
+ export function gradeColumn(col, shipBar, critical) {
51
+ let passed = 0;
52
+ let total = 0;
53
+ let criticalFails = 0;
54
+ let bFails = 0;
55
+ let suspect = 0;
56
+ let errors = 0;
57
+ let notMeasured = 0;
58
+
59
+ for (const id of Object.keys(col.cells)) {
60
+ const cell = col.cells[id];
61
+ if (!cell) continue;
62
+ if (cell.suspect && !cell.override) {
63
+ suspect++;
64
+ continue; // excluded, blocks ship
65
+ }
66
+ const verdict = effective(cell);
67
+ if (verdict === "NOT-MEASURED") {
68
+ notMeasured++;
69
+ continue;
70
+ }
71
+ if (verdict === "ERROR" || verdict === "JUDGE-AMBIGUOUS") {
72
+ errors++;
73
+ continue;
74
+ }
75
+ total++;
76
+ if (verdict === "PASS") {
77
+ passed++;
78
+ continue;
79
+ }
80
+ if (critical.includes(id)) criticalFails++;
81
+ if (/^B/i.test(id)) bFails++;
82
+ }
83
+
84
+ if (col.partial === true) {
85
+ return { passed: 0, total: 0, pct: 0, letter: "-", ship: false, criticalFails, bFails, suspect, errors, notMeasured };
86
+ }
87
+
88
+ const pct = total > 0 ? Math.round((passed * 100) / total) : 0;
89
+ const letter = letterFor(pct);
90
+ const validBar = Number.isInteger(shipBar.total) && shipBar.total >= 1 && Number.isInteger(shipBar.min_pass) && shipBar.min_pass >= 1 && shipBar.min_pass <= shipBar.total;
91
+ const ship =
92
+ validBar &&
93
+ total >= shipBar.total &&
94
+ passed >= shipBar.min_pass &&
95
+ (!shipBar.no_critical_fail || criticalFails === 0) &&
96
+ bFails === 0 &&
97
+ suspect === 0 &&
98
+ errors === 0 &&
99
+ notMeasured === 0;
100
+
101
+ return { passed, total, pct, letter, ship, criticalFails, bFails, suspect, errors, notMeasured };
102
+ }
103
+
104
+ /**
105
+ * Red-vs-green class for ONE scenario — mirrors `classify` in
106
+ * packages/core/src/lift.ts exactly. Kept in the client because the author
107
+ * flips green verdicts live in this UI: a server-computed class would freeze at
108
+ * page load and could show "gained" beside a cell the author just marked FAIL.
109
+ *
110
+ * `liftCell` supplies the red side (verdict + redSuspect, from the baseline run,
111
+ * which this view never edits); `greenCell` is the live report cell, so its
112
+ * override and suspect state are read through the same effective()/suspect rule
113
+ * the grader uses.
114
+ */
115
+ export function liftClass(liftCell, greenCell) {
116
+ const conclusive = (verdict, suspect) =>
117
+ !suspect && verdict !== "ERROR" && verdict !== "NOT-MEASURED" && verdict !== "JUDGE-AMBIGUOUS";
118
+
119
+ const redOk = conclusive(liftCell.red, liftCell.redSuspect);
120
+ const greenOk = conclusive(effective(greenCell), !!greenCell.suspect && !greenCell.override);
121
+ if (!redOk || !greenOk) return "inconclusive";
122
+
123
+ const redPass = liftCell.red === "PASS";
124
+ const greenPass = effective(greenCell) === "PASS";
125
+ if (redPass && greenPass) return "kept";
126
+ if (!redPass && greenPass) return "gained";
127
+ if (redPass && !greenPass) return "regressed";
128
+ return "both-fail";
129
+ }
130
+
131
+ /**
132
+ * Aggregate a column's lift over the live cells — mirrors computeLift's counters
133
+ * in packages/core/src/lift.ts. Only scenarios the baseline also covered are
134
+ * counted, matching the server's intersection rule.
135
+ */
136
+ export function liftSummary(col) {
137
+ const out = { gained: 0, regressed: 0, kept: 0, bothFail: 0, inconclusive: 0, compared: 0, redPassed: 0, greenPassed: 0, delta: 0 };
138
+ if (!col.lift) return null;
139
+ for (const id of Object.keys(col.lift.cells)) {
140
+ const greenCell = col.cells[id];
141
+ if (!greenCell) continue;
142
+ const liftCell = col.lift.cells[id];
143
+ const cls = liftClass(liftCell, greenCell);
144
+ out.compared++;
145
+ if (cls === "both-fail") out.bothFail++;
146
+ else out[cls]++;
147
+ if (cls !== "inconclusive") {
148
+ if (liftCell.red === "PASS") out.redPassed++;
149
+ if (effective(greenCell) === "PASS") out.greenPassed++;
150
+ }
151
+ }
152
+ out.delta = out.greenPassed - out.redPassed;
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * The lift badge for a column with nothing to show in the number: `{text, title}`,
158
+ * or null when the column should carry no badge at all.
159
+ *
160
+ * A *missing* baseline and an *unusable* one are different facts. Nothing is
161
+ * comparable when every shared scenario ran identically in both modes, or when the
162
+ * two sides aggregated differently (red at 1 rep vs green at 3) — a red baseline
163
+ * exists, and "no red baseline" would send the author off to re-run the one thing
164
+ * they already have. The server's headline already states which it is and what to
165
+ * re-run, so it becomes the tooltip verbatim.
166
+ */
167
+ export function liftNoneBadge(col) {
168
+ if (col.lift) {
169
+ return { text: "lift not comparable", title: col.liftHeadline || "nothing in the red baseline could be compared" };
170
+ }
171
+ // Green and force are both skill-delivered, so both can be missing a baseline.
172
+ // A red column is the baseline and gets no badge at all.
173
+ if (col.mode === "green" || col.mode === "force") {
174
+ return { text: "no red baseline", title: "run the same scenarios with --mode red to get a baseline" };
175
+ }
176
+ return null;
177
+ }