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.
@@ -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") conformanceDispatched = false;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.0.8",
3
+ "version": "5.2.0",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "author": "Jacek Juraszek",
6
6
  "type": "module",