@groeponline/pi-missions 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/cli/index.d.ts +17 -0
- package/dist/cli/index.js +713 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/database/schema.sql +322 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3970 -0
- package/dist/index.js.map +1 -0
- package/package.json +95 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3970 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/core/types.ts
|
|
12
|
+
var types_exports = {};
|
|
13
|
+
__export(types_exports, {
|
|
14
|
+
CriterionSchema: () => CriterionSchema,
|
|
15
|
+
DEFAULT_AUTOPILOT: () => DEFAULT_AUTOPILOT,
|
|
16
|
+
DEFAULT_FEATURE_MAX_TOOL_CALLS: () => DEFAULT_FEATURE_MAX_TOOL_CALLS,
|
|
17
|
+
DEFAULT_FEATURE_MAX_WALL_CLOCK_MS: () => DEFAULT_FEATURE_MAX_WALL_CLOCK_MS,
|
|
18
|
+
FeatureSchema: () => FeatureSchema,
|
|
19
|
+
MilestoneSchema: () => MilestoneSchema,
|
|
20
|
+
SCHEMA_VERSION: () => SCHEMA_VERSION,
|
|
21
|
+
STALE_FEATURE_WARN_MS: () => STALE_FEATURE_WARN_MS,
|
|
22
|
+
TOOL_POLICIES: () => TOOL_POLICIES,
|
|
23
|
+
WizardCriterionSchema: () => WizardCriterionSchema,
|
|
24
|
+
WizardFeatureSchema: () => WizardFeatureSchema,
|
|
25
|
+
WizardMilestoneSchema: () => WizardMilestoneSchema,
|
|
26
|
+
WizardOutputSchema: () => WizardOutputSchema,
|
|
27
|
+
formatValidationErrors: () => formatValidationErrors,
|
|
28
|
+
validate: () => validate
|
|
29
|
+
});
|
|
30
|
+
import { Type } from "@sinclair/typebox";
|
|
31
|
+
import { Value } from "@sinclair/typebox/value";
|
|
32
|
+
function validate(schema, value) {
|
|
33
|
+
try {
|
|
34
|
+
if (Value.Check(schema, value)) return { valid: true, errors: [] };
|
|
35
|
+
const errors = [];
|
|
36
|
+
for (const error of Value.Errors(schema, value)) {
|
|
37
|
+
errors.push({ path: error.path, message: error.message, value: error.value });
|
|
38
|
+
}
|
|
39
|
+
return { valid: false, errors };
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return {
|
|
42
|
+
valid: false,
|
|
43
|
+
errors: [{ path: "root", message: e instanceof Error ? e.message : String(e), value }]
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function formatValidationErrors(errors) {
|
|
48
|
+
if (errors.length === 0) return "";
|
|
49
|
+
const lines = ["Validation errors:"];
|
|
50
|
+
for (const e of errors.slice(0, 10)) {
|
|
51
|
+
lines.push(` - ${e.path}: ${e.message}`);
|
|
52
|
+
if (e.value !== void 0) lines.push(` (value: ${JSON.stringify(e.value).slice(0, 50)})`);
|
|
53
|
+
}
|
|
54
|
+
if (errors.length > 10) lines.push(` ... and ${errors.length - 10} more errors`);
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
var SCHEMA_VERSION, DEFAULT_FEATURE_MAX_WALL_CLOCK_MS, DEFAULT_FEATURE_MAX_TOOL_CALLS, STALE_FEATURE_WARN_MS, DEFAULT_AUTOPILOT, TOOL_POLICIES, CriterionSchema, FeatureSchema, MilestoneSchema, WizardCriterionSchema, WizardFeatureSchema, WizardMilestoneSchema, WizardOutputSchema;
|
|
58
|
+
var init_types = __esm({
|
|
59
|
+
"src/core/types.ts"() {
|
|
60
|
+
"use strict";
|
|
61
|
+
SCHEMA_VERSION = 3;
|
|
62
|
+
DEFAULT_FEATURE_MAX_WALL_CLOCK_MS = 30 * 60 * 1e3;
|
|
63
|
+
DEFAULT_FEATURE_MAX_TOOL_CALLS = 150;
|
|
64
|
+
STALE_FEATURE_WARN_MS = 20 * 60 * 1e3;
|
|
65
|
+
DEFAULT_AUTOPILOT = {
|
|
66
|
+
enabled: false,
|
|
67
|
+
mode: "manual",
|
|
68
|
+
iteration: 0,
|
|
69
|
+
maxIterations: 25,
|
|
70
|
+
consecutiveFailures: 0,
|
|
71
|
+
maxConsecutiveFailures: 3,
|
|
72
|
+
noProgressTurns: 0,
|
|
73
|
+
maxNoProgressTurns: 3,
|
|
74
|
+
maxContextPercent: 85,
|
|
75
|
+
startedAt: "",
|
|
76
|
+
continueAcrossFeatures: true,
|
|
77
|
+
requireEvidenceForDone: true
|
|
78
|
+
};
|
|
79
|
+
TOOL_POLICIES = {
|
|
80
|
+
planning: {
|
|
81
|
+
phase: "planning",
|
|
82
|
+
allowedTools: [
|
|
83
|
+
"read",
|
|
84
|
+
"grep",
|
|
85
|
+
"find",
|
|
86
|
+
"ls",
|
|
87
|
+
"mission_next_feature",
|
|
88
|
+
"mission_feature_done",
|
|
89
|
+
"mission_ask_user",
|
|
90
|
+
"mission_block_self",
|
|
91
|
+
"mission_fork",
|
|
92
|
+
"mission_error_status",
|
|
93
|
+
"mission_retry_error",
|
|
94
|
+
"mission_spawn_worker",
|
|
95
|
+
"mission_worker_status",
|
|
96
|
+
"mission_kill_worker"
|
|
97
|
+
],
|
|
98
|
+
maxToolCalls: 30
|
|
99
|
+
},
|
|
100
|
+
execution: {
|
|
101
|
+
phase: "execution",
|
|
102
|
+
allowedTools: [
|
|
103
|
+
"read",
|
|
104
|
+
"write",
|
|
105
|
+
"edit",
|
|
106
|
+
"bash",
|
|
107
|
+
"grep",
|
|
108
|
+
"find",
|
|
109
|
+
"ls",
|
|
110
|
+
"mission_next_feature",
|
|
111
|
+
"mission_feature_done",
|
|
112
|
+
"mission_ask_user",
|
|
113
|
+
"mission_block_self",
|
|
114
|
+
"mission_fork",
|
|
115
|
+
"mission_error_status",
|
|
116
|
+
"mission_retry_error",
|
|
117
|
+
"mission_spawn_worker",
|
|
118
|
+
"mission_worker_status",
|
|
119
|
+
"mission_kill_worker"
|
|
120
|
+
],
|
|
121
|
+
maxToolCalls: 120
|
|
122
|
+
},
|
|
123
|
+
verification: {
|
|
124
|
+
phase: "verification",
|
|
125
|
+
allowedTools: [
|
|
126
|
+
"read",
|
|
127
|
+
"bash",
|
|
128
|
+
"grep",
|
|
129
|
+
"find",
|
|
130
|
+
"ls",
|
|
131
|
+
"mission_next_feature",
|
|
132
|
+
"mission_feature_done",
|
|
133
|
+
"mission_ask_user",
|
|
134
|
+
"mission_block_self",
|
|
135
|
+
"mission_fork",
|
|
136
|
+
"mission_error_status",
|
|
137
|
+
"mission_retry_error",
|
|
138
|
+
"mission_spawn_worker",
|
|
139
|
+
"mission_worker_status",
|
|
140
|
+
"mission_kill_worker"
|
|
141
|
+
],
|
|
142
|
+
maxToolCalls: 60
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
CriterionSchema = Type.Object({
|
|
146
|
+
id: Type.String({ minLength: 1, maxLength: 50 }),
|
|
147
|
+
description: Type.String({ minLength: 1, maxLength: 500 }),
|
|
148
|
+
checkType: Type.Union([Type.Literal("manual"), Type.Literal("bash"), Type.Literal("test_file")]),
|
|
149
|
+
checkCommand: Type.Optional(Type.String({ maxLength: 1e3 })),
|
|
150
|
+
evidence: Type.Optional(Type.String()),
|
|
151
|
+
verified: Type.Boolean(),
|
|
152
|
+
waived: Type.Optional(Type.Boolean())
|
|
153
|
+
});
|
|
154
|
+
FeatureSchema = Type.Object({
|
|
155
|
+
id: Type.String({ pattern: "^F[0-9]{3}$" }),
|
|
156
|
+
milestoneId: Type.String({ pattern: "^M[0-9]{2}$" }),
|
|
157
|
+
title: Type.String({ minLength: 1, maxLength: 200 }),
|
|
158
|
+
description: Type.String({ minLength: 1, maxLength: 2e3 }),
|
|
159
|
+
priority: Type.Integer({ minimum: 1, maximum: 5 }),
|
|
160
|
+
dependsOn: Type.Array(Type.String({ pattern: "^F[0-9]{3}$" })),
|
|
161
|
+
acceptance: Type.Array(CriterionSchema, { minItems: 1 }),
|
|
162
|
+
status: Type.Union([
|
|
163
|
+
Type.Literal("pending"),
|
|
164
|
+
Type.Literal("waiting"),
|
|
165
|
+
Type.Literal("active"),
|
|
166
|
+
Type.Literal("done"),
|
|
167
|
+
Type.Literal("blocked"),
|
|
168
|
+
Type.Literal("failed")
|
|
169
|
+
]),
|
|
170
|
+
sessions: Type.Array(Type.String()),
|
|
171
|
+
toolCallCount: Type.Integer({ minimum: 0 }),
|
|
172
|
+
tokensUsed: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
173
|
+
startedAt: Type.Optional(Type.Integer()),
|
|
174
|
+
completedAt: Type.Optional(Type.Integer()),
|
|
175
|
+
maxWallClockMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
176
|
+
maxToolCalls: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
177
|
+
notes: Type.Optional(Type.String({ maxLength: 1e3 }))
|
|
178
|
+
}, { additionalProperties: false });
|
|
179
|
+
MilestoneSchema = Type.Object({
|
|
180
|
+
id: Type.String({ pattern: "^M[0-9]{2}$" }),
|
|
181
|
+
title: Type.String({ minLength: 1, maxLength: 200 }),
|
|
182
|
+
description: Type.String({ maxLength: 1e3 }),
|
|
183
|
+
status: Type.Union([Type.Literal("pending"), Type.Literal("active"), Type.Literal("complete")]),
|
|
184
|
+
features: Type.Array(FeatureSchema, { minItems: 1 }),
|
|
185
|
+
dependsOn: Type.Optional(Type.Array(Type.String({ pattern: "^M[0-9]{2}$" })))
|
|
186
|
+
});
|
|
187
|
+
WizardCriterionSchema = Type.Object({
|
|
188
|
+
id: Type.Optional(Type.String({ minLength: 1, maxLength: 50 })),
|
|
189
|
+
description: Type.String({ minLength: 1, maxLength: 500 }),
|
|
190
|
+
checkType: Type.Union([Type.Literal("manual"), Type.Literal("bash"), Type.Literal("test_file")]),
|
|
191
|
+
checkCommand: Type.Optional(Type.String({ maxLength: 1e3 }))
|
|
192
|
+
}, { additionalProperties: false });
|
|
193
|
+
WizardFeatureSchema = Type.Object({
|
|
194
|
+
id: Type.Optional(Type.String({ pattern: "^F[0-9]{3}$" })),
|
|
195
|
+
title: Type.String({ minLength: 1, maxLength: 200 }),
|
|
196
|
+
description: Type.String({ minLength: 1, maxLength: 2e3 }),
|
|
197
|
+
priority: Type.Integer({ minimum: 1, maximum: 5 }),
|
|
198
|
+
dependsOn: Type.Array(Type.String({ pattern: "^F[0-9]{3}$" })),
|
|
199
|
+
acceptance: Type.Array(WizardCriterionSchema, { minItems: 1 })
|
|
200
|
+
}, { additionalProperties: false });
|
|
201
|
+
WizardMilestoneSchema = Type.Object({
|
|
202
|
+
id: Type.Optional(Type.String({ pattern: "^M[0-9]{2}$" })),
|
|
203
|
+
title: Type.String({ minLength: 1, maxLength: 200 }),
|
|
204
|
+
description: Type.String({ maxLength: 1e3 }),
|
|
205
|
+
features: Type.Array(WizardFeatureSchema, { minItems: 1 })
|
|
206
|
+
}, { additionalProperties: false });
|
|
207
|
+
WizardOutputSchema = Type.Object({
|
|
208
|
+
title: Type.String({ minLength: 1, maxLength: 200 }),
|
|
209
|
+
milestones: Type.Array(WizardMilestoneSchema, { minItems: 2, maxItems: 20 })
|
|
210
|
+
}, { additionalProperties: false });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// src/core/extension.ts
|
|
215
|
+
import { fileURLToPath } from "url";
|
|
216
|
+
import { dirname as dirname2, resolve as resolve2 } from "path";
|
|
217
|
+
import * as fs4 from "fs";
|
|
218
|
+
|
|
219
|
+
// src/core/state.ts
|
|
220
|
+
import * as fs2 from "fs";
|
|
221
|
+
import * as fsAsync from "fs/promises";
|
|
222
|
+
import * as path3 from "path";
|
|
223
|
+
import * as lockfile from "proper-lockfile";
|
|
224
|
+
|
|
225
|
+
// src/utils/markdown.ts
|
|
226
|
+
init_types();
|
|
227
|
+
|
|
228
|
+
// src/utils/fs.ts
|
|
229
|
+
import * as os from "os";
|
|
230
|
+
import * as path from "path";
|
|
231
|
+
import * as crypto from "crypto";
|
|
232
|
+
function missionsRoot() {
|
|
233
|
+
if (process.env.MISSIONS_ROOT) {
|
|
234
|
+
if (!path.isAbsolute(process.env.MISSIONS_ROOT)) {
|
|
235
|
+
throw new Error(`MISSIONS_ROOT must be an absolute path, got: ${process.env.MISSIONS_ROOT}`);
|
|
236
|
+
}
|
|
237
|
+
return process.env.MISSIONS_ROOT;
|
|
238
|
+
}
|
|
239
|
+
if (process.env.PI_MISSIONS_ROOT) {
|
|
240
|
+
if (!path.isAbsolute(process.env.PI_MISSIONS_ROOT)) {
|
|
241
|
+
throw new Error(`PI_MISSIONS_ROOT must be an absolute path, got: ${process.env.PI_MISSIONS_ROOT}`);
|
|
242
|
+
}
|
|
243
|
+
return process.env.PI_MISSIONS_ROOT;
|
|
244
|
+
}
|
|
245
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
246
|
+
return path.join(home, ".pi", "missions");
|
|
247
|
+
}
|
|
248
|
+
function missionDirSafe(id) {
|
|
249
|
+
const root = path.resolve(missionsRoot());
|
|
250
|
+
const safeId = id.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
251
|
+
const resolved = path.resolve(root, safeId);
|
|
252
|
+
if (!resolved.startsWith(root + path.sep)) {
|
|
253
|
+
throw new Error(`Invalid mission id: path traversal detected (${id})`);
|
|
254
|
+
}
|
|
255
|
+
return resolved;
|
|
256
|
+
}
|
|
257
|
+
function createMissionId(title, now = Date.now()) {
|
|
258
|
+
const date = new Date(now).toISOString().replace(/[-:T.Z]/g, "");
|
|
259
|
+
const stamp = date.slice(0, 17);
|
|
260
|
+
const slug = slugify(title);
|
|
261
|
+
return `pim:${stamp}:${slug}`;
|
|
262
|
+
}
|
|
263
|
+
function isValidMissionId(id) {
|
|
264
|
+
return id.startsWith("pim:") && id.split(":").length === 3;
|
|
265
|
+
}
|
|
266
|
+
function createValidationToken() {
|
|
267
|
+
return crypto.randomBytes(32).toString("hex");
|
|
268
|
+
}
|
|
269
|
+
function slugify(input) {
|
|
270
|
+
return input.toLowerCase().trim().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "mission";
|
|
271
|
+
}
|
|
272
|
+
function sha256(data) {
|
|
273
|
+
return crypto.createHash("sha256").update(data).digest("hex");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/utils/context.ts
|
|
277
|
+
function clip(text, max = 88) {
|
|
278
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
279
|
+
}
|
|
280
|
+
function progressBar(done, total, width = 16) {
|
|
281
|
+
if (total === 0) return `[${"\u2591".repeat(width)}]`;
|
|
282
|
+
const ratio = Math.max(0, Math.min(1, done / total));
|
|
283
|
+
const filled = Math.round(ratio * width);
|
|
284
|
+
return `[${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}]`;
|
|
285
|
+
}
|
|
286
|
+
function featureStatusIcon(status) {
|
|
287
|
+
switch (status) {
|
|
288
|
+
case "done":
|
|
289
|
+
return "\u2705";
|
|
290
|
+
case "active":
|
|
291
|
+
return "\u27A1\uFE0F";
|
|
292
|
+
case "blocked":
|
|
293
|
+
return "\u26D4";
|
|
294
|
+
case "failed":
|
|
295
|
+
return "\u274C";
|
|
296
|
+
case "waiting":
|
|
297
|
+
return "\u23F3";
|
|
298
|
+
default:
|
|
299
|
+
return "\u2022";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function missionStatusIcon(status) {
|
|
303
|
+
switch (status) {
|
|
304
|
+
case "complete":
|
|
305
|
+
return "\u2705";
|
|
306
|
+
case "paused":
|
|
307
|
+
return "\u23F8";
|
|
308
|
+
case "blocked":
|
|
309
|
+
return "\u26D4";
|
|
310
|
+
case "budget_limited":
|
|
311
|
+
return "\u26A0\uFE0F";
|
|
312
|
+
default:
|
|
313
|
+
return "\u{1F3AF}";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function acceptanceProgress(feature) {
|
|
317
|
+
const done = feature.acceptance.filter((ac) => ac.verified || ac.waived).length;
|
|
318
|
+
return { done, total: feature.acceptance.length, label: `${done}/${feature.acceptance.length}` };
|
|
319
|
+
}
|
|
320
|
+
function pendingAcceptance(feature, arrow = "\u2192") {
|
|
321
|
+
return feature.acceptance.filter((ac) => !ac.verified && !ac.waived).map((ac) => ac.checkType === "bash" && ac.checkCommand ? `${ac.id}: ${ac.description} ${arrow} ${ac.checkCommand}` : `${ac.id}: ${ac.description}`);
|
|
322
|
+
}
|
|
323
|
+
function phaseLine(phase) {
|
|
324
|
+
switch (phase) {
|
|
325
|
+
case "planning":
|
|
326
|
+
return "\u{1F50D} Phase: planning \u2014 explore, read, clarify. Avoid writes. Read-only bash.";
|
|
327
|
+
case "verification":
|
|
328
|
+
return "\u2705 Phase: verification \u2014 run checks, capture evidence, report exact gaps.";
|
|
329
|
+
default:
|
|
330
|
+
return "\u{1F527} Phase: execution \u2014 smallest change that satisfies acceptance criteria.";
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function buildMissionBanner(mission) {
|
|
334
|
+
const active = getActiveFeature(mission);
|
|
335
|
+
const p = progress(mission);
|
|
336
|
+
const phase = getMissionPhase(mission);
|
|
337
|
+
const bar = progressBar(p.done, p.total);
|
|
338
|
+
const lines = [
|
|
339
|
+
"## Pi Missions Extension \u2014 Active",
|
|
340
|
+
`Mission: ${mission.title} | Goal: ${mission.goal}`,
|
|
341
|
+
` ${bar} ${p.done}/${p.total} leaf goals (${p.pct}%) \u2014 ${p.done}/${p.total} features`,
|
|
342
|
+
` Status: ${mission.status} | Phase: ${phase}`,
|
|
343
|
+
` State: ~/.pi/missions/${mission.id}/`
|
|
344
|
+
];
|
|
345
|
+
if (active) {
|
|
346
|
+
lines.push("", ` \u25B6 ${active.id}: ${active.title} [${phase}]`, ` ${active.description}`);
|
|
347
|
+
} else {
|
|
348
|
+
lines.push("", " No active feature.");
|
|
349
|
+
}
|
|
350
|
+
return lines.join("\n");
|
|
351
|
+
}
|
|
352
|
+
function buildFeatureBrief(mission, feature, tokenBudget = 250) {
|
|
353
|
+
const phase = getMissionPhase(mission);
|
|
354
|
+
const allFeatures = getAllFeatures(mission);
|
|
355
|
+
const pendingCount = allFeatures.filter((f) => f.status === "pending").length;
|
|
356
|
+
const blockedCount = allFeatures.filter((f) => f.status === "blocked").length;
|
|
357
|
+
const lines = [];
|
|
358
|
+
let used = 0;
|
|
359
|
+
function add(...items) {
|
|
360
|
+
for (const item of items) {
|
|
361
|
+
const cost = item.length / 4;
|
|
362
|
+
if (used + cost > tokenBudget) return;
|
|
363
|
+
lines.push(item);
|
|
364
|
+
used += cost;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const milestoneTitle = mission.milestones.find((m) => m.id === feature.milestoneId)?.title ?? "";
|
|
368
|
+
add(`Goal path: ${milestoneTitle} > ${feature.title}`);
|
|
369
|
+
add("");
|
|
370
|
+
if (feature.acceptance.length) {
|
|
371
|
+
add("**Acceptance:**");
|
|
372
|
+
for (const ac of feature.acceptance) {
|
|
373
|
+
const mark = ac.verified || ac.waived ? "x" : " ";
|
|
374
|
+
const waived = ac.waived ? " (waived)" : "";
|
|
375
|
+
add(`- [${mark}] ${ac.id}: ${ac.description}${waived}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (feature.dependsOn.length) {
|
|
379
|
+
add(`Dependencies: ${feature.dependsOn.join(", ")}`);
|
|
380
|
+
}
|
|
381
|
+
add("");
|
|
382
|
+
add(phaseLine(phase));
|
|
383
|
+
if (pendingCount) add(`\u{1F4CB} ${pendingCount} pending feature(s) queued.`);
|
|
384
|
+
if (blockedCount) add(`\u26D4 ${blockedCount} blocked feature(s).`);
|
|
385
|
+
return lines.join("\n");
|
|
386
|
+
}
|
|
387
|
+
var MISSION_HELP = [
|
|
388
|
+
"### How To Work This Mission",
|
|
389
|
+
"- Work only on the active feature unless the user explicitly redirects you or /mission next changes it.",
|
|
390
|
+
"- During planning: gather context, avoid writes; read-only shell exploration is allowed.",
|
|
391
|
+
"- During execution: make the smallest coherent change that satisfies the mission goal.",
|
|
392
|
+
"- During verification: run relevant checks, capture evidence, and report exact gaps.",
|
|
393
|
+
"- Do not silently mark work complete. Use /mission done or mission_feature_done only with concrete evidence.",
|
|
394
|
+
"- If blocked, use /mission block <reason> or mission_block_self with a clear reason and next option.",
|
|
395
|
+
"",
|
|
396
|
+
"### Mission Commands",
|
|
397
|
+
"- /mission start/new: create a new mission.",
|
|
398
|
+
"- /mission load: resume an existing mission.",
|
|
399
|
+
"- /mission status: show active mission, feature, progress, acceptance criteria.",
|
|
400
|
+
"- /mission next: activate the next unblocked pending feature.",
|
|
401
|
+
"- /mission done: mark the active feature done and save evidence.",
|
|
402
|
+
"- /mission block: block the active feature.",
|
|
403
|
+
"- /mission fork: create a linked alternative feature.",
|
|
404
|
+
"- /mission dashboard: open mission control UI.",
|
|
405
|
+
"- /mission debug: inspect recent mission history.",
|
|
406
|
+
"- /mission metrics: show mission/session metrics.",
|
|
407
|
+
"- /mission templates: create from a built-in template.",
|
|
408
|
+
"- /mission export: export mission report as markdown.",
|
|
409
|
+
"",
|
|
410
|
+
"### Mission Tools",
|
|
411
|
+
"- mission_next_feature: advance to the next pending feature.",
|
|
412
|
+
"- mission_feature_done: mark the active feature done with evidence.",
|
|
413
|
+
"- mission_ask_user: ask for clarification.",
|
|
414
|
+
"- mission_block_self: self-block when stuck.",
|
|
415
|
+
"- mission_fork: split into a linked fork.",
|
|
416
|
+
"- mission_error_status: inspect error recovery state.",
|
|
417
|
+
"- mission_retry_error: retry a retryable recorded error."
|
|
418
|
+
].join("\n");
|
|
419
|
+
function buildMissionHelp() {
|
|
420
|
+
return MISSION_HELP;
|
|
421
|
+
}
|
|
422
|
+
function buildMissionContext(mission) {
|
|
423
|
+
const active = getActiveFeature(mission);
|
|
424
|
+
const banner = buildMissionBanner(mission);
|
|
425
|
+
const brief = active ? buildFeatureBrief(mission, active, 300) : "";
|
|
426
|
+
const help = buildMissionHelp();
|
|
427
|
+
const allFeatures = getAllFeatures(mission);
|
|
428
|
+
const doneRecent = allFeatures.filter((f) => f.status === "done").slice(-3);
|
|
429
|
+
const parts = [banner];
|
|
430
|
+
if (brief) parts.push("", brief);
|
|
431
|
+
parts.push("", help);
|
|
432
|
+
parts.push("", `### Goal Tree`);
|
|
433
|
+
if (active) parts.push(`\u25B6 ${active.title}`);
|
|
434
|
+
if (doneRecent.length) {
|
|
435
|
+
const recent = doneRecent.map((f) => `\u2705 ${f.id} ${f.title}`).join(" | ");
|
|
436
|
+
if (recent) parts.push(`### Recently completed: ${recent}`);
|
|
437
|
+
}
|
|
438
|
+
parts.push("", "Work only on the active feature unless the user or /mission next changes it.");
|
|
439
|
+
return parts.join("\n");
|
|
440
|
+
}
|
|
441
|
+
function buildLeanContext(mission) {
|
|
442
|
+
const active = getActiveFeature(mission);
|
|
443
|
+
if (!active) return `${buildMissionBanner(mission)}
|
|
444
|
+
|
|
445
|
+
No active feature. Use \`/mission status\` for overview.`;
|
|
446
|
+
const acLines = active.acceptance.map((a) => {
|
|
447
|
+
const mark = a.verified || a.waived ? "[x]" : "[ ]";
|
|
448
|
+
return `- ${mark} **${a.id}**: ${a.description}`;
|
|
449
|
+
});
|
|
450
|
+
return `${buildMissionBanner(mission)}
|
|
451
|
+
|
|
452
|
+
${acLines.join("\n")}`;
|
|
453
|
+
}
|
|
454
|
+
function buildCompactionSummary(mission) {
|
|
455
|
+
const active = getActiveFeature(mission);
|
|
456
|
+
const p = progress(mission);
|
|
457
|
+
let blocked = 0;
|
|
458
|
+
let waiting = 0;
|
|
459
|
+
for (const m of mission.milestones) {
|
|
460
|
+
for (const f of m.features) {
|
|
461
|
+
if (f.status === "blocked") blocked++;
|
|
462
|
+
else if (f.status === "waiting") waiting++;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return `Mission: ${mission.title} | ID: ${mission.id}
|
|
466
|
+
Goal: ${mission.goal}
|
|
467
|
+
Progress: ${p.done}/${p.total} features (${p.pct}%) | ${p.done}/${p.total} leaf goals (${p.pct}%) | Status: ${mission.status}
|
|
468
|
+
${active ? `Active: ${active.id} \u2014 ${active.title}` : "Active: none"}
|
|
469
|
+
Blocked/Waiting: ${blocked}/${waiting}
|
|
470
|
+
State: ~/.pi/missions/${mission.id}/
|
|
471
|
+
Resume by loading mission state and continuing the active feature.`;
|
|
472
|
+
}
|
|
473
|
+
function dependsOnChain(mission, feature) {
|
|
474
|
+
const chain = [];
|
|
475
|
+
const visited = /* @__PURE__ */ new Set();
|
|
476
|
+
function trace(fId) {
|
|
477
|
+
if (visited.has(fId)) return;
|
|
478
|
+
visited.add(fId);
|
|
479
|
+
const f = mission.milestones.flatMap((m) => m.features).find((f2) => f2.id === fId);
|
|
480
|
+
if (!f) return;
|
|
481
|
+
if (f.status === "done") return;
|
|
482
|
+
chain.push({ id: f.id, status: f.status, title: f.title });
|
|
483
|
+
for (const depId of f.dependsOn) {
|
|
484
|
+
const dep = mission.milestones.flatMap((m) => m.features).find((f2) => f2.id === depId);
|
|
485
|
+
if (!dep || dep.status === "done") continue;
|
|
486
|
+
trace(depId);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
for (const depId of feature.dependsOn) trace(depId);
|
|
490
|
+
return chain;
|
|
491
|
+
}
|
|
492
|
+
function formatDepChain(chain) {
|
|
493
|
+
if (!chain.length) return "";
|
|
494
|
+
const statusIcon = {
|
|
495
|
+
pending: "\u2022",
|
|
496
|
+
waiting: "\u23F3",
|
|
497
|
+
blocked: "\u26D4",
|
|
498
|
+
active: "\u27A1\uFE0F",
|
|
499
|
+
done: "\u2705",
|
|
500
|
+
failed: "\u274C"
|
|
501
|
+
};
|
|
502
|
+
const parts = chain.map((n) => {
|
|
503
|
+
const icon = statusIcon[n.status] ?? "\u2022";
|
|
504
|
+
const label = n.title ? ` ${clip(n.title, 20)}` : "";
|
|
505
|
+
return `${n.id}(${icon}${label})`;
|
|
506
|
+
});
|
|
507
|
+
return `\u{1F517} ${parts.join(" \u2192 ")}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/utils/markdown.ts
|
|
511
|
+
import * as fs from "fs";
|
|
512
|
+
import * as path2 from "path";
|
|
513
|
+
var MISSION_TEMPLATES = [
|
|
514
|
+
{
|
|
515
|
+
id: "refactor",
|
|
516
|
+
label: "Refactor",
|
|
517
|
+
description: "Safely refactor legacy code",
|
|
518
|
+
goal: "Refactor a module or subsystem to improve maintainability without changing behavior.",
|
|
519
|
+
constraints: "Existing tests must pass. No behavior changes. Keep the public API compatible."
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
id: "fix-bug",
|
|
523
|
+
label: "Fix Bug",
|
|
524
|
+
description: "Investigate and fix a bug",
|
|
525
|
+
goal: "Identify the root cause of a reported bug and apply the minimal fix.",
|
|
526
|
+
constraints: "Add a regression test. Do not refactor unrelated code. Verify the fix with evidence."
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
id: "add-feature",
|
|
530
|
+
label: "Add Feature",
|
|
531
|
+
description: "Implement a new feature",
|
|
532
|
+
goal: "Implement a new capability or endpoint according to the specification.",
|
|
533
|
+
constraints: "Add tests for the new functionality. Keep changes minimal and focused."
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
id: "docs",
|
|
537
|
+
label: "Document",
|
|
538
|
+
description: "Generate or update documentation",
|
|
539
|
+
goal: "Produce accurate, verified documentation for a module, API, or workflow.",
|
|
540
|
+
constraints: "Docs must be accurate and reflect current behavior. No stub or TODO content. Rendered output must be verified."
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: "investigate",
|
|
544
|
+
label: "Investigate",
|
|
545
|
+
description: "Research and analyze a codebase question",
|
|
546
|
+
goal: "Answer a technical question about the codebase through exploration.",
|
|
547
|
+
constraints: "Read-only exploration. No code changes. Provide evidence from the codebase."
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
id: "auth",
|
|
551
|
+
label: "Auth implementation",
|
|
552
|
+
description: "Implement authentication in a codebase",
|
|
553
|
+
goal: "Add or refactor authentication in a codebase.",
|
|
554
|
+
constraints: "Implement secure auth patterns. Add tests. Verify with evidence."
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
id: "ci-cd",
|
|
558
|
+
label: "CI/CD Pipeline",
|
|
559
|
+
description: "Set up or improve CI/CD pipeline",
|
|
560
|
+
goal: "Implement or improve CI/CD pipeline.",
|
|
561
|
+
constraints: "Ensure robust pipeline configuration. Verify with test runs."
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
id: "security-audit",
|
|
565
|
+
label: "Security Audit",
|
|
566
|
+
description: "Find and fix security vulnerabilities",
|
|
567
|
+
goal: "Identify security vulnerabilities in a module, API, or workflow and document findings.",
|
|
568
|
+
constraints: "Do not make permanent changes without explicit user approval. Document all findings with evidence. Prioritize critical/high severity issues."
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
id: "performance-opt",
|
|
572
|
+
label: "Performance Optimization",
|
|
573
|
+
description: "Improve performance of existing code",
|
|
574
|
+
goal: "Identify and eliminate performance bottlenecks in a module or subsystem.",
|
|
575
|
+
constraints: "Measure before and after. Do not degrade correctness or readability. Target meaningful improvements (\u226520% speedup or \u226550% memory reduction)."
|
|
576
|
+
}
|
|
577
|
+
];
|
|
578
|
+
function createMissionFromTemplate(templateId, title) {
|
|
579
|
+
const t = MISSION_TEMPLATES.find((t2) => t2.id === templateId);
|
|
580
|
+
if (!t) return null;
|
|
581
|
+
return createStructuredMission(title || t.label, t.goal, t.constraints);
|
|
582
|
+
}
|
|
583
|
+
function createStructuredMission(title, goal, constraints) {
|
|
584
|
+
const id = createMissionId(title);
|
|
585
|
+
const now = Date.now();
|
|
586
|
+
return {
|
|
587
|
+
schemaVersion: SCHEMA_VERSION,
|
|
588
|
+
id,
|
|
589
|
+
title,
|
|
590
|
+
goal,
|
|
591
|
+
status: "active",
|
|
592
|
+
activeMilestoneId: "M01",
|
|
593
|
+
activeFeatureId: "F001",
|
|
594
|
+
tokensUsed: 0,
|
|
595
|
+
lastContextTokens: 0,
|
|
596
|
+
validationToken: createValidationToken(),
|
|
597
|
+
autopilot: {
|
|
598
|
+
enabled: false,
|
|
599
|
+
mode: "manual",
|
|
600
|
+
iteration: 0,
|
|
601
|
+
maxIterations: 25,
|
|
602
|
+
consecutiveFailures: 0,
|
|
603
|
+
maxConsecutiveFailures: 3,
|
|
604
|
+
noProgressTurns: 0,
|
|
605
|
+
maxNoProgressTurns: 3,
|
|
606
|
+
maxContextPercent: 85,
|
|
607
|
+
startedAt: new Date(now).toISOString(),
|
|
608
|
+
continueAcrossFeatures: true,
|
|
609
|
+
requireEvidenceForDone: true
|
|
610
|
+
},
|
|
611
|
+
createdAt: now,
|
|
612
|
+
updatedAt: now,
|
|
613
|
+
milestones: [
|
|
614
|
+
{
|
|
615
|
+
id: "M01",
|
|
616
|
+
title: "Plan and execute",
|
|
617
|
+
description: constraints ? `Constraints: ${constraints}` : "Initial execution milestone",
|
|
618
|
+
status: "active",
|
|
619
|
+
features: [
|
|
620
|
+
{ id: "F001", milestoneId: "M01", title: "Clarify scope and current state", description: "Read the repository, identify relevant files, constraints, and existing behavior.", priority: 1, dependsOn: [], status: "active", sessions: [], toolCallCount: 0, startedAt: now, acceptance: [{ id: "AC001", description: "Relevant files and constraints documented", checkType: "manual", verified: false }] },
|
|
621
|
+
{ id: "F002", milestoneId: "M01", title: "Implement the core change", description: "Make the smallest coherent implementation that satisfies the mission goal.", priority: 2, dependsOn: ["F001"], status: "pending", sessions: [], toolCallCount: 0, acceptance: [{ id: "AC001", description: "Implementation matches mission goal", checkType: "manual", verified: false }] },
|
|
622
|
+
{ id: "F003", milestoneId: "M01", title: "Verify and summarize", description: "Run relevant checks, capture evidence, and summarize results.", priority: 3, dependsOn: ["F002"], status: "pending", sessions: [], toolCallCount: 0, acceptance: [{ id: "AC001", description: "Verification evidence saved", checkType: "manual", verified: false }] }
|
|
623
|
+
]
|
|
624
|
+
}
|
|
625
|
+
]
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function missionFromWizardOutput(raw, title, goal) {
|
|
629
|
+
const milestones = raw.milestones || [];
|
|
630
|
+
if (milestones.length < 2) return null;
|
|
631
|
+
const id = createMissionId(title);
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
const mapping = [];
|
|
634
|
+
let counter = 0;
|
|
635
|
+
const intermediate = milestones.map((m, mi) => ({
|
|
636
|
+
...m,
|
|
637
|
+
features: (m.features || []).map((f) => {
|
|
638
|
+
const newId = `F${String(++counter).padStart(3, "0")}`;
|
|
639
|
+
mapping.push({ oldId: f.id, newId });
|
|
640
|
+
return { ...f, id: newId, dependsOn: [] };
|
|
641
|
+
})
|
|
642
|
+
}));
|
|
643
|
+
const oldToNewSet = /* @__PURE__ */ new Map();
|
|
644
|
+
for (const { oldId, newId } of mapping) {
|
|
645
|
+
const arr = oldToNewSet.get(oldId) || [];
|
|
646
|
+
arr.push(newId);
|
|
647
|
+
oldToNewSet.set(oldId, arr);
|
|
648
|
+
}
|
|
649
|
+
const wizardFeatures = milestones.flatMap(
|
|
650
|
+
(wm, mi) => wm.features.map((wf) => ({ ...wf, _mi: mi }))
|
|
651
|
+
);
|
|
652
|
+
let mapIdx = 0;
|
|
653
|
+
for (let mi = 0; mi < intermediate.length; mi++) {
|
|
654
|
+
const m = intermediate[mi];
|
|
655
|
+
for (let fi = 0; fi < m.features.length; fi++) {
|
|
656
|
+
const f = m.features[fi];
|
|
657
|
+
mapIdx++;
|
|
658
|
+
const orig = wizardFeatures[mapIdx - 1];
|
|
659
|
+
if (!orig) continue;
|
|
660
|
+
f.dependsOn = (orig.dependsOn || []).filter((d) => oldToNewSet.has(d)).map((d) => {
|
|
661
|
+
const candidates = oldToNewSet.get(d);
|
|
662
|
+
const resolveMilestone = (cid) => {
|
|
663
|
+
const idx = mapping.findIndex((me) => me.newId === cid);
|
|
664
|
+
return idx >= 0 ? wizardFeatures[idx]?._mi : void 0;
|
|
665
|
+
};
|
|
666
|
+
const sameMilestone = candidates.find((cid) => resolveMilestone(cid) === orig._mi);
|
|
667
|
+
if (sameMilestone) return sameMilestone;
|
|
668
|
+
const earlier = candidates.find((cid) => {
|
|
669
|
+
const mi2 = resolveMilestone(cid);
|
|
670
|
+
return mi2 !== void 0 && mi2 < (orig._mi ?? 99);
|
|
671
|
+
});
|
|
672
|
+
if (earlier) return earlier;
|
|
673
|
+
return candidates[0];
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const migrated = intermediate.map((m, mi) => {
|
|
678
|
+
const milestoneId = m.id ?? `M${String(mi + 1).padStart(2, "0")}`;
|
|
679
|
+
return {
|
|
680
|
+
id: milestoneId,
|
|
681
|
+
title: m.title,
|
|
682
|
+
description: m.description || "",
|
|
683
|
+
status: mi === 0 ? "active" : "pending",
|
|
684
|
+
dependsOn: mi > 0 ? [milestones[mi - 1].id || `M${String(mi).padStart(2, "0")}`] : void 0,
|
|
685
|
+
features: m.features.map((f, fi) => {
|
|
686
|
+
const fid = f.id;
|
|
687
|
+
return {
|
|
688
|
+
id: fid,
|
|
689
|
+
milestoneId,
|
|
690
|
+
title: f.title,
|
|
691
|
+
description: f.description || "",
|
|
692
|
+
priority: f.priority || 1,
|
|
693
|
+
dependsOn: f.dependsOn,
|
|
694
|
+
status: mi === 0 && fi === 0 ? "active" : "pending",
|
|
695
|
+
sessions: [],
|
|
696
|
+
toolCallCount: 0,
|
|
697
|
+
acceptance: (f.acceptance || []).map((ac, ai) => ({
|
|
698
|
+
id: ac.id || `AC${String(ai + 1).padStart(3, "0")}`,
|
|
699
|
+
description: ac.description,
|
|
700
|
+
checkType: ac.checkType || "manual",
|
|
701
|
+
checkCommand: ac.checkCommand,
|
|
702
|
+
verified: false
|
|
703
|
+
}))
|
|
704
|
+
};
|
|
705
|
+
})
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
const displayTitle = raw.title || title;
|
|
709
|
+
return {
|
|
710
|
+
schemaVersion: SCHEMA_VERSION,
|
|
711
|
+
id,
|
|
712
|
+
title: displayTitle,
|
|
713
|
+
goal,
|
|
714
|
+
status: "active",
|
|
715
|
+
activeMilestoneId: migrated[0].id,
|
|
716
|
+
activeFeatureId: migrated[0].features[0]?.id || "F001",
|
|
717
|
+
tokensUsed: 0,
|
|
718
|
+
lastContextTokens: 0,
|
|
719
|
+
validationToken: createValidationToken(),
|
|
720
|
+
autopilot: {
|
|
721
|
+
enabled: false,
|
|
722
|
+
mode: "manual",
|
|
723
|
+
iteration: 0,
|
|
724
|
+
maxIterations: 25,
|
|
725
|
+
consecutiveFailures: 0,
|
|
726
|
+
maxConsecutiveFailures: 3,
|
|
727
|
+
noProgressTurns: 0,
|
|
728
|
+
maxNoProgressTurns: 3,
|
|
729
|
+
maxContextPercent: 85,
|
|
730
|
+
startedAt: new Date(now).toISOString(),
|
|
731
|
+
continueAcrossFeatures: true,
|
|
732
|
+
requireEvidenceForDone: true
|
|
733
|
+
},
|
|
734
|
+
createdAt: now,
|
|
735
|
+
updatedAt: now,
|
|
736
|
+
milestones: migrated
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function exportMarkdown(mission) {
|
|
740
|
+
const p = progress(mission);
|
|
741
|
+
const active = getActiveFeature(mission);
|
|
742
|
+
const all = getAllFeatures(mission);
|
|
743
|
+
const done = all.filter((f) => f.status === "done");
|
|
744
|
+
const lines = [
|
|
745
|
+
`# Mission Report: ${mission.title}`,
|
|
746
|
+
"",
|
|
747
|
+
`- **Goal**: ${mission.goal}`,
|
|
748
|
+
`- **Status**: ${mission.status}`,
|
|
749
|
+
`- **Goal tree**: ${p.done}/${p.total} leaf goals (${p.pct}%)`,
|
|
750
|
+
`- **Progress**: ${p.done}/${p.total} (${p.pct}%)`,
|
|
751
|
+
`- **Tokens used**: ${mission.tokensUsed.toLocaleString()}`,
|
|
752
|
+
`- **Created**: ${new Date(mission.createdAt).toISOString()}`,
|
|
753
|
+
"",
|
|
754
|
+
"## Executive Summary",
|
|
755
|
+
"",
|
|
756
|
+
active ? `**Active feature**: ${active.id} \u2014 ${active.title} [${active.status}]` : "**Active feature**: none",
|
|
757
|
+
`**Handoff**: ${mission.autopilot.lastStopReason ?? "none"}${mission.autopilot.lastStopMessage ? ` - ${mission.autopilot.lastStopMessage}` : ""}`,
|
|
758
|
+
"",
|
|
759
|
+
"## Goal Tree",
|
|
760
|
+
""
|
|
761
|
+
];
|
|
762
|
+
for (const m of mission.milestones) {
|
|
763
|
+
const mDone = m.features.filter((f) => f.status === "done").length;
|
|
764
|
+
lines.push(
|
|
765
|
+
`## ${m.id}: ${m.title}`,
|
|
766
|
+
`Status: ${m.status} | Progress: ${mDone}/${m.features.length}`,
|
|
767
|
+
m.description ? `
|
|
768
|
+
${m.description}
|
|
769
|
+
` : "",
|
|
770
|
+
`**Acceptance criteria:**`
|
|
771
|
+
);
|
|
772
|
+
for (const f of m.features) {
|
|
773
|
+
const icon = featureStatusIcon(f.status);
|
|
774
|
+
const ac = acceptanceProgress(f);
|
|
775
|
+
lines.push(
|
|
776
|
+
`### ${f.id}: ${f.title}`,
|
|
777
|
+
`- **Status**: ${f.status} | **Priority**: P${f.priority}`,
|
|
778
|
+
`- **Acceptance**: ${ac.label}`,
|
|
779
|
+
f.dependsOn.length ? `- **Dependencies**: ${f.dependsOn.join(", ")}` : "",
|
|
780
|
+
f.notes ? `- **Notes**: ${f.notes}` : "",
|
|
781
|
+
f.description ? `
|
|
782
|
+
${f.description}
|
|
783
|
+
` : ""
|
|
784
|
+
);
|
|
785
|
+
for (const a of f.acceptance) {
|
|
786
|
+
const mark = a.verified || a.waived ? "\u2713" : "\u2610";
|
|
787
|
+
const waived = a.waived ? " (waived)" : "";
|
|
788
|
+
const cmd = a.checkCommand ? ` \`${a.checkCommand}\`` : "";
|
|
789
|
+
lines.push(`- [${mark}] ${a.id}: ${a.description}${cmd}${waived}`);
|
|
790
|
+
}
|
|
791
|
+
lines.push("");
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (done.length) {
|
|
795
|
+
lines.push("", "## Evidence");
|
|
796
|
+
for (const f of done) {
|
|
797
|
+
const evidenceFile = path2.join(missionDirSafe(mission.id), "evidence", `${f.id}.md`);
|
|
798
|
+
if (fs.existsSync(evidenceFile)) {
|
|
799
|
+
const evidence = fs.readFileSync(evidenceFile, "utf-8");
|
|
800
|
+
lines.push(`### ${f.id}: ${f.title}`, "", evidence.trim(), "");
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const history = readHistory(mission.id);
|
|
805
|
+
if (history.length) {
|
|
806
|
+
lines.push("## Recent History");
|
|
807
|
+
for (const h of history.slice(-10)) {
|
|
808
|
+
const ts = new Date(h.ts * 1e3).toISOString();
|
|
809
|
+
lines.push(`- \`${ts}\` ${h.event}${h.featureId ? ` (${h.featureId})` : ""}${h.note ? ` - ${h.note}` : ""}`);
|
|
810
|
+
}
|
|
811
|
+
lines.push("");
|
|
812
|
+
}
|
|
813
|
+
const nextRunnable = getActiveFeature(mission) ?? getAllFeatures(mission).find((f) => f.status === "pending" || f.status === "waiting");
|
|
814
|
+
if (nextRunnable) {
|
|
815
|
+
lines.push(`**Next runnable**: ${nextRunnable.id} \u2014 ${nextRunnable.title}`);
|
|
816
|
+
}
|
|
817
|
+
lines.push("---", `Generated by pi-missions at ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
818
|
+
return lines.join("\n");
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/utils/mission-builder.ts
|
|
822
|
+
function buildMissionGoalTree(title, goal, milestones) {
|
|
823
|
+
const children = milestones.map((m) => ({
|
|
824
|
+
id: m.id,
|
|
825
|
+
label: m.title,
|
|
826
|
+
status: m.status,
|
|
827
|
+
children: m.features.map((f) => ({
|
|
828
|
+
id: f.id,
|
|
829
|
+
label: f.title,
|
|
830
|
+
status: f.status,
|
|
831
|
+
children: [],
|
|
832
|
+
root: {}
|
|
833
|
+
})),
|
|
834
|
+
root: {}
|
|
835
|
+
}));
|
|
836
|
+
const tree = {
|
|
837
|
+
id: "root",
|
|
838
|
+
label: title,
|
|
839
|
+
status: "active",
|
|
840
|
+
children,
|
|
841
|
+
root: {}
|
|
842
|
+
};
|
|
843
|
+
tree.root = tree;
|
|
844
|
+
for (const mc of tree.children) {
|
|
845
|
+
mc.root = tree;
|
|
846
|
+
for (const fc of mc.children) fc.root = tree;
|
|
847
|
+
}
|
|
848
|
+
return tree;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// src/core/state.ts
|
|
852
|
+
init_types();
|
|
853
|
+
async function withLock(lockPath, callback, stale = 3e4) {
|
|
854
|
+
fs2.mkdirSync(path3.dirname(lockPath), { recursive: true });
|
|
855
|
+
const release = await lockfile.lock(lockPath, {
|
|
856
|
+
retries: { retries: 10, minTimeout: 100, maxTimeout: 500 },
|
|
857
|
+
stale,
|
|
858
|
+
realpath: false
|
|
859
|
+
});
|
|
860
|
+
try {
|
|
861
|
+
return await callback();
|
|
862
|
+
} finally {
|
|
863
|
+
await release();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function getAllFeatures(mission) {
|
|
867
|
+
const result = [];
|
|
868
|
+
for (const m of mission.milestones) {
|
|
869
|
+
for (const f of m.features) {
|
|
870
|
+
result.push(f);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return result;
|
|
874
|
+
}
|
|
875
|
+
function getMilestoneById(mission, id) {
|
|
876
|
+
return mission.milestones.find((m) => m.id === id);
|
|
877
|
+
}
|
|
878
|
+
function getFeatureById(mission, id) {
|
|
879
|
+
for (const m of mission.milestones) {
|
|
880
|
+
for (const f of m.features) {
|
|
881
|
+
if (f.id === id) return f;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return void 0;
|
|
885
|
+
}
|
|
886
|
+
function getActiveFeature(mission) {
|
|
887
|
+
if (!mission.activeFeatureId) return null;
|
|
888
|
+
return getFeatureById(mission, mission.activeFeatureId) ?? null;
|
|
889
|
+
}
|
|
890
|
+
function dependenciesDone(mission, feature) {
|
|
891
|
+
return feature.dependsOn.every((id) => getFeatureById(mission, id)?.status === "done");
|
|
892
|
+
}
|
|
893
|
+
function getNextPendingFeature(mission) {
|
|
894
|
+
return getAllFeatures(mission).filter((f) => (f.status === "pending" || f.status === "waiting") && dependenciesDone(mission, f)).sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id))[0] ?? null;
|
|
895
|
+
}
|
|
896
|
+
function progress(mission) {
|
|
897
|
+
let done = 0;
|
|
898
|
+
let total = 0;
|
|
899
|
+
for (const m of mission.milestones) {
|
|
900
|
+
for (const f of m.features) {
|
|
901
|
+
total++;
|
|
902
|
+
if (f.status === "done") done++;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return { done, total, pct: total ? Math.round(done / total * 100) : 0 };
|
|
906
|
+
}
|
|
907
|
+
function migrateMission(raw) {
|
|
908
|
+
const value = raw;
|
|
909
|
+
const version = value.schemaVersion ?? 1;
|
|
910
|
+
if (version > SCHEMA_VERSION) throw new Error(`Unsupported mission schemaVersion ${version}`);
|
|
911
|
+
if (version === SCHEMA_VERSION) {
|
|
912
|
+
return { ...value, autopilot: { ...DEFAULT_AUTOPILOT, ...value.autopilot } };
|
|
913
|
+
}
|
|
914
|
+
const v1Features = (value.features ?? []).map((f) => ({
|
|
915
|
+
...f,
|
|
916
|
+
toolCallCount: typeof f.toolCallCount === "number" ? f.toolCallCount : 0
|
|
917
|
+
}));
|
|
918
|
+
return {
|
|
919
|
+
schemaVersion: SCHEMA_VERSION,
|
|
920
|
+
id: String(value.id ?? createMissionId(String(value.title ?? "mission"))),
|
|
921
|
+
title: String(value.title ?? "Untitled mission"),
|
|
922
|
+
goal: String(value.goal ?? ""),
|
|
923
|
+
status: value.status ?? "active",
|
|
924
|
+
activeFeatureId: value.activeFeatureId,
|
|
925
|
+
activeMilestoneId: value.activeMilestoneId ?? "M01",
|
|
926
|
+
tokensBudget: value.tokensBudget,
|
|
927
|
+
tokensUsed: value.tokensUsed ?? 0,
|
|
928
|
+
lastContextTokens: value.lastContextTokens ?? 0,
|
|
929
|
+
validationToken: value.validationToken || createValidationToken(),
|
|
930
|
+
autopilot: {
|
|
931
|
+
...DEFAULT_AUTOPILOT,
|
|
932
|
+
...value.autopilot,
|
|
933
|
+
startedAt: value.autopilot?.startedAt ?? new Date(value.createdAt ?? Date.now()).toISOString()
|
|
934
|
+
},
|
|
935
|
+
userPreferences: value.userPreferences,
|
|
936
|
+
createdAt: value.createdAt ?? Date.now(),
|
|
937
|
+
updatedAt: Date.now(),
|
|
938
|
+
milestones: value.milestones ?? [{
|
|
939
|
+
id: "M01",
|
|
940
|
+
title: "Migrated",
|
|
941
|
+
description: "Migrated flat feature list",
|
|
942
|
+
status: "active",
|
|
943
|
+
features: v1Features
|
|
944
|
+
}]
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
async function saveMissionSafe(mission) {
|
|
948
|
+
const dir = missionDirSafe(mission.id);
|
|
949
|
+
const target = path3.join(dir, "plan.json");
|
|
950
|
+
await withLock(target, async () => {
|
|
951
|
+
await fsAsync.mkdir(dir, { recursive: true });
|
|
952
|
+
await fsAsync.mkdir(path3.join(dir, "evidence"), { recursive: true });
|
|
953
|
+
await fsAsync.mkdir(path3.join(dir, "sessions"), { recursive: true });
|
|
954
|
+
const backup = path3.join(dir, "plan.json.bak");
|
|
955
|
+
const preMigration = path3.join(dir, "plan.json.pre-migration.bak");
|
|
956
|
+
const temp = path3.join(dir, "plan.json.tmp");
|
|
957
|
+
if (fs2.existsSync(target)) {
|
|
958
|
+
await fsAsync.copyFile(target, backup);
|
|
959
|
+
if (!fs2.existsSync(preMigration)) await fsAsync.copyFile(target, preMigration);
|
|
960
|
+
}
|
|
961
|
+
mission.updatedAt = Date.now();
|
|
962
|
+
const { goalTree: _, ...serializable } = mission;
|
|
963
|
+
const data = JSON.stringify(serializable, null, 2);
|
|
964
|
+
try {
|
|
965
|
+
await fsAsync.writeFile(temp, data, "utf-8");
|
|
966
|
+
} catch {
|
|
967
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
968
|
+
await fsAsync.writeFile(temp, data, "utf-8");
|
|
969
|
+
}
|
|
970
|
+
await fsAsync.rename(temp, target);
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
function loadMissionFromDisk(id) {
|
|
974
|
+
const dir = missionDirSafe(id);
|
|
975
|
+
for (const name of ["plan.json", "plan.json.bak"]) {
|
|
976
|
+
try {
|
|
977
|
+
const raw = JSON.parse(fs2.readFileSync(path3.join(dir, name), "utf-8"));
|
|
978
|
+
return migrateMission(raw);
|
|
979
|
+
} catch {
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
function readRawSchemaVersion(id) {
|
|
985
|
+
const dir = missionDirSafe(id);
|
|
986
|
+
for (const name of ["plan.json", "plan.json.bak"]) {
|
|
987
|
+
try {
|
|
988
|
+
const raw = JSON.parse(fs2.readFileSync(path3.join(dir, name), "utf-8"));
|
|
989
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && "schemaVersion" in raw) {
|
|
990
|
+
return raw.schemaVersion;
|
|
991
|
+
}
|
|
992
|
+
return 1;
|
|
993
|
+
} catch {
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
async function migrateMissionOnDisk(id) {
|
|
999
|
+
const dir = missionDirSafe(id);
|
|
1000
|
+
const target = path3.join(dir, "plan.json");
|
|
1001
|
+
if (!fs2.existsSync(target)) return null;
|
|
1002
|
+
return withLock(target, async () => {
|
|
1003
|
+
const preBackup = path3.join(dir, `plan.json.pre-migration-${Date.now()}.bak`);
|
|
1004
|
+
await fsAsync.copyFile(target, preBackup);
|
|
1005
|
+
const raw = JSON.parse(await fsAsync.readFile(target, "utf-8"));
|
|
1006
|
+
const migrated = migrateMission(raw);
|
|
1007
|
+
const temp = path3.join(dir, "plan.json.tmp");
|
|
1008
|
+
await fsAsync.writeFile(temp, JSON.stringify(migrated, null, 2), "utf-8");
|
|
1009
|
+
await fsAsync.rename(temp, target);
|
|
1010
|
+
return migrated;
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
function readRawMissionCounts(id) {
|
|
1014
|
+
const dir = missionDirSafe(id);
|
|
1015
|
+
for (const name of ["plan.json", "plan.json.bak"]) {
|
|
1016
|
+
try {
|
|
1017
|
+
const raw = JSON.parse(fs2.readFileSync(path3.join(dir, name), "utf-8"));
|
|
1018
|
+
if (raw && typeof raw === "object") {
|
|
1019
|
+
const o = raw;
|
|
1020
|
+
if (Array.isArray(o.milestones)) {
|
|
1021
|
+
const features = o.milestones.reduce(
|
|
1022
|
+
(s, m) => s + (Array.isArray(m?.features) ? m.features.length : 0),
|
|
1023
|
+
0
|
|
1024
|
+
);
|
|
1025
|
+
return { milestones: o.milestones.length, features };
|
|
1026
|
+
}
|
|
1027
|
+
if (Array.isArray(o.features)) {
|
|
1028
|
+
return { milestones: 1, features: o.features.length };
|
|
1029
|
+
}
|
|
1030
|
+
return { milestones: 0, features: 0 };
|
|
1031
|
+
}
|
|
1032
|
+
} catch {
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
function listMissions() {
|
|
1038
|
+
const root = missionsRoot();
|
|
1039
|
+
if (!fs2.existsSync(root)) return [];
|
|
1040
|
+
return fs2.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).filter((e) => {
|
|
1041
|
+
if (e.name.startsWith("pim-")) return true;
|
|
1042
|
+
if (/^mission-\d{17,}/.test(e.name)) return true;
|
|
1043
|
+
return false;
|
|
1044
|
+
}).map((e) => loadMissionFromDisk(e.name)).filter((m) => Boolean(m)).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1045
|
+
}
|
|
1046
|
+
function appendHistory(mission, entry) {
|
|
1047
|
+
const dir = missionDirSafe(mission.id);
|
|
1048
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
1049
|
+
const line = {
|
|
1050
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
1051
|
+
missionId: mission.id,
|
|
1052
|
+
...entry
|
|
1053
|
+
};
|
|
1054
|
+
fs2.appendFileSync(path3.join(dir, "history.jsonl"), JSON.stringify(line) + "\n", "utf-8");
|
|
1055
|
+
}
|
|
1056
|
+
function readHistory(id) {
|
|
1057
|
+
const file = path3.join(missionDirSafe(id), "history.jsonl");
|
|
1058
|
+
if (!fs2.existsSync(file)) return [];
|
|
1059
|
+
return fs2.readFileSync(file, "utf-8").split("\n").filter(Boolean).flatMap((line) => {
|
|
1060
|
+
try {
|
|
1061
|
+
return [JSON.parse(line)];
|
|
1062
|
+
} catch {
|
|
1063
|
+
return [];
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
function saveEvidence(mission, feature, text) {
|
|
1068
|
+
const dir = path3.join(missionDirSafe(mission.id), "evidence");
|
|
1069
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
1070
|
+
const file = path3.join(dir, `${feature.id}.md`);
|
|
1071
|
+
fs2.writeFileSync(file, text, "utf-8");
|
|
1072
|
+
return file;
|
|
1073
|
+
}
|
|
1074
|
+
function evidenceIntegrityHash(mission, featureId) {
|
|
1075
|
+
const evidenceFile = path3.join(missionDirSafe(mission.id), "evidence", `${featureId}.md`);
|
|
1076
|
+
if (!fs2.existsSync(evidenceFile)) return null;
|
|
1077
|
+
return sha256(fs2.readFileSync(evidenceFile));
|
|
1078
|
+
}
|
|
1079
|
+
function linkSession(mission, sessionFile, agent) {
|
|
1080
|
+
const dir = path3.join(missionDirSafe(mission.id), "sessions");
|
|
1081
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
1082
|
+
const agentSource = agent || process.env.CODING_AGENT || "unknown";
|
|
1083
|
+
const refPath = path3.join(dir, `${path3.basename(sessionFile)}.${agentSource}.ref`);
|
|
1084
|
+
fs2.writeFileSync(refPath, JSON.stringify({
|
|
1085
|
+
sessionFile,
|
|
1086
|
+
agent: agentSource,
|
|
1087
|
+
linkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1088
|
+
linkedAtMs: Date.now()
|
|
1089
|
+
}, null, 2), "utf-8");
|
|
1090
|
+
}
|
|
1091
|
+
function autoBlockBlockedFeatures(mission) {
|
|
1092
|
+
let changed = 0;
|
|
1093
|
+
for (const m of mission.milestones) {
|
|
1094
|
+
for (const f of m.features) {
|
|
1095
|
+
if (f.status === "pending" || f.status === "active") {
|
|
1096
|
+
const isBlocked = f.dependsOn.some((depId) => {
|
|
1097
|
+
const dep = getFeatureById(mission, depId);
|
|
1098
|
+
return dep && dep.status === "blocked";
|
|
1099
|
+
});
|
|
1100
|
+
if (isBlocked) {
|
|
1101
|
+
f.status = "blocked";
|
|
1102
|
+
f.notes = f.notes ? f.notes + "\nAuto-blocked: Dependency is blocked." : "Auto-blocked: Dependency is blocked.";
|
|
1103
|
+
changed++;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return changed;
|
|
1109
|
+
}
|
|
1110
|
+
function autoUnblockResolved(mission) {
|
|
1111
|
+
let changed = 0;
|
|
1112
|
+
for (const m of mission.milestones) {
|
|
1113
|
+
for (const f of m.features) {
|
|
1114
|
+
if (f.status === "blocked") {
|
|
1115
|
+
const depsDone = dependenciesDone(mission, f);
|
|
1116
|
+
const noBlockedDeps = !f.dependsOn.some((depId) => {
|
|
1117
|
+
const dep = getFeatureById(mission, depId);
|
|
1118
|
+
return dep && dep.status === "blocked";
|
|
1119
|
+
});
|
|
1120
|
+
if (depsDone || noBlockedDeps) {
|
|
1121
|
+
f.status = "pending";
|
|
1122
|
+
f.notes = f.notes ? f.notes + "\nAuto-unblocked: Dependencies resolved." : "Auto-unblocked: Dependencies resolved.";
|
|
1123
|
+
changed++;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return changed;
|
|
1129
|
+
}
|
|
1130
|
+
function autoCompleteMilestones(mission) {
|
|
1131
|
+
let completed = 0;
|
|
1132
|
+
for (const m of mission.milestones) {
|
|
1133
|
+
if (m.status === "complete") continue;
|
|
1134
|
+
if (m.features.every((f) => f.status === "done")) {
|
|
1135
|
+
m.status = "complete";
|
|
1136
|
+
completed++;
|
|
1137
|
+
} else if (m.features.some((f) => f.status === "active")) {
|
|
1138
|
+
m.status = "active";
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return completed;
|
|
1142
|
+
}
|
|
1143
|
+
function getMissionPhase(mission) {
|
|
1144
|
+
if (mission.status === "planning") return "planning";
|
|
1145
|
+
const active = getActiveFeature(mission);
|
|
1146
|
+
if (!active) return "execution";
|
|
1147
|
+
const t = `${active.title} ${active.description ?? ""}`.toLowerCase();
|
|
1148
|
+
if (t.includes("verify") || t.includes("test") || t.includes("summarize")) return "verification";
|
|
1149
|
+
if (t.includes("clarify") || t.includes("plan") || t.includes("scope") || t.includes("research") || t.includes("analyze") || t.includes("analyse") || t.includes("inspect") || t.includes("investigate") || t.includes("discover") || t.includes("reconnaissance") || t.includes("current state")) {
|
|
1150
|
+
return "planning";
|
|
1151
|
+
}
|
|
1152
|
+
return "execution";
|
|
1153
|
+
}
|
|
1154
|
+
function allFeaturesDone(mission) {
|
|
1155
|
+
return getAllFeatures(mission).every((f) => f.status === "done");
|
|
1156
|
+
}
|
|
1157
|
+
function autoVerifyAcceptance(feature, execFn) {
|
|
1158
|
+
let verified = 0;
|
|
1159
|
+
for (const ac of feature.acceptance) {
|
|
1160
|
+
if (ac.verified || ac.waived || ac.checkType !== "bash" || !ac.checkCommand) continue;
|
|
1161
|
+
try {
|
|
1162
|
+
const result = execFn(ac.checkCommand);
|
|
1163
|
+
if (result.code === 0) {
|
|
1164
|
+
ac.verified = true;
|
|
1165
|
+
ac.evidence = result.stdout.slice(0, 1e3);
|
|
1166
|
+
verified++;
|
|
1167
|
+
}
|
|
1168
|
+
} catch {
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return verified;
|
|
1172
|
+
}
|
|
1173
|
+
function completeActiveFeature(mission, options) {
|
|
1174
|
+
const feature = getActiveFeature(mission);
|
|
1175
|
+
if (!feature) return { ok: false, reason: "No active mission feature." };
|
|
1176
|
+
if (options.autoVerify && feature._execFn) {
|
|
1177
|
+
autoVerifyAcceptance(feature, feature._execFn);
|
|
1178
|
+
}
|
|
1179
|
+
const unverifiedBash = feature.acceptance.filter(
|
|
1180
|
+
(ac) => !ac.verified && !ac.waived && ac.checkType === "bash"
|
|
1181
|
+
);
|
|
1182
|
+
if (unverifiedBash.length > 0) {
|
|
1183
|
+
const details = unverifiedBash.map((ac) => `${ac.id}: ${ac.description}${ac.checkCommand ? ` [bash: ${ac.checkCommand}]` : ""}`).join("\n");
|
|
1184
|
+
return { ok: false, reason: `Cannot mark feature done: ${unverifiedBash.length} bash acceptance criteria need verification.
|
|
1185
|
+
${details}`, unverifiedBashCount: unverifiedBash.length };
|
|
1186
|
+
}
|
|
1187
|
+
feature.status = "done";
|
|
1188
|
+
feature.completedAt = Date.now();
|
|
1189
|
+
if (options.notes !== void 0) feature.notes = options.notes;
|
|
1190
|
+
if (options.markAcceptanceVerified) {
|
|
1191
|
+
for (const ac of feature.acceptance) if (!ac.waived) ac.verified = true;
|
|
1192
|
+
}
|
|
1193
|
+
const evidenceFile = saveEvidence(mission, feature, options.evidence || "Marked done.");
|
|
1194
|
+
appendHistory(mission, {
|
|
1195
|
+
event: "feature_done",
|
|
1196
|
+
featureId: feature.id,
|
|
1197
|
+
note: options.historyNote ?? options.notes,
|
|
1198
|
+
details: { evidenceFile, ...options.historyDetails }
|
|
1199
|
+
});
|
|
1200
|
+
autoUnblockResolved(mission);
|
|
1201
|
+
const missionComplete = !getNextPendingFeature(mission) && allFeaturesDone(mission);
|
|
1202
|
+
if (missionComplete) {
|
|
1203
|
+
mission.status = "complete";
|
|
1204
|
+
mission.autopilot.enabled = false;
|
|
1205
|
+
mission.autopilot.lastStopReason = "mission_complete";
|
|
1206
|
+
}
|
|
1207
|
+
autoCompleteMilestones(mission);
|
|
1208
|
+
mission.goalTree = buildMissionGoalTree(mission.title, mission.goal, mission.milestones);
|
|
1209
|
+
return { ok: true, feature, evidenceFile, missionComplete };
|
|
1210
|
+
}
|
|
1211
|
+
function activateNextFeature(mission, note) {
|
|
1212
|
+
const active = getActiveFeature(mission);
|
|
1213
|
+
if (active?.status === "active") return { ok: false, reason: "active_not_done", active };
|
|
1214
|
+
autoUnblockResolved(mission);
|
|
1215
|
+
const next = getNextPendingFeature(mission);
|
|
1216
|
+
if (!next) {
|
|
1217
|
+
if (allFeaturesDone(mission)) {
|
|
1218
|
+
mission.status = "complete";
|
|
1219
|
+
mission.autopilot.enabled = false;
|
|
1220
|
+
mission.autopilot.lastStopReason = "mission_complete";
|
|
1221
|
+
autoCompleteMilestones(mission);
|
|
1222
|
+
appendHistory(mission, { event: "mission_complete", note: note ?? "All features complete" });
|
|
1223
|
+
return { ok: false, reason: "mission_complete" };
|
|
1224
|
+
}
|
|
1225
|
+
return { ok: false, reason: "no_unblocked_pending" };
|
|
1226
|
+
}
|
|
1227
|
+
next.status = "active";
|
|
1228
|
+
next.startedAt = next.startedAt ?? Date.now();
|
|
1229
|
+
mission.status = "active";
|
|
1230
|
+
mission.activeFeatureId = next.id;
|
|
1231
|
+
mission.activeMilestoneId = next.milestoneId;
|
|
1232
|
+
appendHistory(mission, { event: "feature_active", featureId: next.id, note });
|
|
1233
|
+
return { ok: true, next };
|
|
1234
|
+
}
|
|
1235
|
+
function computeMissionMetrics(mission) {
|
|
1236
|
+
const all = getAllFeatures(mission);
|
|
1237
|
+
const history = readHistory(mission.id);
|
|
1238
|
+
const doneFeatures = all.filter((f) => f.status === "done");
|
|
1239
|
+
const failedFeatures = all.filter((f) => f.status === "failed");
|
|
1240
|
+
let acceptanceFailures = 0;
|
|
1241
|
+
let evidenceHashErrors = 0;
|
|
1242
|
+
for (const f of all) {
|
|
1243
|
+
acceptanceFailures += f.acceptance.filter((ac) => !ac.verified && !ac.waived).length;
|
|
1244
|
+
if (f.status === "done" && evidenceIntegrityHash(mission, f.id) === null) evidenceHashErrors++;
|
|
1245
|
+
}
|
|
1246
|
+
const completionEvent = history.find((h) => h.event === "mission_complete");
|
|
1247
|
+
const totalWallMs = completionEvent ? completionEvent.ts * 1e3 - mission.createdAt : Date.now() - mission.createdAt;
|
|
1248
|
+
return {
|
|
1249
|
+
missionId: mission.id,
|
|
1250
|
+
created: mission.createdAt,
|
|
1251
|
+
completed: completionEvent?.ts ? completionEvent.ts * 1e3 : void 0,
|
|
1252
|
+
totalFeatures: all.length,
|
|
1253
|
+
featuresDone: doneFeatures.length,
|
|
1254
|
+
featuresFailed: failedFeatures.length,
|
|
1255
|
+
totalTokensUsed: mission.tokensUsed,
|
|
1256
|
+
totalWallClockMs: totalWallMs,
|
|
1257
|
+
acceptanceFailures,
|
|
1258
|
+
evidenceHashErrors
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
function calculateMetricsSummary() {
|
|
1262
|
+
const missions = listMissions();
|
|
1263
|
+
const metrics = missions.map(computeMissionMetrics);
|
|
1264
|
+
if (metrics.length === 0) {
|
|
1265
|
+
return {
|
|
1266
|
+
totalMissions: 0,
|
|
1267
|
+
completedMissions: 0,
|
|
1268
|
+
successRate: 0,
|
|
1269
|
+
averageTokensPerMission: 0,
|
|
1270
|
+
averageFeaturesPerMission: 0,
|
|
1271
|
+
averageCompletionTimeMs: 0
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
const completed = metrics.filter((m) => m.completed !== void 0);
|
|
1275
|
+
const totalTokens = metrics.reduce((s, m) => s + m.totalTokensUsed, 0);
|
|
1276
|
+
const totalFeatures = metrics.reduce((s, m) => s + m.totalFeatures, 0);
|
|
1277
|
+
const totalTime = completed.reduce((s, m) => s + ((m.completed ?? m.created) - m.created), 0);
|
|
1278
|
+
return {
|
|
1279
|
+
totalMissions: metrics.length,
|
|
1280
|
+
completedMissions: completed.length,
|
|
1281
|
+
successRate: completed.length / metrics.length,
|
|
1282
|
+
averageTokensPerMission: totalTokens / metrics.length,
|
|
1283
|
+
averageFeaturesPerMission: totalFeatures / metrics.length,
|
|
1284
|
+
averageCompletionTimeMs: completed.length > 0 ? totalTime / completed.length : 0
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// src/commands/handlers.ts
|
|
1289
|
+
init_types();
|
|
1290
|
+
import * as fs3 from "fs";
|
|
1291
|
+
import * as path4 from "path";
|
|
1292
|
+
init_types();
|
|
1293
|
+
|
|
1294
|
+
// src/ui/components.ts
|
|
1295
|
+
function deriveNextAction(feature) {
|
|
1296
|
+
if (feature.status === "blocked") {
|
|
1297
|
+
return feature.notes ? `Unblock ${feature.id}: ${clip(feature.notes, 72)}` : `Unblock ${feature.id} before continuing`;
|
|
1298
|
+
}
|
|
1299
|
+
if (feature.status === "waiting") {
|
|
1300
|
+
return feature.dependsOn.length ? `Wait for ${feature.dependsOn.join(", ")} before resuming ${feature.id}` : `Resume ${feature.id} when external dependency clears`;
|
|
1301
|
+
}
|
|
1302
|
+
const next = pendingAcceptance(feature, "->")[0];
|
|
1303
|
+
if (next) return `Finish ${feature.id}: ${clip(next, 72)}`;
|
|
1304
|
+
return `Advance ${feature.id}: ${clip(feature.description || feature.title, 72)}`;
|
|
1305
|
+
}
|
|
1306
|
+
function deriveHandoffSummary(mission, active, next) {
|
|
1307
|
+
const stop = mission.autopilot.lastStopReason;
|
|
1308
|
+
const msg = mission.autopilot.lastStopMessage;
|
|
1309
|
+
if (stop === "needs_user_decision" && msg) return `Needs user decision: ${clip(msg, 78)}`;
|
|
1310
|
+
if (stop === "blocked" && msg) return `Blocked: ${clip(msg, 78)}`;
|
|
1311
|
+
if (active?.notes) return `Carry over: ${clip(active.notes, 78)}`;
|
|
1312
|
+
if (next) return `After ${active?.id ?? "current"} hand off to ${next.id} ${clip(next.title, 52)}`;
|
|
1313
|
+
return "Close out evidence and confirm mission state";
|
|
1314
|
+
}
|
|
1315
|
+
function buildMissionControlSummary(mission) {
|
|
1316
|
+
const active = getActiveFeature(mission);
|
|
1317
|
+
const blocked = getAllFeatures(mission).filter((f) => f.status === "blocked");
|
|
1318
|
+
const waiting = getAllFeatures(mission).filter((f) => f.status === "waiting");
|
|
1319
|
+
const nextFeature = getNextPendingFeature(mission);
|
|
1320
|
+
return { active, nextFeature, blocked, waiting, handoff: deriveHandoffSummary(mission, active, nextFeature) };
|
|
1321
|
+
}
|
|
1322
|
+
function updateFooter(ctx, mission) {
|
|
1323
|
+
if (!mission) {
|
|
1324
|
+
ctx.ui.setStatus("pi-mission", "");
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const p = progress(mission);
|
|
1328
|
+
const active = getActiveFeature(mission);
|
|
1329
|
+
const icon = missionStatusIcon(mission.status);
|
|
1330
|
+
const a = mission.autopilot;
|
|
1331
|
+
const ap = a.enabled ? ` \xB7 \u{1F916} ${a.iteration}/${a.maxIterations} np${a.noProgressTurns}/${a.maxNoProgressTurns} f${a.consecutiveFailures}/${a.maxConsecutiveFailures}` : " \xB7 \u23F9 off";
|
|
1332
|
+
const line = active ? `${icon} ${mission.title} [${p.done}/${p.total} ${p.pct}%] \u2014 ${active.id} ${active.title}${ap}` : `${icon} ${mission.title} [${p.done}/${p.total} ${p.pct}%]${ap}`;
|
|
1333
|
+
ctx.ui.setStatus("pi-mission", line);
|
|
1334
|
+
}
|
|
1335
|
+
function statusText(mission) {
|
|
1336
|
+
const p = progress(mission);
|
|
1337
|
+
const s = buildMissionControlSummary(mission);
|
|
1338
|
+
const active = s.active;
|
|
1339
|
+
const lines = [
|
|
1340
|
+
`\u{1F3AF} Mission: ${mission.title}`,
|
|
1341
|
+
`ID: ${mission.id}`,
|
|
1342
|
+
`Status: ${mission.status}`,
|
|
1343
|
+
`Goal: ${mission.goal}`,
|
|
1344
|
+
`Progress: ${p.done}/${p.total} (${p.pct}%)`,
|
|
1345
|
+
active ? `Active: ${active.id} \u2014 ${active.title}` : "Active: none",
|
|
1346
|
+
`Blocked/Waiting: ${s.blocked.length}/${s.waiting.length}`,
|
|
1347
|
+
`Next action: ${active ? deriveNextAction(active) : s.nextFeature ? `Start ${s.nextFeature.id} \u2014 ${s.nextFeature.title}` : "None"}`,
|
|
1348
|
+
`Handoff: ${s.handoff}`,
|
|
1349
|
+
`Autopilot: ${mission.autopilot.enabled ? "ON" : "OFF"} (${mission.autopilot.mode})`,
|
|
1350
|
+
`Iteration: ${mission.autopilot.iteration}/${mission.autopilot.maxIterations}`,
|
|
1351
|
+
`Failures: ${mission.autopilot.consecutiveFailures}/${mission.autopilot.maxConsecutiveFailures}`,
|
|
1352
|
+
`No-progress: ${mission.autopilot.noProgressTurns}/${mission.autopilot.maxNoProgressTurns}`,
|
|
1353
|
+
`Last continuation: ${mission.autopilot.lastContinuationAt ?? "never"}`,
|
|
1354
|
+
`Last stop: ${mission.autopilot.lastStopReason ?? "none"}${mission.autopilot.lastStopMessage ? ` - ${mission.autopilot.lastStopMessage}` : ""}`,
|
|
1355
|
+
""
|
|
1356
|
+
];
|
|
1357
|
+
for (const m of mission.milestones) {
|
|
1358
|
+
const mi = m.status === "complete" ? "\u2705" : m.status === "active" ? "\u27A1\uFE0F" : "\u2022";
|
|
1359
|
+
const mDone = m.features.filter((f) => f.status === "done").length;
|
|
1360
|
+
lines.push(`${mi} ${m.id}: ${m.title} [${mDone}/${m.features.length}]`);
|
|
1361
|
+
for (const f of m.features) {
|
|
1362
|
+
const mark = featureStatusIcon(f.status);
|
|
1363
|
+
const blocked = f.status === "blocked" && f.notes ? ` \u2014 ${f.notes.slice(0, 50)}` : "";
|
|
1364
|
+
lines.push(` ${mark} ${f.id}: ${f.title} (${f.status})${blocked}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return lines.join("\n");
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// src/engines/metrics.ts
|
|
1371
|
+
var SessionMetricsCollector = class _SessionMetricsCollector {
|
|
1372
|
+
metrics;
|
|
1373
|
+
static instance = null;
|
|
1374
|
+
constructor() {
|
|
1375
|
+
this.metrics = this.freshMetrics();
|
|
1376
|
+
}
|
|
1377
|
+
static reset() {
|
|
1378
|
+
_SessionMetricsCollector.instance = null;
|
|
1379
|
+
}
|
|
1380
|
+
static get() {
|
|
1381
|
+
if (!_SessionMetricsCollector.instance) {
|
|
1382
|
+
_SessionMetricsCollector.instance = new _SessionMetricsCollector();
|
|
1383
|
+
}
|
|
1384
|
+
return _SessionMetricsCollector.instance;
|
|
1385
|
+
}
|
|
1386
|
+
freshMetrics() {
|
|
1387
|
+
return {
|
|
1388
|
+
sessionId: `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1389
|
+
startTime: Date.now(),
|
|
1390
|
+
toolCalls: { total: 0, byTool: {}, successful: 0, failed: 0 },
|
|
1391
|
+
tokensUsed: 0,
|
|
1392
|
+
featuresCompleted: 0,
|
|
1393
|
+
errors: { total: 0, byCategory: {} },
|
|
1394
|
+
autoAdvanceCount: 0,
|
|
1395
|
+
stuckDetectionCount: 0
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
1399
|
+
static getInstance() {
|
|
1400
|
+
if (!_SessionMetricsCollector.instance) {
|
|
1401
|
+
_SessionMetricsCollector.instance = new _SessionMetricsCollector();
|
|
1402
|
+
}
|
|
1403
|
+
return _SessionMetricsCollector.instance;
|
|
1404
|
+
}
|
|
1405
|
+
// Instance methods (delegate to static)
|
|
1406
|
+
recordToolCall(tool, success) {
|
|
1407
|
+
_SessionMetricsCollector.recordToolCall(tool, success);
|
|
1408
|
+
}
|
|
1409
|
+
recordTokenUsage(tokens) {
|
|
1410
|
+
_SessionMetricsCollector.addTokens(tokens);
|
|
1411
|
+
}
|
|
1412
|
+
recordFeatureCompleted() {
|
|
1413
|
+
_SessionMetricsCollector.recordFeatureCompleted();
|
|
1414
|
+
}
|
|
1415
|
+
recordError(category) {
|
|
1416
|
+
_SessionMetricsCollector.recordError(category);
|
|
1417
|
+
}
|
|
1418
|
+
recordAutoAdvance() {
|
|
1419
|
+
_SessionMetricsCollector.recordAutoAdvance();
|
|
1420
|
+
}
|
|
1421
|
+
recordStuckDetection() {
|
|
1422
|
+
_SessionMetricsCollector.recordStuckDetection();
|
|
1423
|
+
}
|
|
1424
|
+
endSession() {
|
|
1425
|
+
_SessionMetricsCollector.endSession();
|
|
1426
|
+
}
|
|
1427
|
+
reset() {
|
|
1428
|
+
_SessionMetricsCollector.reset();
|
|
1429
|
+
}
|
|
1430
|
+
getMetrics() {
|
|
1431
|
+
return _SessionMetricsCollector.getMetrics();
|
|
1432
|
+
}
|
|
1433
|
+
getMetricsSummary() {
|
|
1434
|
+
return _SessionMetricsCollector.getMetricsSummary();
|
|
1435
|
+
}
|
|
1436
|
+
exportMetrics() {
|
|
1437
|
+
return JSON.stringify(_SessionMetricsCollector.getMetrics());
|
|
1438
|
+
}
|
|
1439
|
+
static recordToolCall(tool, success) {
|
|
1440
|
+
const s = _SessionMetricsCollector.get();
|
|
1441
|
+
s.metrics.toolCalls.total++;
|
|
1442
|
+
s.metrics.toolCalls.byTool[tool] = (s.metrics.toolCalls.byTool[tool] ?? 0) + 1;
|
|
1443
|
+
if (success) s.metrics.toolCalls.successful++;
|
|
1444
|
+
else s.metrics.toolCalls.failed++;
|
|
1445
|
+
}
|
|
1446
|
+
static recordError(category) {
|
|
1447
|
+
const s = _SessionMetricsCollector.get();
|
|
1448
|
+
s.metrics.errors.total++;
|
|
1449
|
+
s.metrics.errors.byCategory[category] = (s.metrics.errors.byCategory[category] ?? 0) + 1;
|
|
1450
|
+
}
|
|
1451
|
+
static recordFeatureCompleted() {
|
|
1452
|
+
_SessionMetricsCollector.get().metrics.featuresCompleted++;
|
|
1453
|
+
}
|
|
1454
|
+
static recordAutoAdvance() {
|
|
1455
|
+
_SessionMetricsCollector.get().metrics.autoAdvanceCount++;
|
|
1456
|
+
}
|
|
1457
|
+
static recordStuckDetection() {
|
|
1458
|
+
_SessionMetricsCollector.get().metrics.stuckDetectionCount++;
|
|
1459
|
+
}
|
|
1460
|
+
static addTokens(count) {
|
|
1461
|
+
_SessionMetricsCollector.get().metrics.tokensUsed += count;
|
|
1462
|
+
}
|
|
1463
|
+
static endSession() {
|
|
1464
|
+
const s = _SessionMetricsCollector.get();
|
|
1465
|
+
s.metrics.endTime = Date.now();
|
|
1466
|
+
}
|
|
1467
|
+
static getMetrics() {
|
|
1468
|
+
return { ..._SessionMetricsCollector.get().metrics };
|
|
1469
|
+
}
|
|
1470
|
+
static getMetricsSummary() {
|
|
1471
|
+
const m = _SessionMetricsCollector.get().metrics;
|
|
1472
|
+
const duration = m.endTime ? (m.endTime - m.startTime) / 1e3 : (Date.now() - m.startTime) / 1e3;
|
|
1473
|
+
const successRate = m.toolCalls.total === 0 ? 100 : m.toolCalls.successful / m.toolCalls.total * 100;
|
|
1474
|
+
return [
|
|
1475
|
+
`Session: ${m.sessionId}`,
|
|
1476
|
+
`Duration: ${duration.toFixed(1)}s`,
|
|
1477
|
+
`Tool Calls: ${m.toolCalls.total} (${successRate.toFixed(1)}% success)`,
|
|
1478
|
+
`Features Completed: ${m.featuresCompleted}`,
|
|
1479
|
+
`Auto-Advances: ${m.autoAdvanceCount}`,
|
|
1480
|
+
`Stuck Detections: ${m.stuckDetectionCount}`,
|
|
1481
|
+
`Errors: ${m.errors.total}`,
|
|
1482
|
+
`Tokens Used: ${m.tokensUsed}`
|
|
1483
|
+
].join("\n");
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
var sessionMetrics = SessionMetricsCollector.getInstance();
|
|
1487
|
+
|
|
1488
|
+
// src/ui/dashboard.ts
|
|
1489
|
+
function featureNextAction(feature) {
|
|
1490
|
+
if (feature.status === "blocked") return feature.notes ? `Unblock: ${clip(feature.notes)}` : "Unblock before continuing";
|
|
1491
|
+
if (feature.status === "waiting") return feature.dependsOn.length ? `Wait for ${feature.dependsOn.join(", ")}` : "Wait for external dependency";
|
|
1492
|
+
const next = pendingAcceptance(feature)[0];
|
|
1493
|
+
if (next) return `Finish: ${clip(next)}`;
|
|
1494
|
+
return `Advance: ${clip(feature.description || feature.title)}`;
|
|
1495
|
+
}
|
|
1496
|
+
function featureLabel(f) {
|
|
1497
|
+
const ac = acceptanceProgress(f);
|
|
1498
|
+
const badge = f.acceptance.length ? ` [${ac.label} AC]` : "";
|
|
1499
|
+
return `${featureStatusIcon(f.status)} ${f.id} [P${f.priority}] ${f.title}${badge}`;
|
|
1500
|
+
}
|
|
1501
|
+
function featureDescription(f, milestoneId) {
|
|
1502
|
+
const deps = f.dependsOn.length ? ` \u{1F517}${f.dependsOn.join(",")}` : "";
|
|
1503
|
+
return `${milestoneId}: ${f.description.slice(0, 70)}${deps}`;
|
|
1504
|
+
}
|
|
1505
|
+
function buildFeatureItems(mission) {
|
|
1506
|
+
let len = 1;
|
|
1507
|
+
for (const m of mission.milestones) {
|
|
1508
|
+
len += m.features.length;
|
|
1509
|
+
}
|
|
1510
|
+
const items = new Array(len);
|
|
1511
|
+
items[0] = {
|
|
1512
|
+
value: "__session_metrics__",
|
|
1513
|
+
label: "\u{1F4CA} Session Metrics",
|
|
1514
|
+
description: "View current session performance metrics"
|
|
1515
|
+
};
|
|
1516
|
+
let idx = 1;
|
|
1517
|
+
for (const m of mission.milestones) {
|
|
1518
|
+
for (const f of m.features) {
|
|
1519
|
+
items[idx++] = { value: f.id, label: featureLabel(f), description: featureDescription(f, m.id) };
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return items;
|
|
1523
|
+
}
|
|
1524
|
+
function sessionMetricsLines(width) {
|
|
1525
|
+
const m = sessionMetrics.getMetrics();
|
|
1526
|
+
const barW = Math.min(width - 4, 72);
|
|
1527
|
+
const bar = "\u2500".repeat(barW > 0 ? barW : 40);
|
|
1528
|
+
const duration = m.endTime ? (m.endTime - m.startTime) / 1e3 : (Date.now() - m.startTime) / 1e3;
|
|
1529
|
+
const successRate = m.toolCalls.total === 0 ? 100 : m.toolCalls.successful / m.toolCalls.total * 100;
|
|
1530
|
+
const lines = [bar, " \u{1F4CA} Session Metrics"];
|
|
1531
|
+
lines.push(` Session: ${m.sessionId}`);
|
|
1532
|
+
lines.push(` Health: ${m.errors.total === 0 ? "clean" : `${m.errors.total} errors`} | Tool success: ${successRate.toFixed(1)}%`);
|
|
1533
|
+
lines.push(` Throughput: ${m.featuresCompleted} features | ${m.toolCalls.total} tool calls | ${m.tokensUsed} tokens`);
|
|
1534
|
+
lines.push(` Duration: ${duration.toFixed(1)}s | Auto-advances: ${m.autoAdvanceCount} | Stuck: ${m.stuckDetectionCount}`);
|
|
1535
|
+
if (m.errors.total > 0) {
|
|
1536
|
+
lines.push(" Error categories:");
|
|
1537
|
+
for (const [cat, count] of Object.entries(m.errors.byCategory)) lines.push(` - ${cat}: ${count}`);
|
|
1538
|
+
}
|
|
1539
|
+
lines.push(bar);
|
|
1540
|
+
return lines;
|
|
1541
|
+
}
|
|
1542
|
+
var MissionControl = class {
|
|
1543
|
+
mission;
|
|
1544
|
+
tui;
|
|
1545
|
+
onAction;
|
|
1546
|
+
selectedIdx = 0;
|
|
1547
|
+
flatItems = [];
|
|
1548
|
+
filter = "";
|
|
1549
|
+
searchMode = false;
|
|
1550
|
+
width = 80;
|
|
1551
|
+
constructor(mission, tui, onAction) {
|
|
1552
|
+
this.mission = mission;
|
|
1553
|
+
this.tui = tui;
|
|
1554
|
+
this.onAction = onAction;
|
|
1555
|
+
this.flatItems = buildFeatureItems(mission);
|
|
1556
|
+
}
|
|
1557
|
+
visibleItems() {
|
|
1558
|
+
if (!this.filter) return this.flatItems;
|
|
1559
|
+
const q = this.filter.toLowerCase();
|
|
1560
|
+
return this.flatItems.filter(
|
|
1561
|
+
(item) => item.value !== "__session_metrics__" && `${item.value} ${item.label} ${item.description}`.toLowerCase().includes(q)
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
clampSelection() {
|
|
1565
|
+
const visible = this.visibleItems();
|
|
1566
|
+
this.selectedIdx = Math.max(0, Math.min(this.selectedIdx, Math.max(visible.length - 1, 0)));
|
|
1567
|
+
}
|
|
1568
|
+
renderDashboardLines() {
|
|
1569
|
+
const p = progress(this.mission);
|
|
1570
|
+
const s = buildMissionControlSummary(this.mission);
|
|
1571
|
+
const visible = this.visibleItems();
|
|
1572
|
+
const selectedValue = visible[this.selectedIdx]?.value;
|
|
1573
|
+
const icon = missionStatusIcon(this.mission.status);
|
|
1574
|
+
const lines = [
|
|
1575
|
+
`${icon} Mission Control \u2014 ${this.mission.title}`,
|
|
1576
|
+
` Goal: ${clip(this.mission.goal || "No goal", 88)}`,
|
|
1577
|
+
` Focus: ${s.active ? `${s.active.id} ${s.active.title}` : "None"} | Progress: ${p.done}/${p.total} (${p.pct}%)`,
|
|
1578
|
+
` Blocked/Waiting: ${s.blocked.length}/${s.waiting.length} | Handoff: ${clip(s.handoff, 76)}`
|
|
1579
|
+
];
|
|
1580
|
+
if (s.active) lines.push(` Next: ${featureNextAction(s.active)}`);
|
|
1581
|
+
lines.push(this.filter ? ` Filter: ${this.filter} (${visible.length} features)` : " / to search", "");
|
|
1582
|
+
lines.push(" Milestones " + progressBar(p.done, p.total, 12) + ` ${p.done}/${p.total} (${p.pct}%)`, "");
|
|
1583
|
+
if (this.filter && visible.length === 0) {
|
|
1584
|
+
lines.push(` No matching features for "${this.filter}"`, "");
|
|
1585
|
+
}
|
|
1586
|
+
const smItem = this.flatItems[0];
|
|
1587
|
+
if (smItem && smItem.value === "__session_metrics__" && !this.filter) {
|
|
1588
|
+
const smSel = smItem.value === selectedValue;
|
|
1589
|
+
const smPrefix = smSel ? "\u2192" : " ";
|
|
1590
|
+
lines.push(`${smPrefix} ${smItem.label}`, "");
|
|
1591
|
+
if (smSel) {
|
|
1592
|
+
lines.push(...sessionMetricsLines(this.width));
|
|
1593
|
+
lines.push("");
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
for (const m of this.mission.milestones) {
|
|
1597
|
+
const mDone = m.features.filter((f) => f.status === "done").length;
|
|
1598
|
+
const mTotal = m.features.length;
|
|
1599
|
+
const msIcon = m.status === "complete" ? "\u2705" : m.status === "active" ? "\u27A1\uFE0F" : "\u2022";
|
|
1600
|
+
const order = { active: 0, pending: 1, waiting: 2, blocked: 3, failed: 4, done: 5 };
|
|
1601
|
+
const sorted = [...m.features].sort((a, b) => (order[a.status] ?? 5) - (order[b.status] ?? 5) || a.priority - b.priority);
|
|
1602
|
+
const shown = this.filter ? sorted.filter((f) => visible.some((v) => v.value === f.id)) : sorted;
|
|
1603
|
+
if (!shown.length) continue;
|
|
1604
|
+
lines.push(` ${msIcon} ${m.id}: ${m.title} ${progressBar(mDone, mTotal, 12)} ${mDone}/${mTotal}`);
|
|
1605
|
+
for (const f of shown) {
|
|
1606
|
+
const sel = f.id === selectedValue;
|
|
1607
|
+
const prefix = sel ? "\u2192" : " ";
|
|
1608
|
+
const ac = acceptanceProgress(f);
|
|
1609
|
+
const badge = f.acceptance.length ? ` [${ac.label} AC]` : "";
|
|
1610
|
+
const deps = f.dependsOn.length ? ` \u{1F517}${f.dependsOn.join(",")}` : "";
|
|
1611
|
+
lines.push(`${prefix} ${featureStatusIcon(f.status)} ${f.id} [P${f.priority}] ${f.title}${badge}${deps}`);
|
|
1612
|
+
if (sel || f.id === this.mission.activeFeatureId) {
|
|
1613
|
+
lines.push(` ${f.id}: ${f.title}`);
|
|
1614
|
+
if (f.description) lines.push(` ${clip(f.description, 88)}`);
|
|
1615
|
+
const chain = dependsOnChain(this.mission, f);
|
|
1616
|
+
if (chain.length) lines.push(` \u{1F517} Chain: ${formatDepChain(chain)}`);
|
|
1617
|
+
lines.push(` AC: ${ac.label}${f.dependsOn.length ? ` | deps: ${f.dependsOn.join(", ")}` : ""}`);
|
|
1618
|
+
lines.push(` Next: ${featureNextAction(f)}`);
|
|
1619
|
+
for (const a of pendingAcceptance(f).slice(0, 4)) lines.push(` \u2610 ${clip(a, 82)}`);
|
|
1620
|
+
if (f.startedAt && Date.now() - f.startedAt > 6e5) {
|
|
1621
|
+
lines.push(` Active ${Math.round((Date.now() - f.startedAt) / 6e4)}min`);
|
|
1622
|
+
}
|
|
1623
|
+
if (f.toolCallCount > 50) lines.push(` ${f.toolCallCount} tool calls`);
|
|
1624
|
+
if (f.notes) lines.push(` Note: ${clip(f.notes, 82)}`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
lines.push("");
|
|
1628
|
+
}
|
|
1629
|
+
lines.push(" Keys: \u2191\u2193/j/k navigate | Enter select | / search | Backspace/Ctrl+U clear | Esc close");
|
|
1630
|
+
return lines;
|
|
1631
|
+
}
|
|
1632
|
+
// ── Component interface ────────────────────────────────────────────────
|
|
1633
|
+
render(width) {
|
|
1634
|
+
this.width = width;
|
|
1635
|
+
return this.renderDashboardLines();
|
|
1636
|
+
}
|
|
1637
|
+
invalidate() {
|
|
1638
|
+
}
|
|
1639
|
+
handleInput(data) {
|
|
1640
|
+
if (data === "/") {
|
|
1641
|
+
this.searchMode = true;
|
|
1642
|
+
this.filter = "";
|
|
1643
|
+
this.selectedIdx = 0;
|
|
1644
|
+
this.tui.requestRender();
|
|
1645
|
+
return true;
|
|
1646
|
+
}
|
|
1647
|
+
if (this.searchMode && data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) {
|
|
1648
|
+
this.filter += data;
|
|
1649
|
+
this.selectedIdx = 0;
|
|
1650
|
+
this.clampSelection();
|
|
1651
|
+
this.tui.requestRender();
|
|
1652
|
+
return true;
|
|
1653
|
+
}
|
|
1654
|
+
if (data === "\b" || data === "\x7F") {
|
|
1655
|
+
if (this.filter) {
|
|
1656
|
+
this.filter = this.filter.slice(0, -1);
|
|
1657
|
+
this.selectedIdx = 0;
|
|
1658
|
+
this.clampSelection();
|
|
1659
|
+
}
|
|
1660
|
+
this.tui.requestRender();
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
if (data === "") {
|
|
1664
|
+
this.filter = "";
|
|
1665
|
+
this.searchMode = false;
|
|
1666
|
+
this.selectedIdx = 0;
|
|
1667
|
+
this.tui.requestRender();
|
|
1668
|
+
return true;
|
|
1669
|
+
}
|
|
1670
|
+
if (data === "\x1B[B" || data === "j") {
|
|
1671
|
+
this.selectedIdx = Math.min(this.selectedIdx + 1, Math.max(this.visibleItems().length - 1, 0));
|
|
1672
|
+
this.tui.requestRender();
|
|
1673
|
+
return true;
|
|
1674
|
+
}
|
|
1675
|
+
if (data === "\x1B[A" || data === "k") {
|
|
1676
|
+
this.selectedIdx = Math.max(this.selectedIdx - 1, 0);
|
|
1677
|
+
this.tui.requestRender();
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
if (data === "\r" || data === "\n") {
|
|
1681
|
+
const item = this.visibleItems()[this.selectedIdx];
|
|
1682
|
+
if (item && item.value !== "__session_metrics__") {
|
|
1683
|
+
this.onAction?.(item.value);
|
|
1684
|
+
this.tui.hideOverlay();
|
|
1685
|
+
}
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
if (data === "\x1B") {
|
|
1689
|
+
if (this.filter || this.searchMode) {
|
|
1690
|
+
this.filter = "";
|
|
1691
|
+
this.searchMode = false;
|
|
1692
|
+
this.selectedIdx = 0;
|
|
1693
|
+
this.tui.requestRender();
|
|
1694
|
+
return true;
|
|
1695
|
+
}
|
|
1696
|
+
this.tui.hideOverlay();
|
|
1697
|
+
return true;
|
|
1698
|
+
}
|
|
1699
|
+
return false;
|
|
1700
|
+
}
|
|
1701
|
+
dispose() {
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
function missionControlOverlay(mission, onAction) {
|
|
1705
|
+
return (tui) => new MissionControl(mission, tui, onAction);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// src/engines/completion.ts
|
|
1709
|
+
var MAX_RECENT_TOOLS = 20;
|
|
1710
|
+
var MAX_RECENT_TEXTS = 10;
|
|
1711
|
+
var ERROR_FREE_STREAK_THRESHOLD = 5;
|
|
1712
|
+
var STUCK_FAILURE_THRESHOLD = 3;
|
|
1713
|
+
var STUCK_REPEAT_PATTERN_THRESHOLD = 5;
|
|
1714
|
+
var TEXT_LOOP_SIMILARITY_THRESHOLD = 4;
|
|
1715
|
+
var CompletionDetector = class {
|
|
1716
|
+
recentToolCalls = [];
|
|
1717
|
+
recentTextOutputs = [];
|
|
1718
|
+
recordToolCall(tool, success) {
|
|
1719
|
+
this.recentToolCalls.push({ tool, success, timestamp: Date.now() });
|
|
1720
|
+
if (this.recentToolCalls.length > MAX_RECENT_TOOLS) this.recentToolCalls.shift();
|
|
1721
|
+
}
|
|
1722
|
+
recordTextOutput(text) {
|
|
1723
|
+
if (!text || text.length < 20) return;
|
|
1724
|
+
const hash = text.slice(0, 100).toLowerCase().replace(/\s+/g, " ").trim();
|
|
1725
|
+
this.recentTextOutputs.push({ hash, timestamp: Date.now() });
|
|
1726
|
+
if (this.recentTextOutputs.length > MAX_RECENT_TEXTS) this.recentTextOutputs.shift();
|
|
1727
|
+
}
|
|
1728
|
+
clearToolCallHistory() {
|
|
1729
|
+
this.recentToolCalls = [];
|
|
1730
|
+
this.recentTextOutputs = [];
|
|
1731
|
+
}
|
|
1732
|
+
// ── Completion detection ────────────────────────────────────────────────
|
|
1733
|
+
detectCompletion(feature, agentText) {
|
|
1734
|
+
const signals = [];
|
|
1735
|
+
const now = Date.now();
|
|
1736
|
+
const kw = this.detectKeywordSignal(agentText, now);
|
|
1737
|
+
if (kw) signals.push(kw);
|
|
1738
|
+
const ac = this.detectAcceptanceSignal(feature, now);
|
|
1739
|
+
if (ac) signals.push(ac);
|
|
1740
|
+
const tp = this.detectToolPatternSignal(now);
|
|
1741
|
+
if (tp) signals.push(tp);
|
|
1742
|
+
const ef = this.detectErrorFreeStreakSignal(now);
|
|
1743
|
+
if (ef) signals.push(ef);
|
|
1744
|
+
return this.aggregateSignals(signals, feature);
|
|
1745
|
+
}
|
|
1746
|
+
detectKeywordSignal(text, now) {
|
|
1747
|
+
const lower = text.toLowerCase();
|
|
1748
|
+
const keywords = [
|
|
1749
|
+
"done",
|
|
1750
|
+
"complete",
|
|
1751
|
+
"completed",
|
|
1752
|
+
"finished",
|
|
1753
|
+
"implemented",
|
|
1754
|
+
"klaar",
|
|
1755
|
+
"voltooid",
|
|
1756
|
+
"tests pass",
|
|
1757
|
+
"tests slagen",
|
|
1758
|
+
"success",
|
|
1759
|
+
"working",
|
|
1760
|
+
"functional",
|
|
1761
|
+
"ready"
|
|
1762
|
+
];
|
|
1763
|
+
const found = keywords.filter((kw) => lower.includes(kw));
|
|
1764
|
+
if (found.length === 0) return null;
|
|
1765
|
+
const confidence = found.length >= 2 ? "high" : "medium";
|
|
1766
|
+
return { type: "keyword", confidence, evidence: `Found completion keywords: ${found.join(", ")}`, timestamp: now };
|
|
1767
|
+
}
|
|
1768
|
+
detectAcceptanceSignal(feature, now) {
|
|
1769
|
+
if (feature.acceptance.length === 0) return null;
|
|
1770
|
+
const verified = feature.acceptance.filter((ac) => ac.verified).length;
|
|
1771
|
+
const waived = feature.acceptance.filter((ac) => ac.waived).length;
|
|
1772
|
+
const satisfied = verified + waived;
|
|
1773
|
+
const total = feature.acceptance.length;
|
|
1774
|
+
const pct = satisfied / total * 100;
|
|
1775
|
+
if (pct === 100) {
|
|
1776
|
+
return { type: "acceptance", confidence: "high", evidence: `All ${total} acceptance criteria satisfied (${verified} verified, ${waived} waived)`, timestamp: now };
|
|
1777
|
+
}
|
|
1778
|
+
if (pct >= 75) {
|
|
1779
|
+
return { type: "acceptance", confidence: "medium", evidence: `${satisfied}/${total} criteria satisfied (${pct.toFixed(0)}%)`, timestamp: now };
|
|
1780
|
+
}
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
detectToolPatternSignal(now) {
|
|
1784
|
+
if (this.recentToolCalls.length < 3) return null;
|
|
1785
|
+
const reads = this.recentToolCalls.filter((tc) => tc.tool === "read").length;
|
|
1786
|
+
const pct = reads / this.recentToolCalls.length * 100;
|
|
1787
|
+
if (pct >= 70) {
|
|
1788
|
+
return { type: "tool_pattern", confidence: "medium", evidence: `${pct.toFixed(0)}% of recent ${this.recentToolCalls.length} tool calls were read operations`, timestamp: now };
|
|
1789
|
+
}
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
detectErrorFreeStreakSignal(now) {
|
|
1793
|
+
if (this.recentToolCalls.length < ERROR_FREE_STREAK_THRESHOLD) return null;
|
|
1794
|
+
const recent = this.recentToolCalls.slice(-ERROR_FREE_STREAK_THRESHOLD);
|
|
1795
|
+
if (recent.every((tc) => tc.success)) {
|
|
1796
|
+
return { type: "error_free_streak", confidence: "medium", evidence: `Last ${ERROR_FREE_STREAK_THRESHOLD} tool calls successful`, timestamp: now };
|
|
1797
|
+
}
|
|
1798
|
+
return null;
|
|
1799
|
+
}
|
|
1800
|
+
// ── Stuck detection ─────────────────────────────────────────────────────
|
|
1801
|
+
detectTextLoop() {
|
|
1802
|
+
if (this.recentTextOutputs.length < TEXT_LOOP_SIMILARITY_THRESHOLD) {
|
|
1803
|
+
return { isStuck: false, reason: "Insufficient text history", suggestedAction: "continue" };
|
|
1804
|
+
}
|
|
1805
|
+
const recent = this.recentTextOutputs.slice(-TEXT_LOOP_SIMILARITY_THRESHOLD);
|
|
1806
|
+
const unique = new Set(recent.map((t) => t.hash));
|
|
1807
|
+
if (unique.size <= 2) {
|
|
1808
|
+
return { isStuck: true, reason: `Text loop: ${unique.size} unique outputs in last ${TEXT_LOOP_SIMILARITY_THRESHOLD} turns`, suggestedAction: "block_self" };
|
|
1809
|
+
}
|
|
1810
|
+
const stuckPhrases = [
|
|
1811
|
+
"i've been stuck in a loop",
|
|
1812
|
+
"i need to ask the user directly",
|
|
1813
|
+
"actually, let me just provide a final summary",
|
|
1814
|
+
"operation aborted"
|
|
1815
|
+
];
|
|
1816
|
+
let count = 0;
|
|
1817
|
+
for (const t of recent) {
|
|
1818
|
+
if (stuckPhrases.some((p) => t.hash.includes(p))) count++;
|
|
1819
|
+
}
|
|
1820
|
+
if (count >= 3) {
|
|
1821
|
+
return { isStuck: true, reason: `Stuck phrases in ${count}/${TEXT_LOOP_SIMILARITY_THRESHOLD} recent turns`, suggestedAction: "block_self" };
|
|
1822
|
+
}
|
|
1823
|
+
return { isStuck: false, reason: "No text loop detected", suggestedAction: "continue" };
|
|
1824
|
+
}
|
|
1825
|
+
detectStuck() {
|
|
1826
|
+
if (this.recentToolCalls.length < STUCK_FAILURE_THRESHOLD) {
|
|
1827
|
+
return { isStuck: false, reason: "Insufficient tool call history", suggestedAction: "continue" };
|
|
1828
|
+
}
|
|
1829
|
+
const recent = this.recentToolCalls.slice(-STUCK_FAILURE_THRESHOLD);
|
|
1830
|
+
if (recent.every((tc) => !tc.success)) {
|
|
1831
|
+
return { isStuck: true, reason: `${STUCK_FAILURE_THRESHOLD} consecutive failures`, suggestedAction: "block_self" };
|
|
1832
|
+
}
|
|
1833
|
+
const last5 = this.recentToolCalls.slice(-STUCK_REPEAT_PATTERN_THRESHOLD);
|
|
1834
|
+
if (last5.length >= STUCK_REPEAT_PATTERN_THRESHOLD) {
|
|
1835
|
+
const uniqueTools = new Set(last5.map((tc) => tc.tool));
|
|
1836
|
+
if (uniqueTools.size === 1) {
|
|
1837
|
+
return { isStuck: true, reason: `${STUCK_REPEAT_PATTERN_THRESHOLD} consecutive calls to '${last5[0].tool}'`, suggestedAction: "block_self" };
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
const recent10 = this.recentToolCalls.slice(-10);
|
|
1841
|
+
if (recent10.length >= 5) {
|
|
1842
|
+
const failures = recent10.filter((tc) => !tc.success).length;
|
|
1843
|
+
if (failures / recent10.length >= 0.7) {
|
|
1844
|
+
return { isStuck: true, reason: `High failure rate: ${failures}/${recent10.length} recent calls`, suggestedAction: "block_self" };
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return { isStuck: false, reason: "No stuck pattern", suggestedAction: "continue" };
|
|
1848
|
+
}
|
|
1849
|
+
// ── Aggregation ─────────────────────────────────────────────────────────
|
|
1850
|
+
aggregateSignals(signals, feature) {
|
|
1851
|
+
if (signals.length === 0) {
|
|
1852
|
+
return { isComplete: false, confidence: "low", signals: [], suggestedAction: "continue", reason: "No completion signals" };
|
|
1853
|
+
}
|
|
1854
|
+
const high = signals.filter((s) => s.confidence === "high").length;
|
|
1855
|
+
const medium = signals.filter((s) => s.confidence === "medium").length;
|
|
1856
|
+
let confidence;
|
|
1857
|
+
if (high >= 2 || high === 1 && medium >= 2) confidence = "high";
|
|
1858
|
+
else if (high === 1 || medium >= 2) confidence = "medium";
|
|
1859
|
+
else confidence = "low";
|
|
1860
|
+
const acPct = feature.acceptance.length > 0 ? feature.acceptance.filter((ac) => ac.verified || ac.waived).length / feature.acceptance.length * 100 : 0;
|
|
1861
|
+
let suggestedAction;
|
|
1862
|
+
let reason;
|
|
1863
|
+
if (confidence === "high" && acPct >= 100) {
|
|
1864
|
+
suggestedAction = "auto_done";
|
|
1865
|
+
reason = "High confidence, all acceptance criteria satisfied";
|
|
1866
|
+
} else if (confidence === "high" && acPct >= 75) {
|
|
1867
|
+
suggestedAction = "suggest_done";
|
|
1868
|
+
reason = "High confidence but some acceptance unverified";
|
|
1869
|
+
} else if (confidence === "medium" && acPct >= 100) {
|
|
1870
|
+
suggestedAction = "suggest_done";
|
|
1871
|
+
reason = "Medium confidence, all acceptance satisfied";
|
|
1872
|
+
} else if (confidence === "medium") {
|
|
1873
|
+
suggestedAction = "ask_user";
|
|
1874
|
+
reason = "Medium confidence \u2014 ask user";
|
|
1875
|
+
} else {
|
|
1876
|
+
suggestedAction = "continue";
|
|
1877
|
+
reason = "Low confidence \u2014 continue";
|
|
1878
|
+
}
|
|
1879
|
+
return {
|
|
1880
|
+
isComplete: suggestedAction === "auto_done" || suggestedAction === "suggest_done",
|
|
1881
|
+
confidence,
|
|
1882
|
+
signals,
|
|
1883
|
+
suggestedAction,
|
|
1884
|
+
reason
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
var globalDetector = null;
|
|
1889
|
+
function getCompletionDetector() {
|
|
1890
|
+
if (!globalDetector) globalDetector = new CompletionDetector();
|
|
1891
|
+
return globalDetector;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// src/engines/autopilot.ts
|
|
1895
|
+
function shouldContinue(mission, ctx) {
|
|
1896
|
+
const a = mission.autopilot;
|
|
1897
|
+
if (!a.enabled) return { continue: false, reason: "disabled" };
|
|
1898
|
+
if (mission.status === "paused") return { continue: false, reason: "paused_by_user" };
|
|
1899
|
+
if (mission.status === "blocked") return { continue: false, reason: "blocked" };
|
|
1900
|
+
if (mission.status === "complete") return { continue: false, reason: "mission_complete" };
|
|
1901
|
+
if (a.iteration >= a.maxIterations) return { continue: false, reason: "max_iterations" };
|
|
1902
|
+
if (a.consecutiveFailures >= a.maxConsecutiveFailures) return { continue: false, reason: "max_consecutive_failures" };
|
|
1903
|
+
if (a.noProgressTurns >= a.maxNoProgressTurns) return { continue: false, reason: "no_progress" };
|
|
1904
|
+
const usage = ctx?.getContextUsage?.() ?? { percent: 0 };
|
|
1905
|
+
if ((usage.percent ?? 0) > a.maxContextPercent) return { continue: false, reason: "context_limit" };
|
|
1906
|
+
return { continue: true };
|
|
1907
|
+
}
|
|
1908
|
+
function ensureActiveFeature(mission) {
|
|
1909
|
+
let feature = getActiveFeature(mission);
|
|
1910
|
+
if (feature && feature.status === "active") return feature;
|
|
1911
|
+
autoUnblockResolved(mission);
|
|
1912
|
+
const next = getNextPendingFeature(mission);
|
|
1913
|
+
if (next) {
|
|
1914
|
+
next.status = "active";
|
|
1915
|
+
mission.activeFeatureId = next.id;
|
|
1916
|
+
mission.activeMilestoneId = next.milestoneId;
|
|
1917
|
+
appendHistory(mission, { event: "feature_active", featureId: next.id });
|
|
1918
|
+
return next;
|
|
1919
|
+
}
|
|
1920
|
+
if (mission.milestones.every((m) => m.features.every((f) => f.status === "done"))) {
|
|
1921
|
+
mission.status = "complete";
|
|
1922
|
+
mission.autopilot.enabled = false;
|
|
1923
|
+
mission.autopilot.lastStopReason = "mission_complete";
|
|
1924
|
+
}
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
function buildContinuationPrompt(mission) {
|
|
1928
|
+
const feature = getActiveFeature(mission) || { id: "none", title: "no active feature" };
|
|
1929
|
+
return [
|
|
1930
|
+
`Mission: ${mission.title}`,
|
|
1931
|
+
`Active feature: ${feature.id} - ${feature.title}`,
|
|
1932
|
+
`Autopilot iteration: ${mission.autopilot.iteration}`,
|
|
1933
|
+
"",
|
|
1934
|
+
"Continue the mission with ONE controlled turn. After this turn the autopilot will evaluate progress and decide whether to continue or stop.",
|
|
1935
|
+
"Stop conditions: mission complete, user stop, blocker, max iterations, etc."
|
|
1936
|
+
].join("\n");
|
|
1937
|
+
}
|
|
1938
|
+
async function triggerContinuation(pi, ctx, mission) {
|
|
1939
|
+
const decision = shouldContinue(mission, ctx);
|
|
1940
|
+
if (!decision.continue) {
|
|
1941
|
+
mission.autopilot.lastStopReason = decision.reason;
|
|
1942
|
+
await saveMissionSafe(mission);
|
|
1943
|
+
updateFooter(ctx, mission);
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
mission.autopilot.iteration++;
|
|
1947
|
+
mission.autopilot.lastContinuationAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1948
|
+
await pi.sendUserMessage(buildContinuationPrompt(mission));
|
|
1949
|
+
await saveMissionSafe(mission);
|
|
1950
|
+
updateFooter(ctx, mission);
|
|
1951
|
+
}
|
|
1952
|
+
async function processAgentEndForAutopilot(pi, ctx, event, runtime) {
|
|
1953
|
+
const mission = runtime.activeMission;
|
|
1954
|
+
if (!mission?.autopilot?.enabled) return;
|
|
1955
|
+
const feature = getActiveFeature(mission);
|
|
1956
|
+
const text = (event.messages ?? []).flatMap((m) => Array.isArray(m.content) ? m.content : []).filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
1957
|
+
const lower = text.toLowerCase();
|
|
1958
|
+
const wasAborted = /\b(operation aborted|operation cancelled|action cancelled)\b/i.test(text);
|
|
1959
|
+
const detector = getCompletionDetector();
|
|
1960
|
+
const textLoop = detector.detectTextLoop();
|
|
1961
|
+
const inTextLoop = textLoop.isStuck;
|
|
1962
|
+
const wantsToAskUser = /\b(need to ask the user|should ask the user|must ask the user)\b/i.test(lower) && !lower.includes("mission_ask_user");
|
|
1963
|
+
const isBlocked = mission.status === "blocked" || feature && feature.status === "blocked" || /\b(blocked|block self|self-block|stuck|cannot proceed|deadlock|need external|api key|permission)\b/i.test(lower);
|
|
1964
|
+
if (inTextLoop || wasAborted) {
|
|
1965
|
+
mission.autopilot.noProgressTurns += (inTextLoop ? 2 : 0) + (wasAborted ? 1 : 0);
|
|
1966
|
+
}
|
|
1967
|
+
if (wantsToAskUser) {
|
|
1968
|
+
mission.autopilot.enabled = false;
|
|
1969
|
+
mission.autopilot.lastStopReason = "needs_user_decision";
|
|
1970
|
+
mission.autopilot.lastStopMessage = text.slice(0, 200);
|
|
1971
|
+
appendHistory(mission, { event: "autopilot_stopped", note: "needs_user_decision (model wants to ask)" });
|
|
1972
|
+
await saveMissionSafe(mission);
|
|
1973
|
+
updateFooter(ctx, mission);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
if (isBlocked) {
|
|
1977
|
+
mission.autopilot.enabled = false;
|
|
1978
|
+
mission.autopilot.lastStopReason = "blocked";
|
|
1979
|
+
mission.autopilot.lastStopMessage = text.slice(0, 200);
|
|
1980
|
+
if (feature) feature.status = "blocked";
|
|
1981
|
+
appendHistory(mission, { event: "autopilot_stopped", note: "blocked" });
|
|
1982
|
+
await saveMissionSafe(mission);
|
|
1983
|
+
updateFooter(ctx, mission);
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
const madeProgress = !lower.includes("no progress") && !lower.includes("same state");
|
|
1987
|
+
if (!madeProgress) mission.autopilot.noProgressTurns++;
|
|
1988
|
+
else mission.autopilot.noProgressTurns = Math.max(0, mission.autopilot.noProgressTurns - 1);
|
|
1989
|
+
const hasError = lower.includes("error") || lower.includes("failed");
|
|
1990
|
+
if (hasError) mission.autopilot.consecutiveFailures++;
|
|
1991
|
+
else mission.autopilot.consecutiveFailures = 0;
|
|
1992
|
+
const decision = shouldContinue(mission, ctx);
|
|
1993
|
+
if (decision.continue) {
|
|
1994
|
+
await triggerContinuation(pi, ctx, mission);
|
|
1995
|
+
} else {
|
|
1996
|
+
mission.autopilot.enabled = false;
|
|
1997
|
+
mission.autopilot.lastStopReason = decision.reason;
|
|
1998
|
+
appendHistory(mission, { event: "autopilot_stopped", note: decision.reason });
|
|
1999
|
+
await saveMissionSafe(mission);
|
|
2000
|
+
updateFooter(ctx, mission);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// src/engines/worker.ts
|
|
2005
|
+
import { spawn } from "child_process";
|
|
2006
|
+
var activeWorker = null;
|
|
2007
|
+
function getActiveWorker() {
|
|
2008
|
+
return activeWorker;
|
|
2009
|
+
}
|
|
2010
|
+
function isWorkerRunning() {
|
|
2011
|
+
return activeWorker?.status === "running";
|
|
2012
|
+
}
|
|
2013
|
+
function getExtensionPath() {
|
|
2014
|
+
return process.env.PI_MISSIONS_EXTENSION_PATH || null;
|
|
2015
|
+
}
|
|
2016
|
+
var MAX_STDOUT_BYTES = 5e4;
|
|
2017
|
+
var MAX_STDERR_BYTES = 25e3;
|
|
2018
|
+
function buildWorkerPrompt(mission, feature, customPrompt) {
|
|
2019
|
+
const context = buildLeanContext(mission);
|
|
2020
|
+
const lines = [
|
|
2021
|
+
context,
|
|
2022
|
+
"",
|
|
2023
|
+
`You are a worker agent executing a single feature in a software development mission.`,
|
|
2024
|
+
``,
|
|
2025
|
+
`## First: Load the mission`,
|
|
2026
|
+
`Run: /mission load ${mission.id}`,
|
|
2027
|
+
``,
|
|
2028
|
+
`3. Implement the smallest, most coherent change that satisfies the acceptance criteria`,
|
|
2029
|
+
`4. When ALL acceptance criteria are satisfied, call **mission_feature_done** with evidence`,
|
|
2030
|
+
`5. If you get stuck, call **mission_block_self** with a clear reason`,
|
|
2031
|
+
`6. If you need user input, call **mission_ask_user**`,
|
|
2032
|
+
`7. Do NOT move to the NEXT feature \u2014 just complete this one and stop`
|
|
2033
|
+
];
|
|
2034
|
+
if (customPrompt) {
|
|
2035
|
+
lines.push("", "## Additional Instructions", customPrompt);
|
|
2036
|
+
}
|
|
2037
|
+
lines.push(
|
|
2038
|
+
"",
|
|
2039
|
+
"## Output",
|
|
2040
|
+
"Provide a brief summary of what you accomplished, then call mission_feature_done with evidence."
|
|
2041
|
+
);
|
|
2042
|
+
return lines.join("\n");
|
|
2043
|
+
}
|
|
2044
|
+
function spawnWorker(mission, config) {
|
|
2045
|
+
if (isWorkerRunning()) {
|
|
2046
|
+
const running = activeWorker;
|
|
2047
|
+
if (running) {
|
|
2048
|
+
return Promise.resolve({ error: `Worker already running for feature ${running.featureId}. Wait for it to finish.` });
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
const feature = getFeatureById(mission, config.featureId);
|
|
2052
|
+
if (!feature) {
|
|
2053
|
+
return Promise.resolve({ error: `Feature not found: ${config.featureId}` });
|
|
2054
|
+
}
|
|
2055
|
+
const prompt = buildWorkerPrompt(mission, feature, config.customPrompt);
|
|
2056
|
+
const piPath = process.env.PI_PATH || "pi";
|
|
2057
|
+
const model = config.model || process.env.PI_WORKER_MODEL || "auto";
|
|
2058
|
+
const timeoutMs = config.timeoutMs ?? 6e5;
|
|
2059
|
+
const extPath = getExtensionPath();
|
|
2060
|
+
const args = ["--model", model];
|
|
2061
|
+
if (extPath) {
|
|
2062
|
+
args.push("-e", extPath);
|
|
2063
|
+
}
|
|
2064
|
+
args.push(prompt);
|
|
2065
|
+
const child = spawn(piPath, args, {
|
|
2066
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2067
|
+
env: { ...process.env, PI_NO_COLOR: "1" }
|
|
2068
|
+
});
|
|
2069
|
+
let stdout = "";
|
|
2070
|
+
let stderr = "";
|
|
2071
|
+
let killed = false;
|
|
2072
|
+
child.stdout?.on("data", (chunk) => {
|
|
2073
|
+
if (stdout.length < MAX_STDOUT_BYTES) {
|
|
2074
|
+
stdout += chunk.toString().slice(0, MAX_STDOUT_BYTES - stdout.length);
|
|
2075
|
+
}
|
|
2076
|
+
});
|
|
2077
|
+
child.stderr?.on("data", (chunk) => {
|
|
2078
|
+
if (stderr.length < MAX_STDERR_BYTES) {
|
|
2079
|
+
stderr += chunk.toString().slice(0, MAX_STDERR_BYTES - stderr.length);
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
const startedAt = Date.now();
|
|
2083
|
+
activeWorker = {
|
|
2084
|
+
process: child,
|
|
2085
|
+
featureId: config.featureId,
|
|
2086
|
+
startedAt,
|
|
2087
|
+
status: "running"
|
|
2088
|
+
};
|
|
2089
|
+
const timeout = setTimeout(() => {
|
|
2090
|
+
killed = true;
|
|
2091
|
+
if (activeWorker) activeWorker.status = "timeout";
|
|
2092
|
+
child.kill("SIGTERM");
|
|
2093
|
+
setTimeout(() => {
|
|
2094
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
2095
|
+
}, 5e3);
|
|
2096
|
+
}, timeoutMs);
|
|
2097
|
+
return new Promise((resolve3) => {
|
|
2098
|
+
child.on("close", (code, signal) => {
|
|
2099
|
+
clearTimeout(timeout);
|
|
2100
|
+
const durationMs = Date.now() - startedAt;
|
|
2101
|
+
const result = {
|
|
2102
|
+
featureId: config.featureId,
|
|
2103
|
+
exitCode: code,
|
|
2104
|
+
signal: signal ?? null,
|
|
2105
|
+
stdout,
|
|
2106
|
+
stderr,
|
|
2107
|
+
durationMs,
|
|
2108
|
+
killed
|
|
2109
|
+
};
|
|
2110
|
+
if (activeWorker) {
|
|
2111
|
+
activeWorker.result = result;
|
|
2112
|
+
activeWorker.status = result.killed ? "timeout" : result.exitCode === 0 ? "done" : "error";
|
|
2113
|
+
}
|
|
2114
|
+
try {
|
|
2115
|
+
const missionId = mission.id;
|
|
2116
|
+
appendHistory(mission, {
|
|
2117
|
+
event: "worker_finished",
|
|
2118
|
+
featureId: config.featureId,
|
|
2119
|
+
note: `Exit ${code}${signal ? ` (${signal})` : ""} in ${Math.round(durationMs / 1e3)}s`,
|
|
2120
|
+
details: {
|
|
2121
|
+
exitCode: code,
|
|
2122
|
+
signal: signal ?? void 0,
|
|
2123
|
+
durationMs,
|
|
2124
|
+
killed,
|
|
2125
|
+
missionId,
|
|
2126
|
+
stdoutLen: stdout.length,
|
|
2127
|
+
stderrLen: stderr.length,
|
|
2128
|
+
model
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
saveMissionSafe(mission).catch(() => {
|
|
2132
|
+
});
|
|
2133
|
+
} catch {
|
|
2134
|
+
}
|
|
2135
|
+
resolve3(result);
|
|
2136
|
+
});
|
|
2137
|
+
child.on("error", (err) => {
|
|
2138
|
+
clearTimeout(timeout);
|
|
2139
|
+
activeWorker = null;
|
|
2140
|
+
resolve3({
|
|
2141
|
+
error: `Worker process error: ${err.message}`
|
|
2142
|
+
});
|
|
2143
|
+
});
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
function killWorker() {
|
|
2147
|
+
if (!activeWorker || activeWorker.status !== "running") return false;
|
|
2148
|
+
activeWorker.process.kill("SIGTERM");
|
|
2149
|
+
return true;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// src/tools/index.ts
|
|
2153
|
+
init_types();
|
|
2154
|
+
import { Type as Type2 } from "@sinclair/typebox";
|
|
2155
|
+
|
|
2156
|
+
// src/engines/recovery.ts
|
|
2157
|
+
import * as crypto2 from "crypto";
|
|
2158
|
+
var ErrorRecoveryEngine = class {
|
|
2159
|
+
errorRecords = /* @__PURE__ */ new Map();
|
|
2160
|
+
activeRetries = /* @__PURE__ */ new Map();
|
|
2161
|
+
alertCallbacks = /* @__PURE__ */ new Set();
|
|
2162
|
+
consecutiveFailures = /* @__PURE__ */ new Map();
|
|
2163
|
+
alertThresholds = {
|
|
2164
|
+
criticalErrors: 1,
|
|
2165
|
+
consecutiveFailures: 3,
|
|
2166
|
+
totalErrorsBeforeAlert: 5,
|
|
2167
|
+
windowMs: 6e4
|
|
2168
|
+
};
|
|
2169
|
+
strategies = /* @__PURE__ */ new Map([
|
|
2170
|
+
["transient", { category: "transient", maxRetries: 3, backoffMs: 1e3, fallbackAction: "retry" }],
|
|
2171
|
+
["network", { category: "network", maxRetries: 5, backoffMs: 2e3, fallbackAction: "retry" }],
|
|
2172
|
+
["permission", { category: "permission", maxRetries: 0, backoffMs: 0, fallbackAction: "ask_user" }],
|
|
2173
|
+
["permanent", { category: "permanent", maxRetries: 0, backoffMs: 0, fallbackAction: "block" }],
|
|
2174
|
+
["user", { category: "user", maxRetries: 0, backoffMs: 0, fallbackAction: "ask_user" }],
|
|
2175
|
+
["system", { category: "system", maxRetries: 2, backoffMs: 5e3, fallbackAction: "degrade" }],
|
|
2176
|
+
["unknown", { category: "unknown", maxRetries: 1, backoffMs: 1e3, fallbackAction: "skip" }]
|
|
2177
|
+
]);
|
|
2178
|
+
onAlert(cb) {
|
|
2179
|
+
this.alertCallbacks.add(cb);
|
|
2180
|
+
return () => this.alertCallbacks.delete(cb);
|
|
2181
|
+
}
|
|
2182
|
+
setAlertThresholds(t) {
|
|
2183
|
+
Object.assign(this.alertThresholds, t);
|
|
2184
|
+
}
|
|
2185
|
+
fireAlert(type, record, message) {
|
|
2186
|
+
const recent = Array.from(this.errorRecords.values()).filter(
|
|
2187
|
+
(r) => Date.now() - r.timestamp < this.alertThresholds.windowMs
|
|
2188
|
+
);
|
|
2189
|
+
const stats = {
|
|
2190
|
+
total: this.errorRecords.size,
|
|
2191
|
+
critical: recent.filter((r) => r.severity === "critical").length,
|
|
2192
|
+
recent: recent.length
|
|
2193
|
+
};
|
|
2194
|
+
for (const cb of this.alertCallbacks) {
|
|
2195
|
+
try {
|
|
2196
|
+
cb({ type, record, stats, message });
|
|
2197
|
+
} catch {
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
opKey(ctx) {
|
|
2202
|
+
return `${ctx.toolName ?? "unknown"}:${ctx.featureId ?? "global"}`;
|
|
2203
|
+
}
|
|
2204
|
+
categorizeError(ctx) {
|
|
2205
|
+
const msg = ctx.errorMessage.toLowerCase();
|
|
2206
|
+
if (msg.includes("network") || msg.includes("connection") || msg.includes("timeout") || msg.includes("econnrefused") || msg.includes("enotfound") || msg.includes("etimedout")) return "network";
|
|
2207
|
+
if (msg.includes("permission") || msg.includes("access denied") || msg.includes("eacces") || msg.includes("eperm") || ctx.errorType.toLowerCase().includes("permission")) return "permission";
|
|
2208
|
+
if (msg.includes("invalid input") || msg.includes("validation") || msg.includes("user") || ctx.errorType.toLowerCase().includes("validation") || ctx.errorType.toLowerCase().includes("user")) return "user";
|
|
2209
|
+
if (msg.includes("temporary") || msg.includes("retry") || msg.includes("busy") || msg.includes("locked") || msg.includes("eagain") || msg.includes("ebusy")) return "transient";
|
|
2210
|
+
if (msg.includes("memory") || msg.includes("disk") || msg.includes("space") || ctx.errorType.toLowerCase().includes("system")) return "system";
|
|
2211
|
+
if (msg.includes("syntax") || msg.includes("parse") || ctx.errorType.toLowerCase().includes("syntax") || ctx.errorType.toLowerCase().includes("parse")) return "permanent";
|
|
2212
|
+
return "unknown";
|
|
2213
|
+
}
|
|
2214
|
+
determineSeverity(category) {
|
|
2215
|
+
if (category === "permanent" || category === "permission") return "critical";
|
|
2216
|
+
if (category === "system") return "high";
|
|
2217
|
+
if (category === "network" || category === "unknown") return "medium";
|
|
2218
|
+
return "low";
|
|
2219
|
+
}
|
|
2220
|
+
handleError(ctx) {
|
|
2221
|
+
const category = this.categorizeError(ctx);
|
|
2222
|
+
const severity = this.determineSeverity(category);
|
|
2223
|
+
const strategy = this.strategies.get(category) ?? this.strategies.get("unknown");
|
|
2224
|
+
const errorKey = `${ctx.toolName ?? "unknown"}:${ctx.errorType}:${ctx.featureId ?? "none"}`;
|
|
2225
|
+
const retryState = this.activeRetries.get(errorKey);
|
|
2226
|
+
const retryCount = retryState?.count ?? 0;
|
|
2227
|
+
const key = this.opKey(ctx);
|
|
2228
|
+
const consecutive = (this.consecutiveFailures.get(key) ?? 0) + 1;
|
|
2229
|
+
this.consecutiveFailures.set(key, consecutive);
|
|
2230
|
+
const record = {
|
|
2231
|
+
id: crypto2.randomUUID(),
|
|
2232
|
+
context: ctx,
|
|
2233
|
+
category,
|
|
2234
|
+
severity,
|
|
2235
|
+
retryCount,
|
|
2236
|
+
actionTaken: "skip",
|
|
2237
|
+
resolved: false,
|
|
2238
|
+
timestamp: Date.now()
|
|
2239
|
+
};
|
|
2240
|
+
let action;
|
|
2241
|
+
let shouldRetry = false;
|
|
2242
|
+
let retryAfter;
|
|
2243
|
+
if (retryCount < strategy.maxRetries) {
|
|
2244
|
+
action = strategy.fallbackAction ?? "retry";
|
|
2245
|
+
shouldRetry = action === "retry";
|
|
2246
|
+
if (shouldRetry) {
|
|
2247
|
+
retryAfter = strategy.backoffMs * Math.pow(2, retryCount);
|
|
2248
|
+
this.activeRetries.set(errorKey, { count: retryCount + 1, lastAttempt: Date.now() });
|
|
2249
|
+
}
|
|
2250
|
+
} else {
|
|
2251
|
+
action = strategy.fallbackAction ?? "skip";
|
|
2252
|
+
}
|
|
2253
|
+
record.actionTaken = action;
|
|
2254
|
+
this.errorRecords.set(record.id, record);
|
|
2255
|
+
if (severity === "critical") this.fireAlert("error_critical", record, `Critical: ${ctx.errorMessage}`);
|
|
2256
|
+
if (consecutive >= this.alertThresholds.consecutiveFailures) this.fireAlert("error_threshold", record, `${consecutive} consecutive failures for ${key}`);
|
|
2257
|
+
if (action === "skip" || action === "block") this.fireAlert("recovery_failed", record, `Recovery failed: ${action}`);
|
|
2258
|
+
if (retryCount >= strategy.maxRetries && !shouldRetry) this.fireAlert("retry_exhausted", record, `Retries exhausted after ${retryCount} for ${key}`);
|
|
2259
|
+
return { action, shouldRetry, retryAfter, record };
|
|
2260
|
+
}
|
|
2261
|
+
markResolved(recordId) {
|
|
2262
|
+
const record = this.errorRecords.get(recordId);
|
|
2263
|
+
if (record) {
|
|
2264
|
+
record.resolved = true;
|
|
2265
|
+
this.activeRetries.delete(`${record.context.toolName ?? "unknown"}:${record.context.errorType}:${record.context.featureId ?? "none"}`);
|
|
2266
|
+
this.consecutiveFailures.delete(this.opKey(record.context));
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
clearConsecutiveFailures(toolName, featureId) {
|
|
2270
|
+
if (!toolName && !featureId) {
|
|
2271
|
+
this.consecutiveFailures.clear();
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
const filter = `${toolName ?? "unknown"}:${featureId ?? "global"}`;
|
|
2275
|
+
for (const key of this.consecutiveFailures.keys()) {
|
|
2276
|
+
if (key === filter || toolName && key.startsWith(`${toolName}:`) || featureId && key.endsWith(`:${featureId}`)) {
|
|
2277
|
+
this.consecutiveFailures.delete(key);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
getErrorsForFeature(featureId) {
|
|
2282
|
+
return Array.from(this.errorRecords.values()).filter((r) => r.context.featureId === featureId);
|
|
2283
|
+
}
|
|
2284
|
+
getErrorsForMission(missionId) {
|
|
2285
|
+
return Array.from(this.errorRecords.values()).filter((r) => r.context.missionId === missionId);
|
|
2286
|
+
}
|
|
2287
|
+
clearErrors() {
|
|
2288
|
+
this.errorRecords.clear();
|
|
2289
|
+
this.activeRetries.clear();
|
|
2290
|
+
}
|
|
2291
|
+
clearErrorsForFeature(featureId) {
|
|
2292
|
+
for (const [id, record] of this.errorRecords) {
|
|
2293
|
+
if (record.context.featureId === featureId) {
|
|
2294
|
+
this.activeRetries.delete(`${record.context.toolName ?? "unknown"}:${record.context.errorType}:${record.context.featureId ?? "none"}`);
|
|
2295
|
+
this.errorRecords.delete(id);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
getStats() {
|
|
2300
|
+
const records = Array.from(this.errorRecords.values());
|
|
2301
|
+
const byCategory = { transient: 0, permanent: 0, user: 0, system: 0, network: 0, permission: 0, unknown: 0 };
|
|
2302
|
+
const bySeverity = { low: 0, medium: 0, high: 0, critical: 0 };
|
|
2303
|
+
for (const r of records) {
|
|
2304
|
+
byCategory[r.category]++;
|
|
2305
|
+
bySeverity[r.severity]++;
|
|
2306
|
+
}
|
|
2307
|
+
return { total: records.length, resolved: records.filter((r) => r.resolved).length, byCategory, bySeverity };
|
|
2308
|
+
}
|
|
2309
|
+
setStrategy(category, strategy) {
|
|
2310
|
+
this.strategies.set(category, strategy);
|
|
2311
|
+
}
|
|
2312
|
+
};
|
|
2313
|
+
var globalEngine = null;
|
|
2314
|
+
function getErrorRecoveryEngine() {
|
|
2315
|
+
if (!globalEngine) globalEngine = new ErrorRecoveryEngine();
|
|
2316
|
+
return globalEngine;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// src/tools/index.ts
|
|
2320
|
+
var PLANNING_RO_BASH = /* @__PURE__ */ new Set([
|
|
2321
|
+
"cat",
|
|
2322
|
+
"grep",
|
|
2323
|
+
"head",
|
|
2324
|
+
"ls",
|
|
2325
|
+
"pwd",
|
|
2326
|
+
"rg",
|
|
2327
|
+
"tail",
|
|
2328
|
+
"wc"
|
|
2329
|
+
]);
|
|
2330
|
+
function firstWord(cmd) {
|
|
2331
|
+
const m = cmd.trim().match(/^([A-Za-z0-9_.-]+)/);
|
|
2332
|
+
return m?.[1] ?? "";
|
|
2333
|
+
}
|
|
2334
|
+
function hasOpt(cmd, opt) {
|
|
2335
|
+
const escaped = opt.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2336
|
+
return new RegExp(`(?:^|\\s)${escaped}(?:\\s|$)`).test(cmd);
|
|
2337
|
+
}
|
|
2338
|
+
function isReadOnlyFind(cmd) {
|
|
2339
|
+
return /^find(?:\s|$)/.test(cmd) && !/\s-(?:delete|exec|execdir|ok|okdir|fls|fprint|fprint0|fprintf)(?:\s|$)/.test(cmd);
|
|
2340
|
+
}
|
|
2341
|
+
function isReadOnlySed(cmd) {
|
|
2342
|
+
return /^sed\s+-n(?:\s|$)/.test(cmd) && !hasOpt(cmd, "-i") && !hasOpt(cmd, "--in-place");
|
|
2343
|
+
}
|
|
2344
|
+
function isReadOnlyPlanningBash(input) {
|
|
2345
|
+
const cmd = typeof input?.command === "string" ? input.command.trim() : "";
|
|
2346
|
+
if (!cmd) return false;
|
|
2347
|
+
if (/[;&|`$<>\n\r\t]/.test(cmd)) return false;
|
|
2348
|
+
const w = firstWord(cmd);
|
|
2349
|
+
if (PLANNING_RO_BASH.has(w)) return true;
|
|
2350
|
+
const strippedCmd = cmd.replace(/['"\\]/g, "");
|
|
2351
|
+
if (w === "find") return isReadOnlyFind(strippedCmd);
|
|
2352
|
+
if (w === "sed") return isReadOnlySed(strippedCmd);
|
|
2353
|
+
if (w === "git") return /^git\s+(?:status|diff|show|log)(?:\s|$)/.test(cmd);
|
|
2354
|
+
return false;
|
|
2355
|
+
}
|
|
2356
|
+
function toolResultErrorMessage(event) {
|
|
2357
|
+
const text = event.content?.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n").trim();
|
|
2358
|
+
return text || `Tool '${event.toolName}' failed`;
|
|
2359
|
+
}
|
|
2360
|
+
function cloneFeatureForFork(feature, id, title, notes) {
|
|
2361
|
+
const acLen = feature.acceptance.length;
|
|
2362
|
+
const newAcceptance = new Array(acLen);
|
|
2363
|
+
for (let i = 0; i < acLen; i++) {
|
|
2364
|
+
const ac = feature.acceptance[i];
|
|
2365
|
+
newAcceptance[i] = { ...ac, verified: false, evidence: void 0 };
|
|
2366
|
+
}
|
|
2367
|
+
return {
|
|
2368
|
+
...feature,
|
|
2369
|
+
id,
|
|
2370
|
+
title,
|
|
2371
|
+
status: "active",
|
|
2372
|
+
notes,
|
|
2373
|
+
completedAt: void 0,
|
|
2374
|
+
dependsOn: [...feature.dependsOn],
|
|
2375
|
+
sessions: [...feature.sessions],
|
|
2376
|
+
acceptance: newAcceptance
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
function appendForkNote(existing, entries) {
|
|
2380
|
+
const filtered = entries.filter(Boolean);
|
|
2381
|
+
return existing ? `${existing}
|
|
2382
|
+
|
|
2383
|
+
${filtered.join("\n")}` : filtered.join("\n");
|
|
2384
|
+
}
|
|
2385
|
+
function pushSessionRef(feature, ref) {
|
|
2386
|
+
if (ref) feature.sessions.push(ref);
|
|
2387
|
+
}
|
|
2388
|
+
function buildForkKickoffMessage(missionTitle, source, forked, reason, subtask, parentSessionFile) {
|
|
2389
|
+
const lines = [
|
|
2390
|
+
`## Forked Mission: ${missionTitle}`,
|
|
2391
|
+
`Source: ${source.id} \u2014 ${source.title}`,
|
|
2392
|
+
`Fork: ${forked.id} \u2014 ${forked.title}`,
|
|
2393
|
+
`Reason: ${reason}`
|
|
2394
|
+
];
|
|
2395
|
+
if (subtask) lines.push(`Subtask: ${subtask}`);
|
|
2396
|
+
if (parentSessionFile) lines.push(`Parent session: ${parentSessionFile}`);
|
|
2397
|
+
lines.push(
|
|
2398
|
+
"",
|
|
2399
|
+
`Active fork feature: ${forked.id} \u2014 ${forked.title}`,
|
|
2400
|
+
"Continue mission: work ONLY on the forked feature. When complete:",
|
|
2401
|
+
`1. Call mission_feature_done with evidence.`,
|
|
2402
|
+
`2. The original ${source.id} is blocked \u2014 the user will resolve it.`
|
|
2403
|
+
);
|
|
2404
|
+
return lines.join("\n");
|
|
2405
|
+
}
|
|
2406
|
+
function buildManualForkHandoff(missionTitle, source, forked, reason, parentLeafId, parentSessionFile) {
|
|
2407
|
+
const lines = [
|
|
2408
|
+
`\u{1F500} Manual fork handoff for ${missionTitle}:`,
|
|
2409
|
+
` Source: ${source.id} \u2014 ${source.title}`,
|
|
2410
|
+
` Fork: ${forked.id} \u2014 ${forked.title}`,
|
|
2411
|
+
` Reason: ${reason}`
|
|
2412
|
+
];
|
|
2413
|
+
if (parentLeafId) lines.push(` Parent leaf: ${parentLeafId}`);
|
|
2414
|
+
if (parentSessionFile) lines.push(` Parent session: ${parentSessionFile}`);
|
|
2415
|
+
lines.push(
|
|
2416
|
+
"",
|
|
2417
|
+
"Action: open or clone a new Pi session and load this mission via /mission load <id>.",
|
|
2418
|
+
`Focus on: ${forked.id} \u2014 ${forked.title}`
|
|
2419
|
+
);
|
|
2420
|
+
return lines.join("\n");
|
|
2421
|
+
}
|
|
2422
|
+
function injectMissionContext(pi, ctx, mission, reason, content) {
|
|
2423
|
+
const details = { missionId: mission.id, reason, injectedAt: Date.now() };
|
|
2424
|
+
const sm = ctx.sessionManager;
|
|
2425
|
+
if (typeof sm.appendCustomMessageEntry === "function") {
|
|
2426
|
+
sm.appendCustomMessageEntry("pi-mission-context", content, false, details);
|
|
2427
|
+
}
|
|
2428
|
+
pi.appendEntry("pi-mission-context", { ...details, content });
|
|
2429
|
+
}
|
|
2430
|
+
function enforceToolPolicy(toolName, phase, input, allowBashInPlanning, toolCallCount) {
|
|
2431
|
+
const policy = TOOL_POLICIES[phase];
|
|
2432
|
+
if (policy.allowedTools.includes(toolName)) return { blocked: false };
|
|
2433
|
+
const allowedBash = phase === "planning" && toolName === "bash" && (allowBashInPlanning || isReadOnlyPlanningBash(input));
|
|
2434
|
+
if (allowedBash) return { blocked: false };
|
|
2435
|
+
if (phase === "planning" && toolName === "bash") {
|
|
2436
|
+
return { blocked: true, reason: "Tool 'bash' is only allowed in planning phase for single read-only commands: pwd, ls, find, grep, rg, cat, sed -n, head, tail, wc, git status/diff/show/log." };
|
|
2437
|
+
}
|
|
2438
|
+
return { blocked: true, reason: `'${toolName}' not allowed in ${phase}. Allowed: ${policy.allowedTools.join(", ")}` };
|
|
2439
|
+
}
|
|
2440
|
+
function enforceToolMax(phase, count) {
|
|
2441
|
+
const max = TOOL_POLICIES[phase].maxToolCalls;
|
|
2442
|
+
if (count > max) return { blocked: true, reason: `Max tool calls (${max}) exceeded for ${phase} phase.` };
|
|
2443
|
+
return { blocked: false };
|
|
2444
|
+
}
|
|
2445
|
+
function registerMissionTools(_pi, runtime) {
|
|
2446
|
+
const pi = _pi;
|
|
2447
|
+
pi.registerTool({
|
|
2448
|
+
name: "mission_feature_done",
|
|
2449
|
+
label: "Mission Feature Done",
|
|
2450
|
+
description: "Mark the active mission feature as done with evidence.",
|
|
2451
|
+
promptSnippet: "Mark the active mission feature as done with evidence",
|
|
2452
|
+
promptGuidelines: ["Use only after all acceptance criteria are satisfied and evidence is available."],
|
|
2453
|
+
parameters: Type2.Object({
|
|
2454
|
+
evidence: Type2.String({ description: "Completion evidence" }),
|
|
2455
|
+
notes: Type2.Optional(Type2.String({ description: "Optional notes" }))
|
|
2456
|
+
}),
|
|
2457
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
2458
|
+
const m = runtime.activeMission;
|
|
2459
|
+
const f = m ? getActiveFeature(m) : null;
|
|
2460
|
+
if (!m || !f) return { isError: true, content: [{ type: "text", text: "No active mission feature." }], details: {} };
|
|
2461
|
+
const result = completeActiveFeature(m, {
|
|
2462
|
+
evidence: String(params.evidence ?? ""),
|
|
2463
|
+
notes: typeof params.notes === "string" ? params.notes : void 0
|
|
2464
|
+
});
|
|
2465
|
+
if (!result.ok) return { isError: true, content: [{ type: "text", text: `${result.reason}
|
|
2466
|
+
Use /mission edit to waive criteria.` }], details: {} };
|
|
2467
|
+
await saveMissionSafe(m);
|
|
2468
|
+
updateFooter(ctx, m);
|
|
2469
|
+
return { content: [{ type: "text", text: `\u2705 Feature ${result.feature.id} done. Evidence: ${result.evidenceFile}` }], details: { featureId: result.feature.id, evidenceFile: result.evidenceFile }, isError: false };
|
|
2470
|
+
}
|
|
2471
|
+
});
|
|
2472
|
+
pi.registerTool({
|
|
2473
|
+
name: "mission_next_feature",
|
|
2474
|
+
label: "Next Feature",
|
|
2475
|
+
description: "Advance to the next pending mission feature.",
|
|
2476
|
+
parameters: Type2.Object({}),
|
|
2477
|
+
async execute(_id, _p, _sig, _upd, ctx) {
|
|
2478
|
+
const m = runtime.activeMission;
|
|
2479
|
+
if (!m) return { isError: true, content: [{ type: "text", text: "No active mission." }], details: {} };
|
|
2480
|
+
const result = activateNextFeature(m);
|
|
2481
|
+
if (!result.ok) {
|
|
2482
|
+
if (result.reason === "active_not_done") {
|
|
2483
|
+
return { isError: true, content: [{ type: "text", text: `Active feature not done yet: ${result.active.id} \u2014 ${result.active.title}. Use mission_feature_done or /mission block.` }], details: {} };
|
|
2484
|
+
}
|
|
2485
|
+
if (result.reason === "mission_complete") {
|
|
2486
|
+
await saveMissionSafe(m);
|
|
2487
|
+
updateFooter(ctx, m);
|
|
2488
|
+
return { content: [{ type: "text", text: "\u{1F389} Mission complete." }], details: { missionId: m.id }, isError: false };
|
|
2489
|
+
}
|
|
2490
|
+
return { isError: true, content: [{ type: "text", text: "No unblocked pending feature found." }], details: {} };
|
|
2491
|
+
}
|
|
2492
|
+
await saveMissionSafe(m);
|
|
2493
|
+
updateFooter(ctx, m);
|
|
2494
|
+
return { content: [{ type: "text", text: `\u27A1\uFE0F Active feature: ${result.next.id} \u2014 ${result.next.title}
|
|
2495
|
+
${result.next.description}` }], details: { feature: result.next }, isError: false };
|
|
2496
|
+
}
|
|
2497
|
+
});
|
|
2498
|
+
pi.registerTool({
|
|
2499
|
+
name: "mission_ask_user",
|
|
2500
|
+
label: "Ask User",
|
|
2501
|
+
description: "Ask the user a question during mission execution.",
|
|
2502
|
+
promptSnippet: "Ask the user a question",
|
|
2503
|
+
promptGuidelines: [
|
|
2504
|
+
"Use when you need user input to proceed.",
|
|
2505
|
+
"Use 'confirm' for yes/no, 'select' for choices, 'input' for text.",
|
|
2506
|
+
"Always provide a sensible default."
|
|
2507
|
+
],
|
|
2508
|
+
parameters: Type2.Object({
|
|
2509
|
+
question: Type2.String({ description: "The question to ask" }),
|
|
2510
|
+
questionType: Type2.Optional(Type2.String({ description: "input, confirm, or select", enum: ["input", "confirm", "select"] })),
|
|
2511
|
+
options: Type2.Optional(Type2.Array(Type2.String({ description: "Options for select" }))),
|
|
2512
|
+
defaultValue: Type2.Optional(Type2.String({ description: "Default value" })),
|
|
2513
|
+
context: Type2.Optional(Type2.String({ description: "Additional context" }))
|
|
2514
|
+
}),
|
|
2515
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
2516
|
+
const m = runtime.activeMission;
|
|
2517
|
+
if (!m) throw new Error("No active mission.");
|
|
2518
|
+
const f = getActiveFeature(m);
|
|
2519
|
+
m.autopilot.enabled = false;
|
|
2520
|
+
m.autopilot.lastStopReason = "needs_user_decision";
|
|
2521
|
+
m.autopilot.lastStopMessage = String(params.question ?? "");
|
|
2522
|
+
appendHistory(m, { event: "user_asked", featureId: f?.id, note: `Q: ${params.question}`, details: { questionType: params.questionType, options: params.options, defaultValue: params.defaultValue } });
|
|
2523
|
+
const qType = String(params.questionType ?? "input");
|
|
2524
|
+
const dflt = typeof params.defaultValue === "string" ? params.defaultValue : null;
|
|
2525
|
+
let answer = null;
|
|
2526
|
+
let source = "no_ui";
|
|
2527
|
+
if (ctx.hasUI) {
|
|
2528
|
+
try {
|
|
2529
|
+
switch (qType) {
|
|
2530
|
+
case "confirm":
|
|
2531
|
+
answer = await ctx.ui.confirm(String(params.question ?? ""), String(params.context ?? "")) ? "yes" : "no";
|
|
2532
|
+
break;
|
|
2533
|
+
case "select": {
|
|
2534
|
+
const opts = Array.isArray(params.options) ? params.options.map(String) : [];
|
|
2535
|
+
if (!opts.length) throw new Error("Select requires options");
|
|
2536
|
+
const choice = await ctx.ui.select(String(params.question ?? ""), opts);
|
|
2537
|
+
answer = choice || dflt || null;
|
|
2538
|
+
break;
|
|
2539
|
+
}
|
|
2540
|
+
default:
|
|
2541
|
+
answer = await ctx.ui.input(String(params.question ?? ""), String(params.context ?? "")) || dflt || null;
|
|
2542
|
+
}
|
|
2543
|
+
source = "ui";
|
|
2544
|
+
} catch {
|
|
2545
|
+
answer = dflt;
|
|
2546
|
+
source = "default";
|
|
2547
|
+
}
|
|
2548
|
+
} else {
|
|
2549
|
+
answer = dflt || "[No UI available]";
|
|
2550
|
+
}
|
|
2551
|
+
if (f) appendHistory(m, { event: "user_answered", featureId: f.id, note: `A: ${answer}`, details: { answer, answerSource: source, questionType: qType } });
|
|
2552
|
+
if (answer === "ALLOW_BASH_IN_PLANNING") {
|
|
2553
|
+
m.userPreferences = m.userPreferences ?? {};
|
|
2554
|
+
m.userPreferences.allowBashInPlanning = true;
|
|
2555
|
+
await saveMissionSafe(m);
|
|
2556
|
+
}
|
|
2557
|
+
return { content: [{ type: "text", text: `User answered: ${answer}${source !== "ui" ? ` (via ${source})` : ""}` }], details: { question: params.question, answer, answerSource: source }, isError: false };
|
|
2558
|
+
}
|
|
2559
|
+
});
|
|
2560
|
+
pi.registerTool({
|
|
2561
|
+
name: "mission_block_self",
|
|
2562
|
+
label: "Block Self",
|
|
2563
|
+
description: "Block the current feature when stuck.",
|
|
2564
|
+
promptSnippet: "Block the current feature",
|
|
2565
|
+
promptGuidelines: ["Use when stuck. Provide a clear reason."],
|
|
2566
|
+
parameters: Type2.Object({
|
|
2567
|
+
reason: Type2.String({ description: "Reason for blocking" }),
|
|
2568
|
+
context: Type2.Optional(Type2.String({ description: "Additional context" }))
|
|
2569
|
+
}),
|
|
2570
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
2571
|
+
const m = runtime.activeMission;
|
|
2572
|
+
const f = m ? getActiveFeature(m) : null;
|
|
2573
|
+
if (!m || !f) throw new Error("No active feature.");
|
|
2574
|
+
f.status = "blocked";
|
|
2575
|
+
f.notes = `Self-blocked: ${params.reason}${params.context ? `
|
|
2576
|
+
|
|
2577
|
+
Context: ${params.context}` : ""}`;
|
|
2578
|
+
m.status = "blocked";
|
|
2579
|
+
m.autopilot.enabled = false;
|
|
2580
|
+
m.autopilot.lastStopReason = "blocked";
|
|
2581
|
+
m.autopilot.lastStopMessage = String(params.reason ?? "");
|
|
2582
|
+
appendHistory(m, { event: "feature_blocked", featureId: f.id, note: String(params.reason ?? ""), details: { context: params.context, self: true } });
|
|
2583
|
+
autoUnblockResolved(m);
|
|
2584
|
+
const next = getNextPendingFeature(m);
|
|
2585
|
+
if (next) {
|
|
2586
|
+
next.status = "active";
|
|
2587
|
+
m.status = "active";
|
|
2588
|
+
m.activeFeatureId = next.id;
|
|
2589
|
+
m.activeMilestoneId = next.milestoneId;
|
|
2590
|
+
m.autopilot.lastStopReason = void 0;
|
|
2591
|
+
m.autopilot.lastStopMessage = void 0;
|
|
2592
|
+
appendHistory(m, { event: "feature_active", featureId: next.id, note: "Auto-advanced after self-block" });
|
|
2593
|
+
await saveMissionSafe(m);
|
|
2594
|
+
updateFooter(ctx, m);
|
|
2595
|
+
return { content: [{ type: "text", text: `\u{1F6AB} Self-blocked ${f.id}: ${params.reason}
|
|
2596
|
+
\u27A1\uFE0F Auto-advanced to ${next.id} \u2014 ${next.title}` }], details: { featureId: f.id, nextFeatureId: next.id }, isError: false };
|
|
2597
|
+
}
|
|
2598
|
+
await saveMissionSafe(m);
|
|
2599
|
+
updateFooter(ctx, m);
|
|
2600
|
+
throw new Error(`Self-blocked ${f.id}: ${params.reason}. No pending features available.`);
|
|
2601
|
+
}
|
|
2602
|
+
});
|
|
2603
|
+
pi.registerTool({
|
|
2604
|
+
name: "mission_fork",
|
|
2605
|
+
label: "Fork Feature",
|
|
2606
|
+
description: "Fork the current feature into a separate session.",
|
|
2607
|
+
promptSnippet: "Fork the current feature",
|
|
2608
|
+
promptGuidelines: ["Use for parallel work or isolation. Provide a clear reason."],
|
|
2609
|
+
parameters: Type2.Object({
|
|
2610
|
+
reason: Type2.String({ description: "Reason for forking" }),
|
|
2611
|
+
subtask: Type2.Optional(Type2.String({ description: "Specific subtask" }))
|
|
2612
|
+
}),
|
|
2613
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
2614
|
+
const m = runtime.activeMission;
|
|
2615
|
+
const f = m ? getActiveFeature(m) : null;
|
|
2616
|
+
if (!m || !f) throw new Error("No active feature.");
|
|
2617
|
+
const sm = ctx.sessionManager;
|
|
2618
|
+
const reason = String(params.reason ?? "Alternative approach");
|
|
2619
|
+
const parentLeafId = sm.getLeafId?.() ?? null;
|
|
2620
|
+
const parentSessionFile = sm.getSessionFile?.();
|
|
2621
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2622
|
+
const forked = cloneFeatureForFork(f, `${f.id}-fork-${Date.now()}`, `${f.title} [fork]`, `Fork: ${reason}${params.subtask ? ` (${params.subtask})` : ""}`);
|
|
2623
|
+
const milestone = getMilestoneById(m, f.milestoneId);
|
|
2624
|
+
if (!milestone) return { isError: true, content: [{ type: "text", text: "Milestone not found." }], details: {} };
|
|
2625
|
+
f.status = "blocked";
|
|
2626
|
+
f.notes = appendForkNote(f.notes, [`Forked at ${createdAt}`, `Fork: ${forked.id}`, `Reason: ${reason}`, params.subtask ? `Subtask: ${params.subtask}` : "", parentLeafId ? `Leaf: ${parentLeafId}` : "", parentSessionFile ? `Session: ${parentSessionFile}` : ""]);
|
|
2627
|
+
pushSessionRef(f, `fork:${forked.id}`);
|
|
2628
|
+
pushSessionRef(f, parentLeafId ? `leaf:${parentLeafId}` : void 0);
|
|
2629
|
+
pushSessionRef(f, parentSessionFile ? `session:${parentSessionFile}` : void 0);
|
|
2630
|
+
forked.notes = appendForkNote(forked.notes, [`Fork source: ${f.id}`, `Created: ${createdAt}`, `Reason: ${reason}`, params.subtask ? `Subtask: ${params.subtask}` : "", parentSessionFile ? `Parent session: ${parentSessionFile}` : ""]);
|
|
2631
|
+
pushSessionRef(forked, `parent-feature:${f.id}`);
|
|
2632
|
+
pushSessionRef(forked, parentLeafId ? `parent-leaf:${parentLeafId}` : void 0);
|
|
2633
|
+
pushSessionRef(forked, parentSessionFile ? `parent-session:${parentSessionFile}` : void 0);
|
|
2634
|
+
milestone.features.push(forked);
|
|
2635
|
+
m.activeFeatureId = forked.id;
|
|
2636
|
+
m.activeMilestoneId = forked.milestoneId;
|
|
2637
|
+
m.status = "active";
|
|
2638
|
+
appendHistory(m, { event: "feature_forked", featureId: f.id, note: reason, details: { subtask: params.subtask, self: true, forkedFeatureId: forked.id, parentLeafId, parentSessionFile, forkApiAvailable: typeof ctx.fork === "function" } });
|
|
2639
|
+
await saveMissionSafe(m);
|
|
2640
|
+
const kickoff = buildForkKickoffMessage(m.title, f, forked, reason, typeof params.subtask === "string" ? params.subtask : void 0, parentSessionFile);
|
|
2641
|
+
const manual = buildManualForkHandoff(m.title, f, forked, reason, parentLeafId, parentSessionFile);
|
|
2642
|
+
if (parentLeafId && typeof ctx.fork === "function") {
|
|
2643
|
+
const result = await ctx.fork(parentLeafId, {
|
|
2644
|
+
position: "at",
|
|
2645
|
+
withSession: async (fc) => {
|
|
2646
|
+
const fcCtx = fc;
|
|
2647
|
+
const fsf = fc.sessionManager?.getSessionFile?.();
|
|
2648
|
+
const pm = loadMissionFromDisk(m.id);
|
|
2649
|
+
const pf = pm ? getFeatureById(pm, forked.id) : null;
|
|
2650
|
+
if (pm && pf) {
|
|
2651
|
+
pushSessionRef(pf, fsf ? `session:${fsf}` : void 0);
|
|
2652
|
+
appendHistory(pm, { event: "feature_fork_session_created", featureId: forked.id, note: reason, details: { sourceFeatureId: f.id, subtask: params.subtask, forkSessionFile: fsf, parentLeafId, self: true } });
|
|
2653
|
+
await saveMissionSafe(pm);
|
|
2654
|
+
}
|
|
2655
|
+
if (typeof fc.sendUserMessage === "function") await fc.sendUserMessage(kickoff);
|
|
2656
|
+
else fc.ui.notify(`\u{1F33F} Fork active: ${forked.title}
|
|
2657
|
+
|
|
2658
|
+
${kickoff}`, "info");
|
|
2659
|
+
}
|
|
2660
|
+
});
|
|
2661
|
+
if (!result?.cancelled) {
|
|
2662
|
+
return { content: [{ type: "text", text: `\u{1F500} Forked ${f.id} into ${forked.id} \u2014 started a dedicated Pi session
|
|
2663
|
+
|
|
2664
|
+
${kickoff}` }], details: { featureId: f.id, forkedFeatureId: forked.id, reason, subtask: params.subtask, mode: "fork_api" }, isError: false };
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
return { content: [{ type: "text", text: `${manual}
|
|
2668
|
+
|
|
2669
|
+
Kickoff prompt:
|
|
2670
|
+
${kickoff}` }], details: { featureId: f.id, forkedFeatureId: forked.id, reason, subtask: params.subtask, mode: "manual" }, isError: false };
|
|
2671
|
+
}
|
|
2672
|
+
});
|
|
2673
|
+
pi.registerTool({
|
|
2674
|
+
name: "mission_error_status",
|
|
2675
|
+
label: "Error Status",
|
|
2676
|
+
description: "View error recovery statistics.",
|
|
2677
|
+
promptSnippet: "View error status",
|
|
2678
|
+
promptGuidelines: ["Check what errors occurred and their recovery status."],
|
|
2679
|
+
parameters: Type2.Object({
|
|
2680
|
+
scope: Type2.Optional(Type2.String({ description: "'feature' or 'mission'", enum: ["feature", "mission"] }))
|
|
2681
|
+
}),
|
|
2682
|
+
async execute(_id, params, _sig, _upd, _ctx) {
|
|
2683
|
+
const m = runtime.activeMission;
|
|
2684
|
+
if (!m) throw new Error("No active mission.");
|
|
2685
|
+
const f = getActiveFeature(m);
|
|
2686
|
+
const recovery = getErrorRecoveryEngine();
|
|
2687
|
+
const scope = String(params.scope ?? "feature");
|
|
2688
|
+
const errors = scope === "feature" && f ? recovery.getErrorsForFeature(f.id) : recovery.getErrorsForMission(m.id);
|
|
2689
|
+
const stats = recovery.getStats();
|
|
2690
|
+
if (!errors.length) {
|
|
2691
|
+
return { content: [{ type: "text", text: `\u2705 No errors for ${scope === "feature" ? `feature ${f?.id}` : "mission"}.
|
|
2692
|
+
|
|
2693
|
+
Total: ${stats.total}, resolved: ${stats.resolved}` }], details: { scope, errorCount: 0, stats }, isError: false };
|
|
2694
|
+
}
|
|
2695
|
+
const lines = [
|
|
2696
|
+
`\u{1F4CB} Error Status (${scope})`,
|
|
2697
|
+
`Total: ${errors.length}, Resolved: ${errors.filter((e) => e.resolved).length}`,
|
|
2698
|
+
"",
|
|
2699
|
+
"Recent:",
|
|
2700
|
+
...errors.slice(-5).map((e) => `- [${e.resolved ? "\u2713" : "\u2717"}] ${e.context.toolName ?? "?"}: ${e.context.errorMessage.slice(0, 50)}`),
|
|
2701
|
+
"",
|
|
2702
|
+
"By category:",
|
|
2703
|
+
...Object.entries(stats.byCategory).map(([k, v]) => `- ${k}: ${v}`),
|
|
2704
|
+
"",
|
|
2705
|
+
"By severity:",
|
|
2706
|
+
...Object.entries(stats.bySeverity).map(([k, v]) => `- ${k}: ${v}`)
|
|
2707
|
+
];
|
|
2708
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { scope, errors, stats }, isError: false };
|
|
2709
|
+
}
|
|
2710
|
+
});
|
|
2711
|
+
pi.registerTool({
|
|
2712
|
+
name: "mission_spawn_worker",
|
|
2713
|
+
label: "Spawn Worker",
|
|
2714
|
+
description: "Spawn a worker subprocess to autonomously execute a feature.",
|
|
2715
|
+
promptSnippet: "Spawn a worker to execute a feature autonomously",
|
|
2716
|
+
promptGuidelines: [
|
|
2717
|
+
"Use for large features that need focused execution.",
|
|
2718
|
+
"The worker runs in a separate pi process with the feature context.",
|
|
2719
|
+
"Only one worker runs at a time. Check status with mission_worker_status."
|
|
2720
|
+
],
|
|
2721
|
+
parameters: Type2.Object({
|
|
2722
|
+
featureId: Type2.Optional(Type2.String({ description: "Feature ID (defaults to active feature)" })),
|
|
2723
|
+
customPrompt: Type2.Optional(Type2.String({ description: "Custom instructions for the worker" })),
|
|
2724
|
+
model: Type2.Optional(Type2.String({ description: "Model override (default: auto)" }))
|
|
2725
|
+
}),
|
|
2726
|
+
async execute(_id, params, _sig, _upd, _ctx) {
|
|
2727
|
+
const m = runtime.activeMission;
|
|
2728
|
+
const f = m ? getActiveFeature(m) : null;
|
|
2729
|
+
if (!m || !f) throw new Error("No active mission feature.");
|
|
2730
|
+
const featureId = typeof params.featureId === "string" ? params.featureId : f.id;
|
|
2731
|
+
const feat = getFeatureById(m, featureId);
|
|
2732
|
+
if (!feat) throw new Error(`Feature not found: ${featureId}`);
|
|
2733
|
+
if (isWorkerRunning()) {
|
|
2734
|
+
const aw = getActiveWorker();
|
|
2735
|
+
return {
|
|
2736
|
+
isError: true,
|
|
2737
|
+
content: [{ type: "text", text: `Worker already running for ${aw?.featureId}. Use mission_worker_status to check.` }],
|
|
2738
|
+
details: { running: true, featureId: aw?.featureId }
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
feat.status = "active";
|
|
2742
|
+
m.activeFeatureId = feat.id;
|
|
2743
|
+
m.activeMilestoneId = feat.milestoneId;
|
|
2744
|
+
m.status = "active";
|
|
2745
|
+
appendHistory(m, {
|
|
2746
|
+
event: "worker_spawned",
|
|
2747
|
+
featureId: feat.id,
|
|
2748
|
+
note: `Spawning worker for ${feat.id} \u2014 ${feat.title}`,
|
|
2749
|
+
details: { model: params.model, hasCustomPrompt: !!params.customPrompt }
|
|
2750
|
+
});
|
|
2751
|
+
await saveMissionSafe(m);
|
|
2752
|
+
spawnWorker(m, {
|
|
2753
|
+
featureId: feat.id,
|
|
2754
|
+
customPrompt: typeof params.customPrompt === "string" ? params.customPrompt : void 0,
|
|
2755
|
+
model: typeof params.model === "string" ? params.model : void 0
|
|
2756
|
+
}).then((result) => {
|
|
2757
|
+
if ("error" in result) {
|
|
2758
|
+
appendHistory(m, { event: "worker_error", featureId: feat.id, note: result.error });
|
|
2759
|
+
saveMissionSafe(m).catch((saveErr) => {
|
|
2760
|
+
process.stderr.write(`[pi-missions] Failed to save after worker error: ${saveErr}
|
|
2761
|
+
`);
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
}).catch((spawnErr) => {
|
|
2765
|
+
process.stderr.write(`[pi-missions] Worker spawn failed for ${feat.id}: ${spawnErr}
|
|
2766
|
+
`);
|
|
2767
|
+
});
|
|
2768
|
+
return {
|
|
2769
|
+
content: [{
|
|
2770
|
+
type: "text",
|
|
2771
|
+
text: `\u{1F680} Worker spawned for ${feat.id} \u2014 ${feat.title}
|
|
2772
|
+
|
|
2773
|
+
The worker runs autonomously in a separate process. Results are logged to history.
|
|
2774
|
+
Check status: /mission worker-status`
|
|
2775
|
+
}],
|
|
2776
|
+
details: { featureId: feat.id, mode: "async" },
|
|
2777
|
+
isError: false
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
});
|
|
2781
|
+
pi.registerTool({
|
|
2782
|
+
name: "mission_worker_status",
|
|
2783
|
+
label: "Worker Status",
|
|
2784
|
+
description: "Check the status of the currently running worker.",
|
|
2785
|
+
promptSnippet: "Check worker status",
|
|
2786
|
+
promptGuidelines: ["Check if a worker is running and its progress."],
|
|
2787
|
+
parameters: Type2.Object({}),
|
|
2788
|
+
async execute(_id, _p, _sig, _upd, _ctx) {
|
|
2789
|
+
const aw = getActiveWorker();
|
|
2790
|
+
if (!aw) {
|
|
2791
|
+
return { content: [{ type: "text", text: "No worker running." }], details: { running: false }, isError: false };
|
|
2792
|
+
}
|
|
2793
|
+
const elapsed = Math.round((Date.now() - aw.startedAt) / 1e3);
|
|
2794
|
+
const lines = [
|
|
2795
|
+
`\u{1F527} Worker Status`,
|
|
2796
|
+
`Feature: ${aw.featureId}`,
|
|
2797
|
+
`Status: ${aw.status}`,
|
|
2798
|
+
`Running: ${elapsed}s`
|
|
2799
|
+
];
|
|
2800
|
+
if (aw.result) {
|
|
2801
|
+
lines.push(
|
|
2802
|
+
"",
|
|
2803
|
+
"## Last Result",
|
|
2804
|
+
`Exit: ${aw.result.exitCode}${aw.result.signal ? ` (${aw.result.signal})` : ""}`,
|
|
2805
|
+
`Duration: ${Math.round(aw.result.durationMs / 1e3)}s`,
|
|
2806
|
+
`Stdout: ${aw.result.stdout.slice(0, 500)}`,
|
|
2807
|
+
aw.result.stderr ? `Stderr: ${aw.result.stderr.slice(0, 300)}` : ""
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }], details: { running: true, featureId: aw.featureId, status: aw.status, elapsedMs: Date.now() - aw.startedAt }, isError: false };
|
|
2811
|
+
}
|
|
2812
|
+
});
|
|
2813
|
+
pi.registerTool({
|
|
2814
|
+
name: "mission_kill_worker",
|
|
2815
|
+
label: "Kill Worker",
|
|
2816
|
+
description: "Kill the currently running worker process.",
|
|
2817
|
+
promptSnippet: "Kill running worker",
|
|
2818
|
+
promptGuidelines: ["Use to stop a runaway or stuck worker."],
|
|
2819
|
+
parameters: Type2.Object({}),
|
|
2820
|
+
async execute(_id, _p, _sig, _upd, _ctx) {
|
|
2821
|
+
const killed = killWorker();
|
|
2822
|
+
if (!killed) {
|
|
2823
|
+
return { content: [{ type: "text", text: "No worker running to kill." }], details: { killed: false }, isError: false };
|
|
2824
|
+
}
|
|
2825
|
+
return { content: [{ type: "text", text: "\u{1F6D1} Worker killed." }], details: { killed: true }, isError: false };
|
|
2826
|
+
}
|
|
2827
|
+
});
|
|
2828
|
+
pi.registerTool({
|
|
2829
|
+
name: "mission_retry_error",
|
|
2830
|
+
label: "Retry Error",
|
|
2831
|
+
description: "Retry a failed operation or clear errors.",
|
|
2832
|
+
promptSnippet: "Retry failed operation",
|
|
2833
|
+
promptGuidelines: ["Clear error state to allow retry after fixing the root cause."],
|
|
2834
|
+
parameters: Type2.Object({
|
|
2835
|
+
errorId: Type2.Optional(Type2.String({ description: "Error ID to retry" }))
|
|
2836
|
+
}),
|
|
2837
|
+
async execute(_id, params, _sig, _upd, _ctx) {
|
|
2838
|
+
const m = runtime.activeMission;
|
|
2839
|
+
const f = m ? getActiveFeature(m) : null;
|
|
2840
|
+
if (!m || !f) throw new Error("No active feature.");
|
|
2841
|
+
const recovery = getErrorRecoveryEngine();
|
|
2842
|
+
if (typeof params.errorId === "string" && params.errorId) {
|
|
2843
|
+
recovery.markResolved(params.errorId);
|
|
2844
|
+
appendHistory(m, { event: "error_resolved", featureId: f.id, note: `Resolved ${params.errorId}` });
|
|
2845
|
+
return { content: [{ type: "text", text: `\u2705 Error ${params.errorId} resolved.` }], details: { errorId: params.errorId }, isError: false };
|
|
2846
|
+
}
|
|
2847
|
+
recovery.clearErrorsForFeature(f.id);
|
|
2848
|
+
appendHistory(m, { event: "errors_cleared", featureId: f.id, note: "Cleared all errors" });
|
|
2849
|
+
return { content: [{ type: "text", text: `\u2705 Cleared all errors for ${f.id}.` }], details: { featureId: f.id }, isError: false };
|
|
2850
|
+
}
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
// src/commands/handlers.ts
|
|
2855
|
+
var PLANNING_WIZARD_PROMPT = `You are the mission planner for a software development mission. Analyze the user's goal and produce a structured mission plan.
|
|
2856
|
+
|
|
2857
|
+
Goal: {goal}
|
|
2858
|
+
Constraints: {constraints}
|
|
2859
|
+
|
|
2860
|
+
Respond ONLY with a valid JSON object (no markdown, no explanation) in this exact format:
|
|
2861
|
+
{
|
|
2862
|
+
"title": "short mission title",
|
|
2863
|
+
"milestones": [
|
|
2864
|
+
{
|
|
2865
|
+
"id": "M01",
|
|
2866
|
+
"title": "Milestone 1 title",
|
|
2867
|
+
"description": "What this milestone covers",
|
|
2868
|
+
"features": [
|
|
2869
|
+
{
|
|
2870
|
+
"id": "F001",
|
|
2871
|
+
"title": "Feature 1 title",
|
|
2872
|
+
"description": "What this feature does",
|
|
2873
|
+
"priority": 1,
|
|
2874
|
+
"dependsOn": [],
|
|
2875
|
+
"acceptance": [
|
|
2876
|
+
{ "id": "AC001", "description": "Acceptance criterion", "checkType": "manual" }
|
|
2877
|
+
]
|
|
2878
|
+
}
|
|
2879
|
+
]
|
|
2880
|
+
}
|
|
2881
|
+
]
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
Rules:
|
|
2885
|
+
- id format: M01, M02, ... for milestones; F001, F002, ... per milestone
|
|
2886
|
+
- At least 2 milestones, at least 5 total features
|
|
2887
|
+
- Each feature needs at least one acceptance criterion
|
|
2888
|
+
- checkType: "manual" | "bash" | "test_file"
|
|
2889
|
+
- priority: 1 (highest) to 5 (lowest)
|
|
2890
|
+
- Be specific and actionable
|
|
2891
|
+
`;
|
|
2892
|
+
function injectMissionContextWrapper(pi, ctx, mission, reason) {
|
|
2893
|
+
injectMissionContext(pi, ctx, mission, reason, buildMissionContext(mission));
|
|
2894
|
+
}
|
|
2895
|
+
async function handleNew(titleArg, ctx, pi, runtime) {
|
|
2896
|
+
const title = titleArg || "Untitled mission";
|
|
2897
|
+
let goal = title;
|
|
2898
|
+
let constraints = "";
|
|
2899
|
+
let usedWizard = false;
|
|
2900
|
+
if (ctx.hasUI) {
|
|
2901
|
+
goal = await ctx.ui.input("Mission goal", `What should '${title}' achieve?`) || title;
|
|
2902
|
+
constraints = await ctx.ui.input("Constraints", "Hard rules? (tests, no deps, etc.)") || "";
|
|
2903
|
+
}
|
|
2904
|
+
let parsedMission = null;
|
|
2905
|
+
const planningPrompt = PLANNING_WIZARD_PROMPT.replace("{goal}", goal).replace("{constraints}", constraints);
|
|
2906
|
+
const piWithSend = pi;
|
|
2907
|
+
if (piWithSend.sendUserMessage) {
|
|
2908
|
+
try {
|
|
2909
|
+
ctx.ui.notify("\u{1F916} Planning wizard generating milestones\u2026", "info");
|
|
2910
|
+
const response = await piWithSend.sendUserMessage(planningPrompt, { timeoutMs: 6e4 });
|
|
2911
|
+
const text = typeof response === "string" ? response : String(response?.content ?? JSON.stringify(response));
|
|
2912
|
+
const m = String(text).match(/\{[\s\S]*\}/);
|
|
2913
|
+
if (m) {
|
|
2914
|
+
const raw = JSON.parse(m[0]);
|
|
2915
|
+
const validation = validate(WizardOutputSchema, raw);
|
|
2916
|
+
if (!validation.valid) {
|
|
2917
|
+
ctx.ui.notify("Wizard output incomplete; falling back to structured scaffold.", "warning");
|
|
2918
|
+
} else {
|
|
2919
|
+
parsedMission = missionFromWizardOutput(raw, title, goal);
|
|
2920
|
+
if (!parsedMission) ctx.ui.notify("Wizard output too little structure; falling back.", "warning");
|
|
2921
|
+
else usedWizard = true;
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
} catch {
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
const mission = parsedMission ?? createStructuredMission(title, goal, constraints);
|
|
2928
|
+
runtime.activeMission = mission;
|
|
2929
|
+
await saveMissionSafe(mission);
|
|
2930
|
+
appendHistory(mission, { event: "mission_created", note: goal, details: { usedWizard } });
|
|
2931
|
+
pi.appendEntry("pi-mission-active", { missionId: mission.id, validationToken: mission.validationToken });
|
|
2932
|
+
injectMissionContextWrapper(pi, ctx, mission, "mission_started");
|
|
2933
|
+
pi.setSessionName(`\u{1F3AF} ${mission.title}`);
|
|
2934
|
+
updateFooter(ctx, mission);
|
|
2935
|
+
const fc = mission.milestones.reduce((s, m) => s + m.features.length, 0);
|
|
2936
|
+
ctx.ui.notify(usedWizard ? `\u2705 Mission created: ${mission.milestones.length} milestones, ${fc} features (AI-generated)` : `\u2705 Mission created: ${mission.id} \u2014 /mission status or /mission next`, "info");
|
|
2937
|
+
}
|
|
2938
|
+
async function handleTemplates(sub, arg, title, ctx, pi, runtime) {
|
|
2939
|
+
if (!sub || sub === "list") {
|
|
2940
|
+
const lines = ["Available templates:", ""];
|
|
2941
|
+
for (const t of MISSION_TEMPLATES) lines.push(` ${t.id.padEnd(12)} ${t.label.padEnd(20)} ${t.description}`);
|
|
2942
|
+
lines.push("", "/mission templates scaffold <id> [title]");
|
|
2943
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
if (sub === "scaffold" && arg) {
|
|
2947
|
+
const mission = createMissionFromTemplate(arg, title);
|
|
2948
|
+
if (!mission) return ctx.ui.notify(`Unknown template: ${arg}. Use /mission templates list.`, "error");
|
|
2949
|
+
runtime.activeMission = mission;
|
|
2950
|
+
await saveMissionSafe(mission);
|
|
2951
|
+
appendHistory(mission, { event: "mission_created", note: `From template: ${arg}` });
|
|
2952
|
+
pi.appendEntry("pi-mission-active", { missionId: mission.id, validationToken: mission.validationToken });
|
|
2953
|
+
injectMissionContextWrapper(pi, ctx, mission, "mission_started_from_template");
|
|
2954
|
+
pi.setSessionName(`\u{1F3AF} ${mission.title}`);
|
|
2955
|
+
updateFooter(ctx, mission);
|
|
2956
|
+
ctx.ui.notify(`\u2705 Mission created from '${arg}' template: ${mission.id}`, "info");
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
ctx.ui.notify("Usage: /mission templates [list|scaffold <id> [title]]", "warning");
|
|
2960
|
+
}
|
|
2961
|
+
async function handleList(ctx, pi, runtime) {
|
|
2962
|
+
const missions = listMissions();
|
|
2963
|
+
if (!missions.length) return ctx.ui.notify("No missions found.", "info");
|
|
2964
|
+
if (!ctx.hasUI) {
|
|
2965
|
+
return ctx.ui.notify(missions.map((m) => `${m.id} \u2014 ${m.title} (${m.status})`).join("\n"), "info");
|
|
2966
|
+
}
|
|
2967
|
+
const labels = missions.map((m) => `${m.id} \u2014 ${m.title} [${progress(m).done}/${progress(m).total}] ${m.status}`);
|
|
2968
|
+
const choice = await ctx.ui.select("Load mission:", labels);
|
|
2969
|
+
if (!choice) return;
|
|
2970
|
+
await handleLoad(choice.split(" \u2014 ")[0], ctx, pi, runtime);
|
|
2971
|
+
}
|
|
2972
|
+
async function handleLoad(id, ctx, pi, runtime) {
|
|
2973
|
+
if (!id) return ctx.ui.notify("Usage: /mission load <id>", "warning");
|
|
2974
|
+
const mission = loadMissionFromDisk(id);
|
|
2975
|
+
if (!mission) return ctx.ui.notify(`Mission not found: ${id}`, "error");
|
|
2976
|
+
autoBlockBlockedFeatures(mission);
|
|
2977
|
+
runtime.activeMission = mission;
|
|
2978
|
+
pi.appendEntry("pi-mission-active", { missionId: mission.id, validationToken: mission.validationToken });
|
|
2979
|
+
injectMissionContextWrapper(pi, ctx, mission, "mission_loaded");
|
|
2980
|
+
pi.setSessionName(`\u{1F3AF} ${mission.title}`);
|
|
2981
|
+
updateFooter(ctx, mission);
|
|
2982
|
+
ctx.ui.notify(`Loaded mission: ${mission.title}`, "info");
|
|
2983
|
+
}
|
|
2984
|
+
async function handleStatus(ctx, runtime) {
|
|
2985
|
+
const mission = runtime.activeMission;
|
|
2986
|
+
if (!mission) return ctx.ui.notify("No active mission. /mission new or /mission load.", "info");
|
|
2987
|
+
updateFooter(ctx, mission);
|
|
2988
|
+
ctx.ui.notify(statusText(mission), "info");
|
|
2989
|
+
}
|
|
2990
|
+
async function handleHelp(ctx) {
|
|
2991
|
+
ctx.ui.notify(buildMissionHelp(), "info");
|
|
2992
|
+
}
|
|
2993
|
+
async function handleNext(ctx, runtime) {
|
|
2994
|
+
const m = runtime.activeMission;
|
|
2995
|
+
if (!m) return ctx.ui.notify("No active mission.", "warning");
|
|
2996
|
+
const result = activateNextFeature(m);
|
|
2997
|
+
if (!result.ok) {
|
|
2998
|
+
if (result.reason === "active_not_done") {
|
|
2999
|
+
return ctx.ui.notify(`Active feature not done yet: ${result.active.id} \u2014 ${result.active.title}
|
|
3000
|
+
/mission done or /mission block.`, "warning");
|
|
3001
|
+
}
|
|
3002
|
+
if (result.reason === "mission_complete") {
|
|
3003
|
+
await saveMissionSafe(m);
|
|
3004
|
+
updateFooter(ctx, m);
|
|
3005
|
+
return ctx.ui.notify("\u{1F389} Mission complete.", "info");
|
|
3006
|
+
}
|
|
3007
|
+
return ctx.ui.notify("No unblocked pending feature found.", "warning");
|
|
3008
|
+
}
|
|
3009
|
+
autoBlockBlockedFeatures(m);
|
|
3010
|
+
await saveMissionSafe(m);
|
|
3011
|
+
updateFooter(ctx, m);
|
|
3012
|
+
ctx.ui.notify(`\u27A1\uFE0F Active feature: ${result.next.id} \u2014 ${result.next.title}
|
|
3013
|
+
${result.next.description}`, "info");
|
|
3014
|
+
}
|
|
3015
|
+
async function handleDone(evidence, ctx, runtime) {
|
|
3016
|
+
const m = runtime.activeMission;
|
|
3017
|
+
const f = m ? getActiveFeature(m) : null;
|
|
3018
|
+
if (!m || !f) return ctx.ui.notify("No active feature.", "warning");
|
|
3019
|
+
if (!evidence && ctx.hasUI) evidence = await ctx.ui.input("Evidence", "Why is this feature done?") || "Marked done.";
|
|
3020
|
+
const result = completeActiveFeature(m, { evidence: evidence || "Marked done.", autoVerify: true });
|
|
3021
|
+
if (!result.ok) return ctx.ui.notify(`${result.reason}
|
|
3022
|
+
|
|
3023
|
+
/mission edit to waive criteria.`, "warning");
|
|
3024
|
+
await saveMissionSafe(m);
|
|
3025
|
+
updateFooter(ctx, m);
|
|
3026
|
+
const wallMs = f.startedAt && f.completedAt ? f.completedAt - f.startedAt : 0;
|
|
3027
|
+
const isLarge = f.toolCallCount > 50 || wallMs > 6e5;
|
|
3028
|
+
const nextPending = getNextPendingFeature(m);
|
|
3029
|
+
let notify = `\u2705 ${result.feature.id} done. Evidence: ${result.evidenceFile}`;
|
|
3030
|
+
if (isLarge && nextPending && !result.missionComplete) {
|
|
3031
|
+
notify += `
|
|
3032
|
+
|
|
3033
|
+
\u{1F91D} Large feature completed (${f.toolCallCount} calls, ${Math.round(wallMs / 6e4)}min). Consider /handoff "Continue ${m.title} from ${nextPending.id} \u2014 ${nextPending.title}" for a fresh session.`;
|
|
3034
|
+
}
|
|
3035
|
+
ctx.ui.notify(notify, "info");
|
|
3036
|
+
}
|
|
3037
|
+
async function handleBlock(reason, ctx, runtime) {
|
|
3038
|
+
const m = runtime.activeMission;
|
|
3039
|
+
const f = m ? getActiveFeature(m) : null;
|
|
3040
|
+
if (!m || !f) return ctx.ui.notify("No active feature.", "warning");
|
|
3041
|
+
f.status = "blocked";
|
|
3042
|
+
f.notes = reason || "Blocked";
|
|
3043
|
+
appendHistory(m, { event: "feature_blocked", featureId: f.id, note: f.notes });
|
|
3044
|
+
await saveMissionSafe(m);
|
|
3045
|
+
updateFooter(ctx, m);
|
|
3046
|
+
}
|
|
3047
|
+
async function handleRun(ctx, pi, runtime) {
|
|
3048
|
+
const m = runtime.activeMission;
|
|
3049
|
+
if (!m) return ctx.ui.notify("No active mission.", "warning");
|
|
3050
|
+
m.status = "active";
|
|
3051
|
+
m.autopilot = {
|
|
3052
|
+
...m.autopilot,
|
|
3053
|
+
enabled: true,
|
|
3054
|
+
mode: "autopilot",
|
|
3055
|
+
iteration: 0,
|
|
3056
|
+
consecutiveFailures: 0,
|
|
3057
|
+
noProgressTurns: 0,
|
|
3058
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3059
|
+
lastStopReason: void 0,
|
|
3060
|
+
lastStopMessage: void 0
|
|
3061
|
+
};
|
|
3062
|
+
const f = ensureActiveFeature(m);
|
|
3063
|
+
if (!f) {
|
|
3064
|
+
m.autopilot.enabled = false;
|
|
3065
|
+
m.autopilot.lastStopReason = "no_active_feature";
|
|
3066
|
+
await saveMissionSafe(m);
|
|
3067
|
+
updateFooter(ctx, m);
|
|
3068
|
+
return ctx.ui.notify("No runnable feature available.", "warning");
|
|
3069
|
+
}
|
|
3070
|
+
appendHistory(m, { event: "autopilot_started", featureId: f.id });
|
|
3071
|
+
await saveMissionSafe(m);
|
|
3072
|
+
updateFooter(ctx, m);
|
|
3073
|
+
await triggerContinuation(pi, ctx, m);
|
|
3074
|
+
ctx.ui.notify(`Autopilot started for ${f.id} - ${f.title}.`, "info");
|
|
3075
|
+
}
|
|
3076
|
+
async function handleAutopilot(ctx, runtime) {
|
|
3077
|
+
const m = runtime.activeMission;
|
|
3078
|
+
if (!m) return ctx.ui.notify("No active mission.", "warning");
|
|
3079
|
+
const decision = shouldContinue(m, ctx);
|
|
3080
|
+
const a = m.autopilot;
|
|
3081
|
+
ctx.ui.notify([
|
|
3082
|
+
`Autopilot: ${a.enabled ? "ON" : "OFF"} (${a.mode})`,
|
|
3083
|
+
`Iteration: ${a.iteration}/${a.maxIterations}`,
|
|
3084
|
+
`Failures: ${a.consecutiveFailures}/${a.maxConsecutiveFailures}`,
|
|
3085
|
+
`No-progress: ${a.noProgressTurns}/${a.maxNoProgressTurns}`,
|
|
3086
|
+
`Last stop: ${a.lastStopReason ?? "none"}${a.lastStopMessage ? ` - ${a.lastStopMessage}` : ""}`,
|
|
3087
|
+
`Would continue: ${decision.continue ? "yes" : `no (${decision.reason})`}`
|
|
3088
|
+
].join("\n"), "info");
|
|
3089
|
+
}
|
|
3090
|
+
async function handleStop(ctx, runtime) {
|
|
3091
|
+
const m = runtime.activeMission;
|
|
3092
|
+
if (!m) return ctx.ui.notify("No active mission.", "warning");
|
|
3093
|
+
m.autopilot.enabled = false;
|
|
3094
|
+
m.autopilot.mode = "manual";
|
|
3095
|
+
m.autopilot.lastStopReason = "paused_by_user";
|
|
3096
|
+
m.autopilot.lastStopMessage = "Stopped by user.";
|
|
3097
|
+
appendHistory(m, { event: "autopilot_stopped" });
|
|
3098
|
+
await saveMissionSafe(m);
|
|
3099
|
+
updateFooter(ctx, m);
|
|
3100
|
+
ctx.ui.notify("Autopilot stopped.", "info");
|
|
3101
|
+
}
|
|
3102
|
+
async function handlePause(ctx, runtime) {
|
|
3103
|
+
if (!runtime.activeMission) return ctx.ui.notify("No active mission.", "warning");
|
|
3104
|
+
runtime.activeMission.status = "paused";
|
|
3105
|
+
runtime.activeMission.autopilot.enabled = false;
|
|
3106
|
+
runtime.activeMission.autopilot.lastStopReason = "paused_by_user";
|
|
3107
|
+
runtime.activeMission.autopilot.lastStopMessage = "Paused by user.";
|
|
3108
|
+
appendHistory(runtime.activeMission, { event: "mission_paused" });
|
|
3109
|
+
await saveMissionSafe(runtime.activeMission);
|
|
3110
|
+
updateFooter(ctx, runtime.activeMission);
|
|
3111
|
+
}
|
|
3112
|
+
async function handleResume(ctx, runtime) {
|
|
3113
|
+
if (!runtime.activeMission) return ctx.ui.notify("No active mission.", "warning");
|
|
3114
|
+
runtime.activeMission.status = "active";
|
|
3115
|
+
runtime.activeMission.autopilot.enabled = true;
|
|
3116
|
+
runtime.activeMission.autopilot.mode = "autopilot";
|
|
3117
|
+
runtime.activeMission.autopilot.lastStopReason = void 0;
|
|
3118
|
+
runtime.activeMission.autopilot.lastStopMessage = void 0;
|
|
3119
|
+
ensureActiveFeature(runtime.activeMission);
|
|
3120
|
+
appendHistory(runtime.activeMission, { event: "mission_resumed" });
|
|
3121
|
+
await saveMissionSafe(runtime.activeMission);
|
|
3122
|
+
updateFooter(ctx, runtime.activeMission);
|
|
3123
|
+
ctx.ui.notify("Resumed. /mission run for immediate autopilot turn.", "info");
|
|
3124
|
+
}
|
|
3125
|
+
async function handleClear(ctx, runtime) {
|
|
3126
|
+
runtime.activeMission = null;
|
|
3127
|
+
updateFooter(ctx, null);
|
|
3128
|
+
ctx.ui.notify("Mission detached.", "info");
|
|
3129
|
+
}
|
|
3130
|
+
async function handleEdit(featureId, ctx, runtime) {
|
|
3131
|
+
const m = runtime.activeMission;
|
|
3132
|
+
if (!m || !featureId) return ctx.ui.notify("Usage: /mission edit <feature-id>", "warning");
|
|
3133
|
+
const f = getFeatureById(m, featureId);
|
|
3134
|
+
if (!f) return ctx.ui.notify(`Feature not found: ${featureId}`, "error");
|
|
3135
|
+
if (!ctx.hasUI) return ctx.ui.notify(JSON.stringify(f, null, 2), "info");
|
|
3136
|
+
const edited = await ctx.ui.editor("Edit feature JSON", JSON.stringify(f, null, 2));
|
|
3137
|
+
if (!edited) return;
|
|
3138
|
+
let parsed;
|
|
3139
|
+
try {
|
|
3140
|
+
parsed = JSON.parse(edited);
|
|
3141
|
+
} catch (e) {
|
|
3142
|
+
return ctx.ui.notify(`Invalid feature JSON: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
3143
|
+
}
|
|
3144
|
+
const { FeatureSchema: FeatureSchema2 } = await Promise.resolve().then(() => (init_types(), types_exports));
|
|
3145
|
+
const validation = validate(FeatureSchema2, parsed);
|
|
3146
|
+
if (!validation.valid) {
|
|
3147
|
+
const errors = validation.errors.slice(0, 5).map((e) => `- ${e.path}: ${e.message}`).join("\n");
|
|
3148
|
+
return ctx.ui.notify(`Invalid feature:
|
|
3149
|
+
${errors}`, "error");
|
|
3150
|
+
}
|
|
3151
|
+
Object.assign(f, parsed);
|
|
3152
|
+
appendHistory(m, { event: "feature_edited", featureId });
|
|
3153
|
+
await saveMissionSafe(m);
|
|
3154
|
+
updateFooter(ctx, m);
|
|
3155
|
+
}
|
|
3156
|
+
async function forkFeatureInternally(m, reason, pi, ctx) {
|
|
3157
|
+
const f = getActiveFeature(m);
|
|
3158
|
+
if (!f) {
|
|
3159
|
+
ctx.ui.notify("No active feature. Use /mission next to advance, then fork.", "warning");
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
const sm = ctx.sessionManager;
|
|
3163
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3164
|
+
const parentLeafId = sm.getLeafId?.() ?? null;
|
|
3165
|
+
const parentSessionFile = sm.getSessionFile?.();
|
|
3166
|
+
const forkedId = `${f.id}-fork-${Date.now()}`;
|
|
3167
|
+
const forked = cloneFeatureForFork(f, forkedId, `${f.title} [fork]`, `Fork: ${reason}`);
|
|
3168
|
+
const milestone = getMilestoneById(m, f.milestoneId);
|
|
3169
|
+
if (!milestone) {
|
|
3170
|
+
ctx.ui.notify("Milestone not found.", "error");
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
f.status = "blocked";
|
|
3174
|
+
f.notes = appendForkNote(f.notes, [`Forked at ${createdAt}`, `Fork: ${forked.id}`, `Reason: ${reason}`, parentLeafId ? `Leaf: ${parentLeafId}` : "", parentSessionFile ? `Session: ${parentSessionFile}` : ""]);
|
|
3175
|
+
pushSessionRef(f, `fork:${forked.id}`);
|
|
3176
|
+
pushSessionRef(f, parentLeafId ? `leaf:${parentLeafId}` : void 0);
|
|
3177
|
+
pushSessionRef(f, parentSessionFile ? `session:${parentSessionFile}` : void 0);
|
|
3178
|
+
forked.notes = appendForkNote(forked.notes, [`Fork source: ${f.id}`, `Created: ${createdAt}`, `Reason: ${reason}`, parentSessionFile ? `Parent session: ${parentSessionFile}` : ""]);
|
|
3179
|
+
pushSessionRef(forked, `parent-feature:${f.id}`);
|
|
3180
|
+
pushSessionRef(forked, parentLeafId ? `parent-leaf:${parentLeafId}` : void 0);
|
|
3181
|
+
pushSessionRef(forked, parentSessionFile ? `parent-session:${parentSessionFile}` : void 0);
|
|
3182
|
+
milestone.features.push(forked);
|
|
3183
|
+
m.activeFeatureId = forked.id;
|
|
3184
|
+
m.status = "active";
|
|
3185
|
+
appendHistory(m, { event: "feature_forked", featureId: f.id, note: reason, details: { forkedFeatureId: forked.id, parentLeafId, parentSessionFile, forkApiAvailable: typeof ctx.fork === "function" } });
|
|
3186
|
+
await saveMissionSafe(m);
|
|
3187
|
+
const kickoff = buildForkKickoffMessage(m.title, f, forked, reason, void 0, parentSessionFile);
|
|
3188
|
+
let forkResult;
|
|
3189
|
+
if (parentLeafId && typeof ctx.fork === "function") {
|
|
3190
|
+
forkResult = await ctx.fork(parentLeafId, {
|
|
3191
|
+
position: "at",
|
|
3192
|
+
withSession: async (fc) => {
|
|
3193
|
+
const fcCtx = fc;
|
|
3194
|
+
const fsf = fc.sessionManager?.getSessionFile?.();
|
|
3195
|
+
const pm = loadMissionFromDisk(m.id);
|
|
3196
|
+
const pf = pm ? getFeatureById(pm, forked.id) : null;
|
|
3197
|
+
if (pm && pf) {
|
|
3198
|
+
pushSessionRef(pf, fsf ? `session:${fsf}` : void 0);
|
|
3199
|
+
appendHistory(pm, { event: "feature_fork_session_created", featureId: forked.id, note: reason, details: { sourceFeatureId: f.id, forkSessionFile: fsf, parentLeafId } });
|
|
3200
|
+
await saveMissionSafe(pm);
|
|
3201
|
+
}
|
|
3202
|
+
if (typeof fc.sendUserMessage === "function") await fc.sendUserMessage(kickoff);
|
|
3203
|
+
else fc.ui.notify(`\u{1F33F} Fork: ${forked.title}
|
|
3204
|
+
|
|
3205
|
+
${kickoff}`, "info");
|
|
3206
|
+
}
|
|
3207
|
+
});
|
|
3208
|
+
if (!forkResult?.cancelled) return;
|
|
3209
|
+
}
|
|
3210
|
+
const manual = buildManualForkHandoff(m.title, f, forked, reason, parentLeafId, parentSessionFile);
|
|
3211
|
+
ctx.ui.notify(`${manual}
|
|
3212
|
+
|
|
3213
|
+
${kickoff}`, forkResult?.cancelled ? "warning" : "info");
|
|
3214
|
+
}
|
|
3215
|
+
async function handleFork(reason, ctx, runtime) {
|
|
3216
|
+
const m = runtime.activeMission;
|
|
3217
|
+
if (!m) return ctx.ui.notify("No active feature. Forks can only be created from an active feature.", "warning");
|
|
3218
|
+
if (ctx.hasUI) reason = await ctx.ui.input("Alternative approach", reason || "Try a smaller/safer approach") || reason;
|
|
3219
|
+
await forkFeatureInternally(m, reason || "Alternative approach", {}, ctx);
|
|
3220
|
+
}
|
|
3221
|
+
async function handleDashboard(ctx, runtime) {
|
|
3222
|
+
const m = runtime.activeMission;
|
|
3223
|
+
if (!m) return ctx.ui.notify("No active mission.", "warning");
|
|
3224
|
+
if (!ctx.hasUI) {
|
|
3225
|
+
ctx.ui.notify(statusText(m), "info");
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
let selected = null;
|
|
3229
|
+
const ui = ctx.ui;
|
|
3230
|
+
if (typeof ui.custom === "function") {
|
|
3231
|
+
await ui.custom(missionControlOverlay(m, (fid) => {
|
|
3232
|
+
selected = fid;
|
|
3233
|
+
}), { overlay: true });
|
|
3234
|
+
}
|
|
3235
|
+
if (selected) {
|
|
3236
|
+
const f = getFeatureById(m, selected);
|
|
3237
|
+
if (f && m.activeFeatureId !== selected) {
|
|
3238
|
+
f.status = "active";
|
|
3239
|
+
m.activeFeatureId = selected;
|
|
3240
|
+
m.activeMilestoneId = f.milestoneId;
|
|
3241
|
+
m.status = "active";
|
|
3242
|
+
autoBlockBlockedFeatures(m);
|
|
3243
|
+
appendHistory(m, { event: "feature_active", featureId: selected });
|
|
3244
|
+
await saveMissionSafe(m);
|
|
3245
|
+
updateFooter(ctx, m);
|
|
3246
|
+
ctx.ui.notify(`\u27A1\uFE0F Activated: ${selected} \u2014 ${f.title}`, "info");
|
|
3247
|
+
} else if (f) {
|
|
3248
|
+
ctx.ui.notify(`Already active: ${selected} \u2014 ${f.title}`, "info");
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
async function handleDebug(id, ctx, runtime) {
|
|
3253
|
+
const m = id ? loadMissionFromDisk(id) : runtime.activeMission;
|
|
3254
|
+
if (!m) return ctx.ui.notify("No mission to debug.", "warning");
|
|
3255
|
+
const history = readHistory(m.id).slice(-25);
|
|
3256
|
+
ctx.ui.setWidget("pi-mission-debug", [
|
|
3257
|
+
`Mission: ${m.title}`,
|
|
3258
|
+
`Status: ${m.status}`,
|
|
3259
|
+
`Active: ${m.activeFeatureId ?? "none"}`,
|
|
3260
|
+
"\u2500".repeat(80),
|
|
3261
|
+
...history.map((h) => `${new Date(h.ts * 1e3).toISOString()} ${h.event} ${h.featureId ?? ""} ${h.note ?? ""}`)
|
|
3262
|
+
]);
|
|
3263
|
+
}
|
|
3264
|
+
async function handleMetrics(ctx, runtime) {
|
|
3265
|
+
const summary = calculateMetricsSummary();
|
|
3266
|
+
const sess = sessionMetrics.getMetricsSummary();
|
|
3267
|
+
if (summary.totalMissions === 0) return ctx.ui.notify("No missions. /mission new <title>.", "info");
|
|
3268
|
+
ctx.ui.notify([
|
|
3269
|
+
"\u{1F4CA} Mission Metrics Summary",
|
|
3270
|
+
"=".repeat(40),
|
|
3271
|
+
`Total: ${summary.totalMissions}`,
|
|
3272
|
+
`Completed: ${summary.completedMissions}`,
|
|
3273
|
+
`Success: ${(summary.successRate * 100).toFixed(1)}%`,
|
|
3274
|
+
`Avg tokens: ${summary.averageTokensPerMission.toFixed(0)}`,
|
|
3275
|
+
`Avg features: ${summary.averageFeaturesPerMission.toFixed(1)}`,
|
|
3276
|
+
`Avg time: ${(summary.averageCompletionTimeMs / 1e3 / 60).toFixed(1)} min`,
|
|
3277
|
+
"",
|
|
3278
|
+
"\u{1F4C8} Session",
|
|
3279
|
+
"=".repeat(40),
|
|
3280
|
+
sess
|
|
3281
|
+
].join("\n"), "info");
|
|
3282
|
+
const metricsFile = path4.join(missionsRoot(), "metrics-export.json");
|
|
3283
|
+
try {
|
|
3284
|
+
await fs3.promises.mkdir(missionsRoot(), { recursive: true });
|
|
3285
|
+
await fs3.promises.writeFile(metricsFile, JSON.stringify(listMissions().map(computeMissionMetrics), null, 2), "utf-8");
|
|
3286
|
+
ctx.ui.notify(`\u{1F4C1} Exported: ${metricsFile}`, "info");
|
|
3287
|
+
} catch (e) {
|
|
3288
|
+
ctx.ui.notify(`Export failed: ${e instanceof Error ? e.message : String(e)}`, "warning");
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
async function handleHistory(filter, ctx, runtime) {
|
|
3292
|
+
const m = runtime.activeMission;
|
|
3293
|
+
if (!m) return ctx.ui.notify("No active mission. /mission load <id> first.", "warning");
|
|
3294
|
+
const allEntries = readHistory(m.id);
|
|
3295
|
+
if (!allEntries.length) return ctx.ui.notify("No history entries yet.", "info");
|
|
3296
|
+
let entries = allEntries;
|
|
3297
|
+
let label = "All events";
|
|
3298
|
+
if (filter) {
|
|
3299
|
+
const lf = filter.toLowerCase();
|
|
3300
|
+
if (getFeatureById(m, filter)) {
|
|
3301
|
+
entries = entries.filter((e) => e.featureId === filter);
|
|
3302
|
+
label = `Feature ${filter}`;
|
|
3303
|
+
} else {
|
|
3304
|
+
const eventMatch = entries.filter((e) => e.event === filter);
|
|
3305
|
+
if (eventMatch.length > 0) {
|
|
3306
|
+
entries = eventMatch;
|
|
3307
|
+
label = `Event: ${filter}`;
|
|
3308
|
+
} else {
|
|
3309
|
+
entries = entries.filter(
|
|
3310
|
+
(e) => e.event.includes(lf) || (e.note ?? "").toLowerCase().includes(lf) || (e.featureId ?? "").toLowerCase().includes(lf)
|
|
3311
|
+
);
|
|
3312
|
+
label = `Search: "${filter.slice(0, 40)}"`;
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
if (!entries.length) return ctx.ui.notify(`No history entries matching "${filter}".`, "info");
|
|
3316
|
+
}
|
|
3317
|
+
const recent = entries.slice(-40);
|
|
3318
|
+
const features = new Set(recent.map((e) => e.featureId).filter(Boolean));
|
|
3319
|
+
const eventTypes = new Set(recent.map((e) => e.event));
|
|
3320
|
+
const lines = [
|
|
3321
|
+
`\u{1F4DC} Mission History \u2014 ${label} (${recent.length} of ${entries.length} entries)`,
|
|
3322
|
+
`Features: ${[...features].join(", ") || "none"}`,
|
|
3323
|
+
`Event types: ${[...eventTypes].join(", ")}`,
|
|
3324
|
+
"\u2500".repeat(80)
|
|
3325
|
+
];
|
|
3326
|
+
for (const h of recent) {
|
|
3327
|
+
const ts = new Date(h.ts * 1e3).toISOString().replace("T", " ").slice(0, 19);
|
|
3328
|
+
const evt = h.event.padEnd(24);
|
|
3329
|
+
const fid = (h.featureId ?? "").padEnd(8);
|
|
3330
|
+
const note = (h.note ?? "").slice(0, 60);
|
|
3331
|
+
lines.push(`${ts} ${evt} ${fid} ${note}`);
|
|
3332
|
+
}
|
|
3333
|
+
lines.push(
|
|
3334
|
+
"\u2500".repeat(80),
|
|
3335
|
+
"Filters: /mission history [feature_id|event_type|search_term]",
|
|
3336
|
+
`Full log: ~/.pi/missions/${m.id}/history.jsonl`,
|
|
3337
|
+
`jq replay: jq -r '.event + " " + (.featureId // "")' ~/.pi/missions/<id>/history.jsonl`
|
|
3338
|
+
);
|
|
3339
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3340
|
+
}
|
|
3341
|
+
async function handleExport(filename, ctx, runtime) {
|
|
3342
|
+
const m = runtime.activeMission;
|
|
3343
|
+
if (!m) return ctx.ui.notify("No active mission.", "warning");
|
|
3344
|
+
try {
|
|
3345
|
+
const md = exportMarkdown(m);
|
|
3346
|
+
if (filename) {
|
|
3347
|
+
await fs3.promises.writeFile(filename, md, "utf-8");
|
|
3348
|
+
ctx.ui.notify(`\u2705 Exported to ${filename}`, "info");
|
|
3349
|
+
} else {
|
|
3350
|
+
ctx.ui.notify(md, "info");
|
|
3351
|
+
}
|
|
3352
|
+
} catch (e) {
|
|
3353
|
+
ctx.ui.notify(`Export failed: ${e instanceof Error ? e.message : String(e)}`, "warning");
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
async function handleWorker(featureId, ctx, runtime) {
|
|
3357
|
+
const m = runtime.activeMission;
|
|
3358
|
+
if (!m) return ctx.ui.notify("No active mission.", "warning");
|
|
3359
|
+
const fid = featureId || m.activeFeatureId;
|
|
3360
|
+
if (!fid) return ctx.ui.notify("No feature specified and no active feature.", "warning");
|
|
3361
|
+
const f = getFeatureById(m, fid);
|
|
3362
|
+
if (!f) return ctx.ui.notify(`Feature not found: ${fid}`, "error");
|
|
3363
|
+
if (isWorkerRunning()) {
|
|
3364
|
+
const aw = getActiveWorker();
|
|
3365
|
+
const elapsed = aw ? Math.round((Date.now() - aw.startedAt) / 1e3) : 0;
|
|
3366
|
+
return ctx.ui.notify(`Worker already running for ${aw?.featureId} (${elapsed}s). Use /mission worker-status.`, "warning");
|
|
3367
|
+
}
|
|
3368
|
+
f.status = "active";
|
|
3369
|
+
m.activeFeatureId = f.id;
|
|
3370
|
+
m.activeMilestoneId = f.milestoneId;
|
|
3371
|
+
m.status = "active";
|
|
3372
|
+
appendHistory(m, {
|
|
3373
|
+
event: "worker_spawned",
|
|
3374
|
+
featureId: f.id,
|
|
3375
|
+
note: `Worker spawned for ${f.id} \u2014 ${f.title}`
|
|
3376
|
+
});
|
|
3377
|
+
await saveMissionSafe(m);
|
|
3378
|
+
ctx.ui.notify(`\u{1F680} Worker spawned for ${f.id} \u2014 ${f.title}. Check /mission worker-status for progress.`, "info");
|
|
3379
|
+
spawnWorker(m, { featureId: f.id }).then((result) => {
|
|
3380
|
+
if ("error" in result) {
|
|
3381
|
+
ctx.ui.notify(`\u274C Worker error: ${result.error}`, "error");
|
|
3382
|
+
return;
|
|
3383
|
+
}
|
|
3384
|
+
const r = result;
|
|
3385
|
+
const duration = Math.round(r.durationMs / 1e3);
|
|
3386
|
+
const statusIcon = r.killed ? "\u23F1\uFE0F" : r.exitCode === 0 ? "\u2705" : "\u274C";
|
|
3387
|
+
const lines = [
|
|
3388
|
+
`${statusIcon} Worker finished for ${r.featureId}`,
|
|
3389
|
+
`Exit: ${r.exitCode}${r.signal ? ` (${r.signal})` : ""} | Duration: ${duration}s`
|
|
3390
|
+
];
|
|
3391
|
+
const outSummary = r.stdout.slice(-1500);
|
|
3392
|
+
if (outSummary) lines.push("", "\u2500\u2500 Output (last 1500 chars) \u2500\u2500", outSummary);
|
|
3393
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3394
|
+
}).catch((err) => {
|
|
3395
|
+
ctx.ui.notify(`\u274C Worker failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
3396
|
+
});
|
|
3397
|
+
}
|
|
3398
|
+
async function handleWorkerStatus(ctx) {
|
|
3399
|
+
const aw = getActiveWorker();
|
|
3400
|
+
if (!aw) return ctx.ui.notify("No worker running.", "info");
|
|
3401
|
+
const elapsed = Math.round((Date.now() - aw.startedAt) / 1e3);
|
|
3402
|
+
const lines = [
|
|
3403
|
+
`\u{1F527} Worker Status`,
|
|
3404
|
+
`Feature: ${aw.featureId}`,
|
|
3405
|
+
`Status: ${aw.status}`,
|
|
3406
|
+
`Running: ${elapsed}s`
|
|
3407
|
+
];
|
|
3408
|
+
if (aw.result) {
|
|
3409
|
+
lines.push(
|
|
3410
|
+
"",
|
|
3411
|
+
"Last Result:",
|
|
3412
|
+
`Exit: ${aw.result.exitCode}${aw.result.signal ? ` (${aw.result.signal})` : ""}`,
|
|
3413
|
+
`Duration: ${Math.round(aw.result.durationMs / 1e3)}s`
|
|
3414
|
+
);
|
|
3415
|
+
}
|
|
3416
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3417
|
+
}
|
|
3418
|
+
async function handleKillWorker(ctx) {
|
|
3419
|
+
const killed = killWorker();
|
|
3420
|
+
if (!killed) return ctx.ui.notify("No worker running to kill.", "info");
|
|
3421
|
+
ctx.ui.notify("\u{1F6D1} Worker killed.", "info");
|
|
3422
|
+
}
|
|
3423
|
+
async function handleMigrate(id, ctx, runtime) {
|
|
3424
|
+
if (!id) {
|
|
3425
|
+
const missions = listMissions();
|
|
3426
|
+
if (!missions.length) return ctx.ui.notify("No missions found.", "info");
|
|
3427
|
+
const lines = ["\u{1F4CB} Mission Schema Versions", "=".repeat(60)];
|
|
3428
|
+
let needsMigration = 0;
|
|
3429
|
+
for (const m of missions) {
|
|
3430
|
+
const rawVersion2 = readRawSchemaVersion(m.id);
|
|
3431
|
+
const versionStr = rawVersion2 !== null ? `v${rawVersion2}` : "?";
|
|
3432
|
+
const status = rawVersion2 === SCHEMA_VERSION ? "\u2705" : rawVersion2 !== null ? "\u2B06\uFE0F" : "\u2753";
|
|
3433
|
+
if (rawVersion2 !== null && rawVersion2 < SCHEMA_VERSION) needsMigration++;
|
|
3434
|
+
lines.push(`${status} ${m.id.padEnd(28)} ${versionStr.padEnd(6)} ${m.title.slice(0, 30)}`);
|
|
3435
|
+
}
|
|
3436
|
+
lines.push(
|
|
3437
|
+
"=".repeat(60),
|
|
3438
|
+
`Current schema: v${SCHEMA_VERSION}`,
|
|
3439
|
+
needsMigration > 0 ? `${needsMigration} mission(s) need migration. Use /mission migrate <id> to migrate.` : "All missions up to date."
|
|
3440
|
+
);
|
|
3441
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3442
|
+
return;
|
|
3443
|
+
}
|
|
3444
|
+
const rawVersion = readRawSchemaVersion(id);
|
|
3445
|
+
if (rawVersion === null) return ctx.ui.notify(`Mission not found: ${id}`, "error");
|
|
3446
|
+
if (rawVersion >= SCHEMA_VERSION) {
|
|
3447
|
+
return ctx.ui.notify(`Mission ${id} already at v${rawVersion} (current: v${SCHEMA_VERSION}). No migration needed.`, "info");
|
|
3448
|
+
}
|
|
3449
|
+
const rawCounts = readRawMissionCounts(id);
|
|
3450
|
+
const current = loadMissionFromDisk(id);
|
|
3451
|
+
if (!current) return ctx.ui.notify(`Could not load mission ${id}.`, "error");
|
|
3452
|
+
const featuresBefore = rawCounts?.features ?? current.milestones.reduce((s, m) => s + m.features.length, 0);
|
|
3453
|
+
const milestonesBefore = rawCounts?.milestones ?? current.milestones.length;
|
|
3454
|
+
const preview = [
|
|
3455
|
+
`\u2B06\uFE0F Migration preview for ${id}`,
|
|
3456
|
+
"=".repeat(60),
|
|
3457
|
+
`Title: ${current.title}`,
|
|
3458
|
+
`Schema: v${rawVersion} \u2192 v${SCHEMA_VERSION}`,
|
|
3459
|
+
`Status: ${current.status}`,
|
|
3460
|
+
`Milestones: ${milestonesBefore}`,
|
|
3461
|
+
`Features: ${featuresBefore}`,
|
|
3462
|
+
"=".repeat(60),
|
|
3463
|
+
"",
|
|
3464
|
+
"Migration will:",
|
|
3465
|
+
"- Set schemaVersion to v3",
|
|
3466
|
+
rawVersion <= 1 ? "- Wrap flat features into a milestone if needed" : null,
|
|
3467
|
+
rawVersion <= 2 ? "- Add default autopilot settings" : null,
|
|
3468
|
+
"- Create a pre-migration backup",
|
|
3469
|
+
"",
|
|
3470
|
+
`Run /mission migrate ${id} confirm to proceed.`
|
|
3471
|
+
].filter(Boolean).join("\n");
|
|
3472
|
+
ctx.ui.notify(preview, "info");
|
|
3473
|
+
}
|
|
3474
|
+
async function handleMigrateConfirm(id, ctx, runtime) {
|
|
3475
|
+
if (!id) return ctx.ui.notify("Usage: /mission migrate <id> confirm", "warning");
|
|
3476
|
+
const rawVersion = readRawSchemaVersion(id);
|
|
3477
|
+
if (rawVersion === null) return ctx.ui.notify(`Mission not found: ${id}`, "error");
|
|
3478
|
+
if (rawVersion >= SCHEMA_VERSION) {
|
|
3479
|
+
return ctx.ui.notify(`Already at v${rawVersion}. No migration needed.`, "info");
|
|
3480
|
+
}
|
|
3481
|
+
const migrated = await migrateMissionOnDisk(id);
|
|
3482
|
+
if (!migrated) return ctx.ui.notify(`Migration failed for ${id}.`, "error");
|
|
3483
|
+
if (runtime.activeMission && runtime.activeMission.id === id) {
|
|
3484
|
+
runtime.activeMission = migrated;
|
|
3485
|
+
}
|
|
3486
|
+
const fc = migrated.milestones.reduce((s, m) => s + m.features.length, 0);
|
|
3487
|
+
ctx.ui.notify(
|
|
3488
|
+
`\u2705 Migrated ${id} from v${rawVersion} to v${SCHEMA_VERSION}.
|
|
3489
|
+
Milestones: ${migrated.milestones.length}, Features: ${fc}
|
|
3490
|
+
Backup saved to ~/.pi/missions/${id}/plan.json.pre-migration-*.bak`,
|
|
3491
|
+
"info"
|
|
3492
|
+
);
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
// src/commands/index.ts
|
|
3496
|
+
function registerMissionCommand(pi, runtime) {
|
|
3497
|
+
const subs = [
|
|
3498
|
+
"start",
|
|
3499
|
+
"new",
|
|
3500
|
+
"list",
|
|
3501
|
+
"load",
|
|
3502
|
+
"run",
|
|
3503
|
+
"pause",
|
|
3504
|
+
"resume",
|
|
3505
|
+
"stop",
|
|
3506
|
+
"clear",
|
|
3507
|
+
"status",
|
|
3508
|
+
"autopilot",
|
|
3509
|
+
"help",
|
|
3510
|
+
"next",
|
|
3511
|
+
"done",
|
|
3512
|
+
"block",
|
|
3513
|
+
"edit",
|
|
3514
|
+
"fork",
|
|
3515
|
+
"debug",
|
|
3516
|
+
"dashboard",
|
|
3517
|
+
"metrics",
|
|
3518
|
+
"export",
|
|
3519
|
+
"templates",
|
|
3520
|
+
"history",
|
|
3521
|
+
"worker",
|
|
3522
|
+
"worker-status",
|
|
3523
|
+
"kill-worker",
|
|
3524
|
+
"migrate"
|
|
3525
|
+
];
|
|
3526
|
+
pi.registerCommand("mission", {
|
|
3527
|
+
description: `Mission management: ${subs.join("|")}`,
|
|
3528
|
+
getArgumentCompletions: (prefix) => subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),
|
|
3529
|
+
handler: async (args, ctx) => {
|
|
3530
|
+
const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
3531
|
+
switch (sub) {
|
|
3532
|
+
case "start":
|
|
3533
|
+
case "new":
|
|
3534
|
+
return handleNew(rest.join(" "), ctx, pi, runtime);
|
|
3535
|
+
case "list":
|
|
3536
|
+
return handleList(ctx, pi, runtime);
|
|
3537
|
+
case "load":
|
|
3538
|
+
return handleLoad(rest[0], ctx, pi, runtime);
|
|
3539
|
+
case "status":
|
|
3540
|
+
return handleStatus(ctx, runtime);
|
|
3541
|
+
case "help":
|
|
3542
|
+
return handleHelp(ctx);
|
|
3543
|
+
case "dashboard":
|
|
3544
|
+
return handleDashboard(ctx, runtime);
|
|
3545
|
+
case "next":
|
|
3546
|
+
return handleNext(ctx, runtime);
|
|
3547
|
+
case "done":
|
|
3548
|
+
return handleDone(rest.join(" "), ctx, runtime);
|
|
3549
|
+
case "block":
|
|
3550
|
+
return handleBlock(rest.join(" "), ctx, runtime);
|
|
3551
|
+
case "run":
|
|
3552
|
+
return handleRun(ctx, pi, runtime);
|
|
3553
|
+
case "pause":
|
|
3554
|
+
return handlePause(ctx, runtime);
|
|
3555
|
+
case "resume":
|
|
3556
|
+
return handleResume(ctx, runtime);
|
|
3557
|
+
case "stop":
|
|
3558
|
+
return handleStop(ctx, runtime);
|
|
3559
|
+
case "autopilot":
|
|
3560
|
+
return handleAutopilot(ctx, runtime);
|
|
3561
|
+
case "clear":
|
|
3562
|
+
return handleClear(ctx, runtime);
|
|
3563
|
+
case "edit":
|
|
3564
|
+
return handleEdit(rest[0], ctx, runtime);
|
|
3565
|
+
case "fork":
|
|
3566
|
+
return handleFork(rest.join(" "), ctx, runtime);
|
|
3567
|
+
case "debug":
|
|
3568
|
+
return handleDebug(rest[0], ctx, runtime);
|
|
3569
|
+
case "metrics":
|
|
3570
|
+
return handleMetrics(ctx, runtime);
|
|
3571
|
+
case "export":
|
|
3572
|
+
return handleExport(rest[0], ctx, runtime);
|
|
3573
|
+
case "templates":
|
|
3574
|
+
return handleTemplates(rest[0], rest[1], rest.slice(2).join(" "), ctx, pi, runtime);
|
|
3575
|
+
case "history":
|
|
3576
|
+
return handleHistory(rest[0], ctx, runtime);
|
|
3577
|
+
case "worker":
|
|
3578
|
+
return handleWorker(rest[0], ctx, runtime);
|
|
3579
|
+
case "worker-status":
|
|
3580
|
+
return handleWorkerStatus(ctx);
|
|
3581
|
+
case "kill-worker":
|
|
3582
|
+
return handleKillWorker(ctx);
|
|
3583
|
+
case "migrate":
|
|
3584
|
+
if (rest[0] && rest[1] === "confirm") return handleMigrateConfirm(rest[0], ctx, runtime);
|
|
3585
|
+
return handleMigrate(rest[0], ctx, runtime);
|
|
3586
|
+
default:
|
|
3587
|
+
return ctx.ui.notify(`Unknown /mission subcommand: ${sub}`, "warning");
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
3592
|
+
function compactionCheckpoint(pi, runtime) {
|
|
3593
|
+
if (!runtime.activeMission) return;
|
|
3594
|
+
pi.appendEntry("pi-mission-compaction-checkpoint", {
|
|
3595
|
+
missionId: runtime.activeMission.id,
|
|
3596
|
+
summary: buildCompactionSummary(runtime.activeMission),
|
|
3597
|
+
timestamp: Date.now()
|
|
3598
|
+
});
|
|
3599
|
+
}
|
|
3600
|
+
function missionSummaryForTree(runtime) {
|
|
3601
|
+
const mission = runtime.activeMission;
|
|
3602
|
+
if (!mission) return null;
|
|
3603
|
+
const active = getActiveFeature(mission);
|
|
3604
|
+
return `Mission: ${mission.title}${active ? ` \u2014 Feature: ${active.title}` : ""}`;
|
|
3605
|
+
}
|
|
3606
|
+
function saveSessionLink(runtime, sessionFile) {
|
|
3607
|
+
if (runtime.activeMission && sessionFile) {
|
|
3608
|
+
const agent = process.env.CODING_AGENT || "unknown";
|
|
3609
|
+
linkSession(runtime.activeMission, sessionFile, agent);
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
// src/core/extension.ts
|
|
3614
|
+
function latestActiveEntry(entries) {
|
|
3615
|
+
for (const e of [...entries].reverse()) {
|
|
3616
|
+
if (e.type === "pi-mission-active" && e.data && typeof e.data === "object") {
|
|
3617
|
+
if (typeof e.data.missionId === "string") {
|
|
3618
|
+
return {
|
|
3619
|
+
missionId: e.data.missionId,
|
|
3620
|
+
validationToken: typeof e.data.validationToken === "string" ? e.data.validationToken : void 0
|
|
3621
|
+
};
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
if (e.type === "custom" && e.customType === "pi-mission-active" && e.data && typeof e.data === "object") {
|
|
3625
|
+
if (typeof e.data.missionId === "string") {
|
|
3626
|
+
return {
|
|
3627
|
+
missionId: e.data.missionId,
|
|
3628
|
+
validationToken: typeof e.data.validationToken === "string" ? e.data.validationToken : void 0
|
|
3629
|
+
};
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
return null;
|
|
3634
|
+
}
|
|
3635
|
+
function hook(pi, event, handler) {
|
|
3636
|
+
pi.on(event, handler);
|
|
3637
|
+
}
|
|
3638
|
+
function piMissions(pi) {
|
|
3639
|
+
try {
|
|
3640
|
+
const extDir = dirname2(fileURLToPath(import.meta.url));
|
|
3641
|
+
const globalPath = resolve2(extDir, "..", "index.ts");
|
|
3642
|
+
const cwd = typeof process.cwd === "function" ? process.cwd() : "";
|
|
3643
|
+
const localPath = cwd ? resolve2(cwd, ".pi", "extensions", "pi-missions", "index.ts") : "";
|
|
3644
|
+
let extensionPath = globalPath;
|
|
3645
|
+
if (localPath && fs4.existsSync(localPath)) {
|
|
3646
|
+
extensionPath = localPath;
|
|
3647
|
+
const localDir = resolve2(cwd, ".pi", "extensions", "pi-missions");
|
|
3648
|
+
process.env.PI_MISSIONS_PROJECT_DIR = localDir;
|
|
3649
|
+
}
|
|
3650
|
+
process.env.PI_MISSIONS_EXTENSION_PATH = extensionPath;
|
|
3651
|
+
} catch {
|
|
3652
|
+
}
|
|
3653
|
+
const runtime = {
|
|
3654
|
+
activeMission: null,
|
|
3655
|
+
autoSaveInterval: null,
|
|
3656
|
+
phaseToolCallCount: 0,
|
|
3657
|
+
currentPhase: "execution",
|
|
3658
|
+
lastFeatureId: void 0
|
|
3659
|
+
};
|
|
3660
|
+
function scheduleAutoSave(rt) {
|
|
3661
|
+
if (!rt.autoSaveInterval) {
|
|
3662
|
+
rt.autoSaveInterval = setInterval(async () => {
|
|
3663
|
+
if (rt.activeMission?.status === "active") await saveMissionSafe(rt.activeMission);
|
|
3664
|
+
}, 2 * 60 * 1e3);
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
registerMissionCommand(pi, runtime);
|
|
3668
|
+
registerMissionTools(pi, runtime);
|
|
3669
|
+
hook(pi, "session_start", async (...args) => {
|
|
3670
|
+
const _event = args[0];
|
|
3671
|
+
const ctx = args[1];
|
|
3672
|
+
sessionMetrics.reset();
|
|
3673
|
+
const entries = ctx.sessionManager.getEntries();
|
|
3674
|
+
const active = latestActiveEntry(entries);
|
|
3675
|
+
if (!active) {
|
|
3676
|
+
const malformed = entries.some(
|
|
3677
|
+
(e) => (e?.type === "pi-mission-active" || e?.type === "custom" && e?.customType === "pi-mission-active") && e?.data && typeof e.data === "object" && typeof e.data.missionId !== "string"
|
|
3678
|
+
);
|
|
3679
|
+
if (malformed) ctx.ui?.notify("\u26A0\uFE0F Ignoring invalid mission session entry.", "warning");
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
const { missionId, validationToken } = active;
|
|
3683
|
+
if (!isValidMissionId(missionId)) {
|
|
3684
|
+
const fallback = loadMissionFromDisk(missionId);
|
|
3685
|
+
if (!fallback) {
|
|
3686
|
+
ctx.ui?.notify(`\u26A0\uFE0F Mission '${missionId}' not found on disk. /mission load.`, "warning");
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
runtime.activeMission = fallback;
|
|
3690
|
+
autoBlockBlockedFeatures(fallback);
|
|
3691
|
+
updateFooter(ctx, fallback);
|
|
3692
|
+
pi.setSessionName(`\u{1F3AF} ${fallback.title}`);
|
|
3693
|
+
scheduleAutoSave(runtime);
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
const mission = loadMissionFromDisk(missionId);
|
|
3697
|
+
if (!mission) {
|
|
3698
|
+
ctx.ui?.notify(`\u26A0\uFE0F Mission '${missionId}' not found on disk. /mission load.`, "warning");
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
3701
|
+
if (validationToken && validationToken !== mission.validationToken) {
|
|
3702
|
+
ctx.ui?.notify("\u26A0\uFE0F Invalid mission event token.", "warning");
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
runtime.activeMission = mission;
|
|
3706
|
+
autoBlockBlockedFeatures(mission);
|
|
3707
|
+
updateFooter(ctx, mission);
|
|
3708
|
+
pi.setSessionName(`\u{1F3AF} ${mission.title}`);
|
|
3709
|
+
scheduleAutoSave(runtime);
|
|
3710
|
+
});
|
|
3711
|
+
hook(pi, "resources_discover", async () => ({ skillPaths: [], promptPaths: [], themePaths: [] }));
|
|
3712
|
+
hook(pi, "session_before_tree", async () => {
|
|
3713
|
+
const summary = missionSummaryForTree(runtime);
|
|
3714
|
+
if (!summary) return;
|
|
3715
|
+
return { summary: { summary, details: { missionId: runtime.activeMission?.id } } };
|
|
3716
|
+
});
|
|
3717
|
+
hook(pi, "before_agent_start", async (..._args) => {
|
|
3718
|
+
runtime.pendingCompletionAction = void 0;
|
|
3719
|
+
runtime.pendingCompletionReason = void 0;
|
|
3720
|
+
runtime.phaseToolCallCount = 0;
|
|
3721
|
+
const mission = runtime.activeMission;
|
|
3722
|
+
if (!mission || mission.status !== "active") return;
|
|
3723
|
+
runtime.currentPhase = getMissionPhase(mission);
|
|
3724
|
+
const feature = getActiveFeature(mission);
|
|
3725
|
+
if (feature?.status === "active") {
|
|
3726
|
+
runtime.lastFeatureId = feature.id;
|
|
3727
|
+
getCompletionDetector().clearToolCallHistory();
|
|
3728
|
+
getErrorRecoveryEngine().clearErrorsForFeature(feature.id);
|
|
3729
|
+
getErrorRecoveryEngine().onAlert((alert) => {
|
|
3730
|
+
if (!runtime.activeMission) return;
|
|
3731
|
+
appendHistory(runtime.activeMission, {
|
|
3732
|
+
event: "error_alert",
|
|
3733
|
+
featureId: feature.id,
|
|
3734
|
+
note: alert.message.slice(0, 200),
|
|
3735
|
+
details: { alertType: alert.type, errorCategory: alert.record.category, errorSeverity: alert.record.severity, errorCount: alert.stats?.total }
|
|
3736
|
+
});
|
|
3737
|
+
});
|
|
3738
|
+
}
|
|
3739
|
+
});
|
|
3740
|
+
hook(pi, "before_agent_start", async (...args) => {
|
|
3741
|
+
const _event = args[0];
|
|
3742
|
+
const ctx = args[1];
|
|
3743
|
+
const mission = runtime.activeMission;
|
|
3744
|
+
if (!mission || mission.status !== "active") return;
|
|
3745
|
+
updateFooter(ctx, mission);
|
|
3746
|
+
let lean = buildLeanContext(mission);
|
|
3747
|
+
if (runtime.pendingCompletionAction === "ask_user") {
|
|
3748
|
+
const reason = (runtime.pendingCompletionReason ?? "").replace(/^(Medium|Low) confidence - /i, "") || "completion is unclear";
|
|
3749
|
+
lean += `
|
|
3750
|
+
|
|
3751
|
+
\u{1F6A8} **STOP AND CALL THE TOOL**: ${reason}. Call **mission_ask_user** NOW. Do NOT describe \u2014 invoke the tool.`;
|
|
3752
|
+
} else if (runtime.pendingCompletionAction === "suggest_done") {
|
|
3753
|
+
const reason = runtime.pendingCompletionReason || "feature may be complete";
|
|
3754
|
+
lean += `
|
|
3755
|
+
|
|
3756
|
+
\u{1F4A1} ${reason}. Consider **mission_feature_done** if you have concrete evidence.`;
|
|
3757
|
+
}
|
|
3758
|
+
return { message: { customType: "pi-mission-context", content: lean, display: false } };
|
|
3759
|
+
});
|
|
3760
|
+
hook(pi, "tool_call", async (...args) => {
|
|
3761
|
+
const event = args[0];
|
|
3762
|
+
if (!runtime.activeMission) return;
|
|
3763
|
+
runtime.currentPhase = getMissionPhase(runtime.activeMission);
|
|
3764
|
+
const policyResult = enforceToolPolicy(
|
|
3765
|
+
event.toolName,
|
|
3766
|
+
runtime.currentPhase,
|
|
3767
|
+
event.input,
|
|
3768
|
+
runtime.activeMission.userPreferences?.allowBashInPlanning === true,
|
|
3769
|
+
runtime.phaseToolCallCount
|
|
3770
|
+
);
|
|
3771
|
+
if (policyResult.blocked) return { block: true, reason: policyResult.reason };
|
|
3772
|
+
runtime.phaseToolCallCount++;
|
|
3773
|
+
const maxResult = enforceToolMax(runtime.currentPhase, runtime.phaseToolCallCount);
|
|
3774
|
+
if (maxResult.blocked) return { block: true, reason: maxResult.reason };
|
|
3775
|
+
});
|
|
3776
|
+
hook(pi, "tool_result", async (...args) => {
|
|
3777
|
+
const event = args[0];
|
|
3778
|
+
if (!runtime.activeMission) return;
|
|
3779
|
+
const mission = runtime.activeMission;
|
|
3780
|
+
const feature = getActiveFeature(mission);
|
|
3781
|
+
const detector = getCompletionDetector();
|
|
3782
|
+
if (feature?.id && feature.id !== runtime.lastFeatureId) {
|
|
3783
|
+
detector.clearToolCallHistory();
|
|
3784
|
+
getErrorRecoveryEngine().clearErrorsForFeature(feature.id);
|
|
3785
|
+
runtime.lastFeatureId = feature.id;
|
|
3786
|
+
}
|
|
3787
|
+
const success = !event.isError;
|
|
3788
|
+
detector.recordToolCall(event.toolName, success);
|
|
3789
|
+
sessionMetrics.recordToolCall(event.toolName, success);
|
|
3790
|
+
if (!event.isError) {
|
|
3791
|
+
getErrorRecoveryEngine().clearConsecutiveFailures(event.toolName, feature?.id);
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
const recovery = getErrorRecoveryEngine();
|
|
3795
|
+
const errorMessage = toolResultErrorMessage(event);
|
|
3796
|
+
const { action, shouldRetry, retryAfter, record } = recovery.handleError({
|
|
3797
|
+
toolName: event.toolName,
|
|
3798
|
+
featureId: feature?.id,
|
|
3799
|
+
missionId: mission.id,
|
|
3800
|
+
timestamp: Date.now(),
|
|
3801
|
+
errorType: "ToolResultError",
|
|
3802
|
+
errorMessage
|
|
3803
|
+
});
|
|
3804
|
+
sessionMetrics.recordError(record.category);
|
|
3805
|
+
appendHistory(mission, {
|
|
3806
|
+
event: "error_detected",
|
|
3807
|
+
featureId: feature?.id,
|
|
3808
|
+
note: `${event.toolName} failed: ${errorMessage}`,
|
|
3809
|
+
details: { category: record.category, severity: record.severity, action, shouldRetry, retryAfter, retryCount: record.retryCount }
|
|
3810
|
+
});
|
|
3811
|
+
});
|
|
3812
|
+
pi.registerShortcut("ctrl+shift+m", {
|
|
3813
|
+
description: "Open Mission Control dashboard",
|
|
3814
|
+
handler: ((rawCtx) => handleDashboard(rawCtx, runtime))
|
|
3815
|
+
});
|
|
3816
|
+
pi.registerShortcut("ctrl+shift+d", {
|
|
3817
|
+
description: "Mark current feature as done",
|
|
3818
|
+
handler: (async (rawCtx) => {
|
|
3819
|
+
const ctx = rawCtx;
|
|
3820
|
+
const m = runtime.activeMission;
|
|
3821
|
+
const f = m ? getActiveFeature(m) : null;
|
|
3822
|
+
if (!m || !f) return ctx.ui?.notify("No active feature.", "warning");
|
|
3823
|
+
let ok = true;
|
|
3824
|
+
if (ctx.hasUI) ok = await ctx.ui.confirm("Feature done?", `Mark '${f.title}' as completed?`);
|
|
3825
|
+
if (!ok) return;
|
|
3826
|
+
f.status = "done";
|
|
3827
|
+
f.completedAt = Date.now();
|
|
3828
|
+
for (const ac of f.acceptance) if (!ac.waived) ac.verified = true;
|
|
3829
|
+
const evidenceFile = saveEvidence(m, f, "Done via keyboard shortcut.");
|
|
3830
|
+
appendHistory(m, { event: "feature_done", featureId: f.id, note: "Keyboard shortcut", details: { evidenceFile } });
|
|
3831
|
+
autoBlockBlockedFeatures(m);
|
|
3832
|
+
await saveMissionSafe(m);
|
|
3833
|
+
updateFooter(ctx, m);
|
|
3834
|
+
ctx.ui.notify(`\u2705 ${f.title} done!`, "info");
|
|
3835
|
+
})
|
|
3836
|
+
});
|
|
3837
|
+
hook(pi, "turn_end", async (...args) => {
|
|
3838
|
+
const _event = args[0];
|
|
3839
|
+
const ctx = args[1];
|
|
3840
|
+
const m = runtime.activeMission;
|
|
3841
|
+
if (!m) return;
|
|
3842
|
+
const usage = ctx.getContextUsage?.();
|
|
3843
|
+
if (usage?.tokens !== void 0) {
|
|
3844
|
+
const delta = Math.max(0, usage.tokens - m.lastContextTokens);
|
|
3845
|
+
m.tokensUsed += delta;
|
|
3846
|
+
m.lastContextTokens = usage.tokens;
|
|
3847
|
+
if (m.tokensBudget && m.tokensUsed > m.tokensBudget * 0.8 && m.status === "active") {
|
|
3848
|
+
m.status = "budget_limited";
|
|
3849
|
+
ctx.ui.notify("\u26A0\uFE0F Token budget 80% used.", "warning");
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
const leafId = ctx.sessionManager.getLeafId?.();
|
|
3853
|
+
const active = getActiveFeature(m);
|
|
3854
|
+
if (leafId && active) pi.setLabel(leafId, `\u{1F3AF} ${active.title}`);
|
|
3855
|
+
const detector = getCompletionDetector();
|
|
3856
|
+
try {
|
|
3857
|
+
const entries = ctx.sessionManager.getEntries();
|
|
3858
|
+
const lastAsst = entries.filter((e) => e?.role === "assistant").slice(-1)[0];
|
|
3859
|
+
const content = lastAsst?.content;
|
|
3860
|
+
if (Array.isArray(content)) {
|
|
3861
|
+
const text = content.filter((c) => c?.type === "text").map((c) => c.text ?? "").join("\n");
|
|
3862
|
+
if (text) detector.recordTextOutput(text);
|
|
3863
|
+
} else if (typeof content === "string") {
|
|
3864
|
+
detector.recordTextOutput(content);
|
|
3865
|
+
}
|
|
3866
|
+
} catch {
|
|
3867
|
+
}
|
|
3868
|
+
if (active?.status === "active") {
|
|
3869
|
+
const stuck = detector.detectStuck();
|
|
3870
|
+
const textLoop = detector.detectTextLoop();
|
|
3871
|
+
const effective = textLoop.isStuck ? textLoop : stuck;
|
|
3872
|
+
if (effective.isStuck && effective.suggestedAction === "block_self") {
|
|
3873
|
+
sessionMetrics.recordStuckDetection();
|
|
3874
|
+
appendHistory(m, { event: "stuck_detected", featureId: active.id, note: effective.reason, details: { source: textLoop.isStuck ? "text_loop" : "tool_pattern" } });
|
|
3875
|
+
active.status = "blocked";
|
|
3876
|
+
active.notes = `Auto-blocked: ${effective.reason}`;
|
|
3877
|
+
m.status = "blocked";
|
|
3878
|
+
m.autopilot.enabled = false;
|
|
3879
|
+
m.autopilot.lastStopReason = "blocked";
|
|
3880
|
+
m.autopilot.lastStopMessage = effective.reason;
|
|
3881
|
+
ctx.ui.notify(`\u{1F6AB} Auto-blocked: ${effective.reason}`, "warning");
|
|
3882
|
+
await saveMissionSafe(m);
|
|
3883
|
+
} else if (effective.isStuck) {
|
|
3884
|
+
ctx.ui.notify(`\u26A0\uFE0F Stuck detected: ${effective.reason}. Consider mission_block_self.`, "warning");
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
await saveMissionSafe(m);
|
|
3888
|
+
updateFooter(ctx, m);
|
|
3889
|
+
});
|
|
3890
|
+
hook(pi, "agent_end", async (...args) => {
|
|
3891
|
+
const event = args[0];
|
|
3892
|
+
const ctx = args[1];
|
|
3893
|
+
const m = runtime.activeMission;
|
|
3894
|
+
if (m?.autopilot?.enabled) {
|
|
3895
|
+
await processAgentEndForAutopilot(pi, ctx, event, runtime);
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
const feature = m ? getActiveFeature(m) : null;
|
|
3899
|
+
if (!m || !feature || feature.status !== "active") return;
|
|
3900
|
+
const text = (event.messages ?? []).flatMap((msg) => Array.isArray(msg.content) ? msg.content : []).filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
3901
|
+
const detector = getCompletionDetector();
|
|
3902
|
+
const detection = detector.detectCompletion(feature, text);
|
|
3903
|
+
appendHistory(m, {
|
|
3904
|
+
event: "completion_detection",
|
|
3905
|
+
featureId: feature.id,
|
|
3906
|
+
note: detection.reason,
|
|
3907
|
+
details: { isComplete: detection.isComplete, confidence: detection.confidence, suggestedAction: detection.suggestedAction, signals: detection.signals }
|
|
3908
|
+
});
|
|
3909
|
+
if (detection.suggestedAction === "auto_done") {
|
|
3910
|
+
const completed = completeActiveFeature(m, {
|
|
3911
|
+
evidence: `Auto-completed: ${detection.reason}
|
|
3912
|
+
|
|
3913
|
+
Signals:
|
|
3914
|
+
${detection.signals.map((s) => `- ${s.type}: ${s.evidence}`).join("\n")}`,
|
|
3915
|
+
markAcceptanceVerified: true,
|
|
3916
|
+
historyNote: "Auto-completed",
|
|
3917
|
+
historyDetails: { auto: true }
|
|
3918
|
+
});
|
|
3919
|
+
if (!completed.ok) {
|
|
3920
|
+
ctx.ui.notify(`Feature looks complete but cannot auto-complete: ${completed.reason}`, "info");
|
|
3921
|
+
await saveMissionSafe(m);
|
|
3922
|
+
updateFooter(ctx, m);
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
sessionMetrics.recordFeatureCompleted();
|
|
3926
|
+
const next = activateNextFeature(m, "Auto-advanced");
|
|
3927
|
+
if (next.ok) {
|
|
3928
|
+
sessionMetrics.recordAutoAdvance();
|
|
3929
|
+
autoBlockBlockedFeatures(m);
|
|
3930
|
+
const wallMs = completed.feature.startedAt && completed.feature.completedAt ? completed.feature.completedAt - completed.feature.startedAt : 0;
|
|
3931
|
+
const isLarge = completed.feature.toolCallCount > 50 || wallMs > 6e5;
|
|
3932
|
+
const handoffHint = isLarge ? `
|
|
3933
|
+
\u{1F91D} Large feature done (${completed.feature.toolCallCount} calls). Consider /handoff for a fresh session.` : "";
|
|
3934
|
+
ctx.ui.notify(`\u2705 Auto-completed ${completed.feature.id}. Advanced to ${next.next.id} \u2014 ${next.next.title}${handoffHint}`, "info");
|
|
3935
|
+
} else if (next.reason === "mission_complete") {
|
|
3936
|
+
ctx.ui.notify("\u{1F389} Mission complete!", "info");
|
|
3937
|
+
} else {
|
|
3938
|
+
autoBlockBlockedFeatures(m);
|
|
3939
|
+
ctx.ui.notify(`\u2705 Auto-completed ${completed.feature.id}. No pending features.`, "info");
|
|
3940
|
+
}
|
|
3941
|
+
await saveMissionSafe(m);
|
|
3942
|
+
updateFooter(ctx, m);
|
|
3943
|
+
} else if (detection.suggestedAction === "suggest_done") {
|
|
3944
|
+
runtime.pendingCompletionAction = "suggest_done";
|
|
3945
|
+
runtime.pendingCompletionReason = detection.reason;
|
|
3946
|
+
ctx.ui.notify(`Feature '${feature.title}' may be complete (${detection.confidence}). ${detection.reason}`, "info");
|
|
3947
|
+
} else if (detection.suggestedAction === "ask_user") {
|
|
3948
|
+
runtime.pendingCompletionAction = "ask_user";
|
|
3949
|
+
runtime.pendingCompletionReason = detection.reason;
|
|
3950
|
+
ctx.ui.notify(`Feature '${feature.title}' not clear if complete (${detection.confidence}). Model will be prompted to use mission_ask_user.`, "info");
|
|
3951
|
+
}
|
|
3952
|
+
});
|
|
3953
|
+
hook(pi, "session_before_compact", async () => compactionCheckpoint(pi, runtime));
|
|
3954
|
+
hook(pi, "session_shutdown", async (...args) => {
|
|
3955
|
+
const _event = args[0];
|
|
3956
|
+
const ctx = args[1];
|
|
3957
|
+
sessionMetrics.endSession();
|
|
3958
|
+
if (runtime.autoSaveInterval) clearInterval(runtime.autoSaveInterval);
|
|
3959
|
+
runtime.autoSaveInterval = null;
|
|
3960
|
+
if (runtime.activeMission) {
|
|
3961
|
+
saveSessionLink(runtime, ctx.sessionManager.getSessionFile?.());
|
|
3962
|
+
await saveMissionSafe(runtime.activeMission);
|
|
3963
|
+
}
|
|
3964
|
+
updateFooter(ctx, null);
|
|
3965
|
+
});
|
|
3966
|
+
}
|
|
3967
|
+
export {
|
|
3968
|
+
piMissions as default
|
|
3969
|
+
};
|
|
3970
|
+
//# sourceMappingURL=index.js.map
|