@dieulc/workflow 0.9.1 → 0.10.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/review-tools.ts CHANGED
@@ -373,9 +373,28 @@ export interface ReviewSubmitDeps {
373
373
  submit: (
374
374
  payload: ReviewSubmitPayload,
375
375
  ) => Promise<{ ok: boolean; path?: string; error?: string }>;
376
+ /** Chunked submission is optional so older callers/tests keep working. */
377
+ beginPlan?: (totalChunks?: number) => {
378
+ ok: boolean;
379
+ error?: string;
380
+ maxChunkChars?: number;
381
+ };
382
+ appendPlanChunk?: (index: number, content: string) => {
383
+ ok: boolean;
384
+ error?: string;
385
+ received?: number;
386
+ nextIndex?: number;
387
+ totalChars?: number;
388
+ };
389
+ takeAssembledPlan?: () => string | undefined;
390
+ resetPlan?: () => void;
391
+ /** Max characters accepted per chunk (default 6000). */
392
+ maxChunkChars?: number;
376
393
  log: (message: string, level?: "info" | "warning" | "error") => void;
377
394
  }
378
395
 
396
+ const DEFAULT_MAX_CHUNK_CHARS = 6_000;
397
+
379
398
  const VERDICT_SCHEMA = Type.Union([
380
399
  Type.Literal("approve"),
381
400
  Type.Literal("revise"),
@@ -413,6 +432,196 @@ const FINDING_SCHEMA = Type.Object({
413
432
  ]),
414
433
  });
415
434
 
435
+ /**
436
+ * `review_plan_begin` — start a chunked rewrite submission.
437
+ *
438
+ * Long plan rewrites can outlive a gateway's stream duration. This resets the
439
+ * assembler; the reviewer then sends ordered chunks and finalises through
440
+ * `review_submit_plan` (with no `planMarkdown`). The single-shot path remains.
441
+ */
442
+ export function createReviewPlanBeginTool(deps: ReviewSubmitDeps): {
443
+ name: string;
444
+ label: string;
445
+ description: string;
446
+ parameters: TSchema;
447
+ execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
448
+ } {
449
+ return {
450
+ name: "review_plan_begin",
451
+ label: "Begin Chunked Plan",
452
+ description:
453
+ "Start submitting the revised plan in ordered chunks. Use this when the plan is too long for one response, then call review_plan_chunk for each part and review_submit_plan (without planMarkdown) to finalise.",
454
+ parameters: Type.Object({
455
+ totalChunks: Type.Optional(
456
+ Type.Number({
457
+ description:
458
+ "How many chunks you intend to send. Optional; helps the harness report progress.",
459
+ }),
460
+ ),
461
+ }),
462
+ async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
463
+ const [, rawParams] = args as unknown as [
464
+ string,
465
+ { totalChunks?: unknown },
466
+ ];
467
+ if (!deps.beginPlan) {
468
+ return {
469
+ content: [
470
+ {
471
+ type: "text",
472
+ text: "review_plan_begin: chunked submission is unavailable in this round; use review_submit_plan with the complete planMarkdown instead.",
473
+ },
474
+ ],
475
+ isError: true,
476
+ };
477
+ }
478
+ const totalChunks =
479
+ typeof rawParams?.totalChunks === "number" &&
480
+ Number.isInteger(rawParams.totalChunks) &&
481
+ rawParams.totalChunks > 0
482
+ ? rawParams.totalChunks
483
+ : undefined;
484
+ const result = deps.beginPlan(totalChunks);
485
+ if (!result.ok) {
486
+ return {
487
+ content: [
488
+ {
489
+ type: "text",
490
+ text: `review_plan_begin: ${result.error ?? "could not start chunked submission"}`,
491
+ },
492
+ ],
493
+ isError: true,
494
+ };
495
+ }
496
+ const maxChunkChars =
497
+ result.maxChunkChars ?? deps.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS;
498
+ return {
499
+ content: [
500
+ {
501
+ type: "text",
502
+ text: [
503
+ "review_plan_begin: ready.",
504
+ `Send the plan in order with review_plan_chunk (max ${maxChunkChars} characters per chunk).`,
505
+ "After the last chunk, call review_plan_finalize (or review_submit_plan without planMarkdown).",
506
+ ].join("\n"),
507
+ },
508
+ ],
509
+ details: { totalChunks, maxChunkChars },
510
+ };
511
+ },
512
+ };
513
+ }
514
+
515
+ /** `review_plan_chunk` — append one ordered part of the rewrite. */
516
+ export function createReviewPlanChunkTool(deps: ReviewSubmitDeps): {
517
+ name: string;
518
+ label: string;
519
+ description: string;
520
+ parameters: TSchema;
521
+ execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
522
+ } {
523
+ return {
524
+ name: "review_plan_chunk",
525
+ label: "Append Plan Chunk",
526
+ description:
527
+ "Append one ordered chunk of the revised plan. Start with review_plan_begin, send index 1, 2, … without skipping or repeating, then finalise with review_submit_plan (no planMarkdown).",
528
+ parameters: Type.Object({
529
+ index: Type.Number({
530
+ description: "1-based chunk index. Must be the next expected index.",
531
+ }),
532
+ content: Type.String({
533
+ description:
534
+ "The chunk's markdown. Do not repeat earlier chunks or add a Review changes section.",
535
+ }),
536
+ }),
537
+ async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
538
+ const [, rawParams] = args as unknown as [
539
+ string,
540
+ { index?: unknown; content?: unknown },
541
+ ];
542
+ const index =
543
+ typeof rawParams?.index === "number" ? rawParams.index : NaN;
544
+ if (!Number.isInteger(index) || index < 1) {
545
+ return {
546
+ content: [
547
+ {
548
+ type: "text",
549
+ text: "review_plan_chunk: index must be a positive integer.",
550
+ },
551
+ ],
552
+ isError: true,
553
+ };
554
+ }
555
+ const content =
556
+ typeof rawParams?.content === "string" ? rawParams.content : "";
557
+ if (!content) {
558
+ return {
559
+ content: [
560
+ {
561
+ type: "text",
562
+ text: "review_plan_chunk: content is empty.",
563
+ },
564
+ ],
565
+ isError: true,
566
+ };
567
+ }
568
+ const maxChunkChars = deps.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS;
569
+ if (content.length > maxChunkChars) {
570
+ return {
571
+ content: [
572
+ {
573
+ type: "text",
574
+ text: `review_plan_chunk: chunk ${index} is ${content.length} characters; the limit is ${maxChunkChars}. Split it and resend.`,
575
+ },
576
+ ],
577
+ isError: true,
578
+ };
579
+ }
580
+ if (!deps.appendPlanChunk) {
581
+ return {
582
+ content: [
583
+ {
584
+ type: "text",
585
+ text: "review_plan_chunk: chunked submission is unavailable in this round; use review_submit_plan with the complete planMarkdown instead.",
586
+ },
587
+ ],
588
+ isError: true,
589
+ };
590
+ }
591
+ const result = deps.appendPlanChunk(index, content);
592
+ if (!result.ok) {
593
+ return {
594
+ content: [
595
+ {
596
+ type: "text",
597
+ text: `review_plan_chunk: ${result.error ?? "chunk rejected"}`,
598
+ },
599
+ ],
600
+ isError: true,
601
+ };
602
+ }
603
+ const nextIndex = result.nextIndex ?? index + 1;
604
+ return {
605
+ content: [
606
+ {
607
+ type: "text",
608
+ text: [
609
+ `review_plan_chunk: accepted chunk ${index}.`,
610
+ `received ${result.received ?? index} chunk(s), ${result.totalChars ?? content.length} characters so far.`,
611
+ `Next expected index: ${nextIndex}. When done, call review_submit_plan without planMarkdown.`,
612
+ ].join("\n"),
613
+ },
614
+ ],
615
+ details: {
616
+ index,
617
+ nextIndex,
618
+ totalChars: result.totalChars ?? content.length,
619
+ },
620
+ };
621
+ },
622
+ };
623
+ }
624
+
416
625
  /**
417
626
  * `review_submit_plan` — the ONLY write path for Review Mode.
418
627
  *
@@ -438,12 +647,14 @@ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
438
647
  name: "review_submit_plan",
439
648
  label: "Submit Reviewed Plan",
440
649
  description:
441
- "Submit the FINAL revised plan. This is the only way your work reaches the user, and the only file you can write. Pass the complete plan markdown (not a diff or a summary) plus your findings and verdict. The plan body must keep a markdown heading and stay close to the original length; the harness rejects stubs. A `## Review changes` section is appended automatically from your findings, so do not write it yourself. You may call this more than once — the last successful submission wins.",
650
+ "Submit the FINAL revised plan. This is the only way your work reaches the user, and the only file you can write. Pass the complete plan markdown (not a diff or a summary) plus your findings and verdict. After review_plan_begin/review_plan_chunk, omit planMarkdown and the harness uses the assembled chunks. The plan body must keep a markdown heading and stay close to the original length; the harness rejects stubs. A `## Review changes` section is appended automatically from your findings, so do not write it yourself. You may call this more than once — the last successful submission wins.",
442
651
  parameters: Type.Object({
443
- planMarkdown: Type.String({
444
- description:
445
- "The complete revised plan in markdown, preserving the original structure and heading style. Do NOT include a 'Review changes' section.",
446
- }),
652
+ planMarkdown: Type.Optional(
653
+ Type.String({
654
+ description:
655
+ "The complete revised plan in markdown, preserving the original structure and heading style. Omit it after review_plan_begin/review_plan_chunk. Do NOT include a 'Review changes' section.",
656
+ }),
657
+ ),
447
658
  findings: Type.Array(FINDING_SCHEMA, {
448
659
  description:
449
660
  "Every issue you found. Use an empty array if the plan is sound and you are submitting it unchanged.",
@@ -466,10 +677,24 @@ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
466
677
  },
467
678
  ];
468
679
 
469
- const planBody =
680
+ const explicitPlan =
470
681
  typeof rawParams?.planMarkdown === "string"
471
682
  ? rawParams.planMarkdown
472
683
  : "";
684
+ const planBody = explicitPlan.trim()
685
+ ? explicitPlan
686
+ : deps.takeAssembledPlan?.() ?? "";
687
+ if (!planBody.trim()) {
688
+ return {
689
+ content: [
690
+ {
691
+ type: "text",
692
+ text: "review_submit_plan: no plan was provided. Pass planMarkdown, or use review_plan_begin + review_plan_chunk first.",
693
+ },
694
+ ],
695
+ isError: true,
696
+ };
697
+ }
473
698
  const verdict: ReviewVerdict =
474
699
  rawParams?.verdict === "approve" ||
475
700
  rawParams?.verdict === "revise" ||
@@ -563,6 +788,11 @@ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
563
788
  };
564
789
  }
565
790
 
791
+ try {
792
+ deps.resetPlan?.();
793
+ } catch {
794
+ /* the written plan is authoritative; reset is best-effort */
795
+ }
566
796
  return {
567
797
  content: [
568
798
  {
@@ -590,6 +820,70 @@ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
590
820
  };
591
821
  }
592
822
 
823
+ // ── Step 17: review_plan_finalize ────────────────────────────────────
824
+
825
+ /**
826
+ * `review_plan_finalize` — finalise a chunked rewrite.
827
+ *
828
+ * The chunks were accepted by `review_plan_chunk`; this is the step that turns
829
+ * them into the plan file. It delegates to the SAME `review_submit_plan`
830
+ * funnel, so `writePlan` stays the only writer and `notifySubmitted` fires once.
831
+ */
832
+ export function createReviewPlanFinalizeTool(deps: ReviewSubmitDeps): {
833
+ name: string;
834
+ label: string;
835
+ description: string;
836
+ parameters: TSchema;
837
+ execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
838
+ } {
839
+ const submit = createReviewSubmitPlanTool(deps);
840
+ return {
841
+ name: "review_plan_finalize",
842
+ label: "Finalize Chunked Plan",
843
+ description:
844
+ "Finalise the plan you sent with review_plan_begin + review_plan_chunk. The assembled chunks become the plan body; the findings and verdict are recorded exactly as review_submit_plan would. Refuses an incomplete assembly — send the missing chunks first.",
845
+ parameters: Type.Object({
846
+ findings: Type.Array(FINDING_SCHEMA, {
847
+ description:
848
+ "Every issue you found. Use an empty array if the plan is sound and you are submitting it unchanged.",
849
+ }),
850
+ verdict: VERDICT_SCHEMA,
851
+ summary: Type.String({
852
+ description: "One sentence describing what you changed.",
853
+ }),
854
+ }),
855
+ async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
856
+ const [toolCallId, rawParams, ...rest] = args as [
857
+ string,
858
+ Record<string, unknown>,
859
+ ...unknown[],
860
+ ];
861
+ let assembled: string | undefined;
862
+ try {
863
+ assembled = deps.takeAssembledPlan?.();
864
+ } catch {
865
+ assembled = undefined;
866
+ }
867
+ if (!assembled || !assembled.trim()) {
868
+ return {
869
+ content: [
870
+ {
871
+ type: "text",
872
+ text: "review_plan_finalize: no complete plan to finalise. Call review_plan_begin, send every chunk with review_plan_chunk (watch the expected next index), then finalise.",
873
+ },
874
+ ],
875
+ isError: true,
876
+ };
877
+ }
878
+ return submit.execute(
879
+ toolCallId,
880
+ { ...(rawParams ?? {}) },
881
+ ...rest,
882
+ );
883
+ },
884
+ };
885
+ }
886
+
593
887
  // ── Step 15: review_pass_done ────────────────────────────────────────
594
888
 
595
889
  /**
@@ -739,6 +1033,9 @@ export function createReviewTools(
739
1033
  }
740
1034
 
741
1035
  tools.push(createReviewExploreTool(deps.explore));
1036
+ tools.push(createReviewPlanBeginTool(deps.submit));
1037
+ tools.push(createReviewPlanChunkTool(deps.submit));
1038
+ tools.push(createReviewPlanFinalizeTool(deps.submit));
742
1039
  tools.push(createReviewSubmitPlanTool(deps.submit));
743
1040
  tools.push(createReviewPassDoneTool(deps.passDone));
744
1041