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,272 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const MAX_SCOPE_ENTRIES = 40;
|
|
6
|
+
export const MAX_SNAPSHOT_PATHS = 256;
|
|
7
|
+
export const MAX_FINGERPRINT_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function portableRelative(value) {
|
|
10
|
+
return value.split(path.sep).join("/");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function comparable(value, platform = process.platform) {
|
|
14
|
+
const normalized = path.resolve(value);
|
|
15
|
+
|
|
16
|
+
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isInside(root, candidate, platform = process.platform) {
|
|
20
|
+
const base = comparable(root, platform);
|
|
21
|
+
const target = comparable(candidate, platform);
|
|
22
|
+
|
|
23
|
+
return target === base || target.startsWith(`${base}${path.sep}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nearestExistingParent(candidate) {
|
|
27
|
+
let current = candidate;
|
|
28
|
+
while (!fs.existsSync(current)) {
|
|
29
|
+
const parent = path.dirname(current);
|
|
30
|
+
if (parent === current) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function canonicalRoot(root) {
|
|
41
|
+
const resolved = path.resolve(root);
|
|
42
|
+
const stat = fs.statSync(resolved);
|
|
43
|
+
if (!stat.isDirectory()) {
|
|
44
|
+
throw new Error("Scope root must be a directory");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return fs.realpathSync.native(resolved);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveScopedPath(root, input, options = {}) {
|
|
51
|
+
if (typeof input !== "string" || !input.trim() || /[\u0000-\u001f\u007f]/u.test(input)) {
|
|
52
|
+
throw new Error("Scope paths must be non-empty text without control characters");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const trimmed = input.trim();
|
|
56
|
+
if (path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/u.test(trimmed)) {
|
|
57
|
+
throw new Error("Scope paths must be project-relative");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resolvedRoot = canonicalRoot(root);
|
|
61
|
+
const candidate = path.resolve(resolvedRoot, trimmed);
|
|
62
|
+
if (!isInside(resolvedRoot, candidate, options.platform)) {
|
|
63
|
+
throw new Error("Scope path escapes the project root");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const existing = nearestExistingParent(candidate);
|
|
67
|
+
if (!existing) {
|
|
68
|
+
throw new Error("Scope path has no verifiable parent");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const canonicalParent = fs.realpathSync.native(existing);
|
|
72
|
+
if (!isInside(resolvedRoot, canonicalParent, options.platform)) {
|
|
73
|
+
throw new Error("Scope path escapes the project root through a symlink");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const relative = portableRelative(path.relative(resolvedRoot, candidate)) || ".";
|
|
77
|
+
const explicitDirectory = /[\\/]$/u.test(trimmed);
|
|
78
|
+
const directory = explicitDirectory || (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory());
|
|
79
|
+
|
|
80
|
+
return { path: relative, directory };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function normalizeScopeEntries(root, inputs, options = {}) {
|
|
84
|
+
if (!Array.isArray(inputs) || inputs.length === 0) {
|
|
85
|
+
throw new Error("Scope requires at least one path");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (inputs.length > MAX_SCOPE_ENTRIES) {
|
|
89
|
+
throw new Error(`Scope supports at most ${MAX_SCOPE_ENTRIES} paths`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const entries = [];
|
|
94
|
+
for (const input of inputs) {
|
|
95
|
+
const entry = resolveScopedPath(root, input, options);
|
|
96
|
+
const key = `${options.platform === "win32" ? entry.path.toLowerCase() : entry.path}:${entry.directory}`;
|
|
97
|
+
if (seen.has(key)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
seen.add(key);
|
|
102
|
+
entries.push(entry);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (entries.length === 0) {
|
|
106
|
+
throw new Error("Scope requires at least one distinct path");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return entries;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function scopeMatches(entries, relativePath, platform = process.platform) {
|
|
113
|
+
if (!Array.isArray(entries) || typeof relativePath !== "string") {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const normalize = (value) => {
|
|
118
|
+
const result = value.replaceAll("\\", "/").replace(/^\.\//u, "").replace(/\/+$/u, "") || ".";
|
|
119
|
+
|
|
120
|
+
return platform === "win32" ? result.toLowerCase() : result;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const candidate = normalize(relativePath);
|
|
124
|
+
|
|
125
|
+
return entries.some((entry) => {
|
|
126
|
+
const expected = normalize(entry.path);
|
|
127
|
+
if (entry.directory) {
|
|
128
|
+
return expected === "." || candidate === expected || candidate.startsWith(`${expected}/`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return candidate === expected;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Git reports NUL-delimited paths verbatim, so a filename may legally contain a newline. Those paths are reported back
|
|
136
|
+
// through the system prompt, tool results, and the UI, where a raw newline would let a hostile filename forge a line of
|
|
137
|
+
// guidance. Escaping keeps the path identifiable without letting it break out of the line it belongs on.
|
|
138
|
+
export function sanitizePathLabel(value) {
|
|
139
|
+
return String(value).replace(/[%\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, (character) => encodeURIComponent(character));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parsePorcelainEntries(output) {
|
|
143
|
+
if (typeof output !== "string") {
|
|
144
|
+
throw new Error("Git status output is unavailable");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const fields = output.split("\0");
|
|
148
|
+
const entries = [];
|
|
149
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
150
|
+
const field = fields[index];
|
|
151
|
+
if (!field) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (field.length < 4 || field[2] !== " ") {
|
|
156
|
+
throw new Error("Malformed NUL-delimited Git status output");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const status = field.slice(0, 2);
|
|
160
|
+
const candidate = field.slice(3);
|
|
161
|
+
if (!candidate || candidate.includes("\0")) {
|
|
162
|
+
throw new Error("Malformed Git status path");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
entries.push({ status, path: candidate.replaceAll("\\", "/") });
|
|
166
|
+
if (status.includes("R") || status.includes("C")) {
|
|
167
|
+
index += 1;
|
|
168
|
+
if (index >= fields.length || !fields[index]) {
|
|
169
|
+
throw new Error("Malformed Git rename status");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (status.includes("R")) {
|
|
173
|
+
entries.push({ status, path: fields[index].replaceAll("\\", "/") });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return entries;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parsePorcelainZ(output) {
|
|
182
|
+
return [...new Set(parsePorcelainEntries(output).map((entry) => entry.path))].sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function fingerprintPath(root, relativePath) {
|
|
186
|
+
const candidate = path.resolve(root, relativePath);
|
|
187
|
+
if (!isInside(root, candidate)) {
|
|
188
|
+
throw new Error("Git reported a path outside the project root");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let stat;
|
|
192
|
+
try {
|
|
193
|
+
stat = fs.lstatSync(candidate);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code === "ENOENT") {
|
|
196
|
+
return "missing";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (stat.isSymbolicLink()) {
|
|
203
|
+
return `symlink:${fs.readlinkSync(candidate)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!stat.isFile()) {
|
|
207
|
+
return `${stat.mode}:${stat.size}:${stat.mtimeMs}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (stat.size > MAX_FINGERPRINT_BYTES) {
|
|
211
|
+
return `large:${stat.size}:${stat.mtimeMs}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return createHash("sha256").update(fs.readFileSync(candidate)).digest("hex");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createWorktreeSnapshot(root, porcelainOutput) {
|
|
218
|
+
const resolvedRoot = canonicalRoot(root);
|
|
219
|
+
const paths = parsePorcelainZ(porcelainOutput);
|
|
220
|
+
if (paths.length > MAX_SNAPSHOT_PATHS) {
|
|
221
|
+
return {
|
|
222
|
+
root: resolvedRoot,
|
|
223
|
+
paths: paths.slice(0, MAX_SNAPSHOT_PATHS),
|
|
224
|
+
fingerprints: {},
|
|
225
|
+
indeterminate: true,
|
|
226
|
+
reason: `More than ${MAX_SNAPSHOT_PATHS} changed paths`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fingerprints = {};
|
|
231
|
+
for (const relativePath of paths) {
|
|
232
|
+
fingerprints[relativePath] = fingerprintPath(resolvedRoot, relativePath);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { root: resolvedRoot, paths, fingerprints, indeterminate: false };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function compareWorktreeSnapshots(before, after, entries) {
|
|
239
|
+
if (!before || !after || before.indeterminate || after.indeterminate || before.root !== after.root) {
|
|
240
|
+
return { changed: [], outside: [], indeterminate: true };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const candidates = new Set([...before.paths, ...after.paths]);
|
|
244
|
+
const changed = [...candidates]
|
|
245
|
+
.filter((candidate) => before.fingerprints[candidate] !== after.fingerprints[candidate])
|
|
246
|
+
.sort();
|
|
247
|
+
const outside = changed.filter((candidate) => !scopeMatches(entries, candidate));
|
|
248
|
+
|
|
249
|
+
return { changed, outside, indeterminate: false };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function relativeMutationPath(root, input, options = {}) {
|
|
253
|
+
if (typeof input !== "string" || !input.trim() || /[\u0000-\u001f\u007f]/u.test(input)) {
|
|
254
|
+
throw new Error("Mutation path must be non-empty text without control characters");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const relative = !path.isAbsolute(input) && !/^[A-Za-z]:[\\/]/u.test(input);
|
|
258
|
+
const requested = relative ? input.trim() : input;
|
|
259
|
+
const resolvedRoot = canonicalRoot(root);
|
|
260
|
+
const cwd = typeof options.cwd === "string" && options.cwd ? options.cwd : resolvedRoot;
|
|
261
|
+
const candidate = relative ? path.resolve(cwd, requested) : path.resolve(requested);
|
|
262
|
+
if (!isInside(resolvedRoot, candidate, options.platform)) {
|
|
263
|
+
throw new Error("Mutation path escapes the project root");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const existing = nearestExistingParent(candidate);
|
|
267
|
+
if (!existing || !isInside(resolvedRoot, fs.realpathSync.native(existing), options.platform)) {
|
|
268
|
+
throw new Error("Mutation path escapes the project root through a symlink");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return portableRelative(path.relative(resolvedRoot, candidate)) || ".";
|
|
272
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { compareWorktreeSnapshots, createWorktreeSnapshot, normalizeScopeEntries } from "./scope.mjs";
|
|
9
|
+
import {
|
|
10
|
+
createExperiment,
|
|
11
|
+
discardExperiment,
|
|
12
|
+
experimentStatus,
|
|
13
|
+
exportExperimentPatch,
|
|
14
|
+
inspectRepository,
|
|
15
|
+
} from "./experiments.mjs";
|
|
16
|
+
import { validateChallengeSubmission } from "./challenge.mjs";
|
|
17
|
+
|
|
18
|
+
function run(command, args, options = {}) {
|
|
19
|
+
const result = spawnSync(command, args, {
|
|
20
|
+
cwd: options.cwd,
|
|
21
|
+
env: options.env,
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
timeout: options.timeout ?? 30_000,
|
|
24
|
+
maxBuffer: 40 * 1024 * 1024,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return Promise.resolve({
|
|
28
|
+
code: result.status ?? 1,
|
|
29
|
+
stdout: result.stdout ?? "",
|
|
30
|
+
stderr: result.stderr ?? (result.error ? result.error.message : ""),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function git(root, ...args) {
|
|
35
|
+
const result = spawnSync("git", args, { cwd: root, encoding: "utf8", timeout: 30_000 });
|
|
36
|
+
if (result.status !== 0) {
|
|
37
|
+
throw new Error((result.stderr || result.stdout || "Git failed").trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result.stdout;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function repositoryFixture(prefix) {
|
|
44
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
45
|
+
git(root, "init");
|
|
46
|
+
git(root, "config", "user.email", "workflow-smoke@example.invalid");
|
|
47
|
+
git(root, "config", "user.name", "Workflow Smoke");
|
|
48
|
+
fs.mkdirSync(path.join(root, "src"));
|
|
49
|
+
fs.writeFileSync(path.join(root, "src", "inside.txt"), "inside\n");
|
|
50
|
+
fs.writeFileSync(path.join(root, "outside.txt"), "outside\n");
|
|
51
|
+
fs.writeFileSync(path.join(root, "latin1.txt"), Buffer.from("caf\u00e9 latin1\n", "latin1"));
|
|
52
|
+
fs.writeFileSync(path.join(root, ".gitignore"), "ignored-work/\n");
|
|
53
|
+
git(root, "add", ".");
|
|
54
|
+
git(root, "commit", "-m", "base");
|
|
55
|
+
|
|
56
|
+
return root;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function status(root) {
|
|
60
|
+
return git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function scopeSmoke() {
|
|
64
|
+
const root = repositoryFixture("specpi-scope-smoke-");
|
|
65
|
+
try {
|
|
66
|
+
const entries = normalizeScopeEntries(root, ["src/"]);
|
|
67
|
+
const before = createWorktreeSnapshot(root, status(root));
|
|
68
|
+
fs.writeFileSync(path.join(root, "outside.txt"), "changed\n");
|
|
69
|
+
const after = createWorktreeSnapshot(root, status(root));
|
|
70
|
+
const drift = compareWorktreeSnapshots(before, after, entries);
|
|
71
|
+
assert.deepEqual(drift.outside, ["outside.txt"]);
|
|
72
|
+
assert.throws(() => normalizeScopeEntries(root, ["../escape"]), /escapes/);
|
|
73
|
+
|
|
74
|
+
return "scope-drift-monitor-smoke passed: path boundary and observed outside-scope mutation verified";
|
|
75
|
+
} finally {
|
|
76
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function experimentSmoke() {
|
|
81
|
+
const root = repositoryFixture("specpi-experiment-smoke-");
|
|
82
|
+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "specpi-experiment-state-smoke-"));
|
|
83
|
+
try {
|
|
84
|
+
const repository = await inspectRepository(run, root);
|
|
85
|
+
const head = git(root, "rev-parse", "HEAD").trim();
|
|
86
|
+
const record = await createExperiment({
|
|
87
|
+
exec: run,
|
|
88
|
+
stateDir,
|
|
89
|
+
repository,
|
|
90
|
+
card: {
|
|
91
|
+
name: "smoke",
|
|
92
|
+
hypothesis: "Detached work keeps the base clean",
|
|
93
|
+
acceptance: "Export includes tracked and untracked work",
|
|
94
|
+
nonGoals: ["No merge"],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
fs.writeFileSync(path.join(record.worktreePath, "src", "inside.txt"), "changed\n");
|
|
98
|
+
fs.writeFileSync(path.join(record.worktreePath, "new.txt"), "new\n");
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
path.join(record.worktreePath, "latin1.txt"),
|
|
101
|
+
Buffer.from("caf\u00e9 latin1 changed\n", "latin1"),
|
|
102
|
+
);
|
|
103
|
+
fs.mkdirSync(path.join(record.worktreePath, "ignored-work"));
|
|
104
|
+
fs.writeFileSync(path.join(record.worktreePath, "ignored-work", "local.txt"), "ignored but real\n");
|
|
105
|
+
|
|
106
|
+
// Committing inside an experiment moves its HEAD; work measured against HEAD would then report clean.
|
|
107
|
+
fs.writeFileSync(path.join(record.worktreePath, "committed.txt"), "committed work\n");
|
|
108
|
+
git(record.worktreePath, "add", "committed.txt");
|
|
109
|
+
git(record.worktreePath, "commit", "-m", "experiment commit");
|
|
110
|
+
|
|
111
|
+
// Ignored work never reaches a patch, so a discard prompt that cannot see it would delete it silently.
|
|
112
|
+
const state = await experimentStatus(run, record);
|
|
113
|
+
assert.equal(state.ignored, 1);
|
|
114
|
+
assert.deepEqual(state.ignoredPaths, ["ignored-work/"]);
|
|
115
|
+
assert.deepEqual(state.committedPaths, ["committed.txt"]);
|
|
116
|
+
assert.equal(state.hasWork, true);
|
|
117
|
+
|
|
118
|
+
const exported = await exportExperimentPatch({ exec: run, stateDir, record });
|
|
119
|
+
const patchBytes = fs.readFileSync(exported.outputPath);
|
|
120
|
+
const patch = patchBytes.toString("utf8");
|
|
121
|
+
assert.match(patch, /src\/inside\.txt/);
|
|
122
|
+
assert.match(patch, /new\.txt/);
|
|
123
|
+
assert.match(patch, /committed\.txt/);
|
|
124
|
+
// A UTF-8 round trip would replace the latin-1 byte and produce a patch that no longer applies.
|
|
125
|
+
assert.ok(patchBytes.includes(0xe9), "exported patch lost the original non-UTF-8 bytes");
|
|
126
|
+
assert.ok(!patchBytes.includes(Buffer.from("\uFFFD")), "exported patch contains a replacement character");
|
|
127
|
+
assert.equal(status(root), "");
|
|
128
|
+
assert.equal(git(root, "rev-parse", "HEAD").trim(), head);
|
|
129
|
+
|
|
130
|
+
const applyTarget = fs.mkdtempSync(path.join(os.tmpdir(), "specpi-experiment-apply-smoke-"));
|
|
131
|
+
try {
|
|
132
|
+
git(applyTarget, "clone", "--quiet", root, applyTarget);
|
|
133
|
+
git(applyTarget, "apply", "--check", exported.outputPath);
|
|
134
|
+
} finally {
|
|
135
|
+
fs.rmSync(applyTarget, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await discardExperiment({ exec: run, stateDir, record });
|
|
139
|
+
assert.equal(fs.existsSync(record.worktreePath), false);
|
|
140
|
+
|
|
141
|
+
return "guided-experiment-worktrees-smoke passed: detached create, byte-exact appliable export of committed and dirty work, disclosed ignored work, clean base, and discard verified";
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
144
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function challengeSmoke() {
|
|
149
|
+
const submission = {
|
|
150
|
+
verdict: "ready-for-human-review",
|
|
151
|
+
requirements: [{ requirement: "Direct check", status: "proven", evidence: "Smoke passed" }],
|
|
152
|
+
contradictions: [],
|
|
153
|
+
falsePositiveChecks: [],
|
|
154
|
+
scopeFindings: [],
|
|
155
|
+
validationGaps: [],
|
|
156
|
+
residualRisks: ["Model-authored review"],
|
|
157
|
+
nextAction: "Human reviews the result",
|
|
158
|
+
};
|
|
159
|
+
assert.equal(validateChallengeSubmission(submission, { pendingScope: [] }).verdict, "ready-for-human-review");
|
|
160
|
+
assert.throws(
|
|
161
|
+
() => validateChallengeSubmission(submission, { pendingScope: ["outside.txt"] }),
|
|
162
|
+
/scope drift remains pending/,
|
|
163
|
+
);
|
|
164
|
+
assert.throws(
|
|
165
|
+
() =>
|
|
166
|
+
validateChallengeSubmission({ ...submission, contradictions: ["A check disagrees"] }, { pendingScope: [] }),
|
|
167
|
+
/contradictory evidence/,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return "completion-challenge-smoke passed: structured readiness and deterministic rejection gates verified";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runWorkflowControlsSmoke(name) {
|
|
174
|
+
if (name === "scope-drift-monitor-smoke") {
|
|
175
|
+
return scopeSmoke();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (name === "guided-experiment-worktrees-smoke") {
|
|
179
|
+
return experimentSmoke();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (name === "completion-challenge-smoke") {
|
|
183
|
+
return challengeSmoke();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw new Error(`Unknown workflow-controls smoke: ${name}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const invokedDirectly =
|
|
190
|
+
Boolean(process.argv[1]) &&
|
|
191
|
+
(process.platform === "win32"
|
|
192
|
+
? path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase()
|
|
193
|
+
: path.resolve(process.argv[1]) === fileURLToPath(import.meta.url));
|
|
194
|
+
if (invokedDirectly) {
|
|
195
|
+
runWorkflowControlsSmoke(process.argv[2])
|
|
196
|
+
.then((message) => console.log(message))
|
|
197
|
+
.catch((error) => {
|
|
198
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
});
|
|
201
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "specpi",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "An explicit, privacy-conscious, self-improving harness for the Pi coding agent",
|
|
5
|
+
"author": "Tanner Middleton",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/TannerMidd/SpecPi.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/TannerMidd/SpecPi/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/TannerMidd/SpecPi#readme",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-coding-agent",
|
|
19
|
+
"agent-harness"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"specpi": "./scripts/specpi.mjs"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"specpi",
|
|
26
|
+
"specpi.cmd",
|
|
27
|
+
"scripts",
|
|
28
|
+
"browser-runtime",
|
|
29
|
+
"templates",
|
|
30
|
+
"extensions",
|
|
31
|
+
"skills",
|
|
32
|
+
"themes",
|
|
33
|
+
"shell",
|
|
34
|
+
"README.md",
|
|
35
|
+
"site/logo.svg",
|
|
36
|
+
"site/self-improvement-loop-v2.svg",
|
|
37
|
+
"SECURITY.md",
|
|
38
|
+
"SECURITY_MODEL.md",
|
|
39
|
+
"THIRD_PARTY.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"NPM_RELEASE.md"
|
|
43
|
+
],
|
|
44
|
+
"pi": {
|
|
45
|
+
"extensions": [
|
|
46
|
+
"./extensions"
|
|
47
|
+
],
|
|
48
|
+
"skills": [
|
|
49
|
+
"./skills"
|
|
50
|
+
],
|
|
51
|
+
"themes": [
|
|
52
|
+
"./themes"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@earendil-works/pi-ai": "*",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
58
|
+
"@earendil-works/pi-tui": "*",
|
|
59
|
+
"typebox": "*"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"@earendil-works/pi-ai": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@earendil-works/pi-coding-agent": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@earendil-works/pi-tui": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"typebox": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"publishConfig": {
|
|
76
|
+
"access": "public",
|
|
77
|
+
"provenance": true
|
|
78
|
+
},
|
|
79
|
+
"engines": {
|
|
80
|
+
"node": ">=22.19.0"
|
|
81
|
+
},
|
|
82
|
+
"scripts": {
|
|
83
|
+
"test": "node --test",
|
|
84
|
+
"format": "prettier --write \"**/*.{js,mjs,ts,tsx}\" && eslint . --fix --rule \"@stylistic/max-statements-per-line: off\" && prettier --write \"**/*.{js,mjs,ts,tsx}\" && eslint . --fix",
|
|
85
|
+
"format:check": "prettier --check \"**/*.{js,mjs,ts,tsx}\" && eslint .",
|
|
86
|
+
"check:package": "node scripts/check-package.mjs",
|
|
87
|
+
"check:pi-package": "node scripts/check-pi-package.mjs",
|
|
88
|
+
"prepublishOnly": "npm run check",
|
|
89
|
+
"check": "npm run format:check && node --check scripts/specpi.mjs && node --check scripts/lib.mjs && node --check scripts/lock.mjs && node --check scripts/check-release-order.mjs && node --check scripts/verify-artifact.mjs && node --check extensions/spec/core.mjs && node --experimental-strip-types --check extensions/command-guard/index.ts && node --check extensions/command-guard/core.mjs && node --check extensions/command-guard/rules.mjs && node --check extensions/command-guard/bash.mjs && node --check extensions/command-guard/powershell.mjs && node --check extensions/command-guard/cmd.mjs && node --check extensions/command-guard/paths.mjs && node --check extensions/command-guard/redact.mjs && node --check extensions/command-guard/managed-files.mjs && node --check extensions/command-guard/smoke.mjs && node --check extensions/files/core.mjs && node --check extensions/tool-wishlist/core.mjs && node --check extensions/tool-wishlist/registry.mjs && node --check extensions/tool-wishlist/validators.mjs && node --experimental-strip-types --check extensions/tool-wishlist/index.ts && node --experimental-strip-types --check extensions/ui-refresh/index.ts && node --experimental-strip-types --check extensions/workflow-controls/index.ts && node --check extensions/workflow-controls/scope.mjs && node --check extensions/workflow-controls/experiments.mjs && node --check extensions/workflow-controls/challenge.mjs && node --check extensions/workflow-controls/smoke.mjs && node --check extensions/browser/core.mjs && node --check extensions/browser/smoke.mjs && node --check site/cycle.js && node --test && npm run check:package"
|
|
90
|
+
},
|
|
91
|
+
"devDependencies": {
|
|
92
|
+
"@stylistic/eslint-plugin": "5.10.0",
|
|
93
|
+
"@typescript-eslint/parser": "8.68.0",
|
|
94
|
+
"eslint": "10.9.1",
|
|
95
|
+
"prettier": "3.9.6",
|
|
96
|
+
"typescript": "6.0.3"
|
|
97
|
+
}
|
|
98
|
+
}
|