pi-plans 0.6.0 → 0.8.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/AGENTS.md +58 -0
- package/CONTRIBUTING.md +8 -15
- package/README.md +39 -37
- package/agents/execution-reviewer.md +40 -0
- package/agents/reviewer.md +12 -3
- package/index.ts +55 -58
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +50 -60
- package/references/plan-artifact-template.md +81 -60
- package/references/state-and-config.md +60 -44
- package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
- package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
- package/scripts/run-tests.ts +12 -1
- package/scripts/validate.ts +39 -11
- package/skills/debug-and-plan/SKILL.md +3 -3
- package/skills/plan-big/SKILL.md +3 -3
- package/skills/plan-normal/SKILL.md +3 -3
- package/skills/plan-small/SKILL.md +4 -4
- package/skills/plan-with-refs/SKILL.md +6 -6
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +227 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +8 -3
- package/src/code-graph/community.ts +1 -1
- package/src/code-graph/paths.ts +1 -1
- package/src/code-graph/watch.ts +2 -2
- package/src/compaction.ts +3 -3
- package/src/config-command.ts +146 -73
- package/src/dashboard.ts +303 -0
- package/src/exec.ts +1185 -924
- package/src/global-state.ts +304 -0
- package/src/guard.ts +18 -19
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +12 -70
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +19 -3
- package/src/resume-command.ts +45 -129
- package/src/resume.ts +5 -1
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +3 -10
- package/src/staleness.ts +53 -0
- package/src/state.ts +273 -72
- package/src/subagent.ts +19 -29
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +223 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +7 -54
- package/src/workflow-state.ts +76 -58
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-schema.test.ts +0 -12
- package/tests/ask-choice.test.ts +2 -49
- package/tests/ask-form-tool.test.ts +4 -5
- package/tests/ask-form.test.ts +2 -2
- package/tests/auditor.test.ts +210 -0
- package/tests/auto-approve.test.ts +7 -10
- package/tests/autocomplete.test.ts +8 -11
- package/tests/code-graph-apply-action.test.ts +2 -2
- package/tests/code-graph-commands.test.ts +2 -2
- package/tests/code-graph-index.test.ts +2 -2
- package/tests/code-graph-loop.e2e.test.ts +1 -1
- package/tests/code-graph-mutations.test.ts +1 -1
- package/tests/code-graph-rollback.test.ts +1 -1
- package/tests/code-graph-v05.test.ts +2 -2
- package/tests/compaction.test.ts +1 -1
- package/tests/config-command.test.ts +103 -100
- package/tests/dashboard.test.ts +402 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec-review-loop.test.ts +331 -0
- package/tests/exec.test.ts +771 -1706
- package/tests/execute-plan.test.ts +44 -19
- package/tests/extension-load.test.ts +48 -0
- package/tests/global-state.test.ts +371 -0
- package/tests/graph-aware-file-tools.test.ts +5 -5
- package/tests/guard.test.ts +1 -1
- package/tests/multi-run.test.ts +3 -103
- package/tests/plan.test.ts +139 -62
- package/tests/plans.test.ts +7 -79
- package/tests/refine-prompts.test.ts +20 -71
- package/tests/refine-resume.test.ts +27 -22
- package/tests/refine-ui.test.ts +6 -15
- package/tests/resume-lifecycle.test.ts +41 -22
- package/tests/resume.test.ts +39 -81
- package/tests/role-panels.test.ts +391 -0
- package/tests/run-context.test.ts +1 -1
- package/tests/run-ownership.test.ts +1 -1
- package/tests/stale-ctx.test.ts +218 -0
- package/tests/staleness.test.ts +76 -0
- package/tests/state.test.ts +155 -32
- package/tests/subagent-thinking.test.ts +65 -0
- package/tests/subagent-usage.test.ts +1 -1
- package/tests/task-tool.test.ts +61 -0
- package/tests/tasks.test.ts +142 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +73 -90
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +7 -53
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +55 -99
- package/tools/graph-aware-file-tools.ts +4 -10
- package/tools/plans.ts +41 -67
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/agents/executor.md +0 -26
- package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
- package/src/panel.ts +0 -473
- package/src/termination-prompt.ts +0 -73
- package/tests/goal-wait.test.ts +0 -269
- package/tests/panel-i-zero.test.ts +0 -420
- package/tests/panel.test.ts +0 -355
|
@@ -7,7 +7,7 @@ import * as os from "node:os";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { after, before, describe, it } from "node:test";
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
applyLaneResult,
|
|
12
12
|
applyReviewConsolidated,
|
|
13
13
|
createCheckpoint,
|
|
@@ -19,8 +19,11 @@ import {
|
|
|
19
19
|
startReviewRound,
|
|
20
20
|
} from "../src/workflow-state.ts";
|
|
21
21
|
import { initState, startRun } from "../src/state.ts";
|
|
22
|
+
import { setMessagingApi } from "../src/messaging.ts";
|
|
22
23
|
|
|
23
24
|
let tmpRoot: string;
|
|
25
|
+
let globalDir: string;
|
|
26
|
+
let previousGlobalDir: string | undefined;
|
|
24
27
|
|
|
25
28
|
function setupRun(name: string): { workdir: string; runId: string; artifactDir: string } {
|
|
26
29
|
const workdir = path.join(tmpRoot, name);
|
|
@@ -35,9 +38,17 @@ function setupRun(name: string): { workdir: string; runId: string; artifactDir:
|
|
|
35
38
|
|
|
36
39
|
before(() => {
|
|
37
40
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-rr-"));
|
|
41
|
+
// Isolate the global reviewer config (F-002): setRole writes the global
|
|
42
|
+
// file; tests must never touch the developer's ~/.pi/pi-plans.
|
|
43
|
+
previousGlobalDir = process.env.PI_PLANS_GLOBAL_DIR;
|
|
44
|
+
globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-rr-"));
|
|
45
|
+
process.env.PI_PLANS_GLOBAL_DIR = globalDir;
|
|
38
46
|
});
|
|
39
47
|
|
|
40
48
|
after(() => {
|
|
49
|
+
if (previousGlobalDir === undefined) delete process.env.PI_PLANS_GLOBAL_DIR;
|
|
50
|
+
else process.env.PI_PLANS_GLOBAL_DIR = previousGlobalDir;
|
|
51
|
+
fs.rmSync(globalDir, { recursive: true, force: true });
|
|
41
52
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
42
53
|
});
|
|
43
54
|
|
|
@@ -194,6 +205,7 @@ describe("implementation-review round 1 fixes", () => {
|
|
|
194
205
|
hasUI: false,
|
|
195
206
|
ui: { notify: () => {}, setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } },
|
|
196
207
|
};
|
|
208
|
+
setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
|
|
197
209
|
const result = await tool!.execute("t1", { role: "reviewer", planPath, reviewers: 2 }, undefined, undefined, ctx);
|
|
198
210
|
assert.match(result.content[0]?.text ?? "", /current-session/);
|
|
199
211
|
// The durable round exists WITH lanes — the pre-fix crash site.
|
|
@@ -236,9 +248,9 @@ describe("implementation-review round 1 fixes", () => {
|
|
|
236
248
|
// Edit the plan in place — same path, different bytes.
|
|
237
249
|
fs.writeFileSync(planPath, "# plan (edited)\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n", "utf8");
|
|
238
250
|
const { loadExecutionFromCheckpoint } = await import("../src/exec.ts");
|
|
239
|
-
const pi = { appendEntry: () => {}, sendMessage: () => {} } as never;
|
|
240
251
|
const ctx = { cwd: workdir, sessionManager: {}, ui: { setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } } };
|
|
241
|
-
|
|
252
|
+
setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
|
|
253
|
+
const load = loadExecutionFromCheckpoint(ctx as never, run.run_id);
|
|
242
254
|
assert.equal(load.status, "plan-mismatch");
|
|
243
255
|
assert.match((load as { error?: string }).error ?? "", /changed since the approval/);
|
|
244
256
|
});
|
|
@@ -266,9 +278,9 @@ describe("implementation-review round 1 fixes", () => {
|
|
|
266
278
|
});
|
|
267
279
|
mutateCheckpoint(workdir, run.run_id, (cp) => applyExecutionProgress(cp, { doneVcIds: ["VC-001"] }));
|
|
268
280
|
const { loadExecutionFromCheckpoint } = await import("../src/exec.ts");
|
|
269
|
-
const pi = { appendEntry: () => {}, sendMessage: () => {} } as never;
|
|
270
281
|
const ctx = { cwd: workdir, sessionManager: {}, ui: { setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } } };
|
|
271
|
-
|
|
282
|
+
setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
|
|
283
|
+
const load = loadExecutionFromCheckpoint(ctx as never, run.run_id);
|
|
272
284
|
assert.equal(load.status, "loaded");
|
|
273
285
|
assert.equal((load as { reverifyAll?: boolean }).reverifyAll, true, "unverifiable HEAD re-verifies");
|
|
274
286
|
});
|
|
@@ -285,7 +297,7 @@ describe("implementation-review round 1 fixes", () => {
|
|
|
285
297
|
const owner = acquireOwnership(workdir, run.run_id);
|
|
286
298
|
// Another process takes over (fabricated record with a reused/dead pid).
|
|
287
299
|
fs.writeFileSync(
|
|
288
|
-
path.join(workdir, ".git", "
|
|
300
|
+
path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "owner.json"),
|
|
289
301
|
JSON.stringify({ ...owner, pid: process.pid, pidStart: "bogus-start", processToken: "someone-else", generation: owner.generation + 1 }),
|
|
290
302
|
"utf8",
|
|
291
303
|
);
|
|
@@ -323,7 +335,7 @@ describe("implementation-review round 1 fixes", () => {
|
|
|
323
335
|
});
|
|
324
336
|
});
|
|
325
337
|
|
|
326
|
-
describe("
|
|
338
|
+
describe("plan-review reviewer-count defaults (v0.6.1 single-reviewer)", () => {
|
|
327
339
|
async function setupLoopRun(name: string, skill: string, reviewerCount?: number) {
|
|
328
340
|
const workdir = path.join(tmpRoot, name);
|
|
329
341
|
fs.mkdirSync(workdir, { recursive: true });
|
|
@@ -336,16 +348,6 @@ describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
|
|
|
336
348
|
fs.mkdirSync(run.artifact_dir, { recursive: true });
|
|
337
349
|
const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
|
|
338
350
|
fs.writeFileSync(planPath, "# plan\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n", "utf8");
|
|
339
|
-
// refine's round-start guard requires a recorded termination condition
|
|
340
|
-
// before any implementation round, so the loop is always configured;
|
|
341
|
-
// reviewerCount may legitimately be absent (skill default applies).
|
|
342
|
-
mutateCheckpoint(workdir, run.run_id, (cp) =>
|
|
343
|
-
applyImplementationReviewConfigured(
|
|
344
|
-
{ ...cp, phase: "implementation-review", nextAction: "ask-question" },
|
|
345
|
-
"1 round",
|
|
346
|
-
reviewerCount,
|
|
347
|
-
),
|
|
348
|
-
);
|
|
349
351
|
return { workdir, runId: run.run_id, planPath };
|
|
350
352
|
}
|
|
351
353
|
|
|
@@ -373,13 +375,14 @@ describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
|
|
|
373
375
|
hasUI: false,
|
|
374
376
|
ui: { notify: () => {}, setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } },
|
|
375
377
|
};
|
|
376
|
-
|
|
378
|
+
setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
|
|
379
|
+
const result = await tool.execute("t1", { planPath, reviewers: 3 }, undefined, undefined, ctx);
|
|
377
380
|
assert.match(result.content[0]?.text ?? "", /current-session/);
|
|
378
381
|
const loaded = loadCheckpoint(workdir, runId);
|
|
379
382
|
assert.ok(loaded.status === "ok");
|
|
380
383
|
const round = loaded.checkpoint.reviewRounds.at(-1);
|
|
381
384
|
assert.ok(round, "round recorded");
|
|
382
|
-
assert.equal(round!.reviewers, 3, "
|
|
385
|
+
assert.equal(round!.reviewers, 3, "explicit reviewers=3 honored");
|
|
383
386
|
assert.equal(round!.lanes.length, 3, "three lanes spawned");
|
|
384
387
|
});
|
|
385
388
|
|
|
@@ -394,7 +397,8 @@ describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
|
|
|
394
397
|
hasUI: false,
|
|
395
398
|
ui: { notify: () => {}, setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } },
|
|
396
399
|
};
|
|
397
|
-
|
|
400
|
+
setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
|
|
401
|
+
await tool.execute("t1", { planPath, reviewers: 1 }, undefined, undefined, ctx);
|
|
398
402
|
const loaded = loadCheckpoint(workdir, runId);
|
|
399
403
|
assert.ok(loaded.status === "ok");
|
|
400
404
|
const round = loaded.checkpoint.reviewRounds.at(-1);
|
|
@@ -413,11 +417,12 @@ describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
|
|
|
413
417
|
hasUI: false,
|
|
414
418
|
ui: { notify: () => {}, setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } },
|
|
415
419
|
};
|
|
416
|
-
|
|
420
|
+
setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
|
|
421
|
+
await tool.execute("t1", { planPath }, undefined, undefined, ctx);
|
|
417
422
|
const loaded = loadCheckpoint(workdir, runId);
|
|
418
423
|
assert.ok(loaded.status === "ok");
|
|
419
424
|
const round = loaded.checkpoint.reviewRounds.at(-1);
|
|
420
|
-
assert.equal(round!.lanes.length, 1, "no
|
|
425
|
+
assert.equal(round!.lanes.length, 1, "no param → 1 lane default");
|
|
421
426
|
});
|
|
422
427
|
|
|
423
428
|
it("count=2 consolidation text carries the source-reviewer contract (D-7)", async () => {
|
package/tests/refine-ui.test.ts
CHANGED
|
@@ -151,15 +151,6 @@ describe("refine overlay viewport", () => {
|
|
|
151
151
|
);
|
|
152
152
|
assert.ok(reviewer.render(120).some((line) => line.includes("Reviewer (m1)")));
|
|
153
153
|
|
|
154
|
-
const criticizer = new RefineOverlayComponent(
|
|
155
|
-
fakeTheme,
|
|
156
|
-
"criticizer",
|
|
157
|
-
[readyLane("lane-1", "criticizer", "ok")],
|
|
158
|
-
() => {},
|
|
159
|
-
undefined,
|
|
160
|
-
"m2",
|
|
161
|
-
);
|
|
162
|
-
assert.ok(criticizer.render(120).some((line) => line.includes("Criticizer (m2)")));
|
|
163
154
|
});
|
|
164
155
|
|
|
165
156
|
it("renders model-aware titles and footer hints", () => {
|
|
@@ -230,14 +221,14 @@ describe("refine overlay viewport", () => {
|
|
|
230
221
|
const linesText = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n");
|
|
231
222
|
const component = new RefineOverlayComponent(
|
|
232
223
|
fakeTheme,
|
|
233
|
-
"
|
|
234
|
-
[streamingToolLane("lane-1", "
|
|
224
|
+
"reviewer",
|
|
225
|
+
[streamingToolLane("lane-1", "reviewer", linesText)],
|
|
235
226
|
() => {},
|
|
236
227
|
undefined,
|
|
237
228
|
"singularity-gpt/gpt-5.5:xhigh",
|
|
238
229
|
);
|
|
239
230
|
const lines = component.render(90);
|
|
240
|
-
assert.ok(lines.some((line) => line.includes("
|
|
231
|
+
assert.ok(lines.some((line) => line.includes("Reviewer (singularity-gpt/gpt-5.5:xhigh)")));
|
|
241
232
|
assert.ok(lines.some((line) => line.includes("…")));
|
|
242
233
|
assert.ok(lines.some((line) => line.includes("line 3")));
|
|
243
234
|
assert.ok(lines.some((line) => line.includes("line 4")));
|
|
@@ -269,8 +260,8 @@ describe("refine overlay viewport", () => {
|
|
|
269
260
|
it("renders a single pane with pi-btw-style transcript badges", () => {
|
|
270
261
|
const component = new RefineOverlayComponent(
|
|
271
262
|
fakeTheme,
|
|
272
|
-
"
|
|
273
|
-
[readyLane("lane-1", "
|
|
263
|
+
"refs",
|
|
264
|
+
[readyLane("lane-1", "refs", "assistant output")],
|
|
274
265
|
() => {},
|
|
275
266
|
);
|
|
276
267
|
const lines = component.render(90);
|
|
@@ -340,7 +331,7 @@ describe("refine overlay wiring", () => {
|
|
|
340
331
|
it("threads the chrome language through both overlay construction sites (issue #3, VC-007)", () => {
|
|
341
332
|
const refineSource = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
|
|
342
333
|
assert.match(refineSource, /const overlayLang = uiLanguageFromTag\(config\.language\.tag\)/);
|
|
343
|
-
assert.match(refineSource, /new RefineOverlayController\(
|
|
334
|
+
assert.match(refineSource, /new RefineOverlayController\("reviewer", lanes, relayAbort, lang\)/);
|
|
344
335
|
assert.match(refineSource, /modelLabel,\s*\n\s*overlayLang,/);
|
|
345
336
|
const refsSource = fs.readFileSync(path.join(process.cwd(), "tools", "analyze-refs.ts"), "utf8");
|
|
346
337
|
assert.match(
|
|
@@ -25,7 +25,7 @@ import { startExecution } from "../src/exec.ts";
|
|
|
25
25
|
import { resetRunBindingForTests } from "../src/run-context.ts";
|
|
26
26
|
import { processStartOf } from "../src/run-ownership.ts";
|
|
27
27
|
import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyQuestionAsked } from "../src/workflow-state.ts";
|
|
28
|
-
import { getRun, initState, startRun } from "../src/state.ts";
|
|
28
|
+
import { DEFAULT_CONFIG, getRun, initState, resolveArtifactRoot, startRun } from "../src/state.ts";
|
|
29
29
|
|
|
30
30
|
initTheme("dark", false);
|
|
31
31
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-resume-life-"));
|
|
@@ -34,10 +34,15 @@ let serial = 0;
|
|
|
34
34
|
|
|
35
35
|
const PLAN_TEXT = `# Plan
|
|
36
36
|
|
|
37
|
-
##
|
|
37
|
+
## Tasks
|
|
38
38
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
39
|
+
- Task-1: first — files: src/a.ts; wave: 1
|
|
40
|
+
- Task-2: second — files: src/b.ts; wave: 1
|
|
41
|
+
|
|
42
|
+
## Verification Checks
|
|
43
|
+
|
|
44
|
+
- [ ] \`VC-001\` covers \`Task-1\`; pass condition: first.
|
|
45
|
+
- [ ] \`VC-002\` covers \`Task-2\`; pass condition: second.
|
|
41
46
|
`;
|
|
42
47
|
|
|
43
48
|
interface Fixture {
|
|
@@ -173,6 +178,9 @@ function setupRun(name: string): { cwd: string; runId: string; artifactDir: stri
|
|
|
173
178
|
createCheckpoint(cwd, { runId: run.run_id, originWorkdir: cwd, workdir: cwd });
|
|
174
179
|
fs.mkdirSync(run.artifact_dir, { recursive: true });
|
|
175
180
|
fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), PLAN_TEXT, "utf8");
|
|
181
|
+
// The artifact dir lives under .git/ and is never tracked; seed a root file so
|
|
182
|
+
// the "seed" commit exists and resolveHeadAt() yields the approval evidence HEAD.
|
|
183
|
+
fs.writeFileSync(path.join(cwd, "WORKTREE.md"), "seed worktree marker\n", "utf8");
|
|
176
184
|
spawnSync("git", ["add", "-A"], { cwd });
|
|
177
185
|
spawnSync("git", ["commit", "-m", "seed"], { cwd });
|
|
178
186
|
return { cwd, runId: run.run_id, artifactDir: run.artifact_dir };
|
|
@@ -185,12 +193,10 @@ describe("/resume-plans on the real Pi host", () => {
|
|
|
185
193
|
async () => {
|
|
186
194
|
resetRunBindingForTests();
|
|
187
195
|
const { cwd, runId } = setupRun("cross-session");
|
|
188
|
-
// Session 1: execution starts,
|
|
196
|
+
// Session 1: execution starts, Task-1 closes via the task tool,
|
|
197
|
+
// then the session ends.
|
|
189
198
|
const fixture1: Fixture = {
|
|
190
|
-
script: [
|
|
191
|
-
{ kind: "tool" },
|
|
192
|
-
{ kind: "text", text: "[DONE:VC-001] Partial progress; stopping here." },
|
|
193
|
-
],
|
|
199
|
+
script: [{ kind: "text", text: "Partial progress; stopping here." }],
|
|
194
200
|
calls: [],
|
|
195
201
|
};
|
|
196
202
|
const session1 = await makeSession({
|
|
@@ -200,10 +206,18 @@ describe("/resume-plans on the real Pi host", () => {
|
|
|
200
206
|
extraExtension: ((pi: never) => {
|
|
201
207
|
const realPi = pi as unknown as import("@earendil-works/pi-coding-agent").ExtensionAPI;
|
|
202
208
|
realPi.on("session_start", async (_event: unknown, ctx: never) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
const planPath = path.join(artifactDirRelative(cwd), "PLAN_v1.md");
|
|
210
|
+
const planText = fs.readFileSync(planPath, "utf8");
|
|
211
|
+
const { parsePlanTasks, parseChecklist } = await import("../src/plan.ts");
|
|
212
|
+
await startExecution(ctx as never, {
|
|
213
|
+
planPath,
|
|
214
|
+
planTasks: parsePlanTasks(planText),
|
|
215
|
+
items: parseChecklist(planText),
|
|
216
|
+
});
|
|
217
|
+
const { applyTaskUpdate } = await import("../src/task-tool.ts");
|
|
218
|
+
const { getExecution, persistTaskProgress } = await import("../src/exec.ts");
|
|
219
|
+
applyTaskUpdate(getExecution()!.tasks, "Task-1", "complete", "first tests green");
|
|
220
|
+
persistTaskProgress(ctx as never);
|
|
207
221
|
});
|
|
208
222
|
}) as never,
|
|
209
223
|
});
|
|
@@ -214,7 +228,7 @@ describe("/resume-plans on the real Pi host", () => {
|
|
|
214
228
|
const mid = loadCheckpoint(cwd, runId);
|
|
215
229
|
assert.equal(mid.status, "ok");
|
|
216
230
|
if (mid.status === "ok") {
|
|
217
|
-
assert.
|
|
231
|
+
assert.equal(mid.checkpoint.execution?.tasks?.["Task-1"]?.status, "complete", "task progress persisted by session 1");
|
|
218
232
|
assert.ok(mid.checkpoint.execution?.approval?.headAtApproval, "approval evidence persisted");
|
|
219
233
|
}
|
|
220
234
|
} finally {
|
|
@@ -225,7 +239,7 @@ describe("/resume-plans on the real Pi host", () => {
|
|
|
225
239
|
|
|
226
240
|
// Session 2: fresh session, resume via the command.
|
|
227
241
|
const fixture2: Fixture = {
|
|
228
|
-
script: [{ kind: "text", text: "
|
|
242
|
+
script: [{ kind: "text", text: "Resumed; continuing the remaining task." }],
|
|
229
243
|
calls: [],
|
|
230
244
|
};
|
|
231
245
|
const notifies: string[] = [];
|
|
@@ -238,16 +252,19 @@ describe("/resume-plans on the real Pi host", () => {
|
|
|
238
252
|
assert.ok(fixture2.calls.length >= 1);
|
|
239
253
|
const firstPayload = JSON.stringify(fixture2.calls[0]);
|
|
240
254
|
assert.match(firstPayload, /PI-PLANS RESUME/);
|
|
241
|
-
assert.match(firstPayload, /
|
|
255
|
+
assert.match(firstPayload, /plans_update_task/);
|
|
242
256
|
assert.ok(notifies.some((message) => /Resumed/.test(message)), "resume notification shown");
|
|
243
|
-
//
|
|
257
|
+
// Task-1 stays complete across the session boundary.
|
|
244
258
|
const final = loadCheckpoint(cwd, runId);
|
|
245
259
|
assert.equal(final.status, "ok");
|
|
246
260
|
if (final.status === "ok") {
|
|
247
|
-
assert.
|
|
248
|
-
|
|
261
|
+
assert.equal(final.checkpoint.execution?.tasks?.["Task-1"]?.status, "complete");
|
|
262
|
+
// Every task now carries a record: an untouched task is
|
|
263
|
+
// persisted as pending rather than omitted, so the checkpoint
|
|
264
|
+
// distinguishes "never started" from "rolled back with
|
|
265
|
+
// evidence" instead of losing the record entirely.
|
|
266
|
+
assert.equal(final.checkpoint.execution?.tasks?.["Task-2"]?.status, "pending");
|
|
249
267
|
}
|
|
250
|
-
assert.equal(getRun(cwd, runId)?.status, "done");
|
|
251
268
|
} finally {
|
|
252
269
|
session2.session.dispose();
|
|
253
270
|
}
|
|
@@ -313,7 +330,7 @@ describe("/resume-plans on the real Pi host", () => {
|
|
|
313
330
|
const child = spawn("sleep", ["30"], { stdio: "ignore" });
|
|
314
331
|
try {
|
|
315
332
|
fs.writeFileSync(
|
|
316
|
-
path.join(cwd, ".git", "
|
|
333
|
+
path.join(cwd, ".git", "pi-plans", "runs", runId, "owner.json"),
|
|
317
334
|
JSON.stringify({
|
|
318
335
|
schema: 1,
|
|
319
336
|
host: os.hostname(),
|
|
@@ -369,7 +386,9 @@ describe("/resume-plans on the real Pi host", () => {
|
|
|
369
386
|
});
|
|
370
387
|
|
|
371
388
|
function artifactDirRelative(cwd: string): string {
|
|
372
|
-
|
|
389
|
+
// Resolve through the real default so this fixture follows the configured
|
|
390
|
+
// artifact root (inside the state dir by default) instead of hardcoding it.
|
|
391
|
+
const root = resolveArtifactRoot(cwd, DEFAULT_CONFIG.artifact_root);
|
|
373
392
|
return path.join(root, fs.readdirSync(root)[0]!);
|
|
374
393
|
}
|
|
375
394
|
|
package/tests/resume.test.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { migrateRunIntoCurrentWorktree, resumePlansCommand } from "../src/resume
|
|
|
11
11
|
import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyQuestionAsked, applyQuestionAnswered } from "../src/workflow-state.ts";
|
|
12
12
|
import { acquireOwnership, processStartOf } from "../src/run-ownership.ts";
|
|
13
13
|
import { resetRunBindingForTests } from "../src/run-context.ts";
|
|
14
|
-
import { initState, setRunStatus, startRun, StateError } from "../src/state.ts";
|
|
14
|
+
import { initState, setArtifactRoot, setRunStatus, startRun, StateError } from "../src/state.ts";
|
|
15
|
+
import { setMessagingApi } from "../src/messaging.ts";
|
|
15
16
|
|
|
16
17
|
let tmpRoot: string;
|
|
17
18
|
|
|
@@ -63,7 +64,7 @@ function makeCtx(cwd: string, overrides: Partial<CtxMock> = {}): CtxMock {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
function ctxAdapter(mock: CtxMock): unknown {
|
|
66
|
-
|
|
67
|
+
const ctx = {
|
|
67
68
|
cwd: mock.cwd,
|
|
68
69
|
hasUI: mock.hasUI,
|
|
69
70
|
isIdle: () => mock.isIdleResult,
|
|
@@ -84,21 +85,16 @@ function ctxAdapter(mock: CtxMock): unknown {
|
|
|
84
85
|
setStatus: () => {},
|
|
85
86
|
},
|
|
86
87
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
function makePi(mock: CtxMock): unknown {
|
|
90
|
-
return {
|
|
91
|
-
sendUserMessage: async (content: string) => {
|
|
92
|
-
mock.userMessages.push(content);
|
|
93
|
-
},
|
|
88
|
+
setMessagingApi({
|
|
94
89
|
appendEntry: (customType: string, data?: unknown) => {
|
|
95
90
|
mock.entries.push({ customType, data });
|
|
96
91
|
},
|
|
97
92
|
sendMessage: () => {},
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
93
|
+
sendUserMessage: async (content: string) => {
|
|
94
|
+
mock.userMessages.push(content);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return ctx;
|
|
102
98
|
}
|
|
103
99
|
|
|
104
100
|
const BASE_DIR = path.resolve(import.meta.dirname, "..");
|
|
@@ -116,6 +112,9 @@ describe("candidate discovery", () => {
|
|
|
116
112
|
it("lists resumable runs, excludes terminal ones, registry hint first", () => {
|
|
117
113
|
const workdir = setupRepo("discovery");
|
|
118
114
|
const planning = startRun(workdir, { topic: "alpha", skill: "plan-normal", requestText: "a" }).run;
|
|
115
|
+
const verifying = startRun(workdir, { topic: "zeta", skill: "plan-normal", requestText: "f" }).run;
|
|
116
|
+
setRunStatus(workdir, verifying.run_id, "executing");
|
|
117
|
+
setRunStatus(workdir, verifying.run_id, "verifying");
|
|
119
118
|
const stopped = startRun(workdir, { topic: "beta", skill: "plan-normal", requestText: "b" }).run;
|
|
120
119
|
setRunStatus(workdir, stopped.run_id, "executing");
|
|
121
120
|
setRunStatus(workdir, stopped.run_id, "stopped");
|
|
@@ -133,6 +132,9 @@ describe("candidate discovery", () => {
|
|
|
133
132
|
assert.ok(ids.includes(planning.run_id));
|
|
134
133
|
assert.ok(ids.includes(stopped.run_id));
|
|
135
134
|
assert.ok(ids.includes(doneWithReview.run_id), "done with review artifacts resumable");
|
|
135
|
+
assert.ok(ids.includes(verifying.run_id), "verifying runs are resumable");
|
|
136
|
+
const verifyingCandidate = candidates.find((candidate) => candidate.runId === verifying.run_id);
|
|
137
|
+
assert.equal(verifyingCandidate?.phaseLabel, "verifying", "verifying runs keep a distinct resume label even though checkpoint.phase stays executing");
|
|
136
138
|
assert.equal(ids.includes(abandoned.run_id), false);
|
|
137
139
|
assert.equal(ids.includes(done.run_id), false, "plain done excluded");
|
|
138
140
|
|
|
@@ -168,7 +170,7 @@ describe("candidate discovery", () => {
|
|
|
168
170
|
const run = startRun(workdir, { topic: "corrupt", skill: "plan-normal", requestText: "a" }).run;
|
|
169
171
|
createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
|
|
170
172
|
fs.writeFileSync(
|
|
171
|
-
path.join(workdir, ".git", "
|
|
173
|
+
path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "checkpoint.json"),
|
|
172
174
|
"{ broken",
|
|
173
175
|
"utf8",
|
|
174
176
|
);
|
|
@@ -182,27 +184,27 @@ describe("/resume-plans command", () => {
|
|
|
182
184
|
it("refuses non-interactive and busy sessions, notifies when nothing is resumable", async () => {
|
|
183
185
|
const workdir = setupRepo("guards");
|
|
184
186
|
const noUI = makeCtx(workdir, { hasUI: false });
|
|
185
|
-
await resumePlansCommand(
|
|
187
|
+
await resumePlansCommand(ctxAdapter(noUI) as never, BASE_DIR);
|
|
186
188
|
assert.ok(noUI.notifies.some((n) => /interactive/.test(n.message)));
|
|
187
189
|
|
|
188
190
|
const busy = makeCtx(workdir, { isIdleResult: false });
|
|
189
|
-
await resumePlansCommand(
|
|
191
|
+
await resumePlansCommand(ctxAdapter(busy) as never, BASE_DIR);
|
|
190
192
|
assert.ok(busy.notifies.some((n) => /busy/.test(n.message)));
|
|
191
193
|
assert.equal(busy.userMessages.length, 0);
|
|
192
194
|
|
|
193
195
|
const empty = setupRepo("empty-repo");
|
|
194
196
|
const emptyCtx = makeCtx(empty);
|
|
195
|
-
await resumePlansCommand(
|
|
197
|
+
await resumePlansCommand(ctxAdapter(emptyCtx) as never, BASE_DIR);
|
|
196
198
|
assert.ok(emptyCtx.notifies.some((n) => /No resumable/.test(n.message)));
|
|
197
199
|
});
|
|
198
200
|
|
|
199
201
|
it("corrupt checkpoints report and change nothing", async () => {
|
|
200
202
|
const workdir = setupRepo("corrupt-cmd");
|
|
201
203
|
const run = startRun(workdir, { topic: "corruptcmd", skill: "plan-normal", requestText: "a" }).run;
|
|
202
|
-
const cpPath = path.join(workdir, ".git", "
|
|
204
|
+
const cpPath = path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "checkpoint.json");
|
|
203
205
|
fs.writeFileSync(cpPath, "{ broken", "utf8");
|
|
204
206
|
const mock = makeCtx(workdir);
|
|
205
|
-
await resumePlansCommand(
|
|
207
|
+
await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
|
|
206
208
|
assert.ok(mock.notifies.some((n) => /corrupt/.test(n.message)));
|
|
207
209
|
assert.equal(mock.userMessages.length, 0);
|
|
208
210
|
assert.equal(fs.readFileSync(cpPath, "utf8"), "{ broken");
|
|
@@ -225,7 +227,7 @@ describe("/resume-plans command", () => {
|
|
|
225
227
|
void cp;
|
|
226
228
|
|
|
227
229
|
const mock = makeCtx(workdir);
|
|
228
|
-
await resumePlansCommand(
|
|
230
|
+
await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
|
|
229
231
|
assert.equal(mock.userMessages.length, 1, "exactly one kickoff");
|
|
230
232
|
const brief = mock.userMessages[0]!;
|
|
231
233
|
assert.match(brief, /PI-PLANS RESUME/);
|
|
@@ -244,7 +246,7 @@ describe("/resume-plans command", () => {
|
|
|
244
246
|
const latest = startRun(workdir, { topic: "three", skill: "plan-normal", requestText: "c" }).run;
|
|
245
247
|
setRunStatus(workdir, latest.run_id, "abandoned");
|
|
246
248
|
const mock = makeCtx(workdir, { selectAnswer: null });
|
|
247
|
-
await resumePlansCommand(
|
|
249
|
+
await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
|
|
248
250
|
assert.equal(mock.userMessages.length, 0);
|
|
249
251
|
assert.ok(mock.notifies.some((n) => /Cancelled/.test(n.message)));
|
|
250
252
|
assert.equal(mock.selects.length, 1);
|
|
@@ -257,7 +259,7 @@ describe("/resume-plans command", () => {
|
|
|
257
259
|
const child = spawn("sleep", ["30"], { stdio: "ignore" });
|
|
258
260
|
try {
|
|
259
261
|
fs.writeFileSync(
|
|
260
|
-
path.join(workdir, ".git", "
|
|
262
|
+
path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "owner.json"),
|
|
261
263
|
JSON.stringify({
|
|
262
264
|
schema: 1,
|
|
263
265
|
host: os.hostname(),
|
|
@@ -271,7 +273,7 @@ describe("/resume-plans command", () => {
|
|
|
271
273
|
"utf8",
|
|
272
274
|
);
|
|
273
275
|
const mock = makeCtx(workdir);
|
|
274
|
-
await resumePlansCommand(
|
|
276
|
+
await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
|
|
275
277
|
assert.ok(mock.notifies.some((n) => /actively owned/.test(n.message)));
|
|
276
278
|
assert.equal(mock.userMessages.length, 0);
|
|
277
279
|
} finally {
|
|
@@ -280,8 +282,11 @@ describe("/resume-plans command", () => {
|
|
|
280
282
|
});
|
|
281
283
|
|
|
282
284
|
it("cross-worktree: cancel changes nothing; confirm migrates artifacts and resets approval", async () => {
|
|
283
|
-
// Source worktree holds the artifacts inside its own tree.
|
|
285
|
+
// Source worktree holds the artifacts inside its own tree. D-007 only
|
|
286
|
+
// applies to a per-worktree root, so pin one; the default root lives in
|
|
287
|
+
// the shared git common dir and is never migrated.
|
|
284
288
|
const source = setupRepo("xwt-source", { commit: true });
|
|
289
|
+
setArtifactRoot(source, "./docs/pi-plans", "user");
|
|
285
290
|
const run = startRun(source, { topic: "xwt", skill: "plan-normal", requestText: "a" }).run;
|
|
286
291
|
const cp = createCheckpoint(source, { runId: run.run_id, originWorkdir: source, workdir: source });
|
|
287
292
|
void cp;
|
|
@@ -294,12 +299,12 @@ describe("/resume-plans command", () => {
|
|
|
294
299
|
spawnSync("git", ["worktree", "add", target], { cwd: source });
|
|
295
300
|
|
|
296
301
|
const cancelMock = makeCtx(target, { confirmAnswer: false });
|
|
297
|
-
await resumePlansCommand(
|
|
302
|
+
await resumePlansCommand(ctxAdapter(cancelMock) as never, BASE_DIR);
|
|
298
303
|
assert.equal(cancelMock.userMessages.length, 0);
|
|
299
304
|
assert.ok(cancelMock.confirmShown.length >= 1);
|
|
300
305
|
|
|
301
306
|
const goMock = makeCtx(target, { confirmAnswer: true });
|
|
302
|
-
await resumePlansCommand(
|
|
307
|
+
await resumePlansCommand(ctxAdapter(goMock) as never, BASE_DIR);
|
|
303
308
|
assert.equal(goMock.userMessages.length, 1, "one kickoff after migration");
|
|
304
309
|
// Artifacts copied into the target worktree's artifact root.
|
|
305
310
|
const copied = path.join(target, "docs", "pi-plans", path.basename(run.artifact_dir));
|
|
@@ -323,7 +328,7 @@ describe("/resume-plans command", () => {
|
|
|
323
328
|
fs.mkdirSync(run.artifact_dir, { recursive: true });
|
|
324
329
|
fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan", "utf8");
|
|
325
330
|
const mock = makeCtx(workdir, { confirmAnswer: true });
|
|
326
|
-
await resumePlansCommand(
|
|
331
|
+
await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
|
|
327
332
|
assert.equal(mock.userMessages.length, 1);
|
|
328
333
|
assert.match(mock.userMessages[0]!, /re-run the execution handoff/);
|
|
329
334
|
assert.ok(mock.confirmShown.some((c) => /Legacy execution run/.test(c.title)));
|
|
@@ -331,6 +336,7 @@ describe("/resume-plans command", () => {
|
|
|
331
336
|
|
|
332
337
|
it("migrateRunIntoCurrentWorktree aborts on differing conflicts (F-003)", () => {
|
|
333
338
|
const source = setupRepo("mig-overwrite", { commit: true });
|
|
339
|
+
setArtifactRoot(source, "./docs/pi-plans", "user");
|
|
334
340
|
const run = startRun(source, { topic: "mig", skill: "plan-normal", requestText: "a" }).run;
|
|
335
341
|
createCheckpoint(source, { runId: run.run_id, originWorkdir: source, workdir: source });
|
|
336
342
|
fs.mkdirSync(run.artifact_dir, { recursive: true });
|
|
@@ -376,7 +382,7 @@ describe("F-004 ledger reconcile (crash window)", () => {
|
|
|
376
382
|
questionId: "q-x",
|
|
377
383
|
});
|
|
378
384
|
const mock = makeCtx(workdir);
|
|
379
|
-
await resumePlansCommand(
|
|
385
|
+
await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
|
|
380
386
|
assert.equal(mock.userMessages.length, 1);
|
|
381
387
|
const brief = mock.userMessages[0]!;
|
|
382
388
|
assert.doesNotMatch(brief, /PENDING question/, "answered ledger entry wins");
|
|
@@ -386,58 +392,10 @@ describe("F-004 ledger reconcile (crash window)", () => {
|
|
|
386
392
|
});
|
|
387
393
|
});
|
|
388
394
|
|
|
389
|
-
describe("impl-review
|
|
390
|
-
it("
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
{ questionId: "termination-condition", answer: "1 round" },
|
|
394
|
-
{ questionId: "impl-review-reviewer-count", answer: "3" },
|
|
395
|
-
];
|
|
396
|
-
const resolved = resolveImplReviewConfig({ completedRounds: 0 }, ledger);
|
|
397
|
-
assert.equal(resolved.condition, "1 round");
|
|
398
|
-
assert.ok(resolved.conditionFromLedger);
|
|
399
|
-
assert.equal(resolved.reviewerCount, 3);
|
|
400
|
-
assert.ok(resolved.reviewerCountFromLedger);
|
|
401
|
-
assert.deepEqual(resolved.missing, []);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
it("only termination answered: reviewer-count is the single missing question", async () => {
|
|
405
|
-
const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
|
|
406
|
-
const resolved = resolveImplReviewConfig({ completedRounds: 0 }, [
|
|
407
|
-
{ questionId: "termination-condition", answer: "goal wait" },
|
|
408
|
-
]);
|
|
409
|
-
assert.equal(resolved.condition, "goal wait");
|
|
410
|
-
assert.equal(resolved.reviewerCount, undefined);
|
|
411
|
-
assert.deepEqual(resolved.missing, ["impl-review-reviewer-count"]);
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it("persisted checkpoint wins over the ledger; latest ledger entry wins", async () => {
|
|
415
|
-
const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
|
|
416
|
-
const resolved = resolveImplReviewConfig(
|
|
417
|
-
{ terminationCondition: "1 round", reviewerCount: 2, completedRounds: 0 },
|
|
418
|
-
[
|
|
419
|
-
{ questionId: "termination-condition", answer: "goal wait" },
|
|
420
|
-
{ questionId: "impl-review-reviewer-count", answer: "1" },
|
|
421
|
-
],
|
|
422
|
-
);
|
|
423
|
-
assert.equal(resolved.condition, "1 round");
|
|
424
|
-
assert.ok(!resolved.conditionFromLedger);
|
|
425
|
-
assert.equal(resolved.reviewerCount, 2);
|
|
426
|
-
assert.deepEqual(resolved.missing, []);
|
|
427
|
-
const staleThenFresh = resolveImplReviewConfig({ completedRounds: 0 }, [
|
|
428
|
-
{ questionId: "termination-condition", answer: "old" },
|
|
429
|
-
{ questionId: "termination-condition", answer: "new" },
|
|
430
|
-
]);
|
|
431
|
-
assert.equal(staleThenFresh.condition, "new", "latest ledger entry wins");
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it("non-integer or out-of-range ledger counts are ignored, not adopted", async () => {
|
|
435
|
-
const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
|
|
436
|
-
for (const bad of ["0", "4", "three"]) {
|
|
437
|
-
const resolved = resolveImplReviewConfig({ terminationCondition: "1 round", completedRounds: 0 }, [
|
|
438
|
-
{ questionId: "impl-review-reviewer-count", answer: bad },
|
|
439
|
-
]);
|
|
440
|
-
assert.equal(resolved.reviewerCount, undefined, `ledger answer ${bad} ignored`);
|
|
441
|
-
}
|
|
395
|
+
describe("impl-review crash-window recovery (removed in v0.6.1)", () => {
|
|
396
|
+
it("the resolver is gone with the loop: the helper no longer exists", async () => {
|
|
397
|
+
const mod = await import("../src/resume-command.ts");
|
|
398
|
+
assert.equal("resolveImplReviewConfig" in mod, false);
|
|
442
399
|
});
|
|
443
400
|
});
|
|
401
|
+
|