@dieulc/workflow 0.6.1 → 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/edit-policy.ts CHANGED
@@ -753,23 +753,44 @@ export function sanitizeWriteInstruction(raw: unknown): string {
753
753
  * The gate consumes this and `parseWriteGateChoice` below, so a renamed
754
754
  * label can never silently change the gate's behaviour (the unit test maps
755
755
  * every real option through the parser).
756
+ *
757
+ * The labels state the scope they really have, because ONE answer now
758
+ * covers every gated write of the turn (see the turn-scoped helpers below):
759
+ * "Allow once (this turn)" allows the writes the dialog listed, and
760
+ * "Always allow …" persists a grant for every one of them.
756
761
  */
757
762
  export function writeGateOptions(
758
- kind: "path" | "command",
763
+ kind: WriteGateOptionKind,
759
764
  ): readonly string[] {
760
- return kind === "path"
761
- ? [
762
- "Allow once",
763
- "Always allow this path",
764
- "Deny",
765
- WRITE_DENY_WITH_INSTRUCTIONS_LABEL,
766
- ]
767
- : [
768
- "Allow once",
769
- "Always allow this command",
770
- "Deny",
771
- WRITE_DENY_WITH_INSTRUCTIONS_LABEL,
772
- ];
765
+ return [
766
+ "Allow once (this turn)",
767
+ writeGateAlwaysLabel(kind),
768
+ "Deny",
769
+ WRITE_DENY_WITH_INSTRUCTIONS_LABEL,
770
+ ];
771
+ }
772
+
773
+ /**
774
+ * The "always allow" label for a set of items — singular, plural or mixed.
775
+ *
776
+ * The prefix "Always allow" is what `parseWriteGateChoice` matches on, so
777
+ * every variant parses to the same choice.
778
+ */
779
+ export function writeGateAlwaysLabel(kind: WriteGateOptionKind): string {
780
+ switch (kind) {
781
+ case "path":
782
+ return "Always allow this path";
783
+ case "paths":
784
+ return "Always allow these paths";
785
+ case "command":
786
+ return "Always allow this command";
787
+ case "commands":
788
+ return "Always allow these commands";
789
+ case "mixed":
790
+ return "Always allow these";
791
+ default:
792
+ return "Always allow these";
793
+ }
773
794
  }
774
795
 
775
796
  /** What the ask dialog's answer means. */
@@ -855,6 +876,164 @@ export function buildWriteGuidanceMessage(input: {
855
876
  );
856
877
  }
857
878
 
879
+ // ── Turn-scoped gate helpers ───────────────────────────────────────────
880
+ //
881
+ // The gate asks ONE question per assistant turn — not one per tool call.
882
+ //
883
+ // WHY: Pi executes an assistant message's tool calls as a BATCH, and the
884
+ // extension's `tool_call` hook runs in the batch's PREPARATION phase: once
885
+ // per call, sequentially, before any tool in the batch runs
886
+ // (pi-agent-core/dist/agent-loop.js → executeToolCallsParallel →
887
+ // prepareToolCall → beforeToolCall). A model that batches two writes to the
888
+ // same file therefore produced two dialogs back to back, with no agent
889
+ // output in between — and the instruction typed into the first one could
890
+ // not reach the model before the second one opened, because a tool result is
891
+ // appended only after the whole batch and a steer message lands only after
892
+ // the turn's tool calls have run. The user had to retype the same sentence.
893
+ //
894
+ // So the gate collects the turn's gated writes, shows them all in one
895
+ // dialog, and applies the answer to every one of them.
896
+
897
+ /** One gated write covered by a single dialog answer. */
898
+ export interface WriteGateItem {
899
+ /** The tool that would write (edit, write, bash, powershell, …). */
900
+ tool: string;
901
+ /** Human target: the joined paths, or the command/tool key. */
902
+ target: string;
903
+ /** Short cause, e.g. "edits src/a.ts". */
904
+ why?: string;
905
+ /** Stable key a denial is remembered under (see `writeGateTargetKey`). */
906
+ key: string;
907
+ /** Path grants "always allow" would persist for this item. */
908
+ grantPaths?: string[];
909
+ /** Command (or tool) grant "always allow" would persist for this item. */
910
+ grantCommand?: string;
911
+ }
912
+
913
+ /**
914
+ * Which "always allow" label fits a set of items.
915
+ *
916
+ * "path"/"paths" — every item writes files; "command"/"commands" — every
917
+ * item runs a command (or a path-less tool, whose grant is stored in the
918
+ * command list); "mixed" — both in one turn.
919
+ */
920
+ export type WriteGateOptionKind =
921
+ | "path"
922
+ | "paths"
923
+ | "command"
924
+ | "commands"
925
+ | "mixed";
926
+
927
+ /**
928
+ * The stable key a denial is remembered under.
929
+ *
930
+ * Path writes key on their normalized absolute path(s) (so the same file
931
+ * keys the same way whatever the model spelled it as), command writes on
932
+ * their allow-key, and a path-less tool on its tool key.
933
+ */
934
+ export function writeGateTargetKey(
935
+ toolName: string,
936
+ classification: WriteCallClassification,
937
+ cwd: string,
938
+ ): string {
939
+ const paths = classification.paths
940
+ .map((p) => normalizeWritePath(p, cwd))
941
+ .filter((p) => p.length > 0)
942
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
943
+ if (paths.length > 0) return `path:${paths.join("|")}`;
944
+ if (classification.key) return `cmd:${commandAllowKey(classification.key)}`;
945
+ return `tool:${toolAllowKey(toolName)}`;
946
+ }
947
+
948
+ /**
949
+ * Build the item for one classified call.
950
+ *
951
+ * `grantPaths` / `grantCommand` are what "always allow" would persist for
952
+ * this item: the normalized paths for a file write, the allow-key for a
953
+ * command or a path-less tool.
954
+ */
955
+ export function writeGateItemFromClassification(
956
+ toolName: string,
957
+ classification: WriteCallClassification,
958
+ cwd: string,
959
+ ): WriteGateItem {
960
+ const tool = String(toolName ?? "");
961
+ const rawPaths = classification.paths.filter(
962
+ (p) => typeof p === "string" && p.trim().length > 0,
963
+ );
964
+ const grantPaths = rawPaths
965
+ .map((p) => normalizeWritePath(p, cwd))
966
+ .filter((p) => p.length > 0);
967
+ const item: WriteGateItem = {
968
+ tool,
969
+ target: rawPaths.length > 0 ? rawPaths.join(", ") : (classification.key ?? tool),
970
+ why: classification.reason,
971
+ key: writeGateTargetKey(tool, classification, cwd),
972
+ };
973
+ if (grantPaths.length > 0) item.grantPaths = grantPaths;
974
+ else if (classification.key) item.grantCommand = classification.key;
975
+ return item;
976
+ }
977
+
978
+ /** Which "always allow" label fits a set of items. */
979
+ export function writeGateItemsKind(
980
+ items: readonly WriteGateItem[],
981
+ ): WriteGateOptionKind {
982
+ const paths = items.filter((i) => (i.grantPaths?.length ?? 0) > 0);
983
+ const commands = items.filter((i) => Boolean(i.grantCommand));
984
+ if (paths.length > 0 && commands.length > 0) return "mixed";
985
+ if (paths.length > 0) return paths.length === 1 ? "path" : "paths";
986
+ if (commands.length > 0) return commands.length === 1 ? "command" : "commands";
987
+ return "path";
988
+ }
989
+
990
+ /**
991
+ * The human list of the writes a dialog covers:
992
+ * "edits src/a.ts; edits src/b.ts".
993
+ */
994
+ export function describeWriteGateItems(
995
+ items: readonly WriteGateItem[],
996
+ ): string {
997
+ return items
998
+ .map((item) => {
999
+ const why = String(item.why ?? "").trim();
1000
+ return why || String(item.target ?? "").trim();
1001
+ })
1002
+ .filter((text) => text.length > 0)
1003
+ .join("; ");
1004
+ }
1005
+
1006
+ /**
1007
+ * The targets of a set of items, joined for the reason/guidance text:
1008
+ * "src/a.ts, src/b.ts".
1009
+ */
1010
+ export function writeGateTargetsLabel(
1011
+ items: readonly WriteGateItem[],
1012
+ ): string {
1013
+ return items
1014
+ .map((item) => String(item.target ?? "").trim())
1015
+ .filter((text) => text.length > 0)
1016
+ .join(", ");
1017
+ }
1018
+
1019
+ /**
1020
+ * The dialog title.
1021
+ *
1022
+ * One write keeps today's wording verbatim; several writes are listed, so
1023
+ * the user sees exactly what the answer covers.
1024
+ */
1025
+ export function writeGateTitle(items: readonly WriteGateItem[]): string {
1026
+ if (items.length <= 1) {
1027
+ const only = items[0];
1028
+ const why = only?.why ? ` (${only.why})` : "";
1029
+ return `Write policy 'ask': allow this write${why}?`;
1030
+ }
1031
+ return (
1032
+ `Write policy 'ask': allow these ${items.length} writes? ` +
1033
+ `(${describeWriteGateItems(items)})`
1034
+ );
1035
+ }
1036
+
858
1037
  // ── Argument completions (/write-policy) ────────────────────────────
859
1038
 
860
1039
  /** One /write-policy subcommand with its usage hint. */