specpi 0.10.0 → 0.11.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/CHANGELOG.md +169 -150
- package/LICENSE +21 -21
- package/NPM_RELEASE.md +110 -110
- package/README.md +223 -155
- package/SECURITY.md +86 -85
- package/SECURITY_MODEL.md +123 -107
- package/THIRD_PARTY.md +61 -61
- package/browser-runtime/package-lock.json +86 -86
- package/browser-runtime/package.json +15 -15
- package/extensions/command-guard/powershell-parser.ps1 +47 -47
- package/extensions/spec.ts +236 -10
- package/extensions/tool-wishlist/capabilities.json +114 -114
- package/extensions/tool-wishlist/core.mjs +285 -24
- package/extensions/tool-wishlist/index.ts +939 -90
- package/extensions/tool-wishlist/verification.mjs +810 -0
- package/extensions/workflow-controls/challenge.mjs +139 -13
- package/extensions/workflow-controls/index.ts +811 -35
- package/extensions/workflow-controls/scope.mjs +41 -5
- package/extensions/workflow-controls/smoke.mjs +55 -0
- package/extensions/workflow-controls/task-contract.mjs +439 -0
- package/package.json +98 -98
- package/scripts/check-package.mjs +3 -0
- package/scripts/check-pi-package.mjs +103 -4
- package/scripts/pi-test-harness.mjs +309 -0
- package/scripts/specpi.mjs +31 -2
- package/skills/specpi-improve/SKILL.md +60 -54
- package/specpi +0 -0
- package/templates/AGENTS.md +25 -23
- package/templates/settings.json +10 -10
- package/themes/specpi-spec.json +96 -96
|
@@ -18,15 +18,39 @@ import {
|
|
|
18
18
|
archiveWishlist,
|
|
19
19
|
collectChangedFilePaths,
|
|
20
20
|
createIssueDraft,
|
|
21
|
+
latestLocalRetirementDecision,
|
|
22
|
+
latestOutcomeDecision,
|
|
21
23
|
latestRetirementDecision,
|
|
24
|
+
normalizeCapability,
|
|
22
25
|
readCollectionMode,
|
|
23
26
|
recordCapabilityGap,
|
|
24
27
|
refreshWishlist,
|
|
25
28
|
renderWishlistHistory,
|
|
26
29
|
sanitizeWishlistText,
|
|
27
30
|
setCollectionMode,
|
|
31
|
+
wishlistSourceRootIdentity,
|
|
32
|
+
wishlistSourceRootSalt,
|
|
28
33
|
} from "./core.mjs";
|
|
29
34
|
import { VALIDATOR_CATALOG } from "./validators.mjs";
|
|
35
|
+
import { isValidValidatorName, validateCapabilityRegistry } from "./registry.mjs";
|
|
36
|
+
import {
|
|
37
|
+
TASK_CONTRACT_ENTRY,
|
|
38
|
+
createTaskContract,
|
|
39
|
+
markdownPathLabel,
|
|
40
|
+
readTaskContract,
|
|
41
|
+
taskContractScopeViolations,
|
|
42
|
+
} from "../workflow-controls/task-contract.mjs";
|
|
43
|
+
import {
|
|
44
|
+
captureSourceSnapshot,
|
|
45
|
+
assertNoRootNpmrc,
|
|
46
|
+
assertSafeSourceInput,
|
|
47
|
+
canonicalSourceRoot,
|
|
48
|
+
compareSourceSnapshots,
|
|
49
|
+
createVerificationReceipt,
|
|
50
|
+
readSafeSourceText,
|
|
51
|
+
validateSourceSnapshot,
|
|
52
|
+
validateVerificationReceipt,
|
|
53
|
+
} from "./verification.mjs";
|
|
30
54
|
|
|
31
55
|
const agentDir = path.resolve(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"));
|
|
32
56
|
const stateDir = path.join(agentDir, "specpi");
|
|
@@ -51,6 +75,16 @@ interface WishlistReportEntry {
|
|
|
51
75
|
interface ActiveImprovement {
|
|
52
76
|
gapId: string;
|
|
53
77
|
sessionId: string;
|
|
78
|
+
root: string;
|
|
79
|
+
selectionId: string;
|
|
80
|
+
baseline: any;
|
|
81
|
+
checkScript: string;
|
|
82
|
+
scripts: Record<string, string>;
|
|
83
|
+
validatorNames: string[];
|
|
84
|
+
validatorIds: string[];
|
|
85
|
+
registryDefinitions: any[];
|
|
86
|
+
registryHadGap: boolean;
|
|
87
|
+
moduleDigests: Record<string, string>;
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
function sourceCheckout(cwd: string) {
|
|
@@ -61,17 +95,33 @@ function sourceCheckout(cwd: string) {
|
|
|
61
95
|
throw new Error("Run /harness-improvement from a complete SpecPi source checkout");
|
|
62
96
|
}
|
|
63
97
|
|
|
64
|
-
|
|
98
|
+
assertNoRootNpmrc(cwd);
|
|
99
|
+
assertSafeSourceInput(cwd, "package.json");
|
|
100
|
+
assertSafeSourceInput(cwd, "extensions/tool-wishlist/capabilities.json");
|
|
101
|
+
assertSafeSourceInput(cwd, "extensions/tool-wishlist/validators.mjs");
|
|
102
|
+
|
|
103
|
+
const manifest = JSON.parse(readSafeSourceText(cwd, "package.json"));
|
|
65
104
|
if (manifest?.name !== "specpi" || typeof manifest?.scripts?.check !== "string") {
|
|
66
105
|
throw new Error("The current directory is not a verifiable SpecPi source checkout");
|
|
67
106
|
}
|
|
68
107
|
|
|
69
|
-
|
|
108
|
+
const registry = validateCapabilityRegistry(
|
|
109
|
+
JSON.parse(readSafeSourceText(cwd, "extensions/tool-wishlist/capabilities.json")),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
registryFile,
|
|
114
|
+
validatorsFile,
|
|
115
|
+
version: String(manifest.version ?? "unknown"),
|
|
116
|
+
checkScript: manifest.scripts.check,
|
|
117
|
+
scripts: cloneJson(manifest.scripts),
|
|
118
|
+
registry,
|
|
119
|
+
validatorNames: Object.keys(VALIDATOR_CATALOG).sort(),
|
|
120
|
+
};
|
|
70
121
|
}
|
|
71
122
|
|
|
72
123
|
function sourceCapability(cwd: string, gapId: string) {
|
|
73
|
-
const {
|
|
74
|
-
const registry = JSON.parse(fs.readFileSync(registryFile, "utf8"));
|
|
124
|
+
const { registry } = sourceCheckout(cwd);
|
|
75
125
|
const capability = registry?.capabilities?.find((item: any) => item?.id === gapId);
|
|
76
126
|
if (!capability || !Array.isArray(capability.validations) || capability.validations.length === 0) {
|
|
77
127
|
throw new Error(
|
|
@@ -82,6 +132,262 @@ function sourceCapability(cwd: string, gapId: string) {
|
|
|
82
132
|
return capability;
|
|
83
133
|
}
|
|
84
134
|
|
|
135
|
+
function cloneJson<T>(value: T): T {
|
|
136
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sourceModulePaths() {
|
|
140
|
+
return {
|
|
141
|
+
editorConfig: ".editorconfig",
|
|
142
|
+
prettierIgnore: ".prettierignore",
|
|
143
|
+
eslintConfig: "eslint.config.js",
|
|
144
|
+
prettierConfig: "prettier.config.mjs",
|
|
145
|
+
packageCheck: "scripts/check-package.mjs",
|
|
146
|
+
core: "extensions/tool-wishlist/core.mjs",
|
|
147
|
+
dispatcher: "extensions/tool-wishlist/index.ts",
|
|
148
|
+
registry: "extensions/tool-wishlist/registry.mjs",
|
|
149
|
+
validators: "extensions/tool-wishlist/validators.mjs",
|
|
150
|
+
verification: "extensions/tool-wishlist/verification.mjs",
|
|
151
|
+
taskContract: "extensions/workflow-controls/task-contract.mjs",
|
|
152
|
+
scope: "extensions/workflow-controls/scope.mjs",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sourceModuleDigests(snapshot: any) {
|
|
157
|
+
const digests: Record<string, string> = {};
|
|
158
|
+
for (const relativePath of Object.values(sourceModulePaths())) {
|
|
159
|
+
const digest = snapshot.files?.[relativePath]?.sha256;
|
|
160
|
+
if (typeof digest !== "string") {
|
|
161
|
+
throw new Error(`Verification baseline is missing ${markdownPathLabel(relativePath)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
digests[relativePath] = digest;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return digests;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function branchEntries(ctx: any) {
|
|
171
|
+
return (ctx.sessionManager.getBranch?.() ?? []).filter((entry: any) => entry?.type === "custom");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function branchTaskContract(ctx: any, root: string, { tolerateMalformed = false } = {}) {
|
|
175
|
+
try {
|
|
176
|
+
return readTaskContract(branchEntries(ctx), root);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (tolerateMalformed) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseGitInventory(stdout: string) {
|
|
187
|
+
const ignored = new Set<string>();
|
|
188
|
+
const fields = stdout.includes("\0") ? stdout.split("\0") : stdout.split("\n");
|
|
189
|
+
for (const field of fields) {
|
|
190
|
+
if (!field.startsWith("!! ")) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const value = field.slice(3).replace(/[\\/]+$/u, "");
|
|
195
|
+
if (value) {
|
|
196
|
+
ignored.add(value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [...ignored];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function gitIgnoredPaths(pi: any, cwd: string, signal: any) {
|
|
204
|
+
try {
|
|
205
|
+
const result = await pi.exec(
|
|
206
|
+
"git",
|
|
207
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching"],
|
|
208
|
+
{ cwd, signal, timeout: 30_000 },
|
|
209
|
+
);
|
|
210
|
+
if (result.code !== 0 || typeof result.stdout !== "string") {
|
|
211
|
+
if (fs.existsSync(path.join(cwd, ".git")) || fs.existsSync(path.join(cwd, ".gitignore"))) {
|
|
212
|
+
throw new Error("Could not enumerate Git ignored inputs for the SpecPi checkout");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return parseGitInventory(result.stdout);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (signal?.aborted) {
|
|
221
|
+
throw signal.reason ?? error;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function gitHead(pi: any, cwd: string, signal: any) {
|
|
229
|
+
try {
|
|
230
|
+
const result = await pi.exec("git", ["rev-parse", "HEAD"], { cwd, signal, timeout: 30_000 });
|
|
231
|
+
const value = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
232
|
+
if (result.code !== 0 || !/^[a-f0-9]{7,64}$/iu.test(value)) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return value;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (signal?.aborted) {
|
|
239
|
+
throw signal.reason ?? error;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function resolveTaskContractRoot(pi: any, cwd: string, signal: any) {
|
|
247
|
+
let result;
|
|
248
|
+
try {
|
|
249
|
+
result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, signal, timeout: 15_000 });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (signal?.aborted) {
|
|
252
|
+
throw signal.reason ?? error;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return canonicalSourceRoot(cwd);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (signal?.aborted) {
|
|
259
|
+
throw signal.reason ?? new Error("Wishlist report cancelled");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (result?.code === 0 && typeof result.stdout === "string" && result.stdout.trim()) {
|
|
263
|
+
const candidate = path.resolve(cwd, result.stdout.trim());
|
|
264
|
+
const relative = path.relative(candidate, cwd);
|
|
265
|
+
if (relative !== "" && relative.startsWith("..")) {
|
|
266
|
+
throw new Error("Git reported a task contract root outside the current project");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return canonicalSourceRoot(candidate);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return canonicalSourceRoot(cwd);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function captureCheckoutSnapshot(pi: any, cwd: string, signal: any) {
|
|
276
|
+
const ignoredPaths = await gitIgnoredPaths(pi, cwd, signal);
|
|
277
|
+
const headResult = await gitHead(pi, cwd, signal);
|
|
278
|
+
|
|
279
|
+
return captureSourceSnapshot(cwd, {
|
|
280
|
+
...(ignoredPaths === undefined ? {} : { ignoredPaths }),
|
|
281
|
+
...(headResult === undefined ? {} : { head: headResult }),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function policyFromCheckout(checkout: ReturnType<typeof sourceCheckout>, baseline: any, gapId: string) {
|
|
286
|
+
const currentEntry = checkout.registry.capabilities.find((item: any) => item.id === gapId);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
checkScript: checkout.checkScript,
|
|
290
|
+
scripts: cloneJson(checkout.scripts),
|
|
291
|
+
validatorNames: [...checkout.validatorNames],
|
|
292
|
+
registryDefinitions: cloneJson(checkout.registry.capabilities),
|
|
293
|
+
registryHadGap: Boolean(currentEntry),
|
|
294
|
+
validatorIds: currentEntry ? [...currentEntry.validations] : [],
|
|
295
|
+
moduleDigests: sourceModuleDigests(baseline),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sameJson(left: unknown, right: unknown) {
|
|
300
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function assertFrozenSourcePolicy(cwd: string, gapId: string, active: ActiveImprovement) {
|
|
304
|
+
const checkout = sourceCheckout(cwd);
|
|
305
|
+
if (checkout.checkScript !== active.checkScript) {
|
|
306
|
+
throw new Error("The SpecPi check script changed after this improvement was selected; reselect the gap");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!sameJson(checkout.scripts, active.scripts)) {
|
|
310
|
+
throw new Error("The SpecPi npm scripts changed after this improvement was selected; reselect the gap");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!sameJson(checkout.validatorNames, active.validatorNames)) {
|
|
314
|
+
throw new Error("The closed validator catalog changed after this improvement was selected; reselect the gap");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const frozenById = new Map(active.registryDefinitions.map((item: any) => [item.id, item]));
|
|
318
|
+
const currentById = new Map(checkout.registry.capabilities.map((item: any) => [item.id, item]));
|
|
319
|
+
for (const [id, frozen] of frozenById) {
|
|
320
|
+
if (!sameJson(currentById.get(id), frozen)) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`The reviewed registry entry ${id} changed after this improvement was selected; reselect the gap`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const extra = checkout.registry.capabilities.filter((item: any) => !frozenById.has(item.id));
|
|
328
|
+
if (active.registryHadGap) {
|
|
329
|
+
if (extra.length > 0) {
|
|
330
|
+
throw new Error("The capability registry gained entries after selection; reselect the gap");
|
|
331
|
+
}
|
|
332
|
+
} else if (extra.length !== 1 || extra[0]?.id !== gapId) {
|
|
333
|
+
throw new Error("A new improvement may add only its own capability registry entry");
|
|
334
|
+
} else if (
|
|
335
|
+
!Array.isArray(extra[0].validations) ||
|
|
336
|
+
extra[0].validations.length === 0 ||
|
|
337
|
+
extra[0].validations.some((validator: string) => !active.validatorNames.includes(validator))
|
|
338
|
+
) {
|
|
339
|
+
throw new Error("A new capability registry entry must use only the frozen validator catalog");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (active.registryHadGap && !sameJson(currentById.get(gapId)?.validations, active.validatorIds)) {
|
|
343
|
+
throw new Error("The selected capability validator list changed after selection; reselect the gap");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { checkout, capability: currentById.get(gapId) };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function assertFrozenModuleDigests(snapshot: any, active: ActiveImprovement) {
|
|
350
|
+
for (const [relativePath, expected] of Object.entries(active.moduleDigests)) {
|
|
351
|
+
if (snapshot.files?.[relativePath]?.sha256 !== expected) {
|
|
352
|
+
throw new Error(`Protected verification input changed after selection: ${markdownPathLabel(relativePath)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function assertFinishSelectionStillActive(
|
|
358
|
+
current: ActiveImprovement | undefined,
|
|
359
|
+
expected: ActiveImprovement,
|
|
360
|
+
ctx: any,
|
|
361
|
+
signal?: any,
|
|
362
|
+
) {
|
|
363
|
+
if (signal?.aborted) {
|
|
364
|
+
throw signal.reason ?? new Error("Verification cancelled");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (
|
|
368
|
+
!current ||
|
|
369
|
+
current.gapId !== expected.gapId ||
|
|
370
|
+
current.sessionId !== expected.sessionId ||
|
|
371
|
+
current.root !== expected.root ||
|
|
372
|
+
current.selectionId !== expected.selectionId ||
|
|
373
|
+
current.baseline?.digest !== expected.baseline?.digest ||
|
|
374
|
+
current.checkScript !== expected.checkScript ||
|
|
375
|
+
!sameJson(current.scripts, expected.scripts) ||
|
|
376
|
+
!sameJson(current.validatorNames, expected.validatorNames) ||
|
|
377
|
+
!sameJson(current.validatorIds, expected.validatorIds) ||
|
|
378
|
+
!sameJson(current.registryDefinitions, expected.registryDefinitions) ||
|
|
379
|
+
current.registryHadGap !== expected.registryHadGap ||
|
|
380
|
+
!sameJson(current.moduleDigests, expected.moduleDigests) ||
|
|
381
|
+
ctx.sessionManager.getSessionId() !== expected.sessionId
|
|
382
|
+
) {
|
|
383
|
+
throw new Error("The active improvement selection changed while verification was running");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (canonicalSourceRoot(ctx.cwd) !== expected.root) {
|
|
387
|
+
throw new Error("The SpecPi source root changed while verification was running");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
85
391
|
function reportField(body: string, label: string) {
|
|
86
392
|
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
87
393
|
|
|
@@ -229,7 +535,7 @@ function improvementPrompt(group: any, context: ImprovementContext = {}) {
|
|
|
229
535
|
lines.push(
|
|
230
536
|
"",
|
|
231
537
|
"This exact menu selection authorizes implementation of the smallest sufficient intervention in the current SpecPi source checkout. Load and follow the specpi-improve skill. Treat the wishlist evidence as a lead, inspect current behavior, keep scope minimal, and do not ask for another approval unless scope expands or external/remote state would change.",
|
|
232
|
-
"Run direct acceptance checks and focused tests. At the end, call finish_harness_improvement with the gap ID, concise acceptance evidence, and a validation note. That tool
|
|
538
|
+
"First call record_harness_contract with the exact gap, selection ID, source root, proposed requirements, acceptance checks, paths, rollback, and non-goals. Run direct acceptance checks and focused tests. At the end, call finish_harness_improvement with the gap ID, concise acceptance evidence, and a validation note. That tool independently runs the repository gate, verifies registry integration and frozen policy inputs, runs supported capability validators, records a bounded local receipt, and retires the item. If any check fails, do not retire it; leave it selected and report the blocker.",
|
|
233
539
|
);
|
|
234
540
|
|
|
235
541
|
return lines.join("\n");
|
|
@@ -253,29 +559,116 @@ function truncateReportDisplay(markdown: string) {
|
|
|
253
559
|
}
|
|
254
560
|
|
|
255
561
|
function cleanDisplayPath(value: string) {
|
|
256
|
-
return value
|
|
562
|
+
return markdownPathLabel(value);
|
|
257
563
|
}
|
|
258
564
|
|
|
259
565
|
export default function toolWishlist(pi: ExtensionAPI) {
|
|
260
566
|
let activeRunId = randomUUID();
|
|
261
567
|
let activeImprovement: ActiveImprovement | undefined;
|
|
568
|
+
let improvementLifecycleGeneration = 0;
|
|
569
|
+
let improvementMenuGeneration = 0;
|
|
570
|
+
let finishBusy = false;
|
|
571
|
+
let contractBusy = false;
|
|
262
572
|
const supportsReportEntries = typeof pi.registerEntryRenderer === "function";
|
|
263
573
|
|
|
264
|
-
|
|
574
|
+
const restoreActiveImprovement = (ctx: any) => {
|
|
575
|
+
improvementLifecycleGeneration += 1;
|
|
265
576
|
activeImprovement = undefined;
|
|
266
577
|
for (const entry of ctx.sessionManager.getBranch?.() ?? []) {
|
|
267
578
|
if (entry.type !== "custom" || entry.customType !== HARNESS_IMPROVEMENT_ENTRY) {
|
|
268
579
|
continue;
|
|
269
580
|
}
|
|
270
581
|
|
|
271
|
-
const data = entry.data as
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
582
|
+
const data = entry.data as
|
|
583
|
+
| {
|
|
584
|
+
gapId?: string;
|
|
585
|
+
status?: string;
|
|
586
|
+
root?: string;
|
|
587
|
+
selectionId?: string;
|
|
588
|
+
baseline?: any;
|
|
589
|
+
checkScript?: string;
|
|
590
|
+
scripts?: Record<string, string>;
|
|
591
|
+
validatorNames?: string[];
|
|
592
|
+
validatorIds?: string[];
|
|
593
|
+
registryDefinitions?: any[];
|
|
594
|
+
registryHadGap?: boolean;
|
|
595
|
+
moduleDigests?: Record<string, string>;
|
|
596
|
+
}
|
|
597
|
+
| undefined;
|
|
598
|
+
if (data?.status !== "active") {
|
|
599
|
+
activeImprovement = undefined;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
if (
|
|
605
|
+
!data.gapId ||
|
|
606
|
+
!data.root ||
|
|
607
|
+
!data.selectionId ||
|
|
608
|
+
!data.baseline ||
|
|
609
|
+
typeof data.checkScript !== "string" ||
|
|
610
|
+
!data.scripts ||
|
|
611
|
+
typeof data.scripts !== "object" ||
|
|
612
|
+
!Array.isArray(data.validatorNames) ||
|
|
613
|
+
!data.validatorNames.every(isValidValidatorName) ||
|
|
614
|
+
!Array.isArray(data.validatorIds) ||
|
|
615
|
+
!data.validatorIds.every(isValidValidatorName) ||
|
|
616
|
+
!Array.isArray(data.registryDefinitions) ||
|
|
617
|
+
typeof data.registryHadGap !== "boolean" ||
|
|
618
|
+
!data.moduleDigests ||
|
|
619
|
+
typeof data.moduleDigests !== "object"
|
|
620
|
+
) {
|
|
621
|
+
throw new Error("legacy improvement selection metadata");
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const baseline = validateSourceSnapshot(data.baseline, data.root);
|
|
625
|
+
const expectedModuleDigests = sourceModuleDigests(baseline);
|
|
626
|
+
if (!sameJson(data.moduleDigests, expectedModuleDigests)) {
|
|
627
|
+
throw new Error("improvement module baseline mismatch");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
validateCapabilityRegistry({ schema: 1, capabilities: data.registryDefinitions });
|
|
631
|
+
activeImprovement = {
|
|
632
|
+
gapId: data.gapId,
|
|
633
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
634
|
+
root: baseline.root,
|
|
635
|
+
selectionId: data.selectionId,
|
|
636
|
+
baseline,
|
|
637
|
+
checkScript: data.checkScript,
|
|
638
|
+
scripts: cloneJson(data.scripts),
|
|
639
|
+
validatorNames: [...data.validatorNames],
|
|
640
|
+
validatorIds: [...data.validatorIds],
|
|
641
|
+
registryDefinitions: cloneJson(data.registryDefinitions),
|
|
642
|
+
registryHadGap: data.registryHadGap,
|
|
643
|
+
moduleDigests: { ...data.moduleDigests },
|
|
644
|
+
};
|
|
645
|
+
} catch {
|
|
646
|
+
// Older active entries intentionally require a fresh human selection.
|
|
647
|
+
activeImprovement = undefined;
|
|
648
|
+
}
|
|
276
649
|
}
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
pi.on("session_start", (_event, ctx) => {
|
|
653
|
+
restoreActiveImprovement(ctx);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
657
|
+
restoreActiveImprovement(ctx);
|
|
277
658
|
});
|
|
278
659
|
|
|
660
|
+
pi.on("session_shutdown", () => {
|
|
661
|
+
improvementLifecycleGeneration += 1;
|
|
662
|
+
activeImprovement = undefined;
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const assertImprovementStillCurrent = (expected: ActiveImprovement, generation: number, ctx: any, signal?: any) => {
|
|
666
|
+
assertFinishSelectionStillActive(activeImprovement, expected, ctx, signal);
|
|
667
|
+
if (improvementLifecycleGeneration !== generation) {
|
|
668
|
+
throw new Error("The active improvement selection changed with the session branch");
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
|
|
279
672
|
pi.on("before_agent_start", async () => {
|
|
280
673
|
activeRunId = randomUUID();
|
|
281
674
|
});
|
|
@@ -289,7 +682,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
289
682
|
if (supportsReportEntries) {
|
|
290
683
|
pi.appendEntry<WishlistReportEntry>(WISHLIST_REPORT_ENTRY, {
|
|
291
684
|
markdown: content,
|
|
292
|
-
reportPath
|
|
685
|
+
reportPath,
|
|
293
686
|
truncated: display.truncated,
|
|
294
687
|
});
|
|
295
688
|
} else if ((ctx.mode === "tui" || ctx.mode === undefined) && typeof ctx.ui.editor === "function") {
|
|
@@ -381,10 +774,12 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
381
774
|
};
|
|
382
775
|
}
|
|
383
776
|
|
|
777
|
+
const contractRoot = await resolveTaskContractRoot(pi, ctx.cwd, signal);
|
|
778
|
+
const contract = branchTaskContract(ctx, contractRoot, { tolerateMalformed: true });
|
|
384
779
|
const result = await recordCapabilityGap({
|
|
385
780
|
stateDir,
|
|
386
781
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
387
|
-
runId: activeRunId,
|
|
782
|
+
runId: contract?.id ?? activeRunId,
|
|
388
783
|
cwd: ctx.cwd,
|
|
389
784
|
gap: params,
|
|
390
785
|
signal,
|
|
@@ -407,6 +802,138 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
407
802
|
},
|
|
408
803
|
});
|
|
409
804
|
|
|
805
|
+
pi.registerTool({
|
|
806
|
+
name: "record_harness_contract",
|
|
807
|
+
label: "Record Harness Improvement Contract",
|
|
808
|
+
description:
|
|
809
|
+
"Record one bounded task contract for the exact human-selected wishlist improvement. The model may propose objective, hypothesis, requirements, acceptance checks, paths, rollback, and non-goals; this tool never creates human approval or broadens the selected gap.",
|
|
810
|
+
parameters: Type.Object(
|
|
811
|
+
{
|
|
812
|
+
gapId: Type.String({ minLength: 3, maxLength: 120 }),
|
|
813
|
+
selectionId: Type.String({ minLength: 1, maxLength: 120 }),
|
|
814
|
+
sourceRoot: Type.String({ minLength: 1, maxLength: 500 }),
|
|
815
|
+
objective: Type.String({ minLength: 3, maxLength: 600 }),
|
|
816
|
+
hypothesis: Type.String({ minLength: 3, maxLength: 600 }),
|
|
817
|
+
requirements: Type.Array(
|
|
818
|
+
Type.Object(
|
|
819
|
+
{
|
|
820
|
+
id: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })),
|
|
821
|
+
description: Type.String({ minLength: 3, maxLength: 360 }),
|
|
822
|
+
acceptance: Type.String({ minLength: 3, maxLength: 600 }),
|
|
823
|
+
},
|
|
824
|
+
{ additionalProperties: false },
|
|
825
|
+
),
|
|
826
|
+
{ minItems: 1, maxItems: 16 },
|
|
827
|
+
),
|
|
828
|
+
paths: Type.Array(Type.String({ minLength: 1, maxLength: 240 }), { minItems: 1, maxItems: 40 }),
|
|
829
|
+
rollback: Type.String({ minLength: 3, maxLength: 600 }),
|
|
830
|
+
nonGoals: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 16 })),
|
|
831
|
+
},
|
|
832
|
+
{ additionalProperties: false },
|
|
833
|
+
),
|
|
834
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
835
|
+
if (contractBusy) {
|
|
836
|
+
throw new Error("A task contract is already being recorded for this improvement");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
contractBusy = true;
|
|
840
|
+
try {
|
|
841
|
+
const generation = improvementLifecycleGeneration;
|
|
842
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
843
|
+
if (
|
|
844
|
+
!activeImprovement ||
|
|
845
|
+
activeImprovement.gapId !== params.gapId ||
|
|
846
|
+
activeImprovement.sessionId !== sessionId
|
|
847
|
+
) {
|
|
848
|
+
throw new Error("This item was not authorized by /harness-improvement in the current session");
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const expectedSelection = structuredClone(activeImprovement);
|
|
852
|
+
const sourceRoot = canonicalSourceRoot(params.sourceRoot);
|
|
853
|
+
if (sourceRoot !== expectedSelection.root || sourceRoot !== canonicalSourceRoot(ctx.cwd)) {
|
|
854
|
+
throw new Error("The task contract source root must exactly match the selected SpecPi checkout");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (params.selectionId !== expectedSelection.selectionId) {
|
|
858
|
+
throw new Error("The task contract selection ID does not match the active human selection");
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
|
|
862
|
+
const refreshed = await refreshWishlist({ stateDir, signal });
|
|
863
|
+
assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
|
|
864
|
+
const selected = improvementCandidatesFromRefresh(refreshed).find(
|
|
865
|
+
(item: any) => item.canonicalKey === expectedSelection.gapId && item.state === "selected",
|
|
866
|
+
);
|
|
867
|
+
if (!selected) {
|
|
868
|
+
throw new Error(
|
|
869
|
+
"The selected wishlist gap is no longer selected; reselect it before recording a contract",
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const input = {
|
|
874
|
+
objective: params.objective,
|
|
875
|
+
hypothesis: params.hypothesis,
|
|
876
|
+
requirements: params.requirements,
|
|
877
|
+
paths: params.paths,
|
|
878
|
+
rollback: params.rollback,
|
|
879
|
+
nonGoals: params.nonGoals,
|
|
880
|
+
};
|
|
881
|
+
const existing = branchTaskContract(ctx, expectedSelection.root);
|
|
882
|
+
if (
|
|
883
|
+
existing &&
|
|
884
|
+
(existing.origin !== "improvement" ||
|
|
885
|
+
existing.gapId !== expectedSelection.gapId ||
|
|
886
|
+
existing.selectionId !== expectedSelection.selectionId)
|
|
887
|
+
) {
|
|
888
|
+
throw new Error("A different task contract is active; revise it through /task or reselect the gap");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const contract = createTaskContract(
|
|
892
|
+
{
|
|
893
|
+
...input,
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
root: expectedSelection.root,
|
|
897
|
+
origin: "improvement",
|
|
898
|
+
gapId: expectedSelection.gapId,
|
|
899
|
+
selectionId: expectedSelection.selectionId,
|
|
900
|
+
...(existing ? { id: existing.id } : {}),
|
|
901
|
+
},
|
|
902
|
+
);
|
|
903
|
+
if (existing) {
|
|
904
|
+
if (existing.digest !== contract.digest) {
|
|
905
|
+
throw new Error("The improvement task contract is immutable; revise it through /task");
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return {
|
|
909
|
+
content: [
|
|
910
|
+
{
|
|
911
|
+
type: "text",
|
|
912
|
+
text: `Harness improvement contract already recorded (${existing.id}); digest ${existing.digest}.`,
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
details: { contract: existing, idempotent: true },
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
|
|
920
|
+
pi.appendEntry(TASK_CONTRACT_ENTRY, { kind: "set", contract });
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
content: [
|
|
924
|
+
{
|
|
925
|
+
type: "text",
|
|
926
|
+
text: `Harness improvement contract recorded (${contract.id}); digest ${contract.digest}.`,
|
|
927
|
+
},
|
|
928
|
+
],
|
|
929
|
+
details: { contract, idempotent: false },
|
|
930
|
+
};
|
|
931
|
+
} finally {
|
|
932
|
+
contractBusy = false;
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
|
|
410
937
|
pi.registerTool({
|
|
411
938
|
name: "finish_harness_improvement",
|
|
412
939
|
label: "Finish Harness Improvement",
|
|
@@ -433,88 +960,261 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
433
960
|
{ additionalProperties: false },
|
|
434
961
|
),
|
|
435
962
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
!activeImprovement ||
|
|
439
|
-
activeImprovement.gapId !== params.gapId ||
|
|
440
|
-
activeImprovement.sessionId !== sessionId
|
|
441
|
-
) {
|
|
442
|
-
throw new Error("This item was not authorized by /harness-improvement in the current session");
|
|
963
|
+
if (finishBusy) {
|
|
964
|
+
throw new Error("A finish verification is already running for this improvement");
|
|
443
965
|
}
|
|
444
966
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
967
|
+
finishBusy = true;
|
|
968
|
+
try {
|
|
969
|
+
const generation = improvementLifecycleGeneration;
|
|
970
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
971
|
+
const selectedImprovement = activeImprovement;
|
|
972
|
+
if (
|
|
973
|
+
!selectedImprovement ||
|
|
974
|
+
selectedImprovement.gapId !== params.gapId ||
|
|
975
|
+
selectedImprovement.sessionId !== sessionId
|
|
976
|
+
) {
|
|
977
|
+
throw new Error("This item was not authorized by /harness-improvement in the current session");
|
|
978
|
+
}
|
|
452
979
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
980
|
+
const expectedSelection = structuredClone(selectedImprovement);
|
|
981
|
+
assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
|
|
982
|
+
|
|
983
|
+
const root = canonicalSourceRoot(ctx.cwd);
|
|
984
|
+
if (root !== selectedImprovement.root) {
|
|
985
|
+
throw new Error("The SpecPi source root changed after this improvement was selected");
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const contract = branchTaskContract(ctx, root);
|
|
989
|
+
if (
|
|
990
|
+
!contract ||
|
|
991
|
+
contract.origin !== "improvement" ||
|
|
992
|
+
contract.gapId !== params.gapId ||
|
|
993
|
+
contract.selectionId !== selectedImprovement.selectionId ||
|
|
994
|
+
contract.root !== root
|
|
995
|
+
) {
|
|
996
|
+
throw new Error("Record a task contract for this exact improvement selection before finishing");
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const assertFinishStillCurrent = () => {
|
|
1000
|
+
assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
|
|
1001
|
+
const currentContract = branchTaskContract(ctx, root);
|
|
1002
|
+
if (!currentContract || currentContract.digest !== contract.digest) {
|
|
1003
|
+
throw new Error("The improvement task contract changed while verification was running");
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
const refreshed = await refreshWishlist({ stateDir, signal });
|
|
1008
|
+
assertFinishStillCurrent();
|
|
1009
|
+
const selected = improvementCandidatesFromRefresh(refreshed).find(
|
|
1010
|
+
(item: any) => item.canonicalKey === params.gapId,
|
|
1011
|
+
);
|
|
1012
|
+
if (!selected || selected.state !== "selected") {
|
|
1013
|
+
throw new Error(`${params.gapId} is not selected`);
|
|
1014
|
+
}
|
|
459
1015
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
1016
|
+
const baseline = validateSourceSnapshot(selectedImprovement.baseline, root);
|
|
1017
|
+
const before = await captureCheckoutSnapshot(pi, root, signal);
|
|
1018
|
+
assertFinishStillCurrent();
|
|
1019
|
+
const baselineComparison = compareSourceSnapshots(baseline, before);
|
|
1020
|
+
if (baselineComparison.headChanged) {
|
|
1021
|
+
throw new Error("The source checkout HEAD changed after selection; reselect the gap");
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const baselineViolations = taskContractScopeViolations(contract, baselineComparison.changed);
|
|
1025
|
+
if (baselineViolations.length > 0) {
|
|
1026
|
+
throw new Error(
|
|
1027
|
+
`Changes outside the task contract: ${baselineViolations.map(markdownPathLabel).join(", ")}`,
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
assertFrozenModuleDigests(before, selectedImprovement);
|
|
1032
|
+
const policy = assertFrozenSourcePolicy(root, params.gapId, selectedImprovement);
|
|
1033
|
+
const capability = policy.capability;
|
|
1034
|
+
if (!capability || !Array.isArray(capability.validations) || capability.validations.length === 0) {
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
`Implementation is not integrated: the reviewed capability registry has no validated ${params.gapId} entry`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const validators = selectedImprovement.registryHadGap
|
|
1041
|
+
? selectedImprovement.validatorIds
|
|
1042
|
+
: [...capability.validations];
|
|
1043
|
+
if (
|
|
1044
|
+
validators.length === 0 ||
|
|
1045
|
+
validators.some(
|
|
1046
|
+
(validator: string) =>
|
|
1047
|
+
!isValidValidatorName(validator) || !selectedImprovement.validatorNames.includes(validator),
|
|
1048
|
+
)
|
|
1049
|
+
) {
|
|
1050
|
+
throw new Error("The selected capability references a validator outside the frozen closed catalog");
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
1054
|
+
const check = await pi.exec(npm, ["run", "check"], {
|
|
1055
|
+
cwd: root,
|
|
465
1056
|
signal,
|
|
466
|
-
timeout,
|
|
1057
|
+
timeout: 15 * 60 * 1000,
|
|
467
1058
|
});
|
|
468
|
-
|
|
469
|
-
|
|
1059
|
+
assertFinishStillCurrent();
|
|
1060
|
+
if (check.code !== 0) {
|
|
1061
|
+
throw new Error(`SpecPi repository verification failed with exit code ${check.code}`);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const gates = [{ id: "npm run check", exitCode: 0 }];
|
|
1065
|
+
let after = await captureCheckoutSnapshot(pi, root, signal);
|
|
1066
|
+
assertFinishStillCurrent();
|
|
1067
|
+
assertFrozenModuleDigests(after, selectedImprovement);
|
|
1068
|
+
const afterCheck = compareSourceSnapshots(before, after);
|
|
1069
|
+
if (afterCheck.changed.length > 0 || afterCheck.headChanged) {
|
|
1070
|
+
throw new Error("SpecPi check changed the source checkout; verification proof is stale");
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
for (const validator of validators) {
|
|
1074
|
+
const validatorBefore = after;
|
|
1075
|
+
const timeout = VALIDATOR_CATALOG[validator]?.timeoutMs ?? 3 * 60 * 1000;
|
|
1076
|
+
const result = await pi.exec(process.execPath, validatorArgs(validator, root), {
|
|
1077
|
+
cwd: root,
|
|
1078
|
+
signal,
|
|
1079
|
+
timeout,
|
|
1080
|
+
});
|
|
1081
|
+
assertFinishStillCurrent();
|
|
1082
|
+
if (result.code !== 0) {
|
|
1083
|
+
const detail = sanitizeWishlistText(`${result.stderr || result.stdout || ""}`, 300);
|
|
1084
|
+
throw new Error(
|
|
1085
|
+
`Capability validator ${validator} failed with exit code ${result.code}${detail ? `: ${detail}` : ""}`,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
gates.push({ id: validator, exitCode: result.code });
|
|
1090
|
+
after = await captureCheckoutSnapshot(pi, root, signal);
|
|
1091
|
+
assertFinishStillCurrent();
|
|
1092
|
+
assertFrozenModuleDigests(after, selectedImprovement);
|
|
1093
|
+
const validatorComparison = compareSourceSnapshots(validatorBefore, after);
|
|
1094
|
+
if (validatorComparison.changed.length > 0 || validatorComparison.headChanged) {
|
|
1095
|
+
throw new Error(
|
|
1096
|
+
`Validator ${validator} changed the source checkout; verification proof is stale`,
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const finalComparison = compareSourceSnapshots(baseline, after);
|
|
1102
|
+
if (finalComparison.headChanged) {
|
|
1103
|
+
throw new Error("The source checkout HEAD changed during verification; proof is stale");
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const finalViolations = taskContractScopeViolations(contract, finalComparison.changed);
|
|
1107
|
+
if (finalViolations.length > 0) {
|
|
470
1108
|
throw new Error(
|
|
471
|
-
`
|
|
1109
|
+
`Changes outside the task contract: ${finalViolations.map(markdownPathLabel).join(", ")}`,
|
|
472
1110
|
);
|
|
473
1111
|
}
|
|
474
1112
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
journal.changedFiles = changed.files;
|
|
488
|
-
journal.changedFilesTruncated = changed.truncated;
|
|
489
|
-
}
|
|
1113
|
+
assertFrozenModuleDigests(after, selectedImprovement);
|
|
1114
|
+
const receipt = createVerificationReceipt({
|
|
1115
|
+
before,
|
|
1116
|
+
after,
|
|
1117
|
+
registryDigest: after.files["extensions/tool-wishlist/capabilities.json"]?.sha256,
|
|
1118
|
+
validatorDigest: selectedImprovement.moduleDigests["extensions/tool-wishlist/validators.mjs"],
|
|
1119
|
+
gates,
|
|
1120
|
+
contractDigest: contract.digest,
|
|
1121
|
+
gapId: params.gapId,
|
|
1122
|
+
selectionId: selectedImprovement.selectionId,
|
|
1123
|
+
sourceRootSalt: wishlistSourceRootSalt(stateDir),
|
|
1124
|
+
});
|
|
490
1125
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
1126
|
+
const changed = await gitChangedFiles(pi, root, signal);
|
|
1127
|
+
assertFinishStillCurrent();
|
|
1128
|
+
const journal: Record<string, unknown> = {
|
|
1129
|
+
schema: 1,
|
|
1130
|
+
evidence: params.acceptanceEvidence,
|
|
1131
|
+
gates: gates.map((gate) => gate.id),
|
|
1132
|
+
changedFilesTruncated: false,
|
|
1133
|
+
version: policy.checkout.version,
|
|
1134
|
+
receipt,
|
|
1135
|
+
};
|
|
1136
|
+
if (changed) {
|
|
1137
|
+
journal.changedFiles = changed.files;
|
|
1138
|
+
journal.changedFilesTruncated = changed.truncated;
|
|
1139
|
+
}
|
|
502
1140
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
1141
|
+
const note = `${params.validationNote}; ${gates.map((gate) => gate.id).join(", ")} passed`;
|
|
1142
|
+
await appendWishlistDecision({
|
|
1143
|
+
stateDir,
|
|
1144
|
+
action: "retire",
|
|
1145
|
+
canonicalKey: params.gapId,
|
|
1146
|
+
note,
|
|
1147
|
+
signal,
|
|
1148
|
+
journal,
|
|
1149
|
+
precondition: async () => {
|
|
1150
|
+
assertFinishStillCurrent();
|
|
1151
|
+
const currentContract = branchTaskContract(ctx, root);
|
|
1152
|
+
|
|
1153
|
+
const currentRoot = canonicalSourceRoot(ctx.cwd);
|
|
1154
|
+
if (currentRoot !== root) {
|
|
1155
|
+
throw new Error("The SpecPi source root changed while verification was running");
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const currentSnapshot = await captureCheckoutSnapshot(pi, root, signal);
|
|
1159
|
+
assertFrozenModuleDigests(currentSnapshot, selectedImprovement);
|
|
1160
|
+
const currentComparison = compareSourceSnapshots(after, currentSnapshot);
|
|
1161
|
+
if (currentComparison.changed.length > 0 || currentComparison.headChanged) {
|
|
1162
|
+
throw new Error("Source changed after verification; proof was not appended");
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
assertFinishStillCurrent();
|
|
1166
|
+
const currentPolicy = assertFrozenSourcePolicy(root, params.gapId, selectedImprovement);
|
|
1167
|
+
if (!currentPolicy.capability) {
|
|
1168
|
+
throw new Error("The reviewed capability registry entry disappeared before retirement");
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const validatedReceipt = validateVerificationReceipt(receipt);
|
|
1172
|
+
if (
|
|
1173
|
+
validatedReceipt.sourceDigest !== currentSnapshot.digest ||
|
|
1174
|
+
validatedReceipt.registryDigest !==
|
|
1175
|
+
currentSnapshot.files["extensions/tool-wishlist/capabilities.json"]?.sha256 ||
|
|
1176
|
+
validatedReceipt.contractDigest !== currentContract.digest ||
|
|
1177
|
+
validatedReceipt.gapId !== params.gapId ||
|
|
1178
|
+
validatedReceipt.selectionId !== selectedImprovement.selectionId ||
|
|
1179
|
+
validatedReceipt.sourceRootIdentity !== wishlistSourceRootIdentity(stateDir, root)
|
|
1180
|
+
) {
|
|
1181
|
+
throw new Error(
|
|
1182
|
+
"Verification receipt no longer matches the active source, card, or selection",
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
508
1185
|
},
|
|
509
|
-
|
|
510
|
-
|
|
1186
|
+
});
|
|
1187
|
+
assertFinishStillCurrent();
|
|
1188
|
+
pi.appendEntry(HARNESS_IMPROVEMENT_ENTRY, {
|
|
511
1189
|
gapId: params.gapId,
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1190
|
+
status: "finished",
|
|
1191
|
+
root,
|
|
1192
|
+
selectionId: selectedImprovement.selectionId,
|
|
1193
|
+
contractDigest: contract.digest,
|
|
1194
|
+
receiptDigest: receipt.sourceDigest,
|
|
1195
|
+
});
|
|
1196
|
+
if (activeImprovement?.selectionId === selectedImprovement.selectionId) {
|
|
1197
|
+
activeImprovement = undefined;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return {
|
|
1201
|
+
content: [
|
|
1202
|
+
{
|
|
1203
|
+
type: "text",
|
|
1204
|
+
text: `Harness improvement verified and retired: ${params.gapId}. Gates: ${gates.map((gate) => gate.id).join(", ")}. Proof recorded in the improvement journal.`,
|
|
1205
|
+
},
|
|
1206
|
+
],
|
|
1207
|
+
details: {
|
|
1208
|
+
gapId: params.gapId,
|
|
1209
|
+
state: "retired",
|
|
1210
|
+
verifiedBy: gates.map((gate) => gate.id),
|
|
1211
|
+
acceptanceEvidence: params.acceptanceEvidence,
|
|
1212
|
+
journal,
|
|
1213
|
+
},
|
|
1214
|
+
};
|
|
1215
|
+
} finally {
|
|
1216
|
+
finishBusy = false;
|
|
1217
|
+
}
|
|
518
1218
|
},
|
|
519
1219
|
});
|
|
520
1220
|
|
|
@@ -528,7 +1228,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
528
1228
|
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
529
1229
|
box.addChild(new Markdown(data.markdown, 0, 0, getMarkdownTheme()));
|
|
530
1230
|
const suffix = data.truncated ? " (display truncated; file contains the complete report)" : "";
|
|
531
|
-
box.addChild(new Text(theme.fg("dim", `Report: ${data.reportPath}${suffix}`), 0, 0));
|
|
1231
|
+
box.addChild(new Text(theme.fg("dim", `Report: ${cleanDisplayPath(data.reportPath)}${suffix}`), 0, 0));
|
|
532
1232
|
|
|
533
1233
|
return box;
|
|
534
1234
|
});
|
|
@@ -549,15 +1249,38 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
549
1249
|
return;
|
|
550
1250
|
}
|
|
551
1251
|
|
|
1252
|
+
const generation = improvementLifecycleGeneration;
|
|
1253
|
+
const menuGeneration = ++improvementMenuGeneration;
|
|
1254
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1255
|
+
let leafId = ctx.sessionManager.getLeafId?.();
|
|
1256
|
+
let root: string;
|
|
1257
|
+
let checkout: ReturnType<typeof sourceCheckout>;
|
|
552
1258
|
try {
|
|
553
|
-
|
|
1259
|
+
root = canonicalSourceRoot(ctx.cwd);
|
|
1260
|
+
checkout = sourceCheckout(root);
|
|
554
1261
|
} catch (error) {
|
|
555
1262
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
556
1263
|
|
|
557
1264
|
return;
|
|
558
1265
|
}
|
|
559
1266
|
|
|
1267
|
+
const assertSelectionContextCurrent = () => {
|
|
1268
|
+
if (
|
|
1269
|
+
improvementLifecycleGeneration !== generation ||
|
|
1270
|
+
improvementMenuGeneration !== menuGeneration ||
|
|
1271
|
+
ctx.sessionManager.getSessionId() !== sessionId ||
|
|
1272
|
+
ctx.sessionManager.getLeafId?.() !== leafId
|
|
1273
|
+
) {
|
|
1274
|
+
throw new Error("The improvement selection changed with the session branch; choose it again");
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (canonicalSourceRoot(ctx.cwd) !== root) {
|
|
1278
|
+
throw new Error("The SpecPi source root changed while choosing the improvement; choose it again");
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
|
|
560
1282
|
const refreshed = await refreshWishlist({ stateDir });
|
|
1283
|
+
assertSelectionContextCurrent();
|
|
561
1284
|
const improvements = improvementCandidatesFromRefresh(refreshed);
|
|
562
1285
|
if (improvements.length === 0) {
|
|
563
1286
|
ctx.ui.notify("No qualified or review-needed harness improvements are available.", "info");
|
|
@@ -571,6 +1294,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
571
1294
|
return `${status.toUpperCase()} · ${item.title} · ${item.canonicalKey}`;
|
|
572
1295
|
});
|
|
573
1296
|
const chosen = await ctx.ui.select("Choose one harness improvement", labels);
|
|
1297
|
+
assertSelectionContextCurrent();
|
|
574
1298
|
if (!chosen) {
|
|
575
1299
|
return;
|
|
576
1300
|
}
|
|
@@ -580,6 +1304,19 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
580
1304
|
return;
|
|
581
1305
|
}
|
|
582
1306
|
|
|
1307
|
+
let baseline: any;
|
|
1308
|
+
let policy: ReturnType<typeof policyFromCheckout>;
|
|
1309
|
+
try {
|
|
1310
|
+
baseline = await captureCheckoutSnapshot(pi, root, undefined);
|
|
1311
|
+
assertSelectionContextCurrent();
|
|
1312
|
+
checkout = sourceCheckout(root);
|
|
1313
|
+
policy = policyFromCheckout(checkout, baseline, group.canonicalKey);
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1316
|
+
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
583
1320
|
let promptContext: ImprovementContext = {};
|
|
584
1321
|
if (group.reviewNeeded && group.state === "retired") {
|
|
585
1322
|
const signals = typeof group.reviewSignalCount === "number" ? group.reviewSignalCount : 0;
|
|
@@ -599,18 +1336,23 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
599
1336
|
canonicalKey: group.canonicalKey,
|
|
600
1337
|
note: `Reopened for review: ${signals} post-retirement signal(s)`,
|
|
601
1338
|
evidence,
|
|
1339
|
+
precondition: assertSelectionContextCurrent,
|
|
602
1340
|
});
|
|
1341
|
+
assertSelectionContextCurrent();
|
|
603
1342
|
await appendWishlistDecision({
|
|
604
1343
|
stateDir,
|
|
605
1344
|
action: "select",
|
|
606
1345
|
canonicalKey: group.canonicalKey,
|
|
607
1346
|
note: "Chosen through harness improvement menu",
|
|
1347
|
+
precondition: assertSelectionContextCurrent,
|
|
608
1348
|
});
|
|
1349
|
+
assertSelectionContextCurrent();
|
|
609
1350
|
const retirement = latestRetirementDecision(refreshed.decisions ?? [], group.canonicalKey);
|
|
610
1351
|
if (retirement?.journal) {
|
|
611
1352
|
promptContext.originalEvidence = retirement.journal.evidence ?? [];
|
|
612
1353
|
promptContext.changedFiles = retirement.journal.changedFiles ?? [];
|
|
613
|
-
promptContext.changedSince = (await gitLogSince(pi,
|
|
1354
|
+
promptContext.changedSince = (await gitLogSince(pi, root, retirement.timestamp)) ?? undefined;
|
|
1355
|
+
assertSelectionContextCurrent();
|
|
614
1356
|
}
|
|
615
1357
|
} else if (group.state === "open") {
|
|
616
1358
|
await appendWishlistDecision({
|
|
@@ -618,18 +1360,41 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
618
1360
|
action: "select",
|
|
619
1361
|
canonicalKey: group.canonicalKey,
|
|
620
1362
|
note: "Chosen through harness improvement menu",
|
|
1363
|
+
precondition: assertSelectionContextCurrent,
|
|
621
1364
|
});
|
|
1365
|
+
assertSelectionContextCurrent();
|
|
622
1366
|
}
|
|
623
1367
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1368
|
+
assertSelectionContextCurrent();
|
|
1369
|
+
const selectionId = randomUUID();
|
|
1370
|
+
activeImprovement = {
|
|
1371
|
+
gapId: group.canonicalKey,
|
|
1372
|
+
sessionId,
|
|
1373
|
+
root,
|
|
1374
|
+
selectionId,
|
|
1375
|
+
baseline,
|
|
1376
|
+
...policy,
|
|
1377
|
+
};
|
|
1378
|
+
assertSelectionContextCurrent();
|
|
1379
|
+
pi.appendEntry(TASK_CONTRACT_ENTRY, { kind: "cleared" });
|
|
1380
|
+
leafId = ctx.sessionManager.getLeafId?.();
|
|
1381
|
+
assertSelectionContextCurrent();
|
|
1382
|
+
pi.appendEntry(HARNESS_IMPROVEMENT_ENTRY, {
|
|
1383
|
+
gapId: group.canonicalKey,
|
|
1384
|
+
status: "active",
|
|
1385
|
+
root,
|
|
1386
|
+
selectionId,
|
|
1387
|
+
baseline,
|
|
1388
|
+
...policy,
|
|
1389
|
+
});
|
|
1390
|
+
leafId = ctx.sessionManager.getLeafId?.();
|
|
1391
|
+
assertSelectionContextCurrent();
|
|
627
1392
|
pi.sendUserMessage(improvementPrompt(group, promptContext));
|
|
628
1393
|
},
|
|
629
1394
|
});
|
|
630
1395
|
|
|
631
1396
|
const usage =
|
|
632
|
-
"Usage: /wishlist [status|on|off|history [id]|decline <id>|merge <from> <to>|unmerge <merge-decision-id>|draft <id>|archive|reset]";
|
|
1397
|
+
"Usage: /wishlist [status|on|off|history [id]|outcome <id>|decline <id>|merge <from> <to>|unmerge <merge-decision-id>|draft <id>|archive|reset]";
|
|
633
1398
|
pi.registerCommand("wishlist", {
|
|
634
1399
|
description: "View and curate SpecPi's local capability evidence",
|
|
635
1400
|
getArgumentCompletions: (prefix: string) => {
|
|
@@ -638,6 +1403,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
638
1403
|
"on",
|
|
639
1404
|
"off",
|
|
640
1405
|
"history",
|
|
1406
|
+
"outcome",
|
|
641
1407
|
"decline",
|
|
642
1408
|
"merge",
|
|
643
1409
|
"unmerge",
|
|
@@ -658,7 +1424,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
658
1424
|
const result = await refreshWishlist({ stateDir });
|
|
659
1425
|
const metrics = result.metrics;
|
|
660
1426
|
ctx.ui.notify(
|
|
661
|
-
`Wishlist collection: ${mode}; ${result.uniqueGaps} queued gap${result.uniqueGaps === 1 ? "" : "s"}; retirements ${metrics.retirements}, reopen rate ${metrics.reopenRate}
|
|
1427
|
+
`Wishlist collection: ${mode}; ${result.uniqueGaps} queued gap${result.uniqueGaps === 1 ? "" : "s"}; retirements ${metrics.retirements}, reopen rate ${metrics.reopenRateKnown ? `${metrics.reopenRate}%` : "unknown"}, open reviews ${metrics.openReviews}; local cohort rate ${metrics.reopenRateKnown ? `${metrics.reopenRate}%` : "unknown"}, baseline reviews ${metrics.baselineReviews}. ${cleanDisplayPath(result.reportPath)}`,
|
|
662
1428
|
"info",
|
|
663
1429
|
);
|
|
664
1430
|
|
|
@@ -672,6 +1438,89 @@ export default function toolWishlist(pi: ExtensionAPI) {
|
|
|
672
1438
|
return;
|
|
673
1439
|
}
|
|
674
1440
|
|
|
1441
|
+
if (action === "outcome") {
|
|
1442
|
+
if (parts.length !== 1) {
|
|
1443
|
+
ctx.ui.notify(usage, "error");
|
|
1444
|
+
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (readCollectionMode(stateDir) !== "on") {
|
|
1449
|
+
ctx.ui.notify("Record an outcome only while local wishlist collection is on.", "error");
|
|
1450
|
+
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
if (!ctx.hasUI || typeof ctx.ui.select !== "function") {
|
|
1455
|
+
ctx.ui.notify("/wishlist outcome requires an interactive menu.", "error");
|
|
1456
|
+
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const gapId = normalizeCapability(parts[0]);
|
|
1461
|
+
const refreshed = await refreshWishlist({ stateDir });
|
|
1462
|
+
const retirement = latestLocalRetirementDecision(refreshed.decisions, gapId);
|
|
1463
|
+
if (!retirement?.journal?.receipt) {
|
|
1464
|
+
ctx.ui.notify("This gap has no locally recorded retirement receipt to assess.", "error");
|
|
1465
|
+
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const latestOutcome = latestOutcomeDecision(refreshed.decisions, gapId, retirement.id);
|
|
1470
|
+
const choices = ["helped", "failed", "not-exercised", "reverted"];
|
|
1471
|
+
const chosenOutcome = await ctx.ui.select(
|
|
1472
|
+
`How did the retired improvement work for ${gapId}?`,
|
|
1473
|
+
choices,
|
|
1474
|
+
);
|
|
1475
|
+
if (!chosenOutcome || !choices.includes(chosenOutcome)) {
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const requestId = randomUUID();
|
|
1480
|
+
const retirementId = retirement.id;
|
|
1481
|
+
const retirementReceipt = cloneJson(retirement.journal.receipt);
|
|
1482
|
+
const previousOutcomeId = latestOutcome?.id ?? "";
|
|
1483
|
+
const result = await appendWishlistDecision({
|
|
1484
|
+
stateDir,
|
|
1485
|
+
action: "outcome",
|
|
1486
|
+
canonicalKey: gapId,
|
|
1487
|
+
targetKey: retirementId,
|
|
1488
|
+
outcome: chosenOutcome,
|
|
1489
|
+
requestId,
|
|
1490
|
+
previousOutcomeId,
|
|
1491
|
+
note: "",
|
|
1492
|
+
precondition: async ({ decisions }: any) => {
|
|
1493
|
+
if (readCollectionMode(stateDir) !== "on") {
|
|
1494
|
+
throw new Error("Local wishlist collection was turned off before the outcome was recorded");
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const currentRetirement = latestLocalRetirementDecision(decisions, gapId);
|
|
1498
|
+
if (
|
|
1499
|
+
!currentRetirement ||
|
|
1500
|
+
currentRetirement.id !== retirementId ||
|
|
1501
|
+
!sameJson(currentRetirement.journal?.receipt, retirementReceipt)
|
|
1502
|
+
) {
|
|
1503
|
+
throw new Error("The retirement changed while the outcome menu was open; choose again");
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const currentOutcome = latestOutcomeDecision(decisions, gapId, retirementId);
|
|
1507
|
+
if ((currentOutcome?.id ?? "") !== previousOutcomeId) {
|
|
1508
|
+
throw new Error(
|
|
1509
|
+
"The retirement outcome changed while the outcome menu was open; choose again",
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
validateVerificationReceipt(retirementReceipt);
|
|
1514
|
+
if (retirementReceipt.gapId !== gapId) {
|
|
1515
|
+
throw new Error("The retirement receipt is bound to a different wishlist gap");
|
|
1516
|
+
}
|
|
1517
|
+
},
|
|
1518
|
+
});
|
|
1519
|
+
ctx.ui.notify(`Wishlist outcome recorded: ${gapId} → ${chosenOutcome}.`, "info");
|
|
1520
|
+
|
|
1521
|
+
return result;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
675
1524
|
if (action === "archive" || action === "reset") {
|
|
676
1525
|
if (!ctx.hasUI) {
|
|
677
1526
|
ctx.ui.notify(`${action} requires interactive confirmation.`, "error");
|