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
|
@@ -33,11 +33,21 @@ import {
|
|
|
33
33
|
renderChallengeMarkdown,
|
|
34
34
|
validateChallengeSubmission,
|
|
35
35
|
} from "./challenge.mjs";
|
|
36
|
+
import {
|
|
37
|
+
TASK_CONTRACT_ENTRY,
|
|
38
|
+
createTaskContract,
|
|
39
|
+
markdownPathLabel,
|
|
40
|
+
readTaskContract,
|
|
41
|
+
renderTaskContract,
|
|
42
|
+
taskContractScopeViolations,
|
|
43
|
+
validateTaskContract,
|
|
44
|
+
} from "./task-contract.mjs";
|
|
36
45
|
|
|
37
46
|
const agentDir = path.resolve(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"));
|
|
38
47
|
const stateDir = path.join(agentDir, "specpi");
|
|
39
48
|
const SCOPE_ENTRY = "specpi-scope-state";
|
|
40
49
|
const CHALLENGE_ENTRY = "specpi-completion-challenge";
|
|
50
|
+
const TASK_HANDOFF_ENTRY = "specpi-task-handoff";
|
|
41
51
|
const SCOPE_STATUS = "specpi-scope";
|
|
42
52
|
const MAX_PENDING_SCOPE = 40;
|
|
43
53
|
// `read` is the one documented Pi seam that cannot mutate the worktree. Every other tool, including extension-provided
|
|
@@ -57,11 +67,14 @@ interface ScopeState {
|
|
|
57
67
|
observed: string[];
|
|
58
68
|
indeterminate: boolean;
|
|
59
69
|
generation: number;
|
|
70
|
+
taskDigest?: string;
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
interface ActiveChallenge {
|
|
63
74
|
generation: string;
|
|
64
75
|
sessionId: string;
|
|
76
|
+
root: string;
|
|
77
|
+
taskContractDigest?: string;
|
|
65
78
|
facts: any;
|
|
66
79
|
prompt: string;
|
|
67
80
|
delivered: boolean;
|
|
@@ -76,6 +89,21 @@ interface ChallengeEntryData {
|
|
|
76
89
|
createdAt?: string;
|
|
77
90
|
}
|
|
78
91
|
|
|
92
|
+
interface TaskContractEntryData {
|
|
93
|
+
kind: "set" | "cleared";
|
|
94
|
+
contract?: any;
|
|
95
|
+
createdAt?: string;
|
|
96
|
+
reason?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface TaskHandoffEntryData {
|
|
100
|
+
markdown: string;
|
|
101
|
+
contractId: string;
|
|
102
|
+
contractDigest: string;
|
|
103
|
+
indeterminate: boolean;
|
|
104
|
+
createdAt: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
function validScopeEntry(value: any): value is ScopeItem {
|
|
80
108
|
return (
|
|
81
109
|
value &&
|
|
@@ -96,6 +124,7 @@ function emptyScope(root: string): ScopeState {
|
|
|
96
124
|
observed: [],
|
|
97
125
|
indeterminate: false,
|
|
98
126
|
generation: 0,
|
|
127
|
+
taskDigest: undefined,
|
|
99
128
|
};
|
|
100
129
|
}
|
|
101
130
|
|
|
@@ -114,6 +143,128 @@ function parseExperimentCard(source: string, fallbackName: string) {
|
|
|
114
143
|
};
|
|
115
144
|
}
|
|
116
145
|
|
|
146
|
+
function taskContractEditorText(contract: any = {}) {
|
|
147
|
+
const requirements = Array.isArray(contract.requirements) ? contract.requirements : [];
|
|
148
|
+
const paths = Array.isArray(contract.paths) ? contract.paths : [];
|
|
149
|
+
const nonGoals = Array.isArray(contract.nonGoals) ? contract.nonGoals : [];
|
|
150
|
+
const requirementLines =
|
|
151
|
+
requirements.length > 0
|
|
152
|
+
? requirements.flatMap((item: any, index: number) => [
|
|
153
|
+
`- ${item.id ?? `R${index + 1}`}: ${item.description ?? ""}`,
|
|
154
|
+
` Acceptance: ${item.acceptance ?? ""}`,
|
|
155
|
+
])
|
|
156
|
+
: ["- R1: ", " Acceptance: "];
|
|
157
|
+
|
|
158
|
+
return [
|
|
159
|
+
`Objective: ${contract.objective ?? ""}`,
|
|
160
|
+
`Hypothesis: ${contract.hypothesis ?? ""}`,
|
|
161
|
+
"Requirements:",
|
|
162
|
+
...requirementLines,
|
|
163
|
+
"Paths:",
|
|
164
|
+
...(paths.length > 0 ? paths.map((item: string) => `- ${markdownPathLabel(item)}`) : ["- "]),
|
|
165
|
+
`Rollback: ${contract.rollback ?? ""}`,
|
|
166
|
+
"Non-goals:",
|
|
167
|
+
...(nonGoals.length > 0 ? nonGoals.map((item: string) => `- ${item}`) : ["- "]),
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseTaskContractCard(source: string) {
|
|
172
|
+
const fields: Record<string, string> = {};
|
|
173
|
+
const requirements: any[] = [];
|
|
174
|
+
const paths: string[] = [];
|
|
175
|
+
const nonGoals: string[] = [];
|
|
176
|
+
let section = "";
|
|
177
|
+
let currentRequirement: any;
|
|
178
|
+
|
|
179
|
+
for (const rawLine of String(source).split(/\r?\n/u)) {
|
|
180
|
+
const line = rawLine.trimEnd();
|
|
181
|
+
const field = line.match(/^\s*(Objective|Hypothesis|Rollback|Requirements|Paths|Non-goals):\s*(.*)$/iu);
|
|
182
|
+
if (field) {
|
|
183
|
+
const name = field[1].toLowerCase();
|
|
184
|
+
const value = field[2].trim();
|
|
185
|
+
if (["objective", "hypothesis", "rollback"].includes(name)) {
|
|
186
|
+
fields[name] = value;
|
|
187
|
+
section = "";
|
|
188
|
+
} else {
|
|
189
|
+
section = name;
|
|
190
|
+
if (value) {
|
|
191
|
+
if (name === "paths") {
|
|
192
|
+
paths.push(decodeDisplayedPath(value.replace(/^[-*]\s*/u, "").trim()));
|
|
193
|
+
} else if (name === "non-goals") {
|
|
194
|
+
nonGoals.push(value.replace(/^[-*]\s*/u, "").trim());
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!line.trim()) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (section === "requirements") {
|
|
207
|
+
const requirement = line.match(/^\s*[-*]\s*(?:\[?([A-Za-z][A-Za-z0-9_-]*)\]?\s*(?::|[-–—])\s*)?(.+?)\s*$/u);
|
|
208
|
+
const acceptance = line.match(/^\s*(?:Acceptance|Accept):\s*(.*)$/iu);
|
|
209
|
+
if (requirement) {
|
|
210
|
+
currentRequirement = {
|
|
211
|
+
...(requirement[1] ? { id: requirement[1] } : {}),
|
|
212
|
+
description: requirement[2].trim(),
|
|
213
|
+
acceptance: "",
|
|
214
|
+
};
|
|
215
|
+
requirements.push(currentRequirement);
|
|
216
|
+
} else if (acceptance && currentRequirement) {
|
|
217
|
+
currentRequirement.acceptance = acceptance[1].trim();
|
|
218
|
+
}
|
|
219
|
+
} else if (section === "paths") {
|
|
220
|
+
const value = line.replace(/^\s*[-*]\s*/u, "").trim();
|
|
221
|
+
if (value) {
|
|
222
|
+
paths.push(decodeDisplayedPath(value));
|
|
223
|
+
}
|
|
224
|
+
} else if (section === "non-goals") {
|
|
225
|
+
const value = line.replace(/^\s*[-*]\s*/u, "").trim();
|
|
226
|
+
if (value) {
|
|
227
|
+
nonGoals.push(value);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
objective: fields.objective ?? "",
|
|
234
|
+
hypothesis: fields.hypothesis ?? "",
|
|
235
|
+
requirements,
|
|
236
|
+
paths,
|
|
237
|
+
rollback: fields.rollback ?? "",
|
|
238
|
+
nonGoals,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function decodeDisplayedPath(value: string) {
|
|
243
|
+
try {
|
|
244
|
+
return decodeURIComponent(value);
|
|
245
|
+
} catch {
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function experimentCardEditorText(contract: any, fallbackName: string) {
|
|
251
|
+
if (!contract) {
|
|
252
|
+
return `Name: ${fallbackName}\nHypothesis: \nAcceptance: \nNon-goals:\n- `;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const acceptance = contract.requirements.map((item: any) => `${item.id}: ${item.acceptance}`).join("; ");
|
|
256
|
+
const nonGoals =
|
|
257
|
+
contract.nonGoals.length > 0 ? contract.nonGoals.map((item: string) => `- ${item}`).join("\n") : "- ";
|
|
258
|
+
|
|
259
|
+
return [
|
|
260
|
+
`Name: ${contract.objective}`,
|
|
261
|
+
`Hypothesis: ${contract.hypothesis}`,
|
|
262
|
+
`Acceptance: ${acceptance}`,
|
|
263
|
+
"Non-goals:",
|
|
264
|
+
nonGoals,
|
|
265
|
+
].join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
117
268
|
function safeMessage(error: unknown) {
|
|
118
269
|
return (error instanceof Error ? error.message : String(error))
|
|
119
270
|
.replace(/[\u0000-\u001f\u007f]+/gu, " ")
|
|
@@ -122,11 +273,14 @@ function safeMessage(error: unknown) {
|
|
|
122
273
|
|
|
123
274
|
export default function workflowControls(pi: ExtensionAPI) {
|
|
124
275
|
let scope = emptyScope(canonicalRoot(process.cwd()));
|
|
276
|
+
let latestTaskContract: any | undefined;
|
|
125
277
|
let activeChallenge: ActiveChallenge | undefined;
|
|
126
278
|
let latestChallenge: ChallengeEntryData | undefined;
|
|
279
|
+
let taskContractError: string | undefined;
|
|
127
280
|
let observedToolFailures = 0;
|
|
128
281
|
let experimentBusy = false;
|
|
129
282
|
let latestSnapshot: any;
|
|
283
|
+
let sessionGeneration = 0;
|
|
130
284
|
const snapshots = new Map<string, any>();
|
|
131
285
|
const supportsEntryRenderer = typeof pi.registerEntryRenderer === "function";
|
|
132
286
|
|
|
@@ -145,16 +299,125 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
145
299
|
return canonicalRoot(cwd);
|
|
146
300
|
};
|
|
147
301
|
|
|
148
|
-
const
|
|
302
|
+
const branchEntries = (ctx: ExtensionContext) => ctx.sessionManager.getBranch?.() ?? [];
|
|
303
|
+
|
|
304
|
+
// Pi contexts expose the live session through getters. Retaining `ctx` across an await does not retain its
|
|
305
|
+
// original branch, and /tree can change that branch without reloading this extension or changing its session ID.
|
|
306
|
+
const captureSession = (ctx: ExtensionContext) => ({
|
|
307
|
+
generation: sessionGeneration,
|
|
308
|
+
cwd: ctx.cwd,
|
|
309
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
310
|
+
});
|
|
311
|
+
const sessionIsCurrent = (origin: ReturnType<typeof captureSession>, ctx: ExtensionContext) =>
|
|
312
|
+
origin.generation === sessionGeneration &&
|
|
313
|
+
origin.cwd === ctx.cwd &&
|
|
314
|
+
origin.sessionId === ctx.sessionManager.getSessionId();
|
|
315
|
+
|
|
316
|
+
const emitTaskContractChanged = (contract: any | undefined, previousDigest?: string) => {
|
|
317
|
+
pi.events.emit("specpi:task-contract-changed", {
|
|
318
|
+
active: Boolean(contract),
|
|
319
|
+
previousDigest,
|
|
320
|
+
digest: contract?.digest,
|
|
321
|
+
id: contract?.id,
|
|
322
|
+
objective: contract?.objective,
|
|
323
|
+
requirements: contract?.requirements?.length ?? 0,
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const invalidateChallenge = (ctx: ExtensionContext, reason = "task contract changed") => {
|
|
328
|
+
const hadEvidence = Boolean(activeChallenge || latestChallenge);
|
|
329
|
+
activeChallenge = undefined;
|
|
330
|
+
latestChallenge = undefined;
|
|
331
|
+
if (hadEvidence) {
|
|
332
|
+
pi.appendEntry<ChallengeEntryData>(CHALLENGE_ENTRY, {
|
|
333
|
+
kind: "cleared",
|
|
334
|
+
generation: randomUUID(),
|
|
335
|
+
createdAt: new Date().toISOString(),
|
|
336
|
+
reason,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return hadEvidence;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const readCurrentTaskContract = (ctx: ExtensionContext, root: string) => {
|
|
344
|
+
const current = readTaskContract(branchEntries(ctx), root);
|
|
345
|
+
taskContractError = undefined;
|
|
346
|
+
|
|
347
|
+
return current;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const refreshTaskContract = (ctx: ExtensionContext, root: string, { invalidate = true } = {}) => {
|
|
351
|
+
let current;
|
|
352
|
+
try {
|
|
353
|
+
current = readCurrentTaskContract(ctx, root);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
taskContractError = safeMessage(error);
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const previousDigest = latestTaskContract?.digest;
|
|
360
|
+
const changed = previousDigest !== current?.digest;
|
|
361
|
+
latestTaskContract = current;
|
|
362
|
+
if (changed) {
|
|
363
|
+
if (
|
|
364
|
+
invalidate &&
|
|
365
|
+
previousDigest !== current?.digest &&
|
|
366
|
+
(previousDigest !== undefined || activeChallenge || latestChallenge)
|
|
367
|
+
) {
|
|
368
|
+
invalidateChallenge(ctx);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
emitTaskContractChanged(current, previousDigest);
|
|
372
|
+
emitScopeStatus(ctx);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return current;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const persistTaskContract = (contract: any | undefined, ctx: ExtensionContext, reason?: string) => {
|
|
379
|
+
const previousDigest = latestTaskContract?.digest;
|
|
380
|
+
if (contract) {
|
|
381
|
+
const validated = validateTaskContract(contract);
|
|
382
|
+
pi.appendEntry<TaskContractEntryData>(TASK_CONTRACT_ENTRY, {
|
|
383
|
+
kind: "set",
|
|
384
|
+
contract: validated,
|
|
385
|
+
createdAt: new Date().toISOString(),
|
|
386
|
+
});
|
|
387
|
+
latestTaskContract = validated;
|
|
388
|
+
} else {
|
|
389
|
+
pi.appendEntry<TaskContractEntryData>(TASK_CONTRACT_ENTRY, {
|
|
390
|
+
kind: "cleared",
|
|
391
|
+
createdAt: new Date().toISOString(),
|
|
392
|
+
reason,
|
|
393
|
+
});
|
|
394
|
+
latestTaskContract = undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const changed = previousDigest !== latestTaskContract?.digest;
|
|
398
|
+
if (changed) {
|
|
399
|
+
invalidateChallenge(ctx, reason ?? "task contract changed");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
emitTaskContractChanged(latestTaskContract, previousDigest);
|
|
403
|
+
if (changed || !contract) {
|
|
404
|
+
emitScopeStatus(ctx);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const emitScopeStatus = (ctx: ExtensionContext, { taskReviewChanged = false } = {}) => {
|
|
409
|
+
const taskStale = scope.taskDigest !== undefined && latestTaskContract?.digest !== scope.taskDigest;
|
|
149
410
|
const summary = scope.active
|
|
150
411
|
? {
|
|
151
412
|
active: true,
|
|
152
413
|
pending: scope.pending.length,
|
|
153
414
|
entries: scope.entries.length,
|
|
154
415
|
indeterminate: scope.indeterminate,
|
|
416
|
+
taskBound: scope.taskDigest !== undefined,
|
|
417
|
+
taskStale,
|
|
155
418
|
}
|
|
156
|
-
: { active: false, pending: 0, entries: 0, indeterminate: false };
|
|
157
|
-
pi.events.emit("specpi:workflow-status", summary);
|
|
419
|
+
: { active: false, pending: 0, entries: 0, indeterminate: false, taskBound: false, taskStale: false };
|
|
420
|
+
pi.events.emit("specpi:workflow-status", taskReviewChanged ? { ...summary, taskReviewChanged: true } : summary);
|
|
158
421
|
if (!scope.active) {
|
|
159
422
|
ctx.ui.setStatus(SCOPE_STATUS, undefined);
|
|
160
423
|
ctx.ui.setWidget(SCOPE_STATUS, undefined);
|
|
@@ -162,19 +425,20 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
162
425
|
return;
|
|
163
426
|
}
|
|
164
427
|
|
|
165
|
-
const label = scope.pending.length > 0 || scope.indeterminate ? "scope: review" : "scope: clean";
|
|
428
|
+
const label = scope.pending.length > 0 || scope.indeterminate || taskStale ? "scope: review" : "scope: clean";
|
|
166
429
|
ctx.ui.setStatus(SCOPE_STATUS, label);
|
|
167
430
|
ctx.ui.setWidget(SCOPE_STATUS, (_tui, theme) => ({
|
|
168
431
|
invalidate() {},
|
|
169
432
|
render(width: number): string[] {
|
|
170
433
|
const pending = scope.pending.length > 0 ? ` · ${scope.pending.length} pending` : "";
|
|
171
434
|
const uncertain = scope.indeterminate ? " · snapshot uncertain" : "";
|
|
435
|
+
const stale = taskStale ? " · task stale" : "";
|
|
172
436
|
|
|
173
437
|
return [
|
|
174
438
|
truncateToWidth(
|
|
175
439
|
theme.fg(
|
|
176
440
|
scope.pending.length > 0 || scope.indeterminate ? "warning" : "dim",
|
|
177
|
-
`scope · ${scope.entries.length} paths · ${scope.observed.length} changed${pending}${uncertain}`,
|
|
441
|
+
`scope · ${scope.entries.length} paths · ${scope.observed.length} changed${pending}${uncertain}${stale}`,
|
|
178
442
|
),
|
|
179
443
|
width,
|
|
180
444
|
"",
|
|
@@ -195,6 +459,7 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
195
459
|
observed: [...scope.observed],
|
|
196
460
|
indeterminate: scope.indeterminate,
|
|
197
461
|
generation: scope.generation,
|
|
462
|
+
taskDigest: scope.taskDigest,
|
|
198
463
|
});
|
|
199
464
|
emitScopeStatus(ctx);
|
|
200
465
|
};
|
|
@@ -233,27 +498,37 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
233
498
|
return undefined;
|
|
234
499
|
}
|
|
235
500
|
|
|
501
|
+
const generation = sessionGeneration;
|
|
502
|
+
const root = scope.root;
|
|
236
503
|
try {
|
|
237
504
|
const result = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
238
|
-
cwd:
|
|
505
|
+
cwd: root,
|
|
239
506
|
timeout: 30_000,
|
|
240
507
|
});
|
|
508
|
+
if (generation !== sessionGeneration) {
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
241
512
|
if (result.code !== 0 || typeof result.stdout !== "string") {
|
|
242
513
|
// A failed status leaves no trustworthy baseline, so the next tool must observe the worktree afresh
|
|
243
514
|
// rather than diffing against a snapshot taken before the gap.
|
|
244
515
|
latestSnapshot = undefined;
|
|
245
516
|
|
|
246
|
-
return { root
|
|
517
|
+
return { root, paths: [], fingerprints: {}, indeterminate: true };
|
|
247
518
|
}
|
|
248
519
|
|
|
249
|
-
const snapshot = createWorktreeSnapshot(
|
|
520
|
+
const snapshot = createWorktreeSnapshot(root, result.stdout);
|
|
250
521
|
latestSnapshot = snapshot;
|
|
251
522
|
|
|
252
523
|
return snapshot;
|
|
253
524
|
} catch {
|
|
525
|
+
if (generation !== sessionGeneration) {
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
|
|
254
529
|
latestSnapshot = undefined;
|
|
255
530
|
|
|
256
|
-
return { root
|
|
531
|
+
return { root, paths: [], fingerprints: {}, indeterminate: true };
|
|
257
532
|
}
|
|
258
533
|
};
|
|
259
534
|
|
|
@@ -268,27 +543,45 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
268
543
|
return takeSnapshot();
|
|
269
544
|
};
|
|
270
545
|
|
|
271
|
-
const setScopeEntries = (entries: ScopeItem[], ctx: ExtensionContext) => {
|
|
546
|
+
const setScopeEntries = (entries: ScopeItem[], ctx: ExtensionContext, options: { taskDigest?: string } = {}) => {
|
|
272
547
|
if (entries.length === 0 || entries.length > 40) {
|
|
273
548
|
throw new Error("Scope must contain between 1 and 40 paths");
|
|
274
549
|
}
|
|
275
550
|
|
|
276
551
|
scope.active = true;
|
|
277
552
|
scope.entries = entries;
|
|
278
|
-
|
|
553
|
+
if (options.taskDigest !== undefined) {
|
|
554
|
+
scope.taskDigest = options.taskDigest;
|
|
555
|
+
} else {
|
|
556
|
+
scope.taskDigest = undefined;
|
|
557
|
+
scope.pending = scope.pending.filter((item) => !scopeMatches(entries, item));
|
|
558
|
+
}
|
|
559
|
+
|
|
279
560
|
scope.generation += 1;
|
|
280
561
|
latestSnapshot = undefined;
|
|
281
562
|
persistScope(ctx);
|
|
282
563
|
};
|
|
283
564
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
565
|
+
const restoreSession = async (ctx: ExtensionContext) => {
|
|
566
|
+
sessionGeneration += 1;
|
|
567
|
+
const origin = captureSession(ctx);
|
|
568
|
+
// Retire armed prompts synchronously, before root lookup can yield to a tool call or another branch change.
|
|
569
|
+
scope = emptyScope(path.resolve(origin.cwd));
|
|
570
|
+
latestTaskContract = undefined;
|
|
287
571
|
activeChallenge = undefined;
|
|
288
572
|
latestChallenge = undefined;
|
|
573
|
+
taskContractError = undefined;
|
|
289
574
|
observedToolFailures = 0;
|
|
290
575
|
latestSnapshot = undefined;
|
|
291
576
|
snapshots.clear();
|
|
577
|
+
emitScopeStatus(ctx);
|
|
578
|
+
|
|
579
|
+
const root = await resolveRoot(origin.cwd);
|
|
580
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
scope = emptyScope(root);
|
|
292
585
|
|
|
293
586
|
for (const entry of ctx.sessionManager.getBranch?.() ?? []) {
|
|
294
587
|
if (entry.type !== "custom") {
|
|
@@ -319,6 +612,7 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
319
612
|
: [],
|
|
320
613
|
indeterminate: Boolean(data.indeterminate),
|
|
321
614
|
generation: Number.isInteger(data.generation) ? data.generation : 0,
|
|
615
|
+
taskDigest: typeof data.taskDigest === "string" ? data.taskDigest : undefined,
|
|
322
616
|
};
|
|
323
617
|
} else if (data?.active === false) {
|
|
324
618
|
scope = emptyScope(root);
|
|
@@ -333,14 +627,36 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
333
627
|
}
|
|
334
628
|
}
|
|
335
629
|
|
|
630
|
+
try {
|
|
631
|
+
latestTaskContract = readCurrentTaskContract(ctx, root);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
taskContractError = safeMessage(error);
|
|
634
|
+
ctx.ui.notify(`Task contract unavailable: ${taskContractError}`, "error");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (
|
|
638
|
+
latestChallenge &&
|
|
639
|
+
latestChallenge.facts?.taskContractDigest !== latestTaskContract?.digest &&
|
|
640
|
+
(latestTaskContract || latestChallenge.facts?.taskContractDigest !== undefined)
|
|
641
|
+
) {
|
|
642
|
+
latestChallenge = undefined;
|
|
643
|
+
}
|
|
644
|
+
|
|
336
645
|
emitScopeStatus(ctx);
|
|
337
|
-
}
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
pi.on("session_start", (_event, ctx) => restoreSession(ctx));
|
|
649
|
+
pi.on("session_tree", (_event, ctx) => restoreSession(ctx));
|
|
338
650
|
|
|
339
651
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
652
|
+
sessionGeneration += 1;
|
|
340
653
|
snapshots.clear();
|
|
341
654
|
latestSnapshot = undefined;
|
|
655
|
+
latestTaskContract = undefined;
|
|
342
656
|
activeChallenge = undefined;
|
|
657
|
+
latestChallenge = undefined;
|
|
343
658
|
experimentBusy = false;
|
|
659
|
+
taskContractError = undefined;
|
|
344
660
|
ctx.ui.setStatus(SCOPE_STATUS, undefined);
|
|
345
661
|
ctx.ui.setWidget(SCOPE_STATUS, undefined);
|
|
346
662
|
});
|
|
@@ -374,7 +690,11 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
374
690
|
return;
|
|
375
691
|
}
|
|
376
692
|
|
|
377
|
-
|
|
693
|
+
const generation = sessionGeneration;
|
|
694
|
+
const snapshot = await baselineSnapshot();
|
|
695
|
+
if (generation === sessionGeneration) {
|
|
696
|
+
snapshots.set(event.toolCallId, snapshot);
|
|
697
|
+
}
|
|
378
698
|
});
|
|
379
699
|
|
|
380
700
|
pi.on("tool_call", async (event: any, ctx) => {
|
|
@@ -399,6 +719,7 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
399
719
|
|
|
400
720
|
const originalPath = event.input.path;
|
|
401
721
|
const generation = scope.generation;
|
|
722
|
+
const origin = captureSession(ctx);
|
|
402
723
|
if (!ctx.hasUI) {
|
|
403
724
|
addPending([relativePath], ctx);
|
|
404
725
|
|
|
@@ -410,7 +731,7 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
410
731
|
"Allow once without expanding scope",
|
|
411
732
|
"Add this path to scope and allow",
|
|
412
733
|
]);
|
|
413
|
-
if (generation !== scope.generation || event.input.path !== originalPath) {
|
|
734
|
+
if (!sessionIsCurrent(origin, ctx) || generation !== scope.generation || event.input.path !== originalPath) {
|
|
414
735
|
return { block: true, reason: "Scope state or tool input changed during acknowledgement" };
|
|
415
736
|
}
|
|
416
737
|
|
|
@@ -456,7 +777,12 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
456
777
|
return;
|
|
457
778
|
}
|
|
458
779
|
|
|
780
|
+
const origin = captureSession(ctx);
|
|
459
781
|
const after = await takeSnapshot();
|
|
782
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
460
786
|
const comparison = compareWorktreeSnapshots(before, after, scope.entries);
|
|
461
787
|
if (comparison.indeterminate) {
|
|
462
788
|
scope.indeterminate = true;
|
|
@@ -484,11 +810,35 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
484
810
|
return { content: [...event.content, { type: "text", text: warning }] };
|
|
485
811
|
});
|
|
486
812
|
|
|
487
|
-
pi.on("before_agent_start", async (event) => {
|
|
813
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
814
|
+
const origin = captureSession(ctx);
|
|
488
815
|
const guidance = [];
|
|
816
|
+
let currentTask;
|
|
817
|
+
try {
|
|
818
|
+
const root = await resolveRoot(origin.cwd);
|
|
819
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
currentTask = refreshTaskContract(ctx, root);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
taskContractError = safeMessage(error);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (currentTask) {
|
|
833
|
+
guidance.push(`[SPECPI TASK CONTRACT]\n${renderTaskContract(currentTask)}`);
|
|
834
|
+
} else if (taskContractError) {
|
|
835
|
+
guidance.push(`[SPECPI TASK CONTRACT]\nUnavailable: ${taskContractError}`);
|
|
836
|
+
}
|
|
837
|
+
|
|
489
838
|
if (scope.active) {
|
|
839
|
+
const taskStale = scope.taskDigest !== undefined && currentTask?.digest !== scope.taskDigest;
|
|
490
840
|
guidance.push(
|
|
491
|
-
`[SPECPI SCOPE]\nDeclared paths: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}\nPending outside-scope paths: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}. Do not describe pending paths as accepted scope
|
|
841
|
+
`[SPECPI SCOPE]\nDeclared paths: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}\nPending outside-scope paths: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}. Do not describe pending paths as accepted scope.${taskStale ? "\nTask-bound scope is stale; do not widen it automatically." : ""}`,
|
|
492
842
|
);
|
|
493
843
|
}
|
|
494
844
|
|
|
@@ -504,30 +854,348 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
504
854
|
return { systemPrompt: `${event.systemPrompt}\n\n${guidance.join("\n\n")}` };
|
|
505
855
|
});
|
|
506
856
|
|
|
857
|
+
const currentSnapshot = async (root: string) => {
|
|
858
|
+
try {
|
|
859
|
+
const status = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
860
|
+
cwd: root,
|
|
861
|
+
timeout: 30_000,
|
|
862
|
+
});
|
|
863
|
+
if (status.code !== 0 || typeof status.stdout !== "string") {
|
|
864
|
+
return { root, paths: [], fingerprints: {}, indeterminate: true, reason: "Git status failed" };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const snapshot = createWorktreeSnapshot(root, status.stdout);
|
|
868
|
+
const fingerprints: Record<string, string> = {};
|
|
869
|
+
for (const relativePath of snapshot.paths) {
|
|
870
|
+
const fingerprint = handoffFingerprint(snapshot.fingerprints[relativePath]);
|
|
871
|
+
const candidate = path.resolve(root, relativePath);
|
|
872
|
+
const candidateRelative = path.relative(root, candidate);
|
|
873
|
+
if (
|
|
874
|
+
candidateRelative === ".." ||
|
|
875
|
+
candidateRelative.startsWith(`..${path.sep}`) ||
|
|
876
|
+
path.isAbsolute(candidateRelative)
|
|
877
|
+
) {
|
|
878
|
+
fingerprints[relativePath] = `type:unknown;mode:unknown;${fingerprint}`;
|
|
879
|
+
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
try {
|
|
884
|
+
const stat = fs.lstatSync(candidate);
|
|
885
|
+
const type = stat.isSymbolicLink()
|
|
886
|
+
? "symlink"
|
|
887
|
+
: stat.isFile()
|
|
888
|
+
? "file"
|
|
889
|
+
: stat.isDirectory()
|
|
890
|
+
? "directory"
|
|
891
|
+
: "other";
|
|
892
|
+
fingerprints[relativePath] = `type:${type};mode:${(stat.mode & 0o7777).toString(8)};${fingerprint}`;
|
|
893
|
+
} catch {
|
|
894
|
+
fingerprints[relativePath] = `type:missing;mode:unknown;${fingerprint}`;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
return { ...snapshot, fingerprints };
|
|
899
|
+
} catch {
|
|
900
|
+
return { root, paths: [], fingerprints: {}, indeterminate: true, reason: "Git status was unavailable" };
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const handoffFingerprint = (value: unknown) => {
|
|
905
|
+
if (typeof value !== "string") {
|
|
906
|
+
return "unavailable";
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (/^[a-f0-9]{64}$/u.test(value)) {
|
|
910
|
+
return `sha256:${value}`;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (value === "missing") {
|
|
914
|
+
return "missing";
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (value.startsWith("symlink:")) {
|
|
918
|
+
return "symlink";
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (/^[0-9]+:[0-9]+:/u.test(value)) {
|
|
922
|
+
return `metadata:${value}`;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return safeMessage(value);
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const renderTaskHandoff = (contract: any, snapshot: any, root: string) => {
|
|
929
|
+
const indeterminate = Boolean(scope.indeterminate || snapshot?.indeterminate);
|
|
930
|
+
const lines = [
|
|
931
|
+
"## Task Handoff",
|
|
932
|
+
"",
|
|
933
|
+
`Generated for task \`${contract.id}\` at root \`${markdownPathLabel(root)}\`.`,
|
|
934
|
+
"",
|
|
935
|
+
renderTaskContract(contract),
|
|
936
|
+
"",
|
|
937
|
+
"### Current changes",
|
|
938
|
+
];
|
|
939
|
+
if (indeterminate) {
|
|
940
|
+
lines.push(
|
|
941
|
+
`- Snapshot indeterminate: ${safeMessage(snapshot?.reason ?? "some changes may be unobserved")}.`,
|
|
942
|
+
"- Change paths and fingerprints below are incomplete.",
|
|
943
|
+
);
|
|
944
|
+
} else if (snapshot?.paths.length === 0) {
|
|
945
|
+
lines.push("- No changed paths observed.");
|
|
946
|
+
} else {
|
|
947
|
+
for (const relativePath of snapshot.paths.slice(0, 256)) {
|
|
948
|
+
lines.push(
|
|
949
|
+
`- \`${markdownPathLabel(relativePath)}\` — ${handoffFingerprint(snapshot.fingerprints[relativePath])}`,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const violations = indeterminate ? [] : taskContractScopeViolations(contract, snapshot.paths);
|
|
955
|
+
lines.push("", "### Latest review");
|
|
956
|
+
if (!latestChallenge?.result) {
|
|
957
|
+
lines.push("- No completion challenge review is recorded for this task contract.");
|
|
958
|
+
} else {
|
|
959
|
+
lines.push(`- Verdict: **${latestChallenge.result.verdict}**.`);
|
|
960
|
+
for (const item of latestChallenge.result.requirements ?? []) {
|
|
961
|
+
const id = item.id ? `${item.id}: ` : "";
|
|
962
|
+
lines.push(`- ${id}${item.status}: ${safeMessage(item.evidence || "No evidence recorded")}`);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (latestChallenge.result.validationGaps?.length) {
|
|
966
|
+
lines.push(`- Validation gaps: ${latestChallenge.result.validationGaps.map(safeMessage).join("; ")}`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
lines.push("", "### Unresolved facts");
|
|
971
|
+
const unresolved = [];
|
|
972
|
+
if (scope.pending.length > 0) {
|
|
973
|
+
unresolved.push(`Pending scope findings: ${scope.pending.map(markdownPathLabel).join(", ")}`);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (violations.length > 0) {
|
|
977
|
+
unresolved.push(`Task paths outside declared paths: ${violations.map(markdownPathLabel).join(", ")}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (scope.taskDigest !== undefined && scope.taskDigest !== contract.digest) {
|
|
981
|
+
unresolved.push(
|
|
982
|
+
"The imported scope is bound to an older task contract digest; it was not widened automatically.",
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (indeterminate) {
|
|
987
|
+
unresolved.push("The current worktree snapshot may omit changed paths or fingerprints.");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (latestChallenge?.result) {
|
|
991
|
+
for (const item of latestChallenge.result.requirements ?? []) {
|
|
992
|
+
if (item.status !== "proven") {
|
|
993
|
+
unresolved.push(`${item.id ? `${item.id} ` : ""}requirement remains ${item.status}.`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
unresolved.push(
|
|
998
|
+
...(latestChallenge.result.contradictions ?? []).map((item: string) => `Contradiction: ${item}`),
|
|
999
|
+
);
|
|
1000
|
+
unresolved.push(
|
|
1001
|
+
...(latestChallenge.result.residualRisks ?? []).map((item: string) => `Residual risk: ${item}`),
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
lines.push(
|
|
1006
|
+
...(unresolved.length > 0
|
|
1007
|
+
? unresolved.map((item) => `- ${safeMessage(item)}`)
|
|
1008
|
+
: ["- No unresolved facts recorded."]),
|
|
1009
|
+
);
|
|
1010
|
+
lines.push(
|
|
1011
|
+
"",
|
|
1012
|
+
"> This handoff is a bounded view of the current conversation state. It does not launch, export, or commit work.",
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
return lines.join("\n");
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
pi.registerCommand("task", {
|
|
1019
|
+
description: "Set, inspect, or hand off the current task contract",
|
|
1020
|
+
getArgumentCompletions: (prefix: string) =>
|
|
1021
|
+
["set", "clear", "status", "handoff"]
|
|
1022
|
+
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
1023
|
+
.map((value) => ({ value, label: value })),
|
|
1024
|
+
handler: async (args, ctx) => {
|
|
1025
|
+
const origin = captureSession(ctx);
|
|
1026
|
+
const action = args.trim().toLowerCase() || "status";
|
|
1027
|
+
try {
|
|
1028
|
+
const root = await resolveRoot(origin.cwd);
|
|
1029
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const current = refreshTaskContract(ctx, root);
|
|
1034
|
+
if (action === "status") {
|
|
1035
|
+
if (!current) {
|
|
1036
|
+
ctx.ui.notify("No task contract is active for this project.", "info");
|
|
1037
|
+
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
ctx.ui.notify(
|
|
1042
|
+
`Task ${current.id.slice(0, 8)}: ${current.objective}; ${current.requirements.length} fixed requirement(s); digest ${current.digest.slice(0, 12)}.`,
|
|
1043
|
+
"info",
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (action === "clear") {
|
|
1050
|
+
persistTaskContract(undefined, ctx, "task contract cleared by human");
|
|
1051
|
+
ctx.ui.notify(
|
|
1052
|
+
"Task contract cleared. Existing scope remains unchanged and is reported as stale if it was task-bound.",
|
|
1053
|
+
"warning",
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (action === "set") {
|
|
1060
|
+
if (!ctx.hasUI || typeof ctx.ui.editor !== "function") {
|
|
1061
|
+
ctx.ui.notify("/task set requires interactive editor support.", "error");
|
|
1062
|
+
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const edited = await ctx.ui.editor("Task contract", taskContractEditorText(current));
|
|
1067
|
+
if (!sessionIsCurrent(origin, ctx) || edited === undefined) {
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const card = parseTaskContractCard(edited);
|
|
1072
|
+
const contractOrigin = current?.origin === "improvement" ? "improvement" : "human";
|
|
1073
|
+
const contract = createTaskContract(card, {
|
|
1074
|
+
root,
|
|
1075
|
+
origin: contractOrigin,
|
|
1076
|
+
...(contractOrigin === "improvement"
|
|
1077
|
+
? { gapId: current.gapId, selectionId: current.selectionId, id: current.id }
|
|
1078
|
+
: current
|
|
1079
|
+
? { id: current.id }
|
|
1080
|
+
: {}),
|
|
1081
|
+
});
|
|
1082
|
+
persistTaskContract(contract, ctx, "task contract revised by human");
|
|
1083
|
+
ctx.ui.notify(
|
|
1084
|
+
`Task contract set: ${contract.objective} (${contract.requirements.length} requirement(s)).`,
|
|
1085
|
+
"info",
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (action === "handoff") {
|
|
1092
|
+
if (!current) {
|
|
1093
|
+
ctx.ui.notify("No task contract is active for this project.", "error");
|
|
1094
|
+
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const snapshot = await currentSnapshot(root);
|
|
1099
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const markdown = renderTaskHandoff(current, snapshot, root);
|
|
1104
|
+
const data: TaskHandoffEntryData = {
|
|
1105
|
+
markdown,
|
|
1106
|
+
contractId: current.id,
|
|
1107
|
+
contractDigest: current.digest,
|
|
1108
|
+
indeterminate: Boolean(scope.indeterminate || snapshot.indeterminate),
|
|
1109
|
+
createdAt: new Date().toISOString(),
|
|
1110
|
+
};
|
|
1111
|
+
pi.appendEntry(TASK_HANDOFF_ENTRY, data);
|
|
1112
|
+
if (!supportsEntryRenderer && typeof ctx.ui.editor === "function") {
|
|
1113
|
+
await ctx.ui.editor("Task handoff (view only)", markdown);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
ctx.ui.notify("Task handoff rendered in the current conversation.", "info");
|
|
1117
|
+
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
ctx.ui.notify("Usage: /task [set|clear|status|handoff]", "error");
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
taskContractError = safeMessage(error);
|
|
1128
|
+
ctx.ui.notify(taskContractError, "error");
|
|
1129
|
+
}
|
|
1130
|
+
},
|
|
1131
|
+
});
|
|
1132
|
+
|
|
507
1133
|
pi.registerCommand("scope", {
|
|
508
1134
|
description: "Declare expected project paths and review scope drift",
|
|
509
1135
|
getArgumentCompletions: (prefix: string) =>
|
|
510
|
-
["set", "add", "remove", "accept", "recheck", "status", "clear"]
|
|
1136
|
+
["set", "task", "add", "remove", "accept", "recheck", "status", "clear"]
|
|
511
1137
|
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
512
1138
|
.map((value) => ({ value, label: value })),
|
|
513
1139
|
handler: async (args, ctx) => {
|
|
1140
|
+
const origin = captureSession(ctx);
|
|
514
1141
|
const [actionRaw, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
|
|
515
1142
|
const action = actionRaw?.toLowerCase() || (scope.active ? "status" : "set");
|
|
516
1143
|
const requestedPath = rest.join(" ");
|
|
517
1144
|
try {
|
|
1145
|
+
if (action === "task") {
|
|
1146
|
+
const root = await resolveRoot(origin.cwd);
|
|
1147
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const contract = refreshTaskContract(ctx, root);
|
|
1152
|
+
if (!contract) {
|
|
1153
|
+
ctx.ui.notify("No task contract is active for this project.", "error");
|
|
1154
|
+
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (contract.paths.length === 0) {
|
|
1159
|
+
ctx.ui.notify("The active task contract declares no importable paths.", "error");
|
|
1160
|
+
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const entries = normalizeScopeEntries(root, contract.paths);
|
|
1165
|
+
setScopeEntries(entries, ctx, { taskDigest: contract.digest });
|
|
1166
|
+
ctx.ui.notify(
|
|
1167
|
+
`Scope imported from task ${contract.id.slice(0, 8)}. Existing pending findings were preserved; run /scope status to review them.`,
|
|
1168
|
+
scope.pending.length > 0 ? "warning" : "info",
|
|
1169
|
+
);
|
|
1170
|
+
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
518
1174
|
if (action === "status") {
|
|
1175
|
+
const root = await resolveRoot(origin.cwd);
|
|
1176
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
refreshTaskContract(ctx, root);
|
|
1181
|
+
const taskStale = scope.taskDigest !== undefined && latestTaskContract?.digest !== scope.taskDigest;
|
|
519
1182
|
ctx.ui.notify(
|
|
520
1183
|
scope.active
|
|
521
|
-
? `Scope: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}; pending: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}; snapshot: ${scope.indeterminate ? "indeterminate" : "observed"}.`
|
|
1184
|
+
? `Scope: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}; pending: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}; snapshot: ${scope.indeterminate ? "indeterminate" : "observed"}; task binding: ${scope.taskDigest ? (taskStale ? "stale" : "current") : "manual"}.`
|
|
522
1185
|
: "Scope monitoring is inactive.",
|
|
523
|
-
scope.pending.length > 0 || scope.indeterminate ? "warning" : "info",
|
|
1186
|
+
scope.pending.length > 0 || scope.indeterminate || taskStale ? "warning" : "info",
|
|
524
1187
|
);
|
|
525
1188
|
|
|
526
1189
|
return;
|
|
527
1190
|
}
|
|
528
1191
|
|
|
529
1192
|
if (action === "clear") {
|
|
530
|
-
|
|
1193
|
+
const root = await resolveRoot(origin.cwd);
|
|
1194
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
scope = emptyScope(root);
|
|
531
1199
|
scope.generation += 1;
|
|
532
1200
|
// Nothing is observed while scope is off, so the cached baseline is stale the moment it is cleared;
|
|
533
1201
|
// reactivating later must start from a fresh snapshot rather than blame the unmonitored gap on the
|
|
@@ -550,7 +1218,7 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
550
1218
|
.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`)
|
|
551
1219
|
.join("\n");
|
|
552
1220
|
const edited = await ctx.ui.editor("Scope paths — one project-relative path per line", initial);
|
|
553
|
-
if (edited === undefined) {
|
|
1221
|
+
if (!sessionIsCurrent(origin, ctx) || edited === undefined) {
|
|
554
1222
|
return;
|
|
555
1223
|
}
|
|
556
1224
|
|
|
@@ -582,6 +1250,10 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
582
1250
|
// side effect of the next successful comparison. Re-baselining here is that act.
|
|
583
1251
|
latestSnapshot = undefined;
|
|
584
1252
|
const rebaselined = await takeSnapshot();
|
|
1253
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
585
1257
|
scope.indeterminate = Boolean(rebaselined?.indeterminate);
|
|
586
1258
|
scope.generation += 1;
|
|
587
1259
|
persistScope(ctx);
|
|
@@ -630,6 +1302,8 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
630
1302
|
const deactivated = scope.entries.length === 0;
|
|
631
1303
|
if (deactivated) {
|
|
632
1304
|
scope = emptyScope(scope.root);
|
|
1305
|
+
} else {
|
|
1306
|
+
scope.taskDigest = undefined;
|
|
633
1307
|
}
|
|
634
1308
|
|
|
635
1309
|
scope.generation += 1;
|
|
@@ -685,6 +1359,7 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
685
1359
|
}
|
|
686
1360
|
|
|
687
1361
|
scope.active = true;
|
|
1362
|
+
scope.taskDigest = undefined;
|
|
688
1363
|
scope.pending = scope.pending.filter((item) => !scopeMatches([normalized], item));
|
|
689
1364
|
scope.generation += 1;
|
|
690
1365
|
persistScope(ctx);
|
|
@@ -739,7 +1414,8 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
739
1414
|
}
|
|
740
1415
|
}
|
|
741
1416
|
|
|
742
|
-
const
|
|
1417
|
+
const currentTask = refreshTaskContract(ctx, repository.repoRoot);
|
|
1418
|
+
const template = experimentCardEditorText(currentTask, query);
|
|
743
1419
|
const edited = await ctx.ui.editor("Experiment card", template);
|
|
744
1420
|
if (edited === undefined) {
|
|
745
1421
|
return;
|
|
@@ -951,7 +1627,8 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
951
1627
|
requirements: Type.Array(
|
|
952
1628
|
Type.Object(
|
|
953
1629
|
{
|
|
954
|
-
|
|
1630
|
+
id: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })),
|
|
1631
|
+
requirement: Type.Optional(Type.String({ minLength: 1, maxLength: 360 })),
|
|
955
1632
|
status: StringEnum(["proven", "partial", "unproven"] as const),
|
|
956
1633
|
evidence: Type.String({ maxLength: 600 }),
|
|
957
1634
|
},
|
|
@@ -965,6 +1642,7 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
965
1642
|
validationGaps: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
|
|
966
1643
|
residualRisks: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
|
|
967
1644
|
nextAction: Type.String({ maxLength: 500 }),
|
|
1645
|
+
taskContractDigest: Type.Optional(Type.String({ minLength: 64, maxLength: 64 })),
|
|
968
1646
|
},
|
|
969
1647
|
{ additionalProperties: false },
|
|
970
1648
|
);
|
|
@@ -986,7 +1664,26 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
986
1664
|
throw new Error("No matching completion challenge is active in this session");
|
|
987
1665
|
}
|
|
988
1666
|
|
|
989
|
-
|
|
1667
|
+
let currentTask;
|
|
1668
|
+
try {
|
|
1669
|
+
currentTask = readCurrentTaskContract(ctx, activeChallenge.root);
|
|
1670
|
+
} catch {
|
|
1671
|
+
invalidateChallenge(ctx, "task contract could not be revalidated");
|
|
1672
|
+
|
|
1673
|
+
throw new Error("Task contract could not be revalidated for this challenge");
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if (currentTask?.digest !== activeChallenge.taskContractDigest) {
|
|
1677
|
+
invalidateChallenge(ctx, "task contract changed during challenge");
|
|
1678
|
+
|
|
1679
|
+
throw new Error("No matching completion challenge is active for the current task contract");
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const result = validateChallengeSubmission(params, {
|
|
1683
|
+
...activeChallenge.facts,
|
|
1684
|
+
challengeGeneration: activeChallenge.generation,
|
|
1685
|
+
taskContractDigest: activeChallenge.taskContractDigest,
|
|
1686
|
+
});
|
|
990
1687
|
const data: ChallengeEntryData = {
|
|
991
1688
|
kind: "result",
|
|
992
1689
|
generation: activeChallenge.generation,
|
|
@@ -998,6 +1695,7 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
998
1695
|
pi.appendEntry(CHALLENGE_ENTRY, data);
|
|
999
1696
|
latestChallenge = data;
|
|
1000
1697
|
activeChallenge = undefined;
|
|
1698
|
+
emitScopeStatus(ctx, { taskReviewChanged: true });
|
|
1001
1699
|
|
|
1002
1700
|
return {
|
|
1003
1701
|
content: [{ type: "text", text: data.markdown }],
|
|
@@ -1014,8 +1712,26 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
1014
1712
|
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
1015
1713
|
.map((value) => ({ value, label: value })),
|
|
1016
1714
|
handler: async (args, ctx) => {
|
|
1715
|
+
const origin = captureSession(ctx);
|
|
1017
1716
|
const action = args.trim().toLowerCase();
|
|
1018
1717
|
if (action === "status") {
|
|
1718
|
+
try {
|
|
1719
|
+
const root = await resolveRoot(origin.cwd);
|
|
1720
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
refreshTaskContract(ctx, root);
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
ctx.ui.notify(`Task contract unavailable: ${safeMessage(error)}`, "error");
|
|
1731
|
+
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1019
1735
|
if (!latestChallenge) {
|
|
1020
1736
|
ctx.ui.notify("No completed challenge exists on this session branch.", "info");
|
|
1021
1737
|
} else if (supportsEntryRenderer) {
|
|
@@ -1061,20 +1777,49 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
1061
1777
|
}
|
|
1062
1778
|
|
|
1063
1779
|
let snapshot;
|
|
1780
|
+
let challengeRoot: string | undefined;
|
|
1781
|
+
let challengeTask;
|
|
1782
|
+
let challengeTaskScopeStale = scope.taskDigest !== undefined;
|
|
1783
|
+
try {
|
|
1784
|
+
const root = await resolveRoot(origin.cwd);
|
|
1785
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
challengeRoot = root;
|
|
1790
|
+
challengeTask = refreshTaskContract(ctx, root);
|
|
1791
|
+
challengeTaskScopeStale = scope.taskDigest !== undefined && challengeTask?.digest !== scope.taskDigest;
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
ctx.ui.notify(`Task contract unavailable: ${safeMessage(error)}. Challenge was not started.`, "error");
|
|
1798
|
+
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1064
1802
|
try {
|
|
1065
|
-
const root = await resolveRoot(ctx.cwd);
|
|
1066
1803
|
const status = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
1067
|
-
cwd:
|
|
1804
|
+
cwd: challengeRoot,
|
|
1068
1805
|
timeout: 30_000,
|
|
1069
1806
|
});
|
|
1070
|
-
|
|
1807
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
snapshot = status.code === 0 ? createWorktreeSnapshot(challengeRoot, status.stdout) : undefined;
|
|
1071
1812
|
} catch {
|
|
1072
1813
|
snapshot = undefined;
|
|
1073
1814
|
}
|
|
1074
1815
|
|
|
1816
|
+
if (!sessionIsCurrent(origin, ctx)) {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1075
1820
|
let experiment;
|
|
1076
1821
|
try {
|
|
1077
|
-
experiment = findExperiment(stateDir, "",
|
|
1822
|
+
experiment = findExperiment(stateDir, "", origin.cwd);
|
|
1078
1823
|
} catch {
|
|
1079
1824
|
experiment = undefined;
|
|
1080
1825
|
}
|
|
@@ -1082,18 +1827,23 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
1082
1827
|
const facts = boundedChallengeFacts({
|
|
1083
1828
|
changedPaths: snapshot?.paths ?? [],
|
|
1084
1829
|
scopeEntries: scope.active
|
|
1085
|
-
? scope.entries.map((item) => `${
|
|
1830
|
+
? scope.entries.map((item) => `${item.path}${item.directory ? "/" : ""}`)
|
|
1086
1831
|
: [],
|
|
1087
|
-
pendingScope: scope.active ? scope.pending
|
|
1832
|
+
pendingScope: scope.active ? scope.pending : [],
|
|
1088
1833
|
experiment,
|
|
1089
1834
|
observedToolFailures,
|
|
1090
|
-
snapshotIndeterminate: !snapshot || snapshot.indeterminate,
|
|
1835
|
+
snapshotIndeterminate: scope.indeterminate || !snapshot || snapshot.indeterminate,
|
|
1836
|
+
taskContract: challengeTask,
|
|
1837
|
+
taskContractDigest: challengeTask?.digest,
|
|
1838
|
+
scopeTaskStale: challengeTaskScopeStale,
|
|
1091
1839
|
});
|
|
1092
1840
|
const generation = randomUUID();
|
|
1093
1841
|
const prompt = challengePrompt(generation, facts);
|
|
1094
1842
|
activeChallenge = {
|
|
1095
1843
|
generation,
|
|
1096
|
-
sessionId:
|
|
1844
|
+
sessionId: origin.sessionId,
|
|
1845
|
+
root: challengeRoot ?? scope.root,
|
|
1846
|
+
taskContractDigest: facts.taskContractDigest,
|
|
1097
1847
|
facts,
|
|
1098
1848
|
prompt,
|
|
1099
1849
|
delivered: false,
|
|
@@ -1117,6 +1867,32 @@ export default function workflowControls(pi: ExtensionAPI) {
|
|
|
1117
1867
|
});
|
|
1118
1868
|
|
|
1119
1869
|
if (supportsEntryRenderer) {
|
|
1870
|
+
pi.registerEntryRenderer<TaskContractEntryData>(TASK_CONTRACT_ENTRY, (entry, _options, theme) => {
|
|
1871
|
+
const data = entry.data;
|
|
1872
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
1873
|
+
if (data?.kind === "set" && data.contract) {
|
|
1874
|
+
try {
|
|
1875
|
+
box.addChild(new Markdown(renderTaskContract(data.contract), 0, 0, getMarkdownTheme()));
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
box.addChild(
|
|
1878
|
+
new Text(theme.fg("warning", `Task contract unavailable: ${safeMessage(error)}`), 0, 0),
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
} else {
|
|
1882
|
+
box.addChild(new Text(theme.fg("dim", "Task contract cleared"), 0, 0));
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
return box;
|
|
1886
|
+
});
|
|
1887
|
+
|
|
1888
|
+
pi.registerEntryRenderer<TaskHandoffEntryData>(TASK_HANDOFF_ENTRY, (entry, _options, theme) => {
|
|
1889
|
+
const data = entry.data;
|
|
1890
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
1891
|
+
box.addChild(new Markdown(data?.markdown ?? "Task handoff unavailable.", 0, 0, getMarkdownTheme()));
|
|
1892
|
+
|
|
1893
|
+
return box;
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1120
1896
|
pi.registerEntryRenderer<ChallengeEntryData>(CHALLENGE_ENTRY, (entry, _options, theme) => {
|
|
1121
1897
|
const data = entry.data;
|
|
1122
1898
|
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|