specpi 0.10.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/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/NPM_RELEASE.md +110 -0
- package/README.md +155 -0
- package/SECURITY.md +85 -0
- package/SECURITY_MODEL.md +107 -0
- package/THIRD_PARTY.md +61 -0
- package/browser-runtime/package-lock.json +86 -0
- package/browser-runtime/package.json +15 -0
- package/extensions/browser/core.mjs +306 -0
- package/extensions/browser/index.ts +723 -0
- package/extensions/browser/smoke.mjs +47 -0
- package/extensions/command-guard/bash.mjs +1426 -0
- package/extensions/command-guard/cmd.mjs +369 -0
- package/extensions/command-guard/core.mjs +506 -0
- package/extensions/command-guard/index.ts +634 -0
- package/extensions/command-guard/managed-files.mjs +22 -0
- package/extensions/command-guard/paths.mjs +398 -0
- package/extensions/command-guard/powershell-parser.ps1 +47 -0
- package/extensions/command-guard/powershell.mjs +655 -0
- package/extensions/command-guard/redact.mjs +65 -0
- package/extensions/command-guard/rules.mjs +2557 -0
- package/extensions/command-guard/smoke.mjs +422 -0
- package/extensions/files/core.mjs +422 -0
- package/extensions/files/index.ts +678 -0
- package/extensions/spec/core.mjs +47 -0
- package/extensions/spec.ts +457 -0
- package/extensions/tool-wishlist/capabilities.json +114 -0
- package/extensions/tool-wishlist/core.mjs +1525 -0
- package/extensions/tool-wishlist/index.ts +804 -0
- package/extensions/tool-wishlist/registry.mjs +99 -0
- package/extensions/tool-wishlist/validators.mjs +345 -0
- package/extensions/ui-refresh/index.ts +54 -0
- package/extensions/workflow-controls/challenge.mjs +196 -0
- package/extensions/workflow-controls/experiments.mjs +628 -0
- package/extensions/workflow-controls/index.ts +1144 -0
- package/extensions/workflow-controls/scope.mjs +272 -0
- package/extensions/workflow-controls/smoke.mjs +201 -0
- package/package.json +98 -0
- package/scripts/check-package.mjs +483 -0
- package/scripts/check-pi-package.mjs +223 -0
- package/scripts/check-release-order.mjs +97 -0
- package/scripts/lib.mjs +182 -0
- package/scripts/lock.mjs +122 -0
- package/scripts/specpi.mjs +2037 -0
- package/scripts/verify-artifact.mjs +21 -0
- package/shell/pi-profiles.sh +14 -0
- package/site/logo.svg +9 -0
- package/site/self-improvement-loop-v2.svg +108 -0
- package/skills/donsetch/SKILL.md +76 -0
- package/skills/specpi-improve/SKILL.md +54 -0
- package/specpi +4 -0
- package/specpi.cmd +4 -0
- package/templates/AGENTS.md +23 -0
- package/templates/settings.json +10 -0
- package/themes/specpi-spec.json +96 -0
- package/themes/tea-house.json +89 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { VALIDATOR_CATALOG } from "./validators.mjs";
|
|
2
|
+
|
|
3
|
+
const STOP_WORDS = new Set([
|
|
4
|
+
"a",
|
|
5
|
+
"an",
|
|
6
|
+
"and",
|
|
7
|
+
"capability",
|
|
8
|
+
"for",
|
|
9
|
+
"missing",
|
|
10
|
+
"need",
|
|
11
|
+
"needed",
|
|
12
|
+
"of",
|
|
13
|
+
"support",
|
|
14
|
+
"the",
|
|
15
|
+
"to",
|
|
16
|
+
"tool",
|
|
17
|
+
"tools",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const VALIDATORS = new Set(Object.keys(VALIDATOR_CATALOG));
|
|
21
|
+
|
|
22
|
+
function compact(value, maxLength) {
|
|
23
|
+
return String(value ?? "")
|
|
24
|
+
.normalize("NFKC")
|
|
25
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
26
|
+
.replace(/\s+/g, " ")
|
|
27
|
+
.trim()
|
|
28
|
+
.slice(0, maxLength);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeToken(token) {
|
|
32
|
+
if (token.length > 4 && token.endsWith("s") && !token.endsWith("ss")) {
|
|
33
|
+
return token.slice(0, -1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return token;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeCapability(value) {
|
|
40
|
+
const tokens = compact(value, 120)
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
43
|
+
.split(" ")
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map(normalizeToken)
|
|
46
|
+
.filter((token) => !STOP_WORDS.has(token));
|
|
47
|
+
|
|
48
|
+
return [...new Set(tokens)].slice(0, 10).join("-") || "uncategorized-gap";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateCapabilityRegistry(registry) {
|
|
52
|
+
if (registry?.schema !== 1 || !Array.isArray(registry.capabilities)) {
|
|
53
|
+
throw new Error("schema or capabilities array is invalid");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ids = new Set();
|
|
57
|
+
const aliases = new Set();
|
|
58
|
+
for (const item of registry.capabilities) {
|
|
59
|
+
if (
|
|
60
|
+
typeof item?.id !== "string" ||
|
|
61
|
+
normalizeCapability(item.id) !== item.id ||
|
|
62
|
+
typeof item.title !== "string" ||
|
|
63
|
+
item.title.trim().length < 3 ||
|
|
64
|
+
!Array.isArray(item.aliases) ||
|
|
65
|
+
typeof item.shippedVersion !== "string" ||
|
|
66
|
+
typeof item.shippedAt !== "string" ||
|
|
67
|
+
!Number.isFinite(Date.parse(item.shippedAt)) ||
|
|
68
|
+
!Array.isArray(item.validations) ||
|
|
69
|
+
item.validations.length === 0 ||
|
|
70
|
+
item.validations.some((validator) => !VALIDATORS.has(validator))
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(`invalid capability entry: ${item?.id || "unknown"}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ids.has(item.id) || aliases.has(item.id)) {
|
|
76
|
+
throw new Error(`duplicate capability registry key: ${item.id}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
ids.add(item.id);
|
|
80
|
+
for (const alias of item.aliases) {
|
|
81
|
+
if (
|
|
82
|
+
typeof alias !== "string" ||
|
|
83
|
+
normalizeCapability(alias) !== alias ||
|
|
84
|
+
ids.has(alias) ||
|
|
85
|
+
aliases.has(alias)
|
|
86
|
+
) {
|
|
87
|
+
throw new Error(`duplicate or invalid capability alias: ${alias}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
aliases.add(alias);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return registry;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isValidValidatorName(value) {
|
|
98
|
+
return VALIDATORS.has(value);
|
|
99
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Closed capability validators for the SpecPi improvement loop.
|
|
4
|
+
*
|
|
5
|
+
* Every shipped capability links to at least one validator from this catalog.
|
|
6
|
+
* Validators are deterministic, offline, bounded in time, and never touch the
|
|
7
|
+
* live Pi agent directory: each run proves its capability in temporary state.
|
|
8
|
+
*
|
|
9
|
+
* CLI: node validators.mjs <validator> [--state-dir <dir>] [--cwd <dir>] [--browser-runtime <dir>]
|
|
10
|
+
* Exit 0 proves the validator; any other exit code fails the capability gate.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
export const VALIDATOR_CATALOG = Object.freeze({
|
|
20
|
+
"browser-runtime-smoke": Object.freeze({
|
|
21
|
+
description: "Launches the managed browser runtime and proves exact and changed-pixel visual comparisons",
|
|
22
|
+
timeoutMs: 2 * 60 * 1000,
|
|
23
|
+
}),
|
|
24
|
+
"wishlist-state-smoke": Object.freeze({
|
|
25
|
+
description:
|
|
26
|
+
"Drives the wishlist core API in a temporary state directory through record, select, retire with journal, reopen, metrics, and history",
|
|
27
|
+
timeoutMs: 2 * 60 * 1000,
|
|
28
|
+
}),
|
|
29
|
+
"command-guard-smoke": Object.freeze({
|
|
30
|
+
description:
|
|
31
|
+
"Classifies safe, destructive, malformed, protected-path, and nested-shell fixtures with the real command-guard policy",
|
|
32
|
+
timeoutMs: 2 * 60 * 1000,
|
|
33
|
+
}),
|
|
34
|
+
"scope-drift-monitor-smoke": Object.freeze({
|
|
35
|
+
description:
|
|
36
|
+
"Proves bounded scope paths and observed outside-scope mutation detection in a temporary repository",
|
|
37
|
+
timeoutMs: 2 * 60 * 1000,
|
|
38
|
+
}),
|
|
39
|
+
"guided-experiment-worktrees-smoke": Object.freeze({
|
|
40
|
+
description:
|
|
41
|
+
"Proves detached experiment creation, byte-exact appliable export of committed and uncommitted work measured from the recorded base commit, disclosure of ignored work a patch cannot carry, base isolation, and explicit discard",
|
|
42
|
+
timeoutMs: 2 * 60 * 1000,
|
|
43
|
+
}),
|
|
44
|
+
"completion-challenge-smoke": Object.freeze({
|
|
45
|
+
description: "Proves structured readiness validation and deterministic unresolved-evidence rejection",
|
|
46
|
+
timeoutMs: 2 * 60 * 1000,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function validatorNames() {
|
|
51
|
+
return Object.keys(VALIDATOR_CATALOG);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseEnvironment(args) {
|
|
55
|
+
const environment = {};
|
|
56
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
57
|
+
const flag = args[index];
|
|
58
|
+
const value = args[index + 1];
|
|
59
|
+
if (flag === "--state-dir") {
|
|
60
|
+
environment.stateDir = value;
|
|
61
|
+
} else if (flag === "--cwd") {
|
|
62
|
+
environment.cwd = value;
|
|
63
|
+
} else if (flag === "--browser-runtime") {
|
|
64
|
+
environment.browserRuntime = value;
|
|
65
|
+
} else {
|
|
66
|
+
throw new Error(`Unknown validator flag: ${flag}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (value === undefined) {
|
|
70
|
+
throw new Error(`Validator flag ${flag} requires a value`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
index += 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return environment;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runBrowserRuntimeSmoke(environment) {
|
|
80
|
+
if (!environment.browserRuntime) {
|
|
81
|
+
throw new Error("browser-runtime-smoke requires --browser-runtime <managed-runtime-dir>");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const smokeScript = fileURLToPath(new URL("../browser/smoke.mjs", import.meta.url));
|
|
85
|
+
const result = spawnSync(process.execPath, [smokeScript, environment.browserRuntime], {
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
timeout: VALIDATOR_CATALOG["browser-runtime-smoke"].timeoutMs,
|
|
88
|
+
});
|
|
89
|
+
if (result.status !== 0) {
|
|
90
|
+
throw new Error(`${(result.stderr || result.stdout || "browser smoke exited non-zero").trim().slice(0, 300)}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (result.stdout || "Browser runtime smoke passed").trim().split("\n").at(-1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function runWishlistStateSmoke() {
|
|
97
|
+
const core = await import("./core.mjs");
|
|
98
|
+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "specpi-wishlist-state-smoke-"));
|
|
99
|
+
try {
|
|
100
|
+
const gap = {
|
|
101
|
+
capability: "Wishlist state smoke",
|
|
102
|
+
scenario: "A validator exercises the wishlist lifecycle end to end",
|
|
103
|
+
limitation: "The lifecycle under test must be observable without touching live state",
|
|
104
|
+
impact: "degraded",
|
|
105
|
+
workaround: "Manual state inspection",
|
|
106
|
+
suggestedFix: "tool",
|
|
107
|
+
};
|
|
108
|
+
await core.setCollectionMode({ stateDir, mode: "on" });
|
|
109
|
+
await core.recordCapabilityGap({
|
|
110
|
+
stateDir,
|
|
111
|
+
sessionId: "smoke-session-one",
|
|
112
|
+
runId: "run-one",
|
|
113
|
+
cwd: "/smoke/project-a",
|
|
114
|
+
gap,
|
|
115
|
+
now: "2026-01-01T00:00:00.000Z",
|
|
116
|
+
});
|
|
117
|
+
await core.recordCapabilityGap({
|
|
118
|
+
stateDir,
|
|
119
|
+
sessionId: "smoke-session-two",
|
|
120
|
+
runId: "run-two",
|
|
121
|
+
cwd: "/smoke/project-b",
|
|
122
|
+
gap,
|
|
123
|
+
now: "2026-01-02T00:00:00.000Z",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const qualified = await core.refreshWishlist({ stateDir });
|
|
127
|
+
if (qualified.uniqueGaps !== 1 || qualified.occurrences !== 2) {
|
|
128
|
+
throw new Error("Expected one qualified gap with two occurrences");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const candidate = qualified.improvements[0];
|
|
132
|
+
if (!candidate?.qualified || candidate.canonicalKey !== "wishlist-state-smoke") {
|
|
133
|
+
throw new Error("Expected the recorded gap to qualify for improvement");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await core.appendWishlistDecision({
|
|
137
|
+
stateDir,
|
|
138
|
+
action: "select",
|
|
139
|
+
canonicalKey: candidate.canonicalKey,
|
|
140
|
+
note: "smoke selection",
|
|
141
|
+
now: "2026-01-02T06:00:00.000Z",
|
|
142
|
+
});
|
|
143
|
+
const retire = await core.appendWishlistDecision({
|
|
144
|
+
stateDir,
|
|
145
|
+
action: "retire",
|
|
146
|
+
canonicalKey: candidate.canonicalKey,
|
|
147
|
+
note: "wishlist state smoke retirement note",
|
|
148
|
+
now: "2026-01-03T00:00:00.000Z",
|
|
149
|
+
journal: {
|
|
150
|
+
schema: 1,
|
|
151
|
+
evidence: ["wishlist-state-smoke lifecycle completed in temporary state"],
|
|
152
|
+
gates: ["npm run check", "wishlist-state-smoke"],
|
|
153
|
+
changedFiles: ["extensions/tool-wishlist/core.mjs"],
|
|
154
|
+
changedFilesTruncated: false,
|
|
155
|
+
version: "smoke",
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await core.recordCapabilityGap({
|
|
160
|
+
stateDir,
|
|
161
|
+
sessionId: "smoke-session-three",
|
|
162
|
+
runId: "run-three",
|
|
163
|
+
cwd: "/smoke/project-a",
|
|
164
|
+
gap,
|
|
165
|
+
now: "2026-01-04T00:00:00.000Z",
|
|
166
|
+
});
|
|
167
|
+
const reviewing = await core.refreshWishlist({ stateDir });
|
|
168
|
+
if (!reviewing.improvements[0]?.reviewNeeded) {
|
|
169
|
+
throw new Error("Expected a post-retirement review signal");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (reviewing.metrics.retirements !== 1 || reviewing.metrics.openReviews !== 1) {
|
|
173
|
+
throw new Error("Expected one retirement and one open review");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const reopen = await core.appendWishlistDecision({
|
|
177
|
+
stateDir,
|
|
178
|
+
action: "reopen",
|
|
179
|
+
canonicalKey: candidate.canonicalKey,
|
|
180
|
+
note: "Reopened for review: 1 post-retirement signal(s)",
|
|
181
|
+
evidence: ["1 post-retirement signal(s) recorded after the retirement"],
|
|
182
|
+
now: "2026-01-05T00:00:00.000Z",
|
|
183
|
+
});
|
|
184
|
+
const reopened = await core.refreshWishlist({ stateDir });
|
|
185
|
+
const linked = core.linkReopenToRetirement(
|
|
186
|
+
reopened.decisions,
|
|
187
|
+
reopened.decisions.find((decision) => decision.id === reopen.decisionId),
|
|
188
|
+
);
|
|
189
|
+
if (linked?.id !== retire.decisionId) {
|
|
190
|
+
throw new Error("Expected the reopen to link to the latest retirement");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
reopened.metrics.retirements !== 1 ||
|
|
195
|
+
reopened.metrics.reopenRate !== 100 ||
|
|
196
|
+
reopened.metrics.medianDaysToRetire !== 2
|
|
197
|
+
) {
|
|
198
|
+
throw new Error("Expected metrics to reflect one retirement, a full reopen rate, and a two-day median");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const history = core.renderWishlistHistory(reopened.events, reopened.decisions, candidate.canonicalKey);
|
|
202
|
+
if (
|
|
203
|
+
!history.includes("wishlist-state-smoke lifecycle completed in temporary state") ||
|
|
204
|
+
!history.includes("Linked retirement")
|
|
205
|
+
) {
|
|
206
|
+
throw new Error("Expected the journal to render retirement evidence and the reopen linkage");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (process.env.SPECPI_WISHLIST_SMOKE_FAULT === "expectation") {
|
|
210
|
+
throw new Error("Injected expectation fault: metrics.retirements should have been 999");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return "wishlist-state-smoke passed: record, select, retire with journal, reopen with linkage, metrics, and history verified in temporary state";
|
|
214
|
+
} finally {
|
|
215
|
+
fs.rmSync(stateDir, { recursive: true, force: true, maxRetries: 3 });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function runCommandGuardSmoke() {
|
|
220
|
+
const smoke = await import("../command-guard/smoke.mjs");
|
|
221
|
+
if (typeof smoke.runCommandGuardSmoke !== "function") {
|
|
222
|
+
throw new Error("command guard smoke export is unavailable");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return smoke.runCommandGuardSmoke();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runWorkflowControlsSmoke(validator) {
|
|
229
|
+
const smoke = await import("../workflow-controls/smoke.mjs");
|
|
230
|
+
if (typeof smoke.runWorkflowControlsSmoke !== "function") {
|
|
231
|
+
throw new Error("workflow-controls smoke export is unavailable");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return smoke.runWorkflowControlsSmoke(validator);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function runValidatorInProcess(validator, environment) {
|
|
238
|
+
if (validator === "browser-runtime-smoke") {
|
|
239
|
+
return runBrowserRuntimeSmoke(environment);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (validator === "wishlist-state-smoke") {
|
|
243
|
+
return runWishlistStateSmoke();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (validator === "command-guard-smoke") {
|
|
247
|
+
return runCommandGuardSmoke();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (
|
|
251
|
+
validator === "scope-drift-monitor-smoke" ||
|
|
252
|
+
validator === "guided-experiment-worktrees-smoke" ||
|
|
253
|
+
validator === "completion-challenge-smoke"
|
|
254
|
+
) {
|
|
255
|
+
return runWorkflowControlsSmoke(validator);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
throw new Error(`Unknown validator: ${validator}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function runValidator(validator, environment = {}, options = {}) {
|
|
262
|
+
const entry = VALIDATOR_CATALOG[validator];
|
|
263
|
+
if (!entry) {
|
|
264
|
+
return {
|
|
265
|
+
code: 2,
|
|
266
|
+
stdout: "",
|
|
267
|
+
stderr: `Unknown validator: ${validator}. Registered validators: ${validatorNames().join(", ")}`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const args = [fileURLToPath(new URL("./validators.mjs", import.meta.url)), validator];
|
|
272
|
+
if (environment.stateDir) {
|
|
273
|
+
args.push("--state-dir", environment.stateDir);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (environment.cwd) {
|
|
277
|
+
args.push("--cwd", environment.cwd);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (environment.browserRuntime) {
|
|
281
|
+
args.push("--browser-runtime", environment.browserRuntime);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const timeoutMs = options.timeoutMs ?? entry.timeoutMs;
|
|
285
|
+
const result = spawnSync(process.execPath, args, {
|
|
286
|
+
encoding: "utf8",
|
|
287
|
+
timeout: timeoutMs,
|
|
288
|
+
cwd: options.cwd,
|
|
289
|
+
});
|
|
290
|
+
if (result.signal) {
|
|
291
|
+
return { code: 1, stdout: result.stdout ?? "", stderr: `${validator} timed out after ${timeoutMs}ms` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { code: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function main() {
|
|
298
|
+
const [validator, ...flags] = process.argv.slice(2);
|
|
299
|
+
if (!validator || validator.startsWith("-")) {
|
|
300
|
+
console.error(
|
|
301
|
+
"Usage: node validators.mjs <validator> [--state-dir <dir>] [--cwd <dir>] [--browser-runtime <dir>]",
|
|
302
|
+
);
|
|
303
|
+
process.exitCode = 2;
|
|
304
|
+
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!VALIDATOR_CATALOG[validator]) {
|
|
309
|
+
console.error(`Unknown validator: ${validator}. Registered validators: ${validatorNames().join(", ")}`);
|
|
310
|
+
process.exitCode = 2;
|
|
311
|
+
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let environment;
|
|
316
|
+
try {
|
|
317
|
+
environment = parseEnvironment(flags);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.error(error.message);
|
|
320
|
+
process.exitCode = 2;
|
|
321
|
+
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
console.log(await runValidatorInProcess(validator, environment));
|
|
327
|
+
} catch (error) {
|
|
328
|
+
console.error(`${validator} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
329
|
+
process.exitCode = 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const invokedDirectly =
|
|
334
|
+
Boolean(process.argv[1]) &&
|
|
335
|
+
(process.platform === "win32"
|
|
336
|
+
? path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase()
|
|
337
|
+
: path.resolve(process.argv[1]) === fileURLToPath(import.meta.url));
|
|
338
|
+
if (invokedDirectly) {
|
|
339
|
+
// No top-level await here: registry.mjs statically imports this module, and core.mjs
|
|
340
|
+
// dynamically imports it back. A pending top-level await would deadlock that cycle.
|
|
341
|
+
main().catch((error) => {
|
|
342
|
+
console.error(`validators.mjs failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
343
|
+
process.exitCode = 1;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const REFRESH_WIDGET = "specpi-ui-prompt-refresh";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pi's regular-screen renderer can occasionally leave a newly mounted extension
|
|
7
|
+
* prompt queued until the next keyboard event, especially through Windows SSH
|
|
8
|
+
* terminals. Keep an invisible widget solely to obtain the documented TUI
|
|
9
|
+
* handle, then flush one immediate frame after each extension prompt mounts.
|
|
10
|
+
*/
|
|
11
|
+
export default function registerPromptRefresh(pi: ExtensionAPI) {
|
|
12
|
+
let tui: { renderNow(force?: boolean): void } | undefined;
|
|
13
|
+
let refreshPending = false;
|
|
14
|
+
|
|
15
|
+
pi.on("session_start", (_event, ctx) => {
|
|
16
|
+
tui = undefined;
|
|
17
|
+
refreshPending = false;
|
|
18
|
+
if (ctx.mode !== "tui") {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ctx.ui.setWidget(REFRESH_WIDGET, (activeTui) => {
|
|
23
|
+
tui = activeTui;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
invalidate() {},
|
|
27
|
+
render(): string[] {
|
|
28
|
+
return [];
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
pi.on("ui_prompt_start", () => {
|
|
35
|
+
if (!tui || refreshPending) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
refreshPending = true;
|
|
40
|
+
setImmediate(() => {
|
|
41
|
+
refreshPending = false;
|
|
42
|
+
tui?.renderNow();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
47
|
+
if (ctx.mode === "tui") {
|
|
48
|
+
ctx.ui.setWidget(REFRESH_WIDGET, undefined);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
tui = undefined;
|
|
52
|
+
refreshPending = false;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
const MAX_REQUIREMENTS = 16;
|
|
2
|
+
const MAX_LIST = 12;
|
|
3
|
+
|
|
4
|
+
function compact(value, maximum = 360) {
|
|
5
|
+
return String(value ?? "")
|
|
6
|
+
.normalize("NFKC")
|
|
7
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, " ")
|
|
8
|
+
.replace(/\s+/gu, " ")
|
|
9
|
+
.trim()
|
|
10
|
+
.slice(0, maximum);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function textList(value, name) {
|
|
14
|
+
if (!Array.isArray(value) || value.length > MAX_LIST) {
|
|
15
|
+
throw new Error(`${name} must contain at most ${MAX_LIST} items`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return value.map((item) => compact(item)).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateChallengeSubmission(value, facts = {}) {
|
|
22
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
23
|
+
throw new Error("Completion challenge submission is malformed");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const verdicts = new Set(["ready-for-human-review", "incomplete", "blocked"]);
|
|
27
|
+
if (!verdicts.has(value.verdict)) {
|
|
28
|
+
throw new Error("Completion challenge verdict is invalid");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
!Array.isArray(value.requirements) ||
|
|
33
|
+
value.requirements.length === 0 ||
|
|
34
|
+
value.requirements.length > MAX_REQUIREMENTS
|
|
35
|
+
) {
|
|
36
|
+
throw new Error(`Completion challenge requires 1-${MAX_REQUIREMENTS} requirement assessments`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const requirements = value.requirements.map((item) => {
|
|
40
|
+
const requirement = compact(item?.requirement);
|
|
41
|
+
const evidence = compact(item?.evidence, 600);
|
|
42
|
+
if (!requirement || !["proven", "partial", "unproven"].includes(item?.status)) {
|
|
43
|
+
throw new Error("Completion challenge requirement assessment is invalid");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (item.status === "proven" && !evidence) {
|
|
47
|
+
throw new Error("A proven requirement must cite concise evidence");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { requirement, status: item.status, evidence };
|
|
51
|
+
});
|
|
52
|
+
const contradictions = textList(value.contradictions, "contradictions");
|
|
53
|
+
const falsePositiveChecks = textList(value.falsePositiveChecks, "falsePositiveChecks");
|
|
54
|
+
const scopeFindings = textList(value.scopeFindings, "scopeFindings");
|
|
55
|
+
const validationGaps = textList(value.validationGaps, "validationGaps");
|
|
56
|
+
const residualRisks = textList(value.residualRisks, "residualRisks");
|
|
57
|
+
const nextAction = compact(value.nextAction, 500);
|
|
58
|
+
|
|
59
|
+
if (value.verdict !== "ready-for-human-review" && !nextAction) {
|
|
60
|
+
throw new Error("Incomplete and blocked challenges require a concrete next action");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (value.verdict === "ready-for-human-review") {
|
|
64
|
+
const unresolved = requirements.some((item) => item.status !== "proven");
|
|
65
|
+
if (unresolved) {
|
|
66
|
+
throw new Error("Ready verdict rejected: one or more requirements remain unresolved");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (contradictions.length > 0) {
|
|
70
|
+
throw new Error("Ready verdict rejected: contradictory evidence remains");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (validationGaps.length > 0) {
|
|
74
|
+
throw new Error("Ready verdict rejected: validation gaps remain");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (Array.isArray(facts.pendingScope) && facts.pendingScope.length > 0) {
|
|
78
|
+
throw new Error("Ready verdict rejected: scope drift remains pending");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// An indeterminate snapshot means the facts the review was handed may be incomplete. It is a permanent
|
|
82
|
+
// condition outside a Git worktree, so rejecting outright would make a ready verdict unreachable there;
|
|
83
|
+
// instead the limitation must be disclosed rather than silently dropped.
|
|
84
|
+
if (facts.snapshotIndeterminate === true && residualRisks.length === 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"Ready verdict rejected: the change snapshot was indeterminate, so the residual risk of unobserved changes must be disclosed",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
verdict: value.verdict,
|
|
93
|
+
requirements,
|
|
94
|
+
contradictions,
|
|
95
|
+
falsePositiveChecks,
|
|
96
|
+
scopeFindings,
|
|
97
|
+
validationGaps,
|
|
98
|
+
residualRisks,
|
|
99
|
+
nextAction,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function boundedChallengeFacts(value = {}) {
|
|
104
|
+
const paths = (items) =>
|
|
105
|
+
Array.isArray(items)
|
|
106
|
+
? items
|
|
107
|
+
.map((item) => compact(item, 240))
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.slice(0, 40)
|
|
110
|
+
: [];
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
changedPaths: paths(value.changedPaths),
|
|
114
|
+
scopeEntries: paths(value.scopeEntries),
|
|
115
|
+
pendingScope: paths(value.pendingScope),
|
|
116
|
+
experiment: value.experiment
|
|
117
|
+
? {
|
|
118
|
+
id: compact(value.experiment.id, 36),
|
|
119
|
+
name: compact(value.experiment.name, 48),
|
|
120
|
+
acceptance: compact(value.experiment.acceptance, 600),
|
|
121
|
+
baseCommit: compact(value.experiment.baseCommit, 64),
|
|
122
|
+
}
|
|
123
|
+
: undefined,
|
|
124
|
+
observedToolFailures: Math.max(0, Math.min(99, Number(value.observedToolFailures) || 0)),
|
|
125
|
+
snapshotIndeterminate: Boolean(value.snapshotIndeterminate),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function challengePrompt(generation, facts) {
|
|
130
|
+
const bounded = boundedChallengeFacts(facts);
|
|
131
|
+
const lines = [
|
|
132
|
+
`[SPECPI COMPLETION CHALLENGE ${generation}]`,
|
|
133
|
+
"The user explicitly requested an adversarial completion review. Do not continue implementation in this turn.",
|
|
134
|
+
"Use only evidence already available in context and the bounded facts below. Identify uncertainty rather than inventing proof.",
|
|
135
|
+
"Answer all six questions through submit_completion_challenge as the final tool call:",
|
|
136
|
+
"1. Which requirement remains unproven?",
|
|
137
|
+
"2. What evidence contradicts the proposed result?",
|
|
138
|
+
"3. Could any check have passed for the wrong reason?",
|
|
139
|
+
"4. Did scope expand or remain pending?",
|
|
140
|
+
"5. Was runtime, visual, or platform validation required but omitted?",
|
|
141
|
+
"6. What residual risk must be disclosed?",
|
|
142
|
+
"",
|
|
143
|
+
`Changed paths: ${bounded.changedPaths.join(", ") || "none observed"}`,
|
|
144
|
+
`Declared scope: ${bounded.scopeEntries.join(", ") || "inactive"}`,
|
|
145
|
+
`Pending scope drift: ${bounded.pendingScope.join(", ") || "none"}`,
|
|
146
|
+
`Observed tool failures: ${bounded.observedToolFailures}`,
|
|
147
|
+
`Snapshot indeterminate: ${bounded.snapshotIndeterminate ? "yes" : "no"}`,
|
|
148
|
+
];
|
|
149
|
+
if (bounded.experiment) {
|
|
150
|
+
lines.push(
|
|
151
|
+
`Experiment: ${bounded.experiment.name} (${bounded.experiment.id.slice(0, 8)})`,
|
|
152
|
+
`Experiment acceptance check: ${bounded.experiment.acceptance}`,
|
|
153
|
+
`Experiment base: ${bounded.experiment.baseCommit}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
lines.push(
|
|
158
|
+
"A ready-for-human-review verdict is allowed only when every listed requirement is proven, no contradiction or validation gap remains, and no scope drift is pending. When the snapshot above is indeterminate, a ready verdict must also disclose the residual risk that some changes went unobserved. This verdict is a structured model review, not independent verification or completion authority.",
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return lines.join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function renderChallengeMarkdown(result, metadata = {}) {
|
|
165
|
+
const heading =
|
|
166
|
+
result.verdict === "ready-for-human-review"
|
|
167
|
+
? "Ready for human review"
|
|
168
|
+
: result.verdict === "blocked"
|
|
169
|
+
? "Blocked"
|
|
170
|
+
: "Incomplete";
|
|
171
|
+
const lines = [`## Completion Challenge — ${heading}`, "", `Generation: \`${metadata.generation ?? "unknown"}\``];
|
|
172
|
+
lines.push("", "### Requirements");
|
|
173
|
+
for (const item of result.requirements) {
|
|
174
|
+
lines.push(`- **${item.status}** — ${item.requirement}${item.evidence ? ` — ${item.evidence}` : ""}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sections = [
|
|
178
|
+
["Contradictions", result.contradictions],
|
|
179
|
+
["Possible false-positive checks", result.falsePositiveChecks],
|
|
180
|
+
["Scope findings", result.scopeFindings],
|
|
181
|
+
["Validation gaps", result.validationGaps],
|
|
182
|
+
["Residual risks", result.residualRisks],
|
|
183
|
+
];
|
|
184
|
+
for (const [title, items] of sections) {
|
|
185
|
+
lines.push("", `### ${title}`);
|
|
186
|
+
lines.push(...(items.length > 0 ? items.map((item) => `- ${item}`) : ["- None recorded."]));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (result.nextAction) {
|
|
190
|
+
lines.push("", "### Next action", "", result.nextAction);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
lines.push("", "> This is a model-authored challenge result, not independent verification.");
|
|
194
|
+
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|