pi-gauntlet 5.0.8 → 5.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 +11 -0
- package/README.md +21 -6
- package/extensions/lib/plan-check.test.ts +485 -0
- package/extensions/lib/plan-check.ts +829 -0
- package/extensions/phase-tracker.test.ts +469 -2
- package/extensions/phase-tracker.ts +142 -3
- package/package.json +1 -1
- package/skills/brainstorming/gatherer.md +4 -1
- package/skills/chase-bug/SKILL.md +16 -2
- package/skills/check-delivery/SKILL.md +18 -7
- package/skills/dispatching-parallel-agents/SKILL.md +8 -1
- package/skills/linear/SKILL.md +188 -0
- package/skills/requesting-code-review/SKILL.md +1 -1
- package/skills/shape-ticket/SKILL.md +9 -9
- package/skills/subagent-driven-development/SKILL.md +1 -1
- package/skills/writing-plans/SKILL.md +6 -9
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { after, test } from "node:test";
|
|
3
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
+
import { isAbsolute, join } from "node:path";
|
|
6
7
|
import registerPhaseTracker from "./phase-tracker.ts";
|
|
7
8
|
|
|
8
9
|
const tempDirs: string[] = [];
|
|
@@ -476,3 +477,469 @@ test("second implementer after reviews re-arms the warning", async () => {
|
|
|
476
477
|
if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
|
|
477
478
|
}
|
|
478
479
|
});
|
|
480
|
+
|
|
481
|
+
// --- plan_check tool + hash stamp + replay + implement-start gate (gh-19) ---
|
|
482
|
+
|
|
483
|
+
const sha256Hex = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
484
|
+
|
|
485
|
+
const FIXTURE_SPEC = [
|
|
486
|
+
"# Fixture Spec",
|
|
487
|
+
"",
|
|
488
|
+
"## Design",
|
|
489
|
+
"Line A.",
|
|
490
|
+
"This part defines `helperFn()` config.",
|
|
491
|
+
"Another line here.",
|
|
492
|
+
].join("\n");
|
|
493
|
+
|
|
494
|
+
const FIXTURE_PLAN = `# Fixture Plan
|
|
495
|
+
|
|
496
|
+
**Spec:** \`spec.md\`
|
|
497
|
+
|
|
498
|
+
**Verification:** npm test
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Wave 1 - Two parallel tasks
|
|
503
|
+
|
|
504
|
+
### Task 1: Implement helper
|
|
505
|
+
|
|
506
|
+
**Spec:** spec.md § "Design" L4-L6
|
|
507
|
+
|
|
508
|
+
**Files:**
|
|
509
|
+
- Create: lib/task1.ts
|
|
510
|
+
- Modify: file-a.ts
|
|
511
|
+
|
|
512
|
+
This task implements helperFn() for parsing.
|
|
513
|
+
|
|
514
|
+
### Task 2: Implement naming
|
|
515
|
+
|
|
516
|
+
**Spec:** spec.md § "Design" L4-L4
|
|
517
|
+
|
|
518
|
+
**Files:**
|
|
519
|
+
- Create: lib/task2.ts
|
|
520
|
+
- Modify: file-b.ts
|
|
521
|
+
|
|
522
|
+
This task handles naming details.
|
|
523
|
+
|
|
524
|
+
## Spec coverage
|
|
525
|
+
|
|
526
|
+
| anchor | requirement | owner |
|
|
527
|
+
|---|---|---|
|
|
528
|
+
| § "Design" L4-L6 | parser grammar basics | Task 1 |
|
|
529
|
+
| § "Design" L4-L4 | helper naming | Task 2 |
|
|
530
|
+
`;
|
|
531
|
+
|
|
532
|
+
function writePlanFixture(
|
|
533
|
+
dir: string,
|
|
534
|
+
opts: { mutatePlan?: (t: string) => string; mutateSpec?: (t: string) => string } = {},
|
|
535
|
+
) {
|
|
536
|
+
const planText = opts.mutatePlan ? opts.mutatePlan(FIXTURE_PLAN) : FIXTURE_PLAN;
|
|
537
|
+
const specText = opts.mutateSpec ? opts.mutateSpec(FIXTURE_SPEC) : FIXTURE_SPEC;
|
|
538
|
+
const planPath = join(dir, "plan.md");
|
|
539
|
+
const specPath = join(dir, "spec.md");
|
|
540
|
+
writeFileSync(planPath, planText);
|
|
541
|
+
writeFileSync(specPath, specText);
|
|
542
|
+
writeFileSync(join(dir, "file-a.ts"), "// a\n");
|
|
543
|
+
writeFileSync(join(dir, "file-b.ts"), "// b\n");
|
|
544
|
+
return { planPath, specPath, planText, specText };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const readyForImplementBranch = implementBranch().slice(0, 4);
|
|
548
|
+
|
|
549
|
+
const planCheckResult = (details: unknown) => ({
|
|
550
|
+
type: "message",
|
|
551
|
+
message: { role: "toolResult", toolName: "plan_check", details },
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("plan_check is registered", () => {
|
|
555
|
+
const h = harness();
|
|
556
|
+
assert.ok(h.tools.some((t) => t.name === "plan_check"));
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("plan_check pass: text, details, and stamp arming when a flow is entered", async () => {
|
|
560
|
+
const dir = tempCwd();
|
|
561
|
+
const { planPath, specPath, planText, specText } = writePlanFixture(dir);
|
|
562
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
563
|
+
await h.emit("session_start");
|
|
564
|
+
const tool = h.tools.find((t) => t.name === "plan_check")!;
|
|
565
|
+
const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as {
|
|
566
|
+
content: { text: string }[];
|
|
567
|
+
details: { status: string; planPath: string; planSha256: string; specPath: string; specSha256: string };
|
|
568
|
+
};
|
|
569
|
+
assert.match(res.content[0].text, /^PASS/);
|
|
570
|
+
assert.equal(res.details.status, "pass");
|
|
571
|
+
assert.equal(res.details.planPath, planPath);
|
|
572
|
+
assert.equal(res.details.specPath, specPath);
|
|
573
|
+
assert.equal(res.details.planSha256, sha256Hex(readFileSync(planPath)));
|
|
574
|
+
assert.equal(res.details.specSha256, sha256Hex(readFileSync(specPath)));
|
|
575
|
+
assert.ok(isAbsolute(res.details.planPath));
|
|
576
|
+
assert.ok(isAbsolute(res.details.specPath));
|
|
577
|
+
|
|
578
|
+
// stamp armed: implement-start now succeeds.
|
|
579
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
580
|
+
const start = (await phaseTool.execute("t2", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
581
|
+
details: { error?: string; phases: { implement: { status: string } } };
|
|
582
|
+
};
|
|
583
|
+
assert.equal(start.details.error, undefined);
|
|
584
|
+
assert.equal(start.details.phases.implement.status, "in_progress");
|
|
585
|
+
void planText;
|
|
586
|
+
void specText;
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("plan_check pass outside a flow: no stamp, plain-linter text", async () => {
|
|
590
|
+
const dir = tempCwd();
|
|
591
|
+
const { planPath } = writePlanFixture(dir);
|
|
592
|
+
const h = harness({ cwd: dir });
|
|
593
|
+
await h.emit("session_start");
|
|
594
|
+
const tool = h.tools.find((t) => t.name === "plan_check")!;
|
|
595
|
+
const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as { content: { text: string }[] };
|
|
596
|
+
assert.match(res.content[0].text, /^PASS \(no flow to stamp\)/);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("plan_check fail: mutated plan body, no error key, clears stamp", async () => {
|
|
600
|
+
const dir = tempCwd();
|
|
601
|
+
const { planPath } = writePlanFixture(dir, {
|
|
602
|
+
mutatePlan: (t) => t.replace("implements helperFn() for parsing.", "implements the helper for parsing."),
|
|
603
|
+
});
|
|
604
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
605
|
+
await h.emit("session_start");
|
|
606
|
+
const tool = h.tools.find((t) => t.name === "plan_check")!;
|
|
607
|
+
const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as {
|
|
608
|
+
content: { text: string }[];
|
|
609
|
+
details: { status: string; error?: string };
|
|
610
|
+
};
|
|
611
|
+
assert.match(res.content[0].text, /^FAIL/);
|
|
612
|
+
assert.equal(res.details.status, "fail");
|
|
613
|
+
assert.equal("error" in res.details, false);
|
|
614
|
+
|
|
615
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
616
|
+
const start = (await phaseTool.execute("t2", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
617
|
+
details: { error?: string };
|
|
618
|
+
};
|
|
619
|
+
assert.match(start.details.error ?? "", /plan_check/);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("plan_check fail: unresolvable spec path", async () => {
|
|
623
|
+
const dir = tempCwd();
|
|
624
|
+
const { planPath } = writePlanFixture(dir, {
|
|
625
|
+
mutatePlan: (t) => t.replace("**Spec:** `spec.md`", "**Spec:** `missing-spec.md`"),
|
|
626
|
+
});
|
|
627
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
628
|
+
await h.emit("session_start");
|
|
629
|
+
const tool = h.tools.find((t) => t.name === "plan_check")!;
|
|
630
|
+
const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as {
|
|
631
|
+
content: { text: string }[];
|
|
632
|
+
details: { status: string; findings?: { check: string }[]; error?: string };
|
|
633
|
+
};
|
|
634
|
+
assert.match(res.content[0].text, /^FAIL/);
|
|
635
|
+
assert.equal(res.details.status, "fail");
|
|
636
|
+
assert.equal("error" in res.details, false);
|
|
637
|
+
assert.ok(res.details.findings?.some((f) => f.check === "input"));
|
|
638
|
+
|
|
639
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
640
|
+
const start = (await phaseTool.execute("t2", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
641
|
+
details: { error?: string };
|
|
642
|
+
};
|
|
643
|
+
assert.match(start.details.error ?? "", /plan_check/);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("plan_check fail: header **Spec:** extraction is confined to the header (a path-only-looking line below the separator does not count)", async () => {
|
|
647
|
+
const dir = tempCwd();
|
|
648
|
+
const { planPath } = writePlanFixture(dir, {
|
|
649
|
+
mutatePlan: (t) =>
|
|
650
|
+
t
|
|
651
|
+
.replace("**Spec:** `spec.md`\n\n", "")
|
|
652
|
+
.replace(
|
|
653
|
+
"## Wave 1 - Two parallel tasks",
|
|
654
|
+
"## Wave 1 - Two parallel tasks\n\n**Spec:** `not-the-header-spec.md`\n",
|
|
655
|
+
),
|
|
656
|
+
});
|
|
657
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
658
|
+
await h.emit("session_start");
|
|
659
|
+
const tool = h.tools.find((t) => t.name === "plan_check")!;
|
|
660
|
+
const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as {
|
|
661
|
+
content: { text: string }[];
|
|
662
|
+
details: { status: string; findings?: { check: string; reason: string }[] };
|
|
663
|
+
};
|
|
664
|
+
assert.match(res.content[0].text, /^FAIL/);
|
|
665
|
+
assert.equal(res.details.status, "fail");
|
|
666
|
+
assert.ok(
|
|
667
|
+
res.details.findings?.some((f) => f.reason.includes("no path-only **Spec:** header line found in plan")),
|
|
668
|
+
`expected the missing-header-spec finding, got: ${JSON.stringify(res.details.findings)}`,
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("plan_check fail: plan header has no path-only **Spec:** line at all", async () => {
|
|
673
|
+
const dir = tempCwd();
|
|
674
|
+
const { planPath } = writePlanFixture(dir, {
|
|
675
|
+
mutatePlan: (t) => t.replace("**Spec:** `spec.md`\n\n", ""),
|
|
676
|
+
});
|
|
677
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
678
|
+
await h.emit("session_start");
|
|
679
|
+
const tool = h.tools.find((t) => t.name === "plan_check")!;
|
|
680
|
+
const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as {
|
|
681
|
+
content: { text: string }[];
|
|
682
|
+
details: { status: string; findings?: { check: string }[] };
|
|
683
|
+
};
|
|
684
|
+
assert.match(res.content[0].text, /^FAIL/);
|
|
685
|
+
assert.equal(res.details.status, "fail");
|
|
686
|
+
assert.ok(res.details.findings?.some((f) => f.check === "input"));
|
|
687
|
+
|
|
688
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
689
|
+
const start = (await phaseTool.execute("t2", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
690
|
+
details: { error?: string };
|
|
691
|
+
};
|
|
692
|
+
assert.match(start.details.error ?? "", /plan_check/);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("implement-start gate: rejects with no stamp, names plan_check remedy", async () => {
|
|
696
|
+
const h = harness({ branch: readyForImplementBranch });
|
|
697
|
+
await h.emit("session_start");
|
|
698
|
+
const tool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
699
|
+
const res = (await tool.execute("t1", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
700
|
+
content: { text: string }[];
|
|
701
|
+
details: { error?: string };
|
|
702
|
+
};
|
|
703
|
+
assert.match(res.content[0].text, /plan_check/);
|
|
704
|
+
assert.ok(res.details.error);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("implement-start gate: stale plan bytes rejected, names the stale file", async () => {
|
|
708
|
+
const dir = tempCwd();
|
|
709
|
+
const { planPath } = writePlanFixture(dir);
|
|
710
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
711
|
+
await h.emit("session_start");
|
|
712
|
+
const planCheck = h.tools.find((t) => t.name === "plan_check")!;
|
|
713
|
+
await planCheck.execute("t1", { planPath }, undefined, undefined, h.ctx);
|
|
714
|
+
writeFileSync(planPath, readFileSync(planPath, "utf8") + "\nx");
|
|
715
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
716
|
+
const res = (await phaseTool.execute("t2", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
717
|
+
details: { error?: string };
|
|
718
|
+
};
|
|
719
|
+
assert.match(res.details.error ?? "", /stale/);
|
|
720
|
+
assert.match(res.details.error ?? "", new RegExp(planPath.replace(/[/.]/g, "\\$&")));
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("implement-start gate: stale spec bytes rejected, names the stale file", async () => {
|
|
724
|
+
const dir = tempCwd();
|
|
725
|
+
const { planPath, specPath } = writePlanFixture(dir);
|
|
726
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
727
|
+
await h.emit("session_start");
|
|
728
|
+
const planCheck = h.tools.find((t) => t.name === "plan_check")!;
|
|
729
|
+
await planCheck.execute("t1", { planPath }, undefined, undefined, h.ctx);
|
|
730
|
+
writeFileSync(specPath, readFileSync(specPath, "utf8") + "\nx");
|
|
731
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
732
|
+
const res = (await phaseTool.execute("t2", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
733
|
+
details: { error?: string };
|
|
734
|
+
};
|
|
735
|
+
assert.match(res.details.error ?? "", /stale/);
|
|
736
|
+
assert.match(res.details.error ?? "", new RegExp(specPath.replace(/[/.]/g, "\\$&")));
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("implement-start gate: missing stamped file rejected, names the missing path", async () => {
|
|
740
|
+
const dir = tempCwd();
|
|
741
|
+
const { planPath } = writePlanFixture(dir);
|
|
742
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
743
|
+
await h.emit("session_start");
|
|
744
|
+
const planCheck = h.tools.find((t) => t.name === "plan_check")!;
|
|
745
|
+
await planCheck.execute("t1", { planPath }, undefined, undefined, h.ctx);
|
|
746
|
+
rmSync(planPath);
|
|
747
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
748
|
+
const res = (await phaseTool.execute("t2", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
749
|
+
details: { error?: string };
|
|
750
|
+
};
|
|
751
|
+
assert.match(res.details.error ?? "", /missing/);
|
|
752
|
+
assert.match(res.details.error ?? "", new RegExp(planPath.replace(/[/.]/g, "\\$&")));
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("implement-start gate: enforce false skips the gate entirely", async () => {
|
|
756
|
+
const dir = tempCwd({ piGauntlet: { flowGuards: { enforce: false } } });
|
|
757
|
+
const h = harness({ cwd: dir, branch: readyForImplementBranch });
|
|
758
|
+
await h.emit("session_start");
|
|
759
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
760
|
+
const res = (await phaseTool.execute("t1", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
761
|
+
details: { error?: string; phases: { implement: { status: string } } };
|
|
762
|
+
};
|
|
763
|
+
assert.equal(res.details.error, undefined);
|
|
764
|
+
assert.equal(res.details.phases.implement.status, "in_progress");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("replay: a passing plan_check result restores the stamp without re-running the tool", async () => {
|
|
768
|
+
const dir = tempCwd();
|
|
769
|
+
const { planPath, specPath } = writePlanFixture(dir);
|
|
770
|
+
const passDetails = {
|
|
771
|
+
status: "pass",
|
|
772
|
+
planPath,
|
|
773
|
+
planSha256: sha256Hex(readFileSync(planPath)),
|
|
774
|
+
specPath,
|
|
775
|
+
specSha256: sha256Hex(readFileSync(specPath)),
|
|
776
|
+
};
|
|
777
|
+
const h = harness({ cwd: dir, branch: [...readyForImplementBranch, planCheckResult(passDetails)] });
|
|
778
|
+
await h.emit("session_start");
|
|
779
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
780
|
+
const res = (await phaseTool.execute("t1", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
781
|
+
details: { error?: string; phases: { implement: { status: string } } };
|
|
782
|
+
};
|
|
783
|
+
assert.equal(res.details.error, undefined);
|
|
784
|
+
assert.equal(res.details.phases.implement.status, "in_progress");
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("replay: pass then fail clears the stamp", async () => {
|
|
788
|
+
const dir = tempCwd();
|
|
789
|
+
const { planPath, specPath } = writePlanFixture(dir);
|
|
790
|
+
const passDetails = {
|
|
791
|
+
status: "pass",
|
|
792
|
+
planPath,
|
|
793
|
+
planSha256: sha256Hex(readFileSync(planPath)),
|
|
794
|
+
specPath,
|
|
795
|
+
specSha256: sha256Hex(readFileSync(specPath)),
|
|
796
|
+
};
|
|
797
|
+
const failDetails = { status: "fail", planPath };
|
|
798
|
+
const h = harness({
|
|
799
|
+
cwd: dir,
|
|
800
|
+
branch: [...readyForImplementBranch, planCheckResult(passDetails), planCheckResult(failDetails)],
|
|
801
|
+
});
|
|
802
|
+
await h.emit("session_start");
|
|
803
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
804
|
+
const res = (await phaseTool.execute("t1", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
805
|
+
details: { error?: string };
|
|
806
|
+
};
|
|
807
|
+
assert.match(res.details.error ?? "", /plan_check/);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("replay: a plan_check pass observed before any flow entry does not arm the stamp, and a live walk with no live plan_check is rejected", async () => {
|
|
811
|
+
const dir = tempCwd();
|
|
812
|
+
const { planPath, specPath } = writePlanFixture(dir);
|
|
813
|
+
const passDetails = {
|
|
814
|
+
status: "pass",
|
|
815
|
+
planPath,
|
|
816
|
+
planSha256: sha256Hex(readFileSync(planPath)),
|
|
817
|
+
specPath,
|
|
818
|
+
specSha256: sha256Hex(readFileSync(specPath)),
|
|
819
|
+
};
|
|
820
|
+
// plan_check pass happens with gauntletEntered still false: no phase_tracker
|
|
821
|
+
// history precedes it in the branch.
|
|
822
|
+
const h = harness({ cwd: dir, branch: [planCheckResult(passDetails)] });
|
|
823
|
+
await h.emit("session_start");
|
|
824
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
825
|
+
await phaseTool.execute("t1", { action: "start", phase: "brainstorm" }, undefined, undefined, h.ctx);
|
|
826
|
+
await phaseTool.execute("t2", { action: "complete", phase: "brainstorm" }, undefined, undefined, h.ctx);
|
|
827
|
+
await phaseTool.execute("t3", { action: "start", phase: "plan" }, undefined, undefined, h.ctx);
|
|
828
|
+
await phaseTool.execute("t4", { action: "complete", phase: "plan" }, undefined, undefined, h.ctx);
|
|
829
|
+
const res = (await phaseTool.execute("t5", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
830
|
+
details: { error?: string };
|
|
831
|
+
};
|
|
832
|
+
assert.match(res.details.error ?? "", /plan_check/);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("replay: a phase_tracker reset clears the stamp", async () => {
|
|
836
|
+
const dir = tempCwd();
|
|
837
|
+
const { planPath, specPath } = writePlanFixture(dir);
|
|
838
|
+
const passDetails = {
|
|
839
|
+
status: "pass",
|
|
840
|
+
planPath,
|
|
841
|
+
planSha256: sha256Hex(readFileSync(planPath)),
|
|
842
|
+
specPath,
|
|
843
|
+
specSha256: sha256Hex(readFileSync(specPath)),
|
|
844
|
+
};
|
|
845
|
+
const h = harness({
|
|
846
|
+
cwd: dir,
|
|
847
|
+
branch: [
|
|
848
|
+
...readyForImplementBranch,
|
|
849
|
+
planCheckResult(passDetails),
|
|
850
|
+
phaseResult("reset", phases()),
|
|
851
|
+
...readyForImplementBranch,
|
|
852
|
+
],
|
|
853
|
+
});
|
|
854
|
+
await h.emit("session_start");
|
|
855
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
856
|
+
const res = (await phaseTool.execute("t1", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
857
|
+
details: { error?: string };
|
|
858
|
+
};
|
|
859
|
+
assert.match(res.details.error ?? "", /plan_check/);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test("replay via session_fork: a passing plan_check result restores the stamp without re-running the tool", async () => {
|
|
863
|
+
const dir = tempCwd();
|
|
864
|
+
const { planPath, specPath } = writePlanFixture(dir);
|
|
865
|
+
const passDetails = {
|
|
866
|
+
status: "pass",
|
|
867
|
+
planPath,
|
|
868
|
+
planSha256: sha256Hex(readFileSync(planPath)),
|
|
869
|
+
specPath,
|
|
870
|
+
specSha256: sha256Hex(readFileSync(specPath)),
|
|
871
|
+
};
|
|
872
|
+
const h = harness({ cwd: dir, branch: [...readyForImplementBranch, planCheckResult(passDetails)] });
|
|
873
|
+
// The child fork inherits the parent's branch; phase-tracker must rebuild
|
|
874
|
+
// phases/gauntletEntered/the stamp from replay on session_fork exactly as it
|
|
875
|
+
// does on session_start. Without that rebuild, gauntletEntered would stay
|
|
876
|
+
// false (its un-replayed default) and the implement-start gate below would
|
|
877
|
+
// be skipped entirely rather than genuinely satisfied - the plan.status
|
|
878
|
+
// assertion is what tells the two apart (a skipped gate leaves plan pending).
|
|
879
|
+
await h.emit("session_fork");
|
|
880
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
881
|
+
const res = (await phaseTool.execute("t1", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
882
|
+
details: { error?: string; phases: { plan: { status: string }; implement: { status: string } } };
|
|
883
|
+
};
|
|
884
|
+
assert.equal(res.details.error, undefined);
|
|
885
|
+
assert.equal(res.details.phases.plan.status, "complete");
|
|
886
|
+
assert.equal(res.details.phases.implement.status, "in_progress");
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("replay via session_tree: a passing plan_check result restores the stamp without re-running the tool", async () => {
|
|
890
|
+
const dir = tempCwd();
|
|
891
|
+
const { planPath, specPath } = writePlanFixture(dir);
|
|
892
|
+
const passDetails = {
|
|
893
|
+
status: "pass",
|
|
894
|
+
planPath,
|
|
895
|
+
planSha256: sha256Hex(readFileSync(planPath)),
|
|
896
|
+
specPath,
|
|
897
|
+
specSha256: sha256Hex(readFileSync(specPath)),
|
|
898
|
+
};
|
|
899
|
+
const h = harness({ cwd: dir, branch: [...readyForImplementBranch, planCheckResult(passDetails)] });
|
|
900
|
+
// Fork/tree sessions follow the same branch-replay semantics as fork/switch:
|
|
901
|
+
// phase-tracker must rebuild phases/gauntletEntered/the stamp from replay on
|
|
902
|
+
// session_tree exactly as it does on the other three events. Without that
|
|
903
|
+
// rebuild, gauntletEntered would stay false (its un-replayed default) and
|
|
904
|
+
// the implement-start gate below would be skipped entirely rather than
|
|
905
|
+
// genuinely satisfied - the plan.status assertion is what tells the two
|
|
906
|
+
// apart (a skipped gate leaves plan pending).
|
|
907
|
+
await h.emit("session_tree");
|
|
908
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
909
|
+
const res = (await phaseTool.execute("t1", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
910
|
+
details: { error?: string; phases: { plan: { status: string }; implement: { status: string } } };
|
|
911
|
+
};
|
|
912
|
+
assert.equal(res.details.error, undefined);
|
|
913
|
+
assert.equal(res.details.phases.plan.status, "complete");
|
|
914
|
+
assert.equal(res.details.phases.implement.status, "in_progress");
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test("replay via session_switch: pass then fail clears the stamp, rebuilt from replay rather than a live run", async () => {
|
|
918
|
+
const dir = tempCwd();
|
|
919
|
+
const { planPath, specPath } = writePlanFixture(dir);
|
|
920
|
+
const passDetails = {
|
|
921
|
+
status: "pass",
|
|
922
|
+
planPath,
|
|
923
|
+
planSha256: sha256Hex(readFileSync(planPath)),
|
|
924
|
+
specPath,
|
|
925
|
+
specSha256: sha256Hex(readFileSync(specPath)),
|
|
926
|
+
};
|
|
927
|
+
const failDetails = { status: "fail", planPath };
|
|
928
|
+
const h = harness({
|
|
929
|
+
cwd: dir,
|
|
930
|
+
branch: [...readyForImplementBranch, planCheckResult(passDetails), planCheckResult(failDetails)],
|
|
931
|
+
});
|
|
932
|
+
// session_switch must rebuild gauntletEntered (true, from the replayed
|
|
933
|
+
// readyForImplementBranch entries) and the stamp (armed by the pass, then
|
|
934
|
+
// cleared by the fail) purely from replay - no plan_check tool call happens
|
|
935
|
+
// in this test. If the rebuild didn't run, gauntletEntered would stay false
|
|
936
|
+
// and the implement-start gate would be skipped (no error) instead of
|
|
937
|
+
// rejecting for a stale/missing stamp, so this assertion distinguishes the
|
|
938
|
+
// two.
|
|
939
|
+
await h.emit("session_switch");
|
|
940
|
+
const phaseTool = h.tools.find((t) => t.name === "phase_tracker")!;
|
|
941
|
+
const res = (await phaseTool.execute("t1", { action: "start", phase: "implement" }, undefined, undefined, h.ctx)) as {
|
|
942
|
+
details: { error?: string };
|
|
943
|
+
};
|
|
944
|
+
assert.match(res.details.error ?? "", /plan_check/);
|
|
945
|
+
});
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execSync } from "node:child_process";
|
|
13
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
14
|
-
import { join } from "node:path";
|
|
13
|
+
import { existsSync, globSync, readdirSync, readFileSync, readFileSync as readFileBytes } from "node:fs";
|
|
14
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
15
15
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
settingsErrorWarning,
|
|
24
24
|
} from "./lib/gauntlet-settings.ts";
|
|
25
25
|
import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
|
|
26
|
+
import { checkPlan, sha256, type FsPort, type PlanCheckFinding } from "./lib/plan-check.ts";
|
|
26
27
|
import {
|
|
27
28
|
checkSubstep,
|
|
28
29
|
findMarkerFile,
|
|
@@ -58,6 +59,22 @@ interface PhaseTrackerDetails {
|
|
|
58
59
|
error?: string;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
interface PlanCheckStamp {
|
|
63
|
+
planPath: string; // absolute
|
|
64
|
+
planSha256: string;
|
|
65
|
+
specPath: string; // absolute
|
|
66
|
+
specSha256: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface PlanCheckDetails {
|
|
70
|
+
status: "pass" | "fail";
|
|
71
|
+
planPath: string;
|
|
72
|
+
planSha256?: string;
|
|
73
|
+
specPath?: string;
|
|
74
|
+
specSha256?: string;
|
|
75
|
+
findings?: PlanCheckFinding[];
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
// Qualification per spec "Qualifying dispatch": a successful conformance-reviewer
|
|
62
79
|
// child run in the result details (pi-cohort SingleResult: { agent, exitCode }).
|
|
63
80
|
// Management mode and async dispatches return results: [] and fail this check.
|
|
@@ -288,6 +305,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
288
305
|
let phases: PhaseMap = emptyPhases();
|
|
289
306
|
let conformanceDispatched = false;
|
|
290
307
|
let gauntletEntered = false;
|
|
308
|
+
let planCheckStamp: PlanCheckStamp | undefined;
|
|
291
309
|
const attemptedRecoveryEdges = new Set<RecoveryEdge>();
|
|
292
310
|
|
|
293
311
|
let cadenceSeq = 0;
|
|
@@ -363,6 +381,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
363
381
|
phases = emptyPhases();
|
|
364
382
|
conformanceDispatched = false;
|
|
365
383
|
gauntletEntered = false;
|
|
384
|
+
planCheckStamp = undefined;
|
|
366
385
|
attemptedRecoveryEdges.clear();
|
|
367
386
|
firedGuards.clear();
|
|
368
387
|
pendingGuardWarnings.clear();
|
|
@@ -386,7 +405,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
386
405
|
if (details && !details.error) {
|
|
387
406
|
phases = details.phases;
|
|
388
407
|
gauntletEntered = nextGauntletEntered(gauntletEntered, details.action, details.phases.brainstorm.status);
|
|
389
|
-
if (details.action === "reset")
|
|
408
|
+
if (details.action === "reset") {
|
|
409
|
+
conformanceDispatched = false;
|
|
410
|
+
planCheckStamp = undefined;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
} else if (msg.toolName === "plan_check") {
|
|
414
|
+
const d = msg.details as PlanCheckDetails | undefined;
|
|
415
|
+
if (gauntletEntered && d?.status === "pass" && d.planPath && d.planSha256 && d.specPath && d.specSha256) {
|
|
416
|
+
planCheckStamp = { planPath: d.planPath, planSha256: d.planSha256, specPath: d.specPath, specSha256: d.specSha256 };
|
|
417
|
+
} else if (d?.status === "fail") {
|
|
418
|
+
planCheckStamp = undefined;
|
|
390
419
|
}
|
|
391
420
|
} else if (msg.toolName === "subagent") {
|
|
392
421
|
if (qualifiesAsClosureDispatch(msg.details)) conformanceDispatched = true;
|
|
@@ -657,6 +686,87 @@ export default function (pi: ExtensionAPI) {
|
|
|
657
686
|
},
|
|
658
687
|
});
|
|
659
688
|
|
|
689
|
+
const PlanCheckParams = Type.Object({
|
|
690
|
+
planPath: Type.String({ description: "Path to the implementation plan (absolute, or relative to cwd)" }),
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
pi.registerTool({
|
|
694
|
+
name: "plan_check",
|
|
695
|
+
label: "Plan Check",
|
|
696
|
+
description:
|
|
697
|
+
"Deterministically verify an implementation plan against its spec (8 mechanical checks); " +
|
|
698
|
+
"a pass stamps the plan for implement-start.",
|
|
699
|
+
parameters: PlanCheckParams,
|
|
700
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
701
|
+
const fail = (findings: PlanCheckFinding[], planAbs: string) => {
|
|
702
|
+
planCheckStamp = undefined;
|
|
703
|
+
const lines = findings.map((f) => `${f.check} @ line ${f.line}: ${f.reason}\n ${f.text}`);
|
|
704
|
+
return {
|
|
705
|
+
content: [{ type: "text" as const, text: `FAIL (${findings.length} findings)\n${lines.join("\n")}` }],
|
|
706
|
+
details: { status: "fail", planPath: planAbs, findings } as PlanCheckDetails,
|
|
707
|
+
};
|
|
708
|
+
};
|
|
709
|
+
const planAbs = isAbsolute(params.planPath) ? params.planPath : resolve(ctx.cwd, params.planPath);
|
|
710
|
+
try {
|
|
711
|
+
let planBytes: Buffer;
|
|
712
|
+
try {
|
|
713
|
+
planBytes = readFileBytes(planAbs);
|
|
714
|
+
} catch {
|
|
715
|
+
return fail([{ check: "input", line: 0, text: "", reason: `plan file unreadable at ${planAbs}` }], planAbs);
|
|
716
|
+
}
|
|
717
|
+
const planText = planBytes.toString("utf8");
|
|
718
|
+
const planLines = planText.split("\n");
|
|
719
|
+
const planSeparatorIdx = planLines.findIndex((l) => l.trim() === "---");
|
|
720
|
+
const planHeaderLines = planSeparatorIdx === -1 ? planLines : planLines.slice(0, planSeparatorIdx);
|
|
721
|
+
const specLine = planHeaderLines.find((l) => /^\*\*Spec:\*\*/.test(l) && !l.includes("\u00a7"));
|
|
722
|
+
const specPathRaw = specLine
|
|
723
|
+
?.replace(/^\*\*Spec:\*\*/, "")
|
|
724
|
+
.trim()
|
|
725
|
+
.replace(/^`|`$/g, "");
|
|
726
|
+
if (!specPathRaw) {
|
|
727
|
+
return fail(
|
|
728
|
+
[{ check: "input", line: 0, text: "", reason: "no path-only **Spec:** header line found in plan" }],
|
|
729
|
+
planAbs,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
const specAbs = isAbsolute(specPathRaw) ? specPathRaw : resolve(ctx.cwd, specPathRaw);
|
|
733
|
+
let specBytes: Buffer;
|
|
734
|
+
try {
|
|
735
|
+
specBytes = readFileBytes(specAbs);
|
|
736
|
+
} catch {
|
|
737
|
+
return fail([{ check: "input", line: 0, text: "", reason: `spec file unreadable at ${specAbs}` }], planAbs);
|
|
738
|
+
}
|
|
739
|
+
const specText = specBytes.toString("utf8");
|
|
740
|
+
const port: FsPort = {
|
|
741
|
+
exists: (p) => existsSync(resolve(ctx.cwd, p)),
|
|
742
|
+
glob: (pattern) => globSync(pattern, { cwd: ctx.cwd }),
|
|
743
|
+
};
|
|
744
|
+
const findings = checkPlan(planText, specText, port);
|
|
745
|
+
if (findings.length > 0) return fail(findings, planAbs);
|
|
746
|
+
|
|
747
|
+
const planSha256 = sha256(planBytes);
|
|
748
|
+
const specSha256 = sha256(specBytes);
|
|
749
|
+
const details: PlanCheckDetails = {
|
|
750
|
+
status: "pass",
|
|
751
|
+
planPath: planAbs,
|
|
752
|
+
planSha256,
|
|
753
|
+
specPath: specAbs,
|
|
754
|
+
specSha256,
|
|
755
|
+
};
|
|
756
|
+
if (gauntletEntered) {
|
|
757
|
+
planCheckStamp = { planPath: planAbs, planSha256, specPath: specAbs, specSha256 };
|
|
758
|
+
return {
|
|
759
|
+
content: [{ type: "text" as const, text: "PASS \u2014 plan verified and stamped for implement-start" }],
|
|
760
|
+
details,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
return { content: [{ type: "text" as const, text: "PASS (no flow to stamp)" }], details };
|
|
764
|
+
} catch (err) {
|
|
765
|
+
return fail([{ check: "internal", line: 0, text: "", reason: String(err) }], planAbs);
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
|
|
660
770
|
pi.registerTool({
|
|
661
771
|
name: "phase_tracker",
|
|
662
772
|
label: "Phase Tracker",
|
|
@@ -706,6 +816,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
706
816
|
} as PhaseTrackerDetails,
|
|
707
817
|
};
|
|
708
818
|
}
|
|
819
|
+
if (
|
|
820
|
+
params.phase === "implement" &&
|
|
821
|
+
gauntletEntered &&
|
|
822
|
+
resolveFlowGuards(loadGauntletSettings(ctx.cwd).gauntlet).enforce
|
|
823
|
+
) {
|
|
824
|
+
const reject = (text: string) => ({
|
|
825
|
+
content: [{ type: "text" as const, text: `Error: ${text}` }],
|
|
826
|
+
details: { action: "start", phases: { ...phases }, error: text } as PhaseTrackerDetails,
|
|
827
|
+
});
|
|
828
|
+
if (!planCheckStamp) {
|
|
829
|
+
return reject(
|
|
830
|
+
'plan not verified - run plan_check({ planPath: "<plan path>" }) and fix findings before starting implementation',
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
for (const [file, expected] of [
|
|
834
|
+
[planCheckStamp.planPath, planCheckStamp.planSha256],
|
|
835
|
+
[planCheckStamp.specPath, planCheckStamp.specSha256],
|
|
836
|
+
] as const) {
|
|
837
|
+
let bytes: Buffer;
|
|
838
|
+
try {
|
|
839
|
+
bytes = readFileBytes(file);
|
|
840
|
+
} catch {
|
|
841
|
+
return reject(`plan-check stamp file missing at ${file} - re-run plan_check on the current plan path`);
|
|
842
|
+
}
|
|
843
|
+
if (sha256(bytes) !== expected) {
|
|
844
|
+
return reject(`plan-check stamp is stale: ${file} changed since the last pass - re-run plan_check and retry`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
709
848
|
phases = { ...phases, [params.phase]: transitionPhaseState("in_progress") as PhaseState };
|
|
710
849
|
gauntletEntered = nextGauntletEntered(gauntletEntered, "start", phases.brainstorm.status);
|
|
711
850
|
firedGuards.clear();
|
package/package.json
CHANGED