@ordewell/core 0.4.8 → 0.4.10

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/dist/index.js CHANGED
@@ -96,6 +96,11 @@ __export(index_exports, {
96
96
  SettingsService: () => SettingsService,
97
97
  StdioAgentAdapter: () => StdioAgentAdapter,
98
98
  TASK_OPS_ENVELOPE_KEY: () => TASK_OPS_ENVELOPE_KEY,
99
+ TASK_QUERY_ANSWER_OR_OPS: () => TASK_QUERY_ANSWER_OR_OPS,
100
+ TASK_QUERY_ENVELOPE_KEY: () => TASK_QUERY_ENVELOPE_KEY,
101
+ TASK_QUERY_FIELDS: () => TASK_QUERY_FIELDS,
102
+ TASK_QUERY_PROTOCOL: () => TASK_QUERY_PROTOCOL,
103
+ TASK_QUERY_REMINDER: () => TASK_QUERY_REMINDER,
99
104
  TRUNCATED_PLAN_REPAIR_INSTRUCTION: () => TRUNCATED_PLAN_REPAIR_INSTRUCTION,
100
105
  TaskOrchestrator: () => TaskOrchestrator,
101
106
  TmuxRunner: () => TmuxRunner,
@@ -166,6 +171,7 @@ __export(index_exports, {
166
171
  emptyWarnings: () => emptyWarnings,
167
172
  enabledRunners: () => enabledRunners,
168
173
  ensureDir: () => ensureDir,
174
+ ensureStateDirIgnored: () => ensureStateDirIgnored,
169
175
  escapeControlCharsInStrings: () => escapeControlCharsInStrings,
170
176
  executionLogBlock: () => executionLogBlock,
171
177
  executionSummary: () => executionSummary,
@@ -215,6 +221,7 @@ __export(index_exports, {
215
221
  parsePartialPlan: () => parsePartialPlan,
216
222
  parsePlanJson: () => parsePlanJson,
217
223
  parseTaskOpsJson: () => parseTaskOpsJson,
224
+ parseTaskQueryJson: () => parseTaskQueryJson,
218
225
  pendingEditRulesBlock: () => pendingEditRulesBlock,
219
226
  planDirectLaunch: () => planDirectLaunch,
220
227
  planShellLaunch: () => planShellLaunch,
@@ -223,11 +230,13 @@ __export(index_exports, {
223
230
  providerForRunner: () => providerForRunner,
224
231
  reEmitPlanPrompt: () => reEmitPlanPrompt,
225
232
  reEmitTaskOpsPrompt: () => reEmitTaskOpsPrompt,
233
+ reEmitTaskQueryPrompt: () => reEmitTaskQueryPrompt,
226
234
  readDaemonToken: () => readDaemonToken,
227
235
  referencePattern: () => referencePattern,
228
236
  removeTaskFromPlan: () => removeTaskFromPlan,
229
237
  renderPlanMap: () => renderPlanMap,
230
238
  renderPriorOutputs: () => renderPriorOutputs,
239
+ renderTaskQueryAnswer: () => renderTaskQueryAnswer,
231
240
  renumberTasks: () => renumberTasks,
232
241
  repairLoop: () => repairLoop,
233
242
  researchShellWarning: () => researchShellWarning,
@@ -261,7 +270,9 @@ __export(index_exports, {
261
270
  summarizeOutput: () => summarizeOutput,
262
271
  summarizeToolCall: () => summarizeToolCall,
263
272
  taskOpsRejectedPrompt: () => taskOpsRejectedPrompt,
273
+ taskQuerySignature: () => taskQuerySignature,
264
274
  textHasTaskOps: () => textHasTaskOps,
275
+ textHasTaskQuery: () => textHasTaskQuery,
265
276
  tmuxSessionName: () => tmuxSessionName,
266
277
  tmuxSocketName: () => tmuxSocketName,
267
278
  tmuxWindowName: () => tmuxWindowName,
@@ -642,13 +653,1233 @@ var AUTO_COMMANDS = [
642
653
  "jq",
643
654
  "yq"
644
655
  ];
656
+ var UNIVERSAL_FLAGS = ["--help", "--version"];
657
+ var COUNT_FLAG = /^-\d+$/;
658
+ var FLAG_POLICY = {
659
+ ls: {
660
+ cluster: true,
661
+ booleans: [
662
+ "-1",
663
+ "-a",
664
+ "-A",
665
+ "-b",
666
+ "-c",
667
+ "-C",
668
+ "-d",
669
+ "-D",
670
+ "-f",
671
+ "-F",
672
+ "-g",
673
+ "-G",
674
+ "-h",
675
+ "-H",
676
+ "-i",
677
+ "-k",
678
+ "-l",
679
+ "-L",
680
+ "-m",
681
+ "-n",
682
+ "-N",
683
+ "-o",
684
+ "-p",
685
+ "-q",
686
+ "-Q",
687
+ "-r",
688
+ "-R",
689
+ "-s",
690
+ "-S",
691
+ "-t",
692
+ "-u",
693
+ "-U",
694
+ "-v",
695
+ "-x",
696
+ "-X",
697
+ "-Z",
698
+ "--all",
699
+ "--almost-all",
700
+ "--author",
701
+ "--escape",
702
+ "--classify",
703
+ "--file-type",
704
+ "--full-time",
705
+ "--no-group",
706
+ "--human-readable",
707
+ "--si",
708
+ "--dereference-command-line",
709
+ "--inode",
710
+ "--kibibytes",
711
+ "--dereference",
712
+ "--numeric-uid-gid",
713
+ "--literal",
714
+ "--hide-control-chars",
715
+ "--show-control-chars",
716
+ "--quote-name",
717
+ "--reverse",
718
+ "--recursive",
719
+ "--size",
720
+ "--directory",
721
+ "--group-directories-first",
722
+ "--context",
723
+ "--zero"
724
+ ],
725
+ values: [
726
+ "-w",
727
+ "-I",
728
+ "-T",
729
+ "--width",
730
+ "--ignore",
731
+ "--hide",
732
+ "--block-size",
733
+ "--color",
734
+ "--format",
735
+ "--sort",
736
+ "--time",
737
+ "--time-style",
738
+ "--indicator-style",
739
+ "--quoting-style",
740
+ "--tabsize"
741
+ ]
742
+ },
743
+ tree: {
744
+ cluster: true,
745
+ // `-o` is absent on purpose: it is tree's write-to-a-file flag, which the
746
+ // redirect check never sees because there is no redirect.
747
+ booleans: [
748
+ "-a",
749
+ "-d",
750
+ "-f",
751
+ "-i",
752
+ "-l",
753
+ "-x",
754
+ "-C",
755
+ "-n",
756
+ "-p",
757
+ "-s",
758
+ "-h",
759
+ "-u",
760
+ "-g",
761
+ "-D",
762
+ "-t",
763
+ "-c",
764
+ "-r",
765
+ "-F",
766
+ "-q",
767
+ "-N",
768
+ "-Q",
769
+ "-v",
770
+ "-J",
771
+ "-X",
772
+ "-Z",
773
+ "--noreport",
774
+ "--dirsfirst",
775
+ "--du",
776
+ "--prune",
777
+ "--matchdirs",
778
+ "--inodes",
779
+ "--device",
780
+ "--si",
781
+ "--gitignore",
782
+ "--ignore-case",
783
+ "--filesfirst",
784
+ "--nolinks",
785
+ "--hintro"
786
+ ],
787
+ values: [
788
+ "-L",
789
+ "-P",
790
+ "-I",
791
+ "-H",
792
+ "--filelimit",
793
+ "--timefmt",
794
+ "--charset",
795
+ "--sort",
796
+ "--gitfile",
797
+ "--info",
798
+ "--prefix"
799
+ ]
800
+ },
801
+ git: {
802
+ cluster: true,
803
+ // Absent on purpose, and each for a reason that was demonstrated rather than
804
+ // guessed at:
805
+ // -c, --config-env set configuration inline, and several configuration
806
+ // keys name a program to run (`core.pager`, `core.editor`,
807
+ // `diff.external`), so this is an execution flag with one
808
+ // indirection in front of it.
809
+ // --exec-path redirects where git finds its helper programs; a
810
+ // fabricated helper ran during an ordinary remote listing.
811
+ // -O, --open-files-in-pager runs an arbitrary named program over search hits.
812
+ // --upload-pack, --receive-pack name the program run on the other end.
813
+ // --textconv, --ext-diff run filters the repository itself configures.
814
+ // --git-dir, --work-tree repoint git at a tree whose configuration is not
815
+ // the one the developer is looking at.
816
+ // -o, --output write a file. `git ls-files -o` is a legitimate spelling
817
+ // of `--others`, but `git archive -o` writes, and the
818
+ // long spelling stays available for the read.
819
+ booleans: [
820
+ "-a",
821
+ "-b",
822
+ "-E",
823
+ "-F",
824
+ "-h",
825
+ "-H",
826
+ "-i",
827
+ "-I",
828
+ "-l",
829
+ "-p",
830
+ "-P",
831
+ "-q",
832
+ "-r",
833
+ "-R",
834
+ "-s",
835
+ "-t",
836
+ "-u",
837
+ "-v",
838
+ "-w",
839
+ "-z",
840
+ "--oneline",
841
+ "--graph",
842
+ "--stat",
843
+ "--shortstat",
844
+ "--numstat",
845
+ "--dirstat",
846
+ "--summary",
847
+ "--name-only",
848
+ "--name-status",
849
+ "--raw",
850
+ "--patch",
851
+ "--no-patch",
852
+ "--patch-with-stat",
853
+ "--cached",
854
+ "--staged",
855
+ "--porcelain",
856
+ "--short",
857
+ "--long",
858
+ "--branch",
859
+ "--show-stash",
860
+ "--show-current",
861
+ "--all",
862
+ "--tags",
863
+ "--heads",
864
+ "--remotes",
865
+ "--list",
866
+ "--verbose",
867
+ "--quiet",
868
+ "--abbrev-ref",
869
+ "--count",
870
+ "--reverse",
871
+ "--first-parent",
872
+ "--no-merges",
873
+ "--merges",
874
+ "--follow",
875
+ "--full-history",
876
+ "--simplify-merges",
877
+ "--sparse",
878
+ "--dense",
879
+ "--ancestry-path",
880
+ "--left-right",
881
+ "--cherry",
882
+ "--cherry-pick",
883
+ "--cherry-mark",
884
+ "--boundary",
885
+ "--topo-order",
886
+ "--date-order",
887
+ "--author-date-order",
888
+ "--relative-date",
889
+ "--parents",
890
+ "--children",
891
+ "--timestamp",
892
+ "--no-walk",
893
+ "--do-walk",
894
+ "--decorate",
895
+ "--no-decorate",
896
+ "--abbrev-commit",
897
+ "--no-abbrev-commit",
898
+ "--abbrev",
899
+ "--no-abbrev",
900
+ "--color",
901
+ "--no-color",
902
+ "--pretty",
903
+ "--format",
904
+ "--expand-tabs",
905
+ "--no-expand-tabs",
906
+ "--numbered",
907
+ "--email",
908
+ "--no-notes",
909
+ "--notes",
910
+ "--show-signature",
911
+ "--find-renames",
912
+ "--find-copies",
913
+ "--find-copies-harder",
914
+ "--break-rewrites",
915
+ "--no-renames",
916
+ "--irreversible-delete",
917
+ "--full-index",
918
+ "--binary",
919
+ "--text",
920
+ "--check",
921
+ "--exit-code",
922
+ "--word-diff",
923
+ "--function-context",
924
+ "--ignore-all-space",
925
+ "--ignore-space-change",
926
+ "--ignore-space-at-eol",
927
+ "--ignore-blank-lines",
928
+ "--ignore-cr-at-eol",
929
+ "--no-prefix",
930
+ "--default-prefix",
931
+ "--no-textconv",
932
+ "--no-ext-diff",
933
+ "--submodule",
934
+ "--ignore-submodules",
935
+ "--recurse-submodules",
936
+ "--no-recurse-submodules",
937
+ "--others",
938
+ "--deleted",
939
+ "--modified",
940
+ "--unmerged",
941
+ "--stage",
942
+ "--killed",
943
+ "--ignored",
944
+ "--exclude-standard",
945
+ "--error-unmatch",
946
+ "--full-name",
947
+ "--directory",
948
+ "--no-empty-directory",
949
+ "--untracked-files",
950
+ "--no-optional-locks",
951
+ "--no-pager",
952
+ "--paginate",
953
+ "--bare",
954
+ "--literal-pathspecs",
955
+ "--glob-pathspecs",
956
+ "--noglob-pathspecs",
957
+ "--icase-pathspecs",
958
+ "--line-number",
959
+ "--column",
960
+ "--heading",
961
+ "--no-index",
962
+ "--untracked",
963
+ "--no-recursive",
964
+ "--name-rev",
965
+ "--root",
966
+ "--dirty",
967
+ "--broken",
968
+ "--always",
969
+ "--merged",
970
+ "--no-merged",
971
+ "--show-toplevel",
972
+ "--show-cdup",
973
+ "--git-common-dir",
974
+ "--absolute-git-dir",
975
+ "--is-inside-work-tree",
976
+ "--is-inside-git-dir",
977
+ "--is-bare-repository",
978
+ "--symbolic",
979
+ "--symbolic-full-name",
980
+ "--verify",
981
+ "--short-hash",
982
+ "--textual",
983
+ "--batch",
984
+ "--batch-check",
985
+ "--batch-all-objects",
986
+ "--buffer",
987
+ "--follow-symlinks",
988
+ "--use-mailmap"
989
+ ],
990
+ values: [
991
+ "-n",
992
+ "-C",
993
+ "-B",
994
+ "-M",
995
+ "-L",
996
+ "-S",
997
+ "-G",
998
+ "-U",
999
+ "-e",
1000
+ "-m",
1001
+ "-f",
1002
+ "-g",
1003
+ "-x",
1004
+ "-X",
1005
+ "-W",
1006
+ "--max-count",
1007
+ "--skip",
1008
+ "--since",
1009
+ "--after",
1010
+ "--until",
1011
+ "--before",
1012
+ "--author",
1013
+ "--grep",
1014
+ "--grep-reflog",
1015
+ "--committer",
1016
+ "--date",
1017
+ "--diff-filter",
1018
+ "--unified",
1019
+ "--stat-width",
1020
+ "--stat-name-width",
1021
+ "--stat-count",
1022
+ "--word-diff-regex",
1023
+ "--line-prefix",
1024
+ "--src-prefix",
1025
+ "--dst-prefix",
1026
+ "--pickaxe-regex",
1027
+ "--anchored",
1028
+ "--color-moved",
1029
+ "--color-words",
1030
+ "--inter-hunk-context",
1031
+ "--max-parents",
1032
+ "--min-parents",
1033
+ "--simplify-by-decoration",
1034
+ "--glob",
1035
+ "--exclude",
1036
+ "--sort",
1037
+ "--contains",
1038
+ "--points-at",
1039
+ "--decorate-refs",
1040
+ "--decorate-refs-exclude",
1041
+ "--encoding",
1042
+ "--context",
1043
+ "--max-depth",
1044
+ // `--filters` is absent from this list on purpose: it runs the clean and
1045
+ // smudge filters the repository under research configures, which is the
1046
+ // same execution vector as the configuration flag above with a different
1047
+ // spelling.
1048
+ "--and",
1049
+ "--or",
1050
+ "--not",
1051
+ "--all-match",
1052
+ "--threads",
1053
+ "--path"
1054
+ ],
1055
+ patterns: [COUNT_FLAG]
1056
+ },
1057
+ wc: {
1058
+ cluster: true,
1059
+ booleans: [
1060
+ "-c",
1061
+ "-l",
1062
+ "-m",
1063
+ "-w",
1064
+ "-L",
1065
+ "--bytes",
1066
+ "--chars",
1067
+ "--lines",
1068
+ "--words",
1069
+ "--max-line-length",
1070
+ "--total"
1071
+ ]
1072
+ },
1073
+ du: {
1074
+ cluster: true,
1075
+ booleans: [
1076
+ "-0",
1077
+ "-a",
1078
+ "-b",
1079
+ "-c",
1080
+ "-D",
1081
+ "-h",
1082
+ "-H",
1083
+ "-k",
1084
+ "-l",
1085
+ "-L",
1086
+ "-m",
1087
+ "-P",
1088
+ "-s",
1089
+ "-S",
1090
+ "-x",
1091
+ "--all",
1092
+ "--apparent-size",
1093
+ "--bytes",
1094
+ "--total",
1095
+ "--dereference-args",
1096
+ "--human-readable",
1097
+ "--si",
1098
+ "--inodes",
1099
+ "--count-links",
1100
+ "--dereference",
1101
+ "--no-dereference",
1102
+ "--separate-dirs",
1103
+ "--summarize",
1104
+ "--one-file-system",
1105
+ "--null"
1106
+ ],
1107
+ values: [
1108
+ "-d",
1109
+ "-t",
1110
+ "-B",
1111
+ "-X",
1112
+ "--max-depth",
1113
+ "--threshold",
1114
+ "--block-size",
1115
+ "--exclude",
1116
+ "--exclude-from",
1117
+ "--time",
1118
+ "--time-style",
1119
+ "--files0-from"
1120
+ ]
1121
+ },
1122
+ df: {
1123
+ cluster: true,
1124
+ booleans: [
1125
+ "-a",
1126
+ "-h",
1127
+ "-H",
1128
+ "-i",
1129
+ "-k",
1130
+ "-l",
1131
+ "-m",
1132
+ "-P",
1133
+ "-T",
1134
+ "-v",
1135
+ "--all",
1136
+ "--human-readable",
1137
+ "--si",
1138
+ "--inodes",
1139
+ "--local",
1140
+ "--portability",
1141
+ "--print-type",
1142
+ "--total",
1143
+ "--sync",
1144
+ "--no-sync"
1145
+ ],
1146
+ values: ["-B", "-t", "-x", "--block-size", "--type", "--exclude-type"]
1147
+ },
1148
+ file: {
1149
+ cluster: true,
1150
+ // `-C`/`--compile` is absent: it writes a compiled magic file.
1151
+ booleans: [
1152
+ "-b",
1153
+ "-c",
1154
+ "-h",
1155
+ "-i",
1156
+ "-k",
1157
+ "-L",
1158
+ "-n",
1159
+ "-N",
1160
+ "-p",
1161
+ "-r",
1162
+ "-s",
1163
+ "-S",
1164
+ "-v",
1165
+ "-z",
1166
+ "-0",
1167
+ "--brief",
1168
+ "--mime",
1169
+ "--mime-type",
1170
+ "--mime-encoding",
1171
+ "--dereference",
1172
+ "--no-dereference",
1173
+ "--keep-going",
1174
+ "--no-buffer",
1175
+ "--no-pad",
1176
+ "--raw",
1177
+ "--special-files",
1178
+ "--uncompress",
1179
+ "--print0",
1180
+ "--checking-printout"
1181
+ ],
1182
+ values: [
1183
+ "-e",
1184
+ "-m",
1185
+ "-P",
1186
+ "-F",
1187
+ "--exclude",
1188
+ "--magic-file",
1189
+ "--separator",
1190
+ "--parameter",
1191
+ "--extension",
1192
+ "--files-from"
1193
+ ]
1194
+ },
1195
+ head: {
1196
+ cluster: true,
1197
+ booleans: ["-q", "-v", "-z", "--quiet", "--silent", "--verbose", "--zero-terminated"],
1198
+ values: ["-c", "-n", "--bytes", "--lines"],
1199
+ patterns: [COUNT_FLAG]
1200
+ },
1201
+ tail: {
1202
+ cluster: true,
1203
+ booleans: [
1204
+ "-f",
1205
+ "-F",
1206
+ "-q",
1207
+ "-v",
1208
+ "-z",
1209
+ "--follow",
1210
+ "--retry",
1211
+ "--quiet",
1212
+ "--silent",
1213
+ "--verbose",
1214
+ "--zero-terminated"
1215
+ ],
1216
+ values: [
1217
+ "-c",
1218
+ "-n",
1219
+ "-s",
1220
+ "--bytes",
1221
+ "--lines",
1222
+ "--sleep-interval",
1223
+ "--pid",
1224
+ "--max-unchanged-stats"
1225
+ ],
1226
+ patterns: [COUNT_FLAG]
1227
+ },
1228
+ cat: {
1229
+ cluster: true,
1230
+ booleans: [
1231
+ "-A",
1232
+ "-b",
1233
+ "-e",
1234
+ "-E",
1235
+ "-n",
1236
+ "-s",
1237
+ "-t",
1238
+ "-T",
1239
+ "-u",
1240
+ "-v",
1241
+ "--show-all",
1242
+ "--number-nonblank",
1243
+ "--show-ends",
1244
+ "--number",
1245
+ "--squeeze-blank",
1246
+ "--show-tabs",
1247
+ "--show-nonprinting"
1248
+ ]
1249
+ },
1250
+ sort: {
1251
+ cluster: true,
1252
+ // Absent on purpose: `-o`/`--output` writes a file with no redirect for the
1253
+ // redirect check to see, and `--compress-program` names a program that sort
1254
+ // runs over its own temporary files — a permitted binary as an interpreter.
1255
+ booleans: [
1256
+ "-b",
1257
+ "-c",
1258
+ "-C",
1259
+ "-d",
1260
+ "-f",
1261
+ "-g",
1262
+ "-h",
1263
+ "-i",
1264
+ "-M",
1265
+ "-n",
1266
+ "-r",
1267
+ "-R",
1268
+ "-s",
1269
+ "-u",
1270
+ "-V",
1271
+ "-z",
1272
+ "--ignore-leading-blanks",
1273
+ "--check",
1274
+ "--dictionary-order",
1275
+ "--ignore-case",
1276
+ "--general-numeric-sort",
1277
+ "--human-numeric-sort",
1278
+ "--ignore-nonprinting",
1279
+ "--month-sort",
1280
+ "--numeric-sort",
1281
+ "--random-sort",
1282
+ "--reverse",
1283
+ "--stable",
1284
+ "--unique",
1285
+ "--version-sort",
1286
+ "--zero-terminated",
1287
+ "--debug"
1288
+ ],
1289
+ values: [
1290
+ "-k",
1291
+ "-t",
1292
+ "-S",
1293
+ "-T",
1294
+ "--key",
1295
+ "--field-separator",
1296
+ "--buffer-size",
1297
+ "--temporary-directory",
1298
+ "--random-source",
1299
+ "--batch-size",
1300
+ "--parallel",
1301
+ "--sort"
1302
+ ]
1303
+ },
1304
+ uniq: {
1305
+ cluster: true,
1306
+ booleans: [
1307
+ "-c",
1308
+ "-d",
1309
+ "-D",
1310
+ "-i",
1311
+ "-u",
1312
+ "-z",
1313
+ "--count",
1314
+ "--repeated",
1315
+ "--all-repeated",
1316
+ "--ignore-case",
1317
+ "--unique",
1318
+ "--zero-terminated"
1319
+ ],
1320
+ values: ["-f", "-s", "-w", "--skip-fields", "--skip-chars", "--check-chars", "--group"]
1321
+ },
1322
+ // No flag on either runs a program or writes a file, and the planner's
1323
+ // commonest use of `echo` is a section marker — `echo --- files changed ---`
1324
+ // is three tokens that all begin with a dash and none of them is a flag.
1325
+ echo: { freeform: true },
1326
+ printf: { freeform: true },
1327
+ date: {
1328
+ cluster: true,
1329
+ // `-s`/`--set` is absent: it sets the system clock.
1330
+ booleans: [
1331
+ "-u",
1332
+ "-R",
1333
+ "-I",
1334
+ "--utc",
1335
+ "--universal",
1336
+ "--rfc-email",
1337
+ "--rfc-3339",
1338
+ "--iso-8601",
1339
+ "--debug"
1340
+ ],
1341
+ values: ["-d", "-f", "-r", "--date", "--file", "--reference"]
1342
+ },
1343
+ stat: {
1344
+ cluster: true,
1345
+ booleans: ["-f", "-L", "-t", "--file-system", "--terse", "--dereference"],
1346
+ values: ["-c", "--format", "--printf", "--cached"]
1347
+ },
1348
+ basename: {
1349
+ cluster: true,
1350
+ booleans: ["-a", "-z", "--multiple", "--zero"],
1351
+ values: ["-s", "--suffix"]
1352
+ },
1353
+ dirname: {
1354
+ cluster: true,
1355
+ booleans: ["-z", "--zero"]
1356
+ },
1357
+ realpath: {
1358
+ cluster: true,
1359
+ booleans: [
1360
+ "-e",
1361
+ "-m",
1362
+ "-L",
1363
+ "-P",
1364
+ "-q",
1365
+ "-s",
1366
+ "-z",
1367
+ "--canonicalize-existing",
1368
+ "--canonicalize-missing",
1369
+ "--logical",
1370
+ "--physical",
1371
+ "--quiet",
1372
+ "--strip",
1373
+ "--no-symlinks",
1374
+ "--zero"
1375
+ ],
1376
+ values: ["--relative-to", "--relative-base"]
1377
+ },
1378
+ pwd: { cluster: true, booleans: ["-L", "-P", "--logical", "--physical"] },
1379
+ which: { cluster: true, booleans: ["-a", "-s", "--all"] },
1380
+ type: { cluster: true, booleans: ["-a", "-f", "-p", "-P", "-t"] },
1381
+ uname: {
1382
+ cluster: true,
1383
+ booleans: [
1384
+ "-a",
1385
+ "-i",
1386
+ "-m",
1387
+ "-n",
1388
+ "-o",
1389
+ "-p",
1390
+ "-r",
1391
+ "-s",
1392
+ "-v",
1393
+ "--all",
1394
+ "--kernel-name",
1395
+ "--nodename",
1396
+ "--kernel-release",
1397
+ "--kernel-version",
1398
+ "--machine",
1399
+ "--processor",
1400
+ "--hardware-platform",
1401
+ "--operating-system"
1402
+ ]
1403
+ },
1404
+ whoami: {},
1405
+ cut: {
1406
+ cluster: true,
1407
+ // `--output-delimiter` names a separator string, not a file.
1408
+ booleans: ["-n", "-s", "-z", "--only-delimited", "--complement", "--zero-terminated"],
1409
+ values: [
1410
+ "-b",
1411
+ "-c",
1412
+ "-d",
1413
+ "-f",
1414
+ "--bytes",
1415
+ "--characters",
1416
+ "--delimiter",
1417
+ "--fields",
1418
+ "--output-delimiter"
1419
+ ]
1420
+ },
1421
+ nl: {
1422
+ cluster: true,
1423
+ booleans: ["-p", "--no-renumber"],
1424
+ values: [
1425
+ "-b",
1426
+ "-d",
1427
+ "-f",
1428
+ "-h",
1429
+ "-i",
1430
+ "-l",
1431
+ "-n",
1432
+ "-s",
1433
+ "-v",
1434
+ "-w",
1435
+ "--body-numbering",
1436
+ "--section-delimiter",
1437
+ "--footer-numbering",
1438
+ "--header-numbering",
1439
+ "--join-blank-lines",
1440
+ "--line-increment",
1441
+ "--number-format",
1442
+ "--number-separator",
1443
+ "--starting-line-number",
1444
+ "--number-width"
1445
+ ]
1446
+ },
1447
+ rg: {
1448
+ cluster: true,
1449
+ // `--pre` runs a named preprocessor over every file searched and
1450
+ // `--hostname-bin` runs a named program to resolve the hostname; both are
1451
+ // absent, and `--pre-glob` with them.
1452
+ booleans: [
1453
+ "-a",
1454
+ "-c",
1455
+ "-F",
1456
+ "-h",
1457
+ "-H",
1458
+ "-i",
1459
+ "-I",
1460
+ "-l",
1461
+ "-L",
1462
+ "-n",
1463
+ "-N",
1464
+ "-o",
1465
+ "-p",
1466
+ "-P",
1467
+ "-q",
1468
+ "-s",
1469
+ "-S",
1470
+ "-u",
1471
+ "-uu",
1472
+ "-uuu",
1473
+ "-U",
1474
+ "-v",
1475
+ "-V",
1476
+ "-w",
1477
+ "-x",
1478
+ "-z",
1479
+ "--text",
1480
+ "--count",
1481
+ "--count-matches",
1482
+ "--fixed-strings",
1483
+ "--files",
1484
+ "--files-with-matches",
1485
+ "--files-without-match",
1486
+ "--follow",
1487
+ "--hidden",
1488
+ "--no-hidden",
1489
+ "--ignore-case",
1490
+ "--case-sensitive",
1491
+ "--smart-case",
1492
+ "--invert-match",
1493
+ "--line-number",
1494
+ "--no-line-number",
1495
+ "--with-filename",
1496
+ "--no-filename",
1497
+ "--heading",
1498
+ "--no-heading",
1499
+ "--column",
1500
+ "--no-column",
1501
+ "--byte-offset",
1502
+ "--vimgrep",
1503
+ "--json",
1504
+ "--stats",
1505
+ "--trim",
1506
+ "--pretty",
1507
+ "--passthru",
1508
+ "--word-regexp",
1509
+ "--line-regexp",
1510
+ "--multiline",
1511
+ "--multiline-dotall",
1512
+ "--no-multiline",
1513
+ "--crlf",
1514
+ "--null",
1515
+ "--null-data",
1516
+ "--binary",
1517
+ "--search-zip",
1518
+ "--no-ignore",
1519
+ "--no-ignore-dot",
1520
+ "--no-ignore-exclude",
1521
+ "--no-ignore-files",
1522
+ "--no-ignore-global",
1523
+ "--no-ignore-parent",
1524
+ "--no-ignore-vcs",
1525
+ "--no-ignore-messages",
1526
+ "--no-messages",
1527
+ "--no-require-git",
1528
+ "--require-git",
1529
+ "--one-file-system",
1530
+ "--pcre2",
1531
+ "--no-pcre2",
1532
+ "--auto-hybrid-regex",
1533
+ "--line-buffered",
1534
+ "--block-buffered",
1535
+ "--unicode",
1536
+ "--no-unicode",
1537
+ "--unrestricted",
1538
+ "--type-list",
1539
+ "--debug",
1540
+ "--trace",
1541
+ "--include-zero",
1542
+ "--no-context-separator",
1543
+ "--stop-on-nonmatch",
1544
+ "--quiet",
1545
+ "--no-binary"
1546
+ ],
1547
+ values: [
1548
+ "-A",
1549
+ "-B",
1550
+ "-C",
1551
+ "-d",
1552
+ "-e",
1553
+ "-E",
1554
+ "-f",
1555
+ "-g",
1556
+ "-j",
1557
+ "-m",
1558
+ "-M",
1559
+ "-r",
1560
+ "-t",
1561
+ "-T",
1562
+ "--after-context",
1563
+ "--before-context",
1564
+ "--context",
1565
+ "--context-separator",
1566
+ "--field-context-separator",
1567
+ "--field-match-separator",
1568
+ "--regexp",
1569
+ "--file",
1570
+ "--glob",
1571
+ "--iglob",
1572
+ "--glob-case-insensitive",
1573
+ "--type",
1574
+ "--type-not",
1575
+ "--type-add",
1576
+ "--type-clear",
1577
+ "--max-count",
1578
+ "--max-columns",
1579
+ "--max-depth",
1580
+ "--maxdepth",
1581
+ "--max-filesize",
1582
+ "--replace",
1583
+ "--threads",
1584
+ "--encoding",
1585
+ "--engine",
1586
+ "--sort",
1587
+ "--sortr",
1588
+ "--color",
1589
+ "--colors",
1590
+ "--path-separator",
1591
+ "--ignore-file",
1592
+ "--dfa-size-limit",
1593
+ "--regex-size-limit",
1594
+ "--binary-files",
1595
+ "--max-columns-preview"
1596
+ ]
1597
+ },
1598
+ grep: {
1599
+ cluster: true,
1600
+ // `-o` here is `--only-matching`, which writes nothing. It is the same two
1601
+ // characters as `sort -o`, which writes a file — the reason these sets are
1602
+ // per-binary rather than one shared list.
1603
+ booleans: [
1604
+ "-a",
1605
+ "-b",
1606
+ "-c",
1607
+ "-E",
1608
+ "-F",
1609
+ "-G",
1610
+ "-H",
1611
+ "-h",
1612
+ "-i",
1613
+ "-I",
1614
+ "-l",
1615
+ "-L",
1616
+ "-n",
1617
+ "-o",
1618
+ "-P",
1619
+ "-q",
1620
+ "-r",
1621
+ "-R",
1622
+ "-s",
1623
+ "-T",
1624
+ "-U",
1625
+ "-v",
1626
+ "-w",
1627
+ "-x",
1628
+ "-y",
1629
+ "-z",
1630
+ "-Z",
1631
+ "--extended-regexp",
1632
+ "--fixed-strings",
1633
+ "--basic-regexp",
1634
+ "--perl-regexp",
1635
+ "--ignore-case",
1636
+ "--no-ignore-case",
1637
+ "--invert-match",
1638
+ "--word-regexp",
1639
+ "--line-regexp",
1640
+ "--count",
1641
+ "--files-with-matches",
1642
+ "--files-without-match",
1643
+ "--line-number",
1644
+ "--with-filename",
1645
+ "--no-filename",
1646
+ "--only-matching",
1647
+ "--quiet",
1648
+ "--silent",
1649
+ "--no-messages",
1650
+ "--text",
1651
+ "--binary",
1652
+ "--line-buffered",
1653
+ "--null",
1654
+ "--null-data",
1655
+ "--byte-offset",
1656
+ "--recursive",
1657
+ "--dereference-recursive",
1658
+ "--initial-tab",
1659
+ "--unix-byte-offsets",
1660
+ "--no-group-separator",
1661
+ "--color",
1662
+ "--colour"
1663
+ ],
1664
+ values: [
1665
+ "-A",
1666
+ "-B",
1667
+ "-C",
1668
+ "-d",
1669
+ "-D",
1670
+ "-e",
1671
+ "-f",
1672
+ "-m",
1673
+ "--after-context",
1674
+ "--before-context",
1675
+ "--context",
1676
+ "--regexp",
1677
+ "--file",
1678
+ "--max-count",
1679
+ "--devices",
1680
+ "--directories",
1681
+ "--binary-files",
1682
+ "--include",
1683
+ "--exclude",
1684
+ "--exclude-dir",
1685
+ "--exclude-from",
1686
+ "--label",
1687
+ "--group-separator"
1688
+ ]
1689
+ },
1690
+ find: {
1691
+ // No clustering: find spells its flags as single-dash words, so walking the
1692
+ // characters of `-name` would read it as five short flags. And its arguments
1693
+ // are an expression, so `--` does not end them.
1694
+ expressionArgs: true,
1695
+ // Absent on purpose: `-exec`, `-execdir`, `-ok` and `-okdir` run an inner
1696
+ // command (also caught by the named guard below, which says so more
1697
+ // precisely), `-delete` removes files, and `-fprint`, `-fprint0`, `-fprintf`
1698
+ // and `-fls` are find's write family — the same write as `> file` with no
1699
+ // redirect for the redirect check to see.
1700
+ booleans: [
1701
+ "-H",
1702
+ "-L",
1703
+ "-P",
1704
+ "-a",
1705
+ "-and",
1706
+ "-o",
1707
+ "-or",
1708
+ "-not",
1709
+ "-d",
1710
+ "-depth",
1711
+ "-daystart",
1712
+ "-empty",
1713
+ "-executable",
1714
+ "-false",
1715
+ "-follow",
1716
+ "-help",
1717
+ "--help",
1718
+ "-ignore_readdir_race",
1719
+ "-noignore_readdir_race",
1720
+ "-ls",
1721
+ "-mount",
1722
+ "-nogroup",
1723
+ "-noleaf",
1724
+ "-nouser",
1725
+ "-print",
1726
+ "-print0",
1727
+ "-prune",
1728
+ "-quit",
1729
+ "-readable",
1730
+ "-true",
1731
+ "-version",
1732
+ "-writable",
1733
+ "-xdev"
1734
+ ],
1735
+ values: [
1736
+ "-maxdepth",
1737
+ "-mindepth",
1738
+ "-name",
1739
+ "-iname",
1740
+ "-path",
1741
+ "-ipath",
1742
+ "-wholename",
1743
+ "-iwholename",
1744
+ "-regex",
1745
+ "-iregex",
1746
+ "-lname",
1747
+ "-ilname",
1748
+ "-type",
1749
+ "-xtype",
1750
+ "-size",
1751
+ "-newer",
1752
+ "-anewer",
1753
+ "-cnewer",
1754
+ "-mtime",
1755
+ "-mmin",
1756
+ "-ctime",
1757
+ "-cmin",
1758
+ "-atime",
1759
+ "-amin",
1760
+ "-used",
1761
+ "-uid",
1762
+ "-gid",
1763
+ "-user",
1764
+ "-group",
1765
+ "-perm",
1766
+ "-links",
1767
+ "-inum",
1768
+ "-samefile",
1769
+ "-fstype",
1770
+ "-regextype",
1771
+ "-files0-from",
1772
+ "-printf",
1773
+ "-context",
1774
+ "-D"
1775
+ ],
1776
+ // `find -O2` glues the optimisation level on, and `-newermt`/`-newerat` and
1777
+ // friends spell the two comparison fields into the flag name.
1778
+ patterns: [/^-O\d$/, /^-newer[acmBt][acmBt]$/]
1779
+ },
1780
+ jq: {
1781
+ cluster: true,
1782
+ booleans: [
1783
+ "-a",
1784
+ "-c",
1785
+ "-C",
1786
+ "-e",
1787
+ "-h",
1788
+ "-j",
1789
+ "-M",
1790
+ "-n",
1791
+ "-r",
1792
+ "-R",
1793
+ "-s",
1794
+ "-S",
1795
+ "--ascii-output",
1796
+ "--compact-output",
1797
+ "--color-output",
1798
+ "--monochrome-output",
1799
+ "--exit-status",
1800
+ "--join-output",
1801
+ "--null-input",
1802
+ "--raw-input",
1803
+ "--raw-output",
1804
+ "--raw-output0",
1805
+ "--slurp",
1806
+ "--sort-keys",
1807
+ "--seq",
1808
+ "--stream",
1809
+ "--stream-errors",
1810
+ "--tab",
1811
+ "--unbuffered",
1812
+ "--args",
1813
+ "--jsonargs"
1814
+ ],
1815
+ values: [
1816
+ "-f",
1817
+ "-L",
1818
+ "--arg",
1819
+ "--argjson",
1820
+ "--slurpfile",
1821
+ "--rawfile",
1822
+ "--indent",
1823
+ "--from-file"
1824
+ ]
1825
+ },
1826
+ yq: {
1827
+ cluster: true,
1828
+ // `-i`/`--inplace`/`--in-place` rewrite the file, and `-s`/`--split-exp`
1829
+ // writes one file per document. All absent — and all bare or near-bare
1830
+ // booleans, which is why exempting booleans was not an option.
1831
+ booleans: [
1832
+ "-C",
1833
+ "-e",
1834
+ "-h",
1835
+ "-j",
1836
+ "-M",
1837
+ "-n",
1838
+ "-N",
1839
+ "-P",
1840
+ "-r",
1841
+ "-S",
1842
+ "-v",
1843
+ "-y",
1844
+ "-Y",
1845
+ "--colors",
1846
+ "--no-colors",
1847
+ "--exit-status",
1848
+ "--no-doc",
1849
+ "--null-input",
1850
+ "--prettyPrint",
1851
+ "--raw-output",
1852
+ "--sort-keys",
1853
+ "--tojson",
1854
+ "--yaml-output",
1855
+ "--yaml-roundtrip",
1856
+ "--unwrapScalar",
1857
+ "--header-preprocess",
1858
+ "--no-exit-status",
1859
+ "--verbose",
1860
+ "--xml-strict-mode"
1861
+ ],
1862
+ values: [
1863
+ "-I",
1864
+ "-o",
1865
+ "-p",
1866
+ "-L",
1867
+ "--indent",
1868
+ "--output-format",
1869
+ "--input-format",
1870
+ "--expression",
1871
+ "--width",
1872
+ "--xml-attribute-prefix",
1873
+ "--xml-content-name"
1874
+ ]
1875
+ }
1876
+ };
645
1877
  var GIT_READONLY_SUBCOMMANDS = [
646
1878
  "log",
647
1879
  "status",
648
1880
  "diff",
649
1881
  "show",
650
1882
  "ls-files",
651
- "ls-remote",
652
1883
  "ls-tree",
653
1884
  "branch",
654
1885
  "tag",
@@ -763,6 +1994,26 @@ var REFUSED_SUBCOMMANDS = {
763
1994
  az: ["login", "logout"],
764
1995
  terraform: ["apply", "destroy"]
765
1996
  };
1997
+ var SHELL_KEYWORDS = [
1998
+ "{",
1999
+ "}",
2000
+ "if",
2001
+ "then",
2002
+ "elif",
2003
+ "else",
2004
+ "fi",
2005
+ "for",
2006
+ "while",
2007
+ "until",
2008
+ "do",
2009
+ "done",
2010
+ "case",
2011
+ "esac",
2012
+ "select",
2013
+ "function",
2014
+ "time",
2015
+ "export"
2016
+ ];
766
2017
  var INTERPRETERS = [
767
2018
  "sh",
768
2019
  "bash",
@@ -1206,16 +2457,79 @@ function pathLikeArgs(command, opts = {}) {
1206
2457
  return !a.startsWith("-") && looksLikePath(a) ? [a] : [];
1207
2458
  }));
1208
2459
  }
2460
+ function matchFlag(spec, booleans, values, token) {
2461
+ if (token.startsWith("--")) {
2462
+ const eq = token.indexOf("=");
2463
+ const name = eq > 0 ? token.slice(0, eq) : token;
2464
+ if (booleans.has(name)) return "boolean";
2465
+ if (values.has(name)) return eq > 0 ? "boolean" : "value";
2466
+ return void 0;
2467
+ }
2468
+ if (booleans.has(token)) return "boolean";
2469
+ if (values.has(token)) return "value";
2470
+ if (spec.patterns?.some((p) => p.test(token))) return "boolean";
2471
+ if (!spec.cluster) return void 0;
2472
+ for (let i = 1; i < token.length; i++) {
2473
+ const short = `-${token[i]}`;
2474
+ if (booleans.has(short)) continue;
2475
+ if (values.has(short)) return i === token.length - 1 ? "value" : "boolean";
2476
+ return void 0;
2477
+ }
2478
+ return "boolean";
2479
+ }
2480
+ function takesNextToken(next) {
2481
+ if (next === void 0) return false;
2482
+ return !/^-./.test(next) || /^-\d/.test(next);
2483
+ }
2484
+ function flagLabel(token) {
2485
+ const eq = token.startsWith("--") ? token.indexOf("=") : -1;
2486
+ return eq > 0 ? token.slice(0, eq) : token;
2487
+ }
2488
+ function scanFlags(seg) {
2489
+ const spec = FLAG_POLICY[seg.binary];
2490
+ if (!spec || spec.freeform) return void 0;
2491
+ const booleans = /* @__PURE__ */ new Set([...spec.booleans ?? [], ...UNIVERSAL_FLAGS]);
2492
+ const values = new Set(spec.values ?? []);
2493
+ const operands = [];
2494
+ let i = 0;
2495
+ while (i < seg.args.length) {
2496
+ const token = seg.args[i];
2497
+ if (token === "--" && !spec.expressionArgs) {
2498
+ operands.push(...seg.args.slice(i + 1));
2499
+ break;
2500
+ }
2501
+ if (!/^-./.test(token)) {
2502
+ operands.push(token);
2503
+ i++;
2504
+ continue;
2505
+ }
2506
+ const shape = matchFlag(spec, booleans, values, token);
2507
+ if (shape === void 0) return { unknown: flagLabel(token), operands };
2508
+ i += shape === "value" && takesNextToken(seg.args[i + 1]) ? 2 : 1;
2509
+ }
2510
+ return { operands };
2511
+ }
2512
+ function subcommandOf(seg) {
2513
+ const scan = scanFlags(seg);
2514
+ if (scan) return scan.operands[0];
2515
+ return seg.args.find((a) => !a.startsWith("-"));
2516
+ }
2517
+ var SCOPE_LEAD_ARGS = 2;
1209
2518
  function scopeFor(seg) {
1210
2519
  if (!MULTIPLEXERS.includes(seg.binary)) return seg.binary;
1211
- const sub = seg.args.find((a) => !a.startsWith("-"));
1212
- return sub ? `${seg.binary} ${sub}` : seg.binary;
2520
+ const lead = [];
2521
+ for (const arg of seg.args) {
2522
+ if (arg.startsWith("-")) break;
2523
+ lead.push(arg);
2524
+ if (lead.length === SCOPE_LEAD_ARGS) break;
2525
+ }
2526
+ return [seg.binary, ...lead].join(" ");
1213
2527
  }
1214
2528
  function isAuto(seg) {
1215
2529
  if (!AUTO_COMMANDS.includes(seg.binary)) return false;
1216
2530
  if (seg.expandable) return false;
1217
2531
  if (seg.binary !== "git") return true;
1218
- const sub = seg.args.find((a) => !a.startsWith("-"));
2532
+ const sub = subcommandOf(seg);
1219
2533
  return sub !== void 0 && GIT_READONLY_SUBCOMMANDS.includes(sub);
1220
2534
  }
1221
2535
  function refusalFor(seg) {
@@ -1225,29 +2539,31 @@ function refusalFor(seg) {
1225
2539
  if (seg.binary === "eval" || seg.binary === "exec") {
1226
2540
  return `"${seg.binary}" runs a string as a command, which this classifier cannot inspect. Use the read-only research tools, or describe it as a task.`;
1227
2541
  }
2542
+ if (seg.binary === "source" || seg.binary === ".") {
2543
+ return `"${seg.binary}" runs a file's contents as commands, which this classifier cannot inspect. Use the read-only research tools, or describe it as a task.`;
2544
+ }
2545
+ if (SHELL_KEYWORDS.includes(seg.binary)) {
2546
+ return `"${seg.binary}" is a shell keyword or compound-command opener. This classifier only inspects the command that runs first, and a keyword hides the real one from it. Run the inner command directly, or describe it as a task.`;
2547
+ }
1228
2548
  const subs = REFUSED_SUBCOMMANDS[seg.binary];
1229
2549
  if (subs) {
1230
- const sub = seg.args.find((a) => !a.startsWith("-"));
2550
+ const sub = subcommandOf(seg);
1231
2551
  if (sub && subs.includes(sub)) {
1232
2552
  return `"${seg.binary} ${sub}" changes state or reaches outward. You are a read-only planner \u2014 put it in the plan and let the runner do it.`;
1233
2553
  }
1234
2554
  }
1235
2555
  if (seg.binary === "git") {
1236
- const sub = seg.args.find((a) => !a.startsWith("-"));
2556
+ const sub = subcommandOf(seg);
1237
2557
  if (sub === "branch" || sub === "tag") {
1238
2558
  const flag = seg.args.find((a) => ["-D", "-d", "-m", "-M", "--delete", "--move"].includes(a));
1239
2559
  if (flag) {
1240
2560
  return `"git ${sub} ${flag}" mutates refs. You are a read-only planner \u2014 describe the change as a task instead.`;
1241
2561
  }
1242
- }
1243
- const execPathFlag = seg.args.find((a) => a === "--exec-path" || a.startsWith("--exec-path="));
1244
- if (execPathFlag) {
1245
- return `"git ${execPathFlag}" redirects where git looks for its helper programs, and can make it run an arbitrary one. Use the read-only research tools, or describe the change as a task.`;
1246
- }
1247
- if (sub === "grep") {
1248
- const pagerFlag = seg.args.find((a) => a === "-O" || a === "--open-files-in-pager" || a.startsWith("--open-files-in-pager="));
1249
- if (pagerFlag) {
1250
- return `"git grep ${pagerFlag}" opens the results in an arbitrary named program. Use the read-only research tools, or describe the change as a task.`;
2562
+ const listing = seg.args.includes("-l") || seg.args.includes("--list");
2563
+ const scan2 = scanFlags(seg);
2564
+ const target = scan2?.operands[1];
2565
+ if (!listing && target) {
2566
+ return `"git ${sub} ${target}" creates or moves a ref. You are a read-only planner \u2014 describe the change as a task instead.`;
1251
2567
  }
1252
2568
  }
1253
2569
  }
@@ -1275,6 +2591,13 @@ function refusalFor(seg) {
1275
2591
  if (seg.assignments.length > 0) {
1276
2592
  return `The environment assignment "${seg.assignments[0]}" decides what "${seg.binary}" actually does, and this classifier cannot judge the value. Re-run the command without the assignment.`;
1277
2593
  }
2594
+ const scan = scanFlags(seg);
2595
+ if (scan?.unknown) {
2596
+ return `"${scan.unknown}" is not a flag this classifier knows to be read-only on "${seg.binary}", and flags on otherwise read-only binaries can run a helper program or write a file. Re-run with read-only flags only, or describe the work as a task.`;
2597
+ }
2598
+ if (seg.binary === "uniq" && scan && scan.operands.length > 1) {
2599
+ return `"uniq" writes to its second file argument ("${scan.operands[1]}"). You are a read-only planner \u2014 pipe the output instead of naming an output file, or describe the write as a task.`;
2600
+ }
1278
2601
  return void 0;
1279
2602
  }
1280
2603
  function withWrapperNote(reason, wrappers) {
@@ -1661,6 +2984,139 @@ It is outside the auto-allowed read-only set (${scope}). Continue with the read-
1661
2984
  }
1662
2985
  };
1663
2986
 
2987
+ // src/utils/redactSecrets.ts
2988
+ var REDACTION_MARKER = "REDACTED";
2989
+ function marker(kind, length) {
2990
+ return `[${REDACTION_MARKER} ${kind}: ${length} chars]`;
2991
+ }
2992
+ var LINE_PREFIX = String.raw`(?:[ \t]*\d+\||[^\s:]*:\d+:|)[ \t]*`;
2993
+ var LINE_PREFIX_ANCHORED = new RegExp("^" + LINE_PREFIX);
2994
+ var CREDENTIAL_WORD = String.raw`(?:api[_-]?keys?|secrets?|passwords?|passwd|passphrase|tokens?|access[_-]?keys?|private[_-]?keys?|client[_-]?secrets?|credentials?|auth)(?![a-z])`;
2995
+ var NAMED_CREDENTIAL = new RegExp(
2996
+ String.raw`((?:^|[^A-Za-z0-9_])["']?[A-Za-z0-9_.\-]*` + CREDENTIAL_WORD + String.raw`[A-Za-z0-9_.\-]*["']?[ \t]*(?:=>|[:=])[ \t]*)(["'\x60]?)([A-Za-z0-9_\-./+=:~!@#%^&*]{8,})\2`,
2997
+ "gi"
2998
+ );
2999
+ var DOTENV_CREDENTIAL = new RegExp(
3000
+ String.raw`^(` + LINE_PREFIX + String.raw`(?:export[ \t]+)?[A-Z0-9_]*` + CREDENTIAL_WORD.toUpperCase() + String.raw`[A-Z0-9_]*[ \t]*=[ \t]*)(["'\x60]?)([^\s"'\x60]{3,})\2[ \t]*$`,
3001
+ "gm"
3002
+ );
3003
+ var PROVIDER_KEY_PATTERNS = [
3004
+ // OpenAI / Anthropic / OpenRouter and the many `sk-` derivatives
3005
+ /(?<![A-Za-z0-9])sk-(?:ant-|proj-|or-v1-|live-|test-)?[A-Za-z0-9_-]{20,}/g,
3006
+ // GitHub personal-access, OAuth, app and refresh tokens
3007
+ /(?<![A-Za-z0-9])(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{22,}/g,
3008
+ // Slack bot / user / app tokens
3009
+ /(?<![A-Za-z0-9])xox[abprse]-[A-Za-z0-9-]{10,}/g,
3010
+ // Stripe secret and restricted keys
3011
+ /(?<![A-Za-z0-9])[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g,
3012
+ // Google API keys and OAuth access tokens
3013
+ /(?<![A-Za-z0-9])AIza[A-Za-z0-9_-]{30,}/g,
3014
+ /(?<![A-Za-z0-9])ya29\.[A-Za-z0-9_-]{20,}/g,
3015
+ // npm, Hugging Face, Groq, GitLab, DigitalOcean, Shopify
3016
+ /(?<![A-Za-z0-9])(?:npm|hf|gsk|glpat|dop_v1|shpat|shpss)[_-][A-Za-z0-9]{20,}/g,
3017
+ /(?<![A-Za-z0-9])xai-[A-Za-z0-9]{20,}/g,
3018
+ // Three-segment JWTs, whose payload segment is base64 for `{"`
3019
+ /(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
3020
+ ];
3021
+ var CLOUD_ACCESS_KEY_ID = /(?<![A-Za-z0-9])(?:A3T[A-Z0-9]{2}|AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA)[A-Z0-9]{16}(?![A-Za-z0-9])/g;
3022
+ var PEM_BEGIN = /-----BEGIN ((?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?)-----/;
3023
+ var PEM_BODY_LINE = /^[A-Za-z0-9+/=:,.\- \t]*$/;
3024
+ function redactPrivateKeyBlocks(text) {
3025
+ if (!PEM_BEGIN.test(text)) return text;
3026
+ const out = [];
3027
+ let label = null;
3028
+ let bodyChars = 0;
3029
+ const closeBlock = () => {
3030
+ if (label === null) return;
3031
+ out.push(marker("private key", bodyChars));
3032
+ label = null;
3033
+ bodyChars = 0;
3034
+ };
3035
+ for (const line of text.split("\n")) {
3036
+ if (label === null) {
3037
+ out.push(line);
3038
+ const begun = PEM_BEGIN.exec(line);
3039
+ if (begun) label = begun[1];
3040
+ continue;
3041
+ }
3042
+ const content = line.replace(LINE_PREFIX_ANCHORED, "");
3043
+ if (content.includes(`-----END ${label}-----`)) {
3044
+ closeBlock();
3045
+ out.push(line);
3046
+ } else if (PEM_BODY_LINE.test(content)) {
3047
+ bodyChars += content.trim().length;
3048
+ } else {
3049
+ closeBlock();
3050
+ out.push(line);
3051
+ }
3052
+ }
3053
+ closeBlock();
3054
+ return out.join("\n");
3055
+ }
3056
+ var NUMERIC = /^[-+]?\d+(?:\.\d+)?$/;
3057
+ var IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
3058
+ var MEMBER_EXPRESSION = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$/;
3059
+ var SLUG = /^[a-z]+(?:[-_][a-z]+)+$/;
3060
+ var PATHISH = /^(?:\.{0,2}\/|~\/)/;
3061
+ var URLISH = /^[a-z][a-z0-9+.-]*:\/\//i;
3062
+ var CODE_KEYWORDS = /* @__PURE__ */ new Set([
3063
+ "true",
3064
+ "false",
3065
+ "null",
3066
+ "none",
3067
+ "nil",
3068
+ "undefined",
3069
+ "void",
3070
+ "string",
3071
+ "number",
3072
+ "boolean",
3073
+ "object",
3074
+ "any",
3075
+ "unknown",
3076
+ "bool",
3077
+ "str",
3078
+ "int"
3079
+ ]);
3080
+ var UNAMBIGUOUS_CREDENTIAL_NAME = /(?:password|passwd|passphrase|secret|credentials?|private[_-]?key)/i;
3081
+ function looksLikeKeyMaterial(value) {
3082
+ const hasDigit = /\d/.test(value);
3083
+ const hasAlpha = /[A-Za-z]/.test(value);
3084
+ const mixedCase = /[a-z]/.test(value) && /[A-Z]/.test(value);
3085
+ return hasDigit && hasAlpha || mixedCase || value.length >= 24;
3086
+ }
3087
+ function looksLikeCode(value) {
3088
+ return NUMERIC.test(value) || CODE_KEYWORDS.has(value.toLowerCase()) || MEMBER_EXPRESSION.test(value) || PATHISH.test(value) || URLISH.test(value);
3089
+ }
3090
+ function trimTrailingPunctuation(value) {
3091
+ const match = /[.:!]+$/.exec(value);
3092
+ if (!match || match.index === 0) return { value, tail: "" };
3093
+ return { value: value.slice(0, match.index), tail: match[0] };
3094
+ }
3095
+ function redactSecrets(text) {
3096
+ if (!text) return text;
3097
+ let out = redactPrivateKeyBlocks(text);
3098
+ out = out.replace(DOTENV_CREDENTIAL, (full, head, quote, value) => {
3099
+ if (looksLikeCode(value) || value.startsWith("$")) return full;
3100
+ return `${head}${quote}${marker("credential", value.length)}${quote}`;
3101
+ });
3102
+ out = out.replace(NAMED_CREDENTIAL, (full, head, quote, raw) => {
3103
+ const { value, tail } = trimTrailingPunctuation(raw);
3104
+ if (value.length < 8) return full;
3105
+ if (looksLikeCode(value) || value.startsWith("$")) return full;
3106
+ if (UNAMBIGUOUS_CREDENTIAL_NAME.test(head) && /\d/.test(value)) {
3107
+ return `${head}${quote}${marker("credential", value.length)}${quote}${tail}`;
3108
+ }
3109
+ if (IDENTIFIER.test(value) || SLUG.test(value)) return full;
3110
+ if (!UNAMBIGUOUS_CREDENTIAL_NAME.test(head) && !looksLikeKeyMaterial(value)) return full;
3111
+ return `${head}${quote}${marker("credential", value.length)}${quote}${tail}`;
3112
+ });
3113
+ for (const pattern of PROVIDER_KEY_PATTERNS) {
3114
+ out = out.replace(pattern, (hit) => marker("api key", hit.length));
3115
+ }
3116
+ out = out.replace(CLOUD_ACCESS_KEY_ID, (hit) => marker("access key id", hit.length));
3117
+ return out;
3118
+ }
3119
+
1664
3120
  // src/services/executeTool.ts
1665
3121
  var GREP_OUTPUT_MODES = ["content", "files", "count"];
1666
3122
  function num(value) {
@@ -1687,6 +3143,10 @@ function grepOptions(args) {
1687
3143
  }
1688
3144
  var STOPPED_TOOL_RESULT = "Stopped by the user \u2014 this call was not executed, and no further tool calls will run this turn.";
1689
3145
  async function executeTool(name, args, fs10, fetcher, signal) {
3146
+ const outcome = await runTool(name, args, fs10, fetcher, signal);
3147
+ return { ...outcome, output: redactSecrets(outcome.output) };
3148
+ }
3149
+ async function runTool(name, args, fs10, fetcher, signal) {
1690
3150
  if (signal?.aborted) return { success: false, output: STOPPED_TOOL_RESULT, truncated: false };
1691
3151
  switch (name) {
1692
3152
  case "read_file": {
@@ -2787,7 +4247,7 @@ function formatError(err) {
2787
4247
  var defaultLogger = new ConsoleLogger();
2788
4248
 
2789
4249
  // src/services/BaseAiService.ts
2790
- var import_uuid2 = require("uuid");
4250
+ var import_uuid3 = require("uuid");
2791
4251
 
2792
4252
  // src/services/ModeResolver.ts
2793
4253
  function runnerModesFrom(registry, runners) {
@@ -2910,61 +4370,376 @@ function modesFor(scope, modes) {
2910
4370
  return scoped;
2911
4371
  }
2912
4372
 
2913
- // src/services/PlanPrompts.ts
2914
- function buildResearchToolsPrompt(subagentsEnabled = false) {
2915
- const lines = [
2916
- "You have access to the following tools to explore the workspace:",
2917
- "",
2918
- "read_file(path: string, offset?: number, limit?: number) - Read file contents with line numbers and optional pagination. offset is a 0-based line number (default 0). limit is max lines (default 2000). Output lines are prefixed with their line numbers. When a file has more lines than requested, a hint shows the offset to use next. Files larger than ~1 MB are rejected \u2014 use grep instead.",
2919
- "read_files(paths: string[]) - Read multiple files at once. Batch reads when you have several known paths \u2014 this is faster than calling read_file repeatedly.",
2920
- 'glob(pattern: string) - Find files matching a glob pattern (e.g. "src/**/*.ts"). Use for finding files by name/path. Results sorted by modification time, newest first.',
2921
- 'grep(pattern: string, include?: string) - Search for a regex pattern inside file contents. Use for finding code patterns, function definitions, imports, etc. Use "include" to filter by file type (e.g. "*.ts").',
2922
- "list_dir(path: string, depth?: number) - List directory contents as a tree. Use for orientation \u2014 to see what directories and files exist. Use depth=2 or depth=3 for a tree view.",
2923
- "bash(command: string) - Run a read-only shell command (ls, tree, git log/status/diff, cat, head, wc, etc.). Do NOT use bash for file search \u2014 use the dedicated glob and grep tools instead.",
2924
- "",
2925
- "TOOL CHOICE GUIDE:",
2926
- "- list_dir: for browsing/orientation and understanding project structure",
2927
- "- glob: for finding files by name/path pattern",
2928
- "- grep: for searching inside file contents",
2929
- "",
2930
- "When multiple independent tool calls are needed, batch them in a single response to save round-trips. For example, if you need to read README.md and AGENTS.md, call read_files with both paths at once."
2931
- ];
2932
- if (subagentsEnabled) {
2933
- lines.push(
2934
- "",
2935
- 'spawn_research_agent(prompt: string) - Launch a stateless read-only research agent that explores the workspace with its own tools and returns a digest. Use it to delegate an open-ended exploration thread \u2014 a subsystem to map, or a "how does X work here" question \u2014 whenever one emerges during research. To explore several independent areas, launch the agents CONCURRENTLY: put multiple spawn_research_agent calls in one reply and they run in parallel.',
2936
- "The agent sees nothing of this conversation, so its prompt must be self-contained: the area or question, the paths/symbols to start from, and exactly what the digest must report back. Do NOT use it on small or single-subsystem repos, or to read one known file \u2014 direct tools are faster there."
2937
- );
2938
- }
2939
- return lines.join("\n");
2940
- }
2941
- function buildSubagentSystemPrompt() {
2942
- return [
2943
- "You are a read-only research subagent working for a project planner. You receive one self-contained research task, explore the workspace with your tools, and reply with a single digest. You never modify files, never fetch URLs, and cannot spawn further agents.",
2944
- "",
2945
- "Work efficiently: batch independent tool calls in one reply; prefer glob/grep to locate, then read only what matters. Your tool budget is small \u2014 stop exploring when you can answer the task.",
2946
- "",
2947
- "Your final reply IS the digest \u2014 it goes straight back to the planner, not to a human. It must contain:",
2948
- "- The exact file paths (and key line references) relevant to the task",
2949
- "- The key symbols/functions involved and how they connect",
2950
- "- Constraints, gotchas, or conventions the planner must respect",
2951
- "- Direct answers to every question the task asked",
2952
- "Plain prose/markdown only. No questions, no task plans, no JSON. Be dense and specific; vague summaries are useless to the planner."
2953
- ].join("\n");
2954
- }
2955
- function buildModeGuideForRunners(runners, _autonomousDefault) {
2956
- const lines = ['Assign a "taskMode" to each AI task based on the runner:'];
2957
- for (const r of runners) {
2958
- if (r === "claude-code") {
2959
- lines.push('- For claude-code: "acceptEdits" (edit automatically, recommended), "default" (ask before edits), "plan" (read-only analysis), "bypassPermissions" (skip all prompts, CI only).');
2960
- } else if (r === "opencode") {
2961
- lines.push('- For opencode: "build" (full access agent), "plan" (read-only analysis).');
2962
- } else {
2963
- lines.push(`- For ${r}: use the modes from the runner's manifest.`);
2964
- }
4373
+ // src/services/JsonExtractor.ts
4374
+ var PLAN_ENVELOPE_KEY = "tasks";
4375
+ var TASK_OPS_ENVELOPE_KEY = "taskOps";
4376
+ var TASK_QUERY_ENVELOPE_KEY = "taskQuery";
4377
+ var PlanParseError = class extends Error {
4378
+ raw;
4379
+ /**
4380
+ * True when the reply was cut off mid-object (output-token limit) rather
4381
+ * than malformed — the two need different repairs: a truncated emission will
4382
+ * be cut at the same point again unless the retry frees context or asks for
4383
+ * terser output.
4384
+ */
4385
+ truncated;
4386
+ /**
4387
+ * True when the JSON parsed fine and a *shape* rule rejected it. The generic
4388
+ * "could not be parsed as JSON" corrective is a lie for these, so the model
4389
+ * re-sends the same object; a semantic rejection must be repaired by naming
4390
+ * the rule it broke.
4391
+ */
4392
+ semantic;
4393
+ constructor(message, raw, opts) {
4394
+ super(message);
4395
+ this.name = "PlanParseError";
4396
+ this.raw = raw;
4397
+ this.truncated = opts?.truncated ?? false;
4398
+ this.semantic = opts?.semantic ?? false;
2965
4399
  }
2966
- lines.push('Use the recommended mode for implementation. Use "plan" for analysis-only requests.');
2967
- return lines.join("\n");
4400
+ };
4401
+ function stripModelNoise(raw) {
4402
+ return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/```json\s*/gi, "").replace(/```\s*/g, "").trim();
4403
+ }
4404
+ function balancedObjectAt(text, start) {
4405
+ let depth = 0;
4406
+ let inString = false;
4407
+ let escaped = false;
4408
+ for (let i = start; i < text.length; i++) {
4409
+ const ch = text[i];
4410
+ if (inString) {
4411
+ if (escaped) escaped = false;
4412
+ else if (ch === "\\") escaped = true;
4413
+ else if (ch === '"') inString = false;
4414
+ continue;
4415
+ }
4416
+ if (ch === '"') inString = true;
4417
+ else if (ch === "{") depth++;
4418
+ else if (ch === "}") {
4419
+ depth--;
4420
+ if (depth === 0) return { json: text.slice(start, i + 1), balanced: true };
4421
+ }
4422
+ }
4423
+ return { json: text.slice(start), balanced: false };
4424
+ }
4425
+ function extractObjectsWithKey(raw, key) {
4426
+ const stripped = stripModelNoise(raw);
4427
+ const keyRe = new RegExp(`"${key}"\\s*:`);
4428
+ const matches = [];
4429
+ let firstObject = null;
4430
+ let sawBrace = false;
4431
+ let sawUnbalanced = false;
4432
+ let inString = false;
4433
+ let escaped = false;
4434
+ for (let i = 0; i < stripped.length; i++) {
4435
+ const ch = stripped[i];
4436
+ if (inString) {
4437
+ if (escaped) escaped = false;
4438
+ else if (ch === "\\") escaped = true;
4439
+ else if (ch === '"') inString = false;
4440
+ continue;
4441
+ }
4442
+ if (ch === '"') {
4443
+ inString = true;
4444
+ continue;
4445
+ }
4446
+ if (ch !== "{") continue;
4447
+ sawBrace = true;
4448
+ const obj = balancedObjectAt(stripped, i);
4449
+ if (obj.balanced && keyRe.test(obj.json)) {
4450
+ matches.push(obj.json);
4451
+ i += obj.json.length - 1;
4452
+ continue;
4453
+ }
4454
+ if (!obj.balanced) sawUnbalanced = true;
4455
+ if (!firstObject && obj.balanced) firstObject = obj.json;
4456
+ }
4457
+ return {
4458
+ matches,
4459
+ fallback: firstObject !== null ? { json: firstObject, balanced: true } : { json: stripped, balanced: false },
4460
+ sawBrace,
4461
+ sawUnbalanced
4462
+ };
4463
+ }
4464
+ function extractObjectWithBalance(raw) {
4465
+ const { matches, fallback, sawBrace } = extractObjectsWithKey(raw, PLAN_ENVELOPE_KEY);
4466
+ if (matches.length > 0) return { json: matches[0], balanced: true, sawBrace };
4467
+ return { ...fallback, sawBrace };
4468
+ }
4469
+ function extractJsonObject(raw) {
4470
+ return extractObjectWithBalance(raw).json;
4471
+ }
4472
+ function stripTrailingCommas(json) {
4473
+ return json.replace(/,(\s*[}\]])/g, "$1");
4474
+ }
4475
+ function escapeControlCharsInStrings(json) {
4476
+ let out = "";
4477
+ let inString = false;
4478
+ let escaped = false;
4479
+ for (const ch of json) {
4480
+ if (inString) {
4481
+ if (escaped) {
4482
+ out += ch;
4483
+ escaped = false;
4484
+ continue;
4485
+ }
4486
+ if (ch === "\\") {
4487
+ out += ch;
4488
+ escaped = true;
4489
+ continue;
4490
+ }
4491
+ if (ch === '"') {
4492
+ out += ch;
4493
+ inString = false;
4494
+ continue;
4495
+ }
4496
+ if (ch === "\n") {
4497
+ out += "\\n";
4498
+ continue;
4499
+ }
4500
+ if (ch === "\r") {
4501
+ out += "\\r";
4502
+ continue;
4503
+ }
4504
+ if (ch === " ") {
4505
+ out += "\\t";
4506
+ continue;
4507
+ }
4508
+ out += ch;
4509
+ continue;
4510
+ }
4511
+ if (ch === '"') inString = true;
4512
+ out += ch;
4513
+ }
4514
+ return out;
4515
+ }
4516
+
4517
+ // src/services/TaskQuery.ts
4518
+ var TASK_QUERY_FIELDS = [
4519
+ "description",
4520
+ "prompt",
4521
+ "userSteps",
4522
+ "verdict",
4523
+ "outputSummary",
4524
+ "userStoriesCovered"
4525
+ ];
4526
+ function textHasTaskQuery(text) {
4527
+ return text.includes(`"${TASK_QUERY_ENVELOPE_KEY}"`);
4528
+ }
4529
+ function parseTaskQueryJson(text) {
4530
+ const { matches, fallback } = extractObjectsWithKey(text, TASK_QUERY_ENVELOPE_KEY);
4531
+ const candidates = matches.length > 0 ? [...matches].reverse() : [fallback.json];
4532
+ let lastError = null;
4533
+ for (const json of candidates) {
4534
+ try {
4535
+ return parseTaskQueryObject(json, text);
4536
+ } catch (err) {
4537
+ if (!(err instanceof PlanParseError)) throw err;
4538
+ if (!lastError) lastError = err;
4539
+ }
4540
+ }
4541
+ throw lastError ?? new PlanParseError("No JSON object found in task-query reply", text);
4542
+ }
4543
+ function parseTaskQueryObject(json, text) {
4544
+ let parsed;
4545
+ try {
4546
+ parsed = JSON.parse(escapeControlCharsInStrings(stripTrailingCommas(json)));
4547
+ } catch (err) {
4548
+ throw new PlanParseError(`Task-query JSON is invalid: ${err instanceof Error ? err.message : String(err)}`, text);
4549
+ }
4550
+ const raw = parsed.taskQuery;
4551
+ const body = Array.isArray(raw) ? { tasks: raw } : raw;
4552
+ if (typeof body !== "object" || body === null) {
4553
+ throw new PlanParseError('"taskQuery" must be an object (or an array of task references)', text);
4554
+ }
4555
+ const { tasks: rawTasks, fields: rawFields, catalog: rawCatalog } = body;
4556
+ const tasks = [];
4557
+ if (rawTasks !== void 0) {
4558
+ if (!Array.isArray(rawTasks)) throw new PlanParseError('"tasks" in a taskQuery must be an array of task references', text);
4559
+ for (const ref of rawTasks) {
4560
+ if (typeof ref !== "string" && typeof ref !== "number") {
4561
+ throw new PlanParseError(`Invalid task reference "${String(ref)}" in taskQuery`, text);
4562
+ }
4563
+ tasks.push(String(ref).trim());
4564
+ }
4565
+ }
4566
+ let fields;
4567
+ if (rawFields !== void 0) {
4568
+ if (!Array.isArray(rawFields)) throw new PlanParseError('"fields" in a taskQuery must be an array of field names', text);
4569
+ fields = [];
4570
+ for (const f of rawFields) {
4571
+ if (typeof f !== "string" || !TASK_QUERY_FIELDS.includes(f)) {
4572
+ throw new PlanParseError(`Unknown taskQuery field "${String(f)}" \u2014 choose from ${TASK_QUERY_FIELDS.join(", ")}`, text);
4573
+ }
4574
+ fields.push(f);
4575
+ }
4576
+ }
4577
+ const catalog = rawCatalog === true;
4578
+ if (tasks.length === 0 && !catalog) {
4579
+ throw new PlanParseError('A taskQuery must name at least one task or set "catalog": true', text);
4580
+ }
4581
+ return { tasks, fields, catalog };
4582
+ }
4583
+ function taskQuerySignature(query) {
4584
+ return JSON.stringify([query.tasks, query.fields ?? null, query.catalog]);
4585
+ }
4586
+ var TASK_QUERY_ANSWER_OR_OPS = "You have now read everything you asked for. Do not send another taskQuery this turn: answer the user in prose, or emit the taskOps JSON for the change you came to make.";
4587
+ var TASK_QUERY_PROTOCOL = [
4588
+ "READING A TASK BEFORE YOU EDIT IT:",
4589
+ "The plan block you are shown each turn carries only short fields \u2014 it never contains a task's prompt, its user steps, or a completed task's verdict. Never rewrite a field you have not read. To read one, reply with ONLY this JSON object:",
4590
+ ` {"${TASK_QUERY_ENVELOPE_KEY}":{"tasks":["<id or #order>", "..."],"fields":[${TASK_QUERY_FIELDS.map((f) => `"${f}"`).join(", ")}],"catalog":true}}`,
4591
+ '- "tasks": one or more task references. Ask for everything you need in ONE query \u2014 each query costs a round-trip.',
4592
+ '- "fields": optional. Omit it to get every field above.',
4593
+ '- "catalog": optional. Set it to true for the full runner and model catalog \u2014 every model id with its label and thinking-effort variants, and every task mode with its description. It works before any task exists, and may be used on its own.',
4594
+ "Ordewell answers immediately with the detail and changes nothing. Then reply again with your taskOps JSON, or with prose for the user.",
4595
+ "Three queries per user message; after that every answer also tells you to land the turn. Do not ask the same question twice."
4596
+ ];
4597
+ var TASK_QUERY_REMINDER = `- To READ a task in full (prompt, user steps, verdict, output, user stories) or the whole model/mode catalog before editing, reply with ONLY {"${TASK_QUERY_ENVELOPE_KEY}":{"tasks":["<id or #order>"],"catalog":true}} \u2014 it changes nothing, and you then reply again with your ops.`;
4598
+ function findTask(tasks, ref) {
4599
+ const byId = tasks.find((t) => t.id === ref);
4600
+ if (byId) return byId;
4601
+ const orderStr = ref.startsWith("#") ? ref.slice(1) : ref;
4602
+ if (/^\d+$/.test(orderStr)) {
4603
+ const byOrder = tasks.filter((t) => t.order === Number(orderStr));
4604
+ if (byOrder.length === 1) return byOrder[0];
4605
+ }
4606
+ return tasks.find((t) => t.title === ref);
4607
+ }
4608
+ function renderField(task, field) {
4609
+ switch (field) {
4610
+ case "description":
4611
+ return [`description: ${task.description || "(none)"}`];
4612
+ case "prompt":
4613
+ return task.prompt ? ["prompt:", task.prompt] : ["prompt: (none)"];
4614
+ case "userSteps": {
4615
+ const steps = task.userSteps ?? [];
4616
+ if (steps.length === 0) return ["userSteps: (none)"];
4617
+ return ["userSteps:", ...steps.map((s) => ` ${s.order}. [${s.completed ? "x" : " "}] ${s.instruction}`)];
4618
+ }
4619
+ case "verdict": {
4620
+ const v = task.verdict;
4621
+ if (!v) return ["verdict: (none)"];
4622
+ const checks = v.checks.map((c) => `${c.name}=${c.skipped ? "skipped" : c.passed ? "pass" : "fail"}`).join(", ");
4623
+ return [`verdict: ${v.outcome.toUpperCase()} \u2014 ${v.reason}${checks ? ` [${checks}]` : ""}`];
4624
+ }
4625
+ case "outputSummary": {
4626
+ const o = task.outputSummary;
4627
+ if (!o) return ["outputSummary: (none)"];
4628
+ return [`outputSummary: ${o.reviewReason}`, ...o.logTail ? ["log tail:", o.logTail] : []];
4629
+ }
4630
+ case "userStoriesCovered": {
4631
+ const stories = task.userStoriesCovered ?? [];
4632
+ if (stories.length === 0) return ["userStoriesCovered: (none)"];
4633
+ return ["userStoriesCovered:", ...stories.map((s) => ` - ${s}`)];
4634
+ }
4635
+ }
4636
+ }
4637
+ function renderTask(task, fields) {
4638
+ return [
4639
+ `#${task.order} id=${task.id} "${task.title}" [${task.status}] type:${task.type === "user" ? "MAN" : "AI"}`,
4640
+ ...fields.flatMap((f) => renderField(task, f)),
4641
+ ""
4642
+ ];
4643
+ }
4644
+ function renderCatalog(catalog) {
4645
+ const lines = [];
4646
+ for (const runner of catalog.runners) {
4647
+ const models = catalog.models[runner] ?? [];
4648
+ lines.push(`${runner} models:`);
4649
+ if (models.length === 0) lines.push(" (none discovered)");
4650
+ for (const m of models) {
4651
+ const variants = m.variants?.length ? ` variants: ${m.variants.map((v) => `${v.id}${v.label && v.label !== v.id ? ` (${v.label})` : ""}`).join(", ")}` : " variants: none";
4652
+ lines.push(` - ${m.modelId} \u2014 ${m.modelLabel}${variants}`);
4653
+ }
4654
+ const modes = catalog.modes[runner] ?? [];
4655
+ lines.push(`${runner} task modes:`);
4656
+ if (modes.length === 0) lines.push(" (none declared)");
4657
+ const defaultId = resolveDefaultMode(modes, catalog.autonomousDefault);
4658
+ for (const mode of modes) {
4659
+ lines.push(` - ${mode.id} \u2014 ${mode.label}: ${mode.description}${mode.id === defaultId ? " (default)" : ""}`);
4660
+ }
4661
+ }
4662
+ return lines;
4663
+ }
4664
+ function renderTaskQueryAnswer(query, tasks, catalog) {
4665
+ const fields = query.fields ?? TASK_QUERY_FIELDS;
4666
+ const blocks = [];
4667
+ if (query.tasks.length > 0) {
4668
+ const body = [];
4669
+ for (const ref of query.tasks) {
4670
+ const task = findTask(tasks, ref);
4671
+ if (!task) {
4672
+ body.push(`${ref}: no task matches this reference in the current plan.`, "");
4673
+ continue;
4674
+ }
4675
+ body.push(...renderTask(task, fields));
4676
+ }
4677
+ blocks.push(["<task_detail>", ...body, "</task_detail>"].join("\n"));
4678
+ }
4679
+ if (query.catalog) {
4680
+ blocks.push(["<runner_catalog>", ...renderCatalog(catalog), "</runner_catalog>"].join("\n"));
4681
+ }
4682
+ blocks.push(
4683
+ "That is everything you asked for. Nothing has changed. Reply now with the taskOps JSON for the edit you intended, or answer the user in prose."
4684
+ );
4685
+ return blocks.join("\n\n");
4686
+ }
4687
+
4688
+ // src/services/PlanPrompts.ts
4689
+ function buildResearchToolsPrompt(subagentsEnabled = false) {
4690
+ const lines = [
4691
+ "You have access to the following tools to explore the workspace:",
4692
+ "",
4693
+ "read_file(path: string, offset?: number, limit?: number) - Read file contents with line numbers and optional pagination. offset is a 0-based line number (default 0). limit is max lines (default 2000). Output lines are prefixed with their line numbers. When a file has more lines than requested, a hint shows the offset to use next. Files larger than ~1 MB are rejected \u2014 use grep instead.",
4694
+ "read_files(paths: string[]) - Read multiple files at once. Batch reads when you have several known paths \u2014 this is faster than calling read_file repeatedly.",
4695
+ 'glob(pattern: string) - Find files matching a glob pattern (e.g. "src/**/*.ts"). Use for finding files by name/path. Results sorted by modification time, newest first.',
4696
+ 'grep(pattern: string, include?: string) - Search for a regex pattern inside file contents. Use for finding code patterns, function definitions, imports, etc. Use "include" to filter by file type (e.g. "*.ts").',
4697
+ "list_dir(path: string, depth?: number) - List directory contents as a tree. Use for orientation \u2014 to see what directories and files exist. Use depth=2 or depth=3 for a tree view.",
4698
+ "bash(command: string) - Run a read-only shell command (ls, tree, git log/status/diff, cat, head, wc, etc.). Do NOT use bash for file search \u2014 use the dedicated glob and grep tools instead.",
4699
+ "",
4700
+ "TOOL CHOICE GUIDE:",
4701
+ "- list_dir: for browsing/orientation and understanding project structure",
4702
+ "- glob: for finding files by name/path pattern",
4703
+ "- grep: for searching inside file contents",
4704
+ "",
4705
+ "When multiple independent tool calls are needed, batch them in a single response to save round-trips. For example, if you need to read README.md and AGENTS.md, call read_files with both paths at once."
4706
+ ];
4707
+ if (subagentsEnabled) {
4708
+ lines.push(
4709
+ "",
4710
+ 'spawn_research_agent(prompt: string) - Launch a stateless read-only research agent that explores the workspace with its own tools and returns a digest. Use it to delegate an open-ended exploration thread \u2014 a subsystem to map, or a "how does X work here" question \u2014 whenever one emerges during research. To explore several independent areas, launch the agents CONCURRENTLY: put multiple spawn_research_agent calls in one reply and they run in parallel.',
4711
+ "The agent sees nothing of this conversation, so its prompt must be self-contained: the area or question, the paths/symbols to start from, and exactly what the digest must report back. Do NOT use it on small or single-subsystem repos, or to read one known file \u2014 direct tools are faster there."
4712
+ );
4713
+ }
4714
+ return lines.join("\n");
4715
+ }
4716
+ function buildSubagentSystemPrompt() {
4717
+ return [
4718
+ "You are a read-only research subagent working for a project planner. You receive one self-contained research task, explore the workspace with your tools, and reply with a single digest. You never modify files, never fetch URLs, and cannot spawn further agents.",
4719
+ "",
4720
+ "Work efficiently: batch independent tool calls in one reply; prefer glob/grep to locate, then read only what matters. Your tool budget is small \u2014 stop exploring when you can answer the task.",
4721
+ "",
4722
+ "Your final reply IS the digest \u2014 it goes straight back to the planner, not to a human. It must contain:",
4723
+ "- The exact file paths (and key line references) relevant to the task",
4724
+ "- The key symbols/functions involved and how they connect",
4725
+ "- Constraints, gotchas, or conventions the planner must respect",
4726
+ "- Direct answers to every question the task asked",
4727
+ "Plain prose/markdown only. No questions, no task plans, no JSON. Be dense and specific; vague summaries are useless to the planner."
4728
+ ].join("\n");
4729
+ }
4730
+ function buildModeGuideForRunners(runners, _autonomousDefault) {
4731
+ const lines = ['Assign a "taskMode" to each AI task based on the runner:'];
4732
+ for (const r of runners) {
4733
+ if (r === "claude-code") {
4734
+ lines.push('- For claude-code: "acceptEdits" (edit automatically, recommended), "default" (ask before edits), "plan" (read-only analysis), "bypassPermissions" (skip all prompts, CI only).');
4735
+ } else if (r === "opencode") {
4736
+ lines.push('- For opencode: "build" (full access agent), "plan" (read-only analysis).');
4737
+ } else {
4738
+ lines.push(`- For ${r}: use the modes from the runner's manifest.`);
4739
+ }
4740
+ }
4741
+ lines.push('Use the recommended mode for implementation. Use "plan" for analysis-only requests.');
4742
+ return lines.join("\n");
2968
4743
  }
2969
4744
  function buildModeExamplesForRunners(runners) {
2970
4745
  return runners.map((r) => {
@@ -2999,7 +4774,8 @@ function researchPhaseBlock(harnessMode) {
2999
4774
  ];
3000
4775
  const toolSpecific = harnessMode ? [
3001
4776
  "- Explore the repository with your own tools until you understand its architecture",
3002
- "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace"
4777
+ "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace",
4778
+ "- If you delegate exploration to your own agents, WAIT for their results inside this reply. Do NOT launch them in the background or async and end your turn saying you will report back later: your turn ending is what hands the conversation back to the user, and anything you say after it never reaches them."
3003
4779
  ] : [
3004
4780
  "- Use list_dir with depth=2-3 for a tree overview",
3005
4781
  "- Use glob and grep to find relevant source files",
@@ -3164,6 +4940,11 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
3164
4940
  "TASK MODE:",
3165
4941
  modeGuide,
3166
4942
  "",
4943
+ // Shared by both variants on purpose: the read channel is a text envelope
4944
+ // exactly so a harness planner, which Ordewell cannot hand tools to, speaks
4945
+ // the same protocol as an API-backed one (ADR-0009).
4946
+ ...TASK_QUERY_PROTOCOL,
4947
+ "",
3167
4948
  context ? `PROJECT CONTEXT:
3168
4949
  ${context}
3169
4950
  ` : "",
@@ -3471,7 +5252,8 @@ function buildMergePrompt(taskIds, tasks) {
3471
5252
  "The merged task takes the union of their dependencies (excluding the merged tasks themselves) and preserves their user stories.",
3472
5253
  'Set "assignedRunner" and "assignedModel" on the merged task \u2014 use one of the runners and models listed in <available_models> above. Prefer the strongest model if the merged work is complex.',
3473
5254
  'Reply with ONLY a taskOps JSON object using a single "merge" op:',
3474
- ` {"taskOps":[{"op":"merge","taskIds":[${selected.map((t) => `"${t.id}"`).join(", ")}],"merged":{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}}}]}`
5255
+ ` {"taskOps":[{"op":"merge","taskIds":[${selected.map((t) => `"${t.id}"`).join(", ")}],"merged":{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}}}]}`,
5256
+ `If this merge needs a companion op in the same batch (e.g. an added task that should depend on the merge result), give the merge op a "handle" (any unused name) and reference it from the later op's taskId/dependencies.`
3475
5257
  ].join("\n");
3476
5258
  }
3477
5259
  function buildSplitPrompt(taskId, tasks) {
@@ -3483,153 +5265,11 @@ function buildSplitPrompt(taskId, tasks) {
3483
5265
  "The first part inherits the original task's dependencies. Each later part depends on the previous part. Tasks that depended on the original now depend on the LAST part.",
3484
5266
  'Set "assignedRunner" and "assignedModel" on each part \u2014 use the runners and models listed in <available_models> above. You may assign different models to different parts (e.g. a stronger model for a complex part, a faster one for a simple part).',
3485
5267
  'Reply with ONLY a taskOps JSON object using a single "split" op:',
3486
- ` {"taskOps":[{"op":"split","taskId":"${task.id}","parts":[{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}},...]}]}`
5268
+ ` {"taskOps":[{"op":"split","taskId":"${task.id}","parts":[{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}},...]}]}`,
5269
+ `If this split needs a companion op in the same batch (e.g. another task that should depend on the last part), give the split op a "handle" (any unused name) \u2014 it names the last part \u2014 and reference it from the later op's taskId/dependencies.`
3487
5270
  ].join("\n");
3488
5271
  }
3489
5272
 
3490
- // src/services/JsonExtractor.ts
3491
- var PLAN_ENVELOPE_KEY = "tasks";
3492
- var TASK_OPS_ENVELOPE_KEY = "taskOps";
3493
- var PlanParseError = class extends Error {
3494
- raw;
3495
- /**
3496
- * True when the reply was cut off mid-object (output-token limit) rather
3497
- * than malformed — the two need different repairs: a truncated emission will
3498
- * be cut at the same point again unless the retry frees context or asks for
3499
- * terser output.
3500
- */
3501
- truncated;
3502
- /**
3503
- * True when the JSON parsed fine and a *shape* rule rejected it. The generic
3504
- * "could not be parsed as JSON" corrective is a lie for these, so the model
3505
- * re-sends the same object; a semantic rejection must be repaired by naming
3506
- * the rule it broke.
3507
- */
3508
- semantic;
3509
- constructor(message, raw, opts) {
3510
- super(message);
3511
- this.name = "PlanParseError";
3512
- this.raw = raw;
3513
- this.truncated = opts?.truncated ?? false;
3514
- this.semantic = opts?.semantic ?? false;
3515
- }
3516
- };
3517
- function stripModelNoise(raw) {
3518
- return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/```json\s*/gi, "").replace(/```\s*/g, "").trim();
3519
- }
3520
- function balancedObjectAt(text, start) {
3521
- let depth = 0;
3522
- let inString = false;
3523
- let escaped = false;
3524
- for (let i = start; i < text.length; i++) {
3525
- const ch = text[i];
3526
- if (inString) {
3527
- if (escaped) escaped = false;
3528
- else if (ch === "\\") escaped = true;
3529
- else if (ch === '"') inString = false;
3530
- continue;
3531
- }
3532
- if (ch === '"') inString = true;
3533
- else if (ch === "{") depth++;
3534
- else if (ch === "}") {
3535
- depth--;
3536
- if (depth === 0) return { json: text.slice(start, i + 1), balanced: true };
3537
- }
3538
- }
3539
- return { json: text.slice(start), balanced: false };
3540
- }
3541
- function extractObjectsWithKey(raw, key) {
3542
- const stripped = stripModelNoise(raw);
3543
- const keyRe = new RegExp(`"${key}"\\s*:`);
3544
- const matches = [];
3545
- let firstObject = null;
3546
- let sawBrace = false;
3547
- let sawUnbalanced = false;
3548
- let inString = false;
3549
- let escaped = false;
3550
- for (let i = 0; i < stripped.length; i++) {
3551
- const ch = stripped[i];
3552
- if (inString) {
3553
- if (escaped) escaped = false;
3554
- else if (ch === "\\") escaped = true;
3555
- else if (ch === '"') inString = false;
3556
- continue;
3557
- }
3558
- if (ch === '"') {
3559
- inString = true;
3560
- continue;
3561
- }
3562
- if (ch !== "{") continue;
3563
- sawBrace = true;
3564
- const obj = balancedObjectAt(stripped, i);
3565
- if (obj.balanced && keyRe.test(obj.json)) {
3566
- matches.push(obj.json);
3567
- i += obj.json.length - 1;
3568
- continue;
3569
- }
3570
- if (!obj.balanced) sawUnbalanced = true;
3571
- if (!firstObject && obj.balanced) firstObject = obj.json;
3572
- }
3573
- return {
3574
- matches,
3575
- fallback: firstObject !== null ? { json: firstObject, balanced: true } : { json: stripped, balanced: false },
3576
- sawBrace,
3577
- sawUnbalanced
3578
- };
3579
- }
3580
- function extractObjectWithBalance(raw) {
3581
- const { matches, fallback, sawBrace } = extractObjectsWithKey(raw, PLAN_ENVELOPE_KEY);
3582
- if (matches.length > 0) return { json: matches[0], balanced: true, sawBrace };
3583
- return { ...fallback, sawBrace };
3584
- }
3585
- function extractJsonObject(raw) {
3586
- return extractObjectWithBalance(raw).json;
3587
- }
3588
- function stripTrailingCommas(json) {
3589
- return json.replace(/,(\s*[}\]])/g, "$1");
3590
- }
3591
- function escapeControlCharsInStrings(json) {
3592
- let out = "";
3593
- let inString = false;
3594
- let escaped = false;
3595
- for (const ch of json) {
3596
- if (inString) {
3597
- if (escaped) {
3598
- out += ch;
3599
- escaped = false;
3600
- continue;
3601
- }
3602
- if (ch === "\\") {
3603
- out += ch;
3604
- escaped = true;
3605
- continue;
3606
- }
3607
- if (ch === '"') {
3608
- out += ch;
3609
- inString = false;
3610
- continue;
3611
- }
3612
- if (ch === "\n") {
3613
- out += "\\n";
3614
- continue;
3615
- }
3616
- if (ch === "\r") {
3617
- out += "\\r";
3618
- continue;
3619
- }
3620
- if (ch === " ") {
3621
- out += "\\t";
3622
- continue;
3623
- }
3624
- out += ch;
3625
- continue;
3626
- }
3627
- if (ch === '"') inString = true;
3628
- out += ch;
3629
- }
3630
- return out;
3631
- }
3632
-
3633
5273
  // src/services/PlanValidator.ts
3634
5274
  function parsePlanJson(raw, runners, runnerModes, autonomousDefault = true) {
3635
5275
  const { matches, fallback, sawUnbalanced } = extractObjectsWithKey(raw, PLAN_ENVELOPE_KEY);
@@ -3870,6 +5510,171 @@ function validatePlanModification(ctx) {
3870
5510
  return { valid: allErrors.length === 0, errors: allErrors };
3871
5511
  }
3872
5512
 
5513
+ // src/services/TaskOps.ts
5514
+ var import_uuid2 = require("uuid");
5515
+
5516
+ // src/services/ModelAllowlistResolver.ts
5517
+ function effectiveAllowlist(allowlist, runner, modelsByRunner) {
5518
+ if (!allowlist || allowlist.length === 0) return void 0;
5519
+ if (!modelsByRunner) return allowlist;
5520
+ const own = new Set((modelsByRunner[runner] ?? []).map((m) => m.modelId));
5521
+ const elsewhere = /* @__PURE__ */ new Set();
5522
+ for (const [other, models] of Object.entries(modelsByRunner)) {
5523
+ if (other === runner) continue;
5524
+ for (const m of models ?? []) elsewhere.add(m.modelId);
5525
+ }
5526
+ const kept = allowlist.filter((id) => own.has(id) || !elsewhere.has(id));
5527
+ return kept.length > 0 ? kept : void 0;
5528
+ }
5529
+ function filterModelsForPrompt(modelsByRunner, perRunnerAllowlist) {
5530
+ const result = {};
5531
+ for (const [runner, models] of Object.entries(modelsByRunner)) {
5532
+ if (!models) continue;
5533
+ const allowed = effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
5534
+ if (!allowed) {
5535
+ result[runner] = models;
5536
+ continue;
5537
+ }
5538
+ const allowedSet = new Set(allowed);
5539
+ const intersection = models.filter((m) => allowedSet.has(m.modelId));
5540
+ const covered = new Set(intersection.map((m) => m.modelId));
5541
+ const missing = allowed.filter((id) => !covered.has(id)).map((id) => ({ modelId: id, modelLabel: id.split("/").pop() || id, variants: [] }));
5542
+ result[runner] = [...intersection, ...missing];
5543
+ }
5544
+ return result;
5545
+ }
5546
+ var EFFORT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
5547
+ var EFFORT_ALIASES = { disabled: "none", adaptive: "medium" };
5548
+ function clampThinkingEffort(effort, variants) {
5549
+ if (!effort) return void 0;
5550
+ if (variants.length === 0) return void 0;
5551
+ const ids = new Set(variants.map((v) => v.id));
5552
+ if (ids.has(effort)) return effort;
5553
+ const canon = EFFORT_ALIASES[effort] ?? effort;
5554
+ if (ids.has(canon)) return canon;
5555
+ const idx = EFFORT_LADDER.indexOf(canon);
5556
+ if (idx < 0) return void 0;
5557
+ for (let d = 1; d < EFFORT_LADDER.length; d++) {
5558
+ const lower = EFFORT_LADDER[idx - d];
5559
+ if (lower && ids.has(lower)) return lower;
5560
+ const higher = EFFORT_LADDER[idx + d];
5561
+ if (higher && ids.has(higher)) return higher;
5562
+ }
5563
+ return void 0;
5564
+ }
5565
+ function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRunner) {
5566
+ const clampVariant = (task) => {
5567
+ if (!task.assignedModel || !modelsByRunner) return task;
5568
+ const model = modelsByRunner[task.assignedRunner]?.find(
5569
+ (m) => m.modelId === task.assignedModel.modelId
5570
+ );
5571
+ if (!model) return task;
5572
+ const clamped = clampThinkingEffort(task.assignedModel.thinkingEffort, model.variants);
5573
+ return {
5574
+ ...task,
5575
+ assignedModel: {
5576
+ ...task.assignedModel,
5577
+ thinkingEffort: clamped,
5578
+ availableVariants: model.variants.map((v) => v.id)
5579
+ }
5580
+ };
5581
+ };
5582
+ const allowedFor = (runner) => effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
5583
+ const assignmentFor = (runner, modelId) => {
5584
+ const known = modelsByRunner?.[runner]?.find((m) => m.modelId === modelId);
5585
+ return {
5586
+ modelId,
5587
+ modelLabel: known?.modelLabel ?? modelId,
5588
+ thinkingEffort: void 0,
5589
+ ...known ? { availableVariants: known.variants.map((v) => v.id) } : {}
5590
+ };
5591
+ };
5592
+ return tasks.map((task) => {
5593
+ if (task.type === "user") return task;
5594
+ if (allowedRunners && !allowedRunners.includes(task.assignedRunner)) {
5595
+ const fallbackRunner = allowedRunners[0];
5596
+ const fallbackModels = allowedFor(fallbackRunner);
5597
+ const fallbackModel = fallbackModels?.length ? assignmentFor(fallbackRunner, fallbackModels[0]) : modelsByRunner?.[fallbackRunner]?.[0] ? assignmentFor(fallbackRunner, modelsByRunner[fallbackRunner][0].modelId) : task.assignedModel;
5598
+ return clampVariant({
5599
+ ...task,
5600
+ assignedRunner: fallbackRunner,
5601
+ assignedModel: fallbackModel
5602
+ });
5603
+ }
5604
+ const allowlist = allowedFor(task.assignedRunner);
5605
+ if (!task.assignedModel) {
5606
+ if (allowlist && allowlist.length > 0) {
5607
+ return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) });
5608
+ }
5609
+ const discovered = modelsByRunner?.[task.assignedRunner]?.[0];
5610
+ if (discovered) {
5611
+ return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, discovered.modelId) });
5612
+ }
5613
+ return task;
5614
+ }
5615
+ if (!allowlist || allowlist.length === 0) return clampVariant(task);
5616
+ if (allowlist.includes(task.assignedModel.modelId)) return clampVariant(task);
5617
+ return { ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) };
5618
+ });
5619
+ }
5620
+
5621
+ // src/services/TaskEditValidator.ts
5622
+ var AI_ONLY_FIELDS = ["assignedModel", "thinkingEffort", "taskMode", "autonomy"];
5623
+ function checkModelAndModeValidity(runner, assignedModel, taskMode, catalog) {
5624
+ if (!catalog) return { ok: true };
5625
+ if (assignedModel) {
5626
+ const models = catalog.modelsByRunner[runner];
5627
+ if (models) {
5628
+ const modelId = assignedModel.modelId;
5629
+ if (!models.some((m) => m.modelId === modelId)) {
5630
+ return { ok: false, error: `Runner "${runner}" does not offer model "${modelId}"` };
5631
+ }
5632
+ const allowed = effectiveAllowlist(catalog.perRunnerAllowlist?.[runner], runner, catalog.modelsByRunner);
5633
+ if (allowed && !allowed.includes(modelId)) {
5634
+ return { ok: false, error: `Model "${modelId}" is excluded by the allowlist for runner "${runner}"` };
5635
+ }
5636
+ }
5637
+ }
5638
+ if (taskMode !== void 0) {
5639
+ const modes = catalog.runnerModes[runner];
5640
+ if (modes && modes.length > 0 && !modes.some((m) => m.id === taskMode)) {
5641
+ return { ok: false, error: `Runner "${runner}" has no task mode "${taskMode}" \u2014 valid modes: ${modes.map((m) => m.id).join(", ")}` };
5642
+ }
5643
+ }
5644
+ return { ok: true };
5645
+ }
5646
+ function typeCoherenceCheck(target, changes) {
5647
+ if (!changes.type || changes.type === target.type) return null;
5648
+ if (changes.type === "user") {
5649
+ if (!Array.isArray(changes.userSteps) || changes.userSteps.length === 0) {
5650
+ return { ok: false, error: `"${target.title}" cannot become a manual task without user steps \u2014 include "userSteps" in this edit` };
5651
+ }
5652
+ return { ok: true, clear: AI_ONLY_FIELDS.filter((f) => target[f] !== void 0) };
5653
+ }
5654
+ if (!changes.prompt || !changes.prompt.trim()) {
5655
+ return { ok: false, error: `"${target.title}" cannot become an AI task without a prompt \u2014 include "prompt" in this edit` };
5656
+ }
5657
+ return { ok: true, clear: target.userSteps && target.userSteps.length > 0 ? ["userSteps"] : [] };
5658
+ }
5659
+ function validateTaskEdit(actor, tasks, taskId, changes, catalog) {
5660
+ const target = tasks.find((t) => t.id === taskId);
5661
+ if (!target) return { ok: false, error: "Task not found" };
5662
+ if (actor === "planner") {
5663
+ if (target.status === "in_progress") return { ok: false, error: `"${target.title}" is running and cannot be modified` };
5664
+ if (target.status === "completed") return { ok: false, error: `"${target.title}" is completed and cannot be modified` };
5665
+ }
5666
+ if (changes.dependencies) {
5667
+ const depCheck = canSetDependencies(tasks, taskId, changes.dependencies);
5668
+ if (!depCheck.ok) return depCheck;
5669
+ }
5670
+ const coherence = typeCoherenceCheck(target, changes);
5671
+ if (coherence && !coherence.ok) return coherence;
5672
+ const runner = changes.assignedRunner ?? target.assignedRunner;
5673
+ const validity = checkModelAndModeValidity(runner, changes.assignedModel, changes.taskMode, catalog);
5674
+ if (!validity.ok) return validity;
5675
+ return coherence ?? { ok: true };
5676
+ }
5677
+
3873
5678
  // src/services/TaskOps.ts
3874
5679
  var UPDATABLE_FIELDS = [
3875
5680
  "title",
@@ -3882,7 +5687,8 @@ var UPDATABLE_FIELDS = [
3882
5687
  "thinkingEffort",
3883
5688
  "type",
3884
5689
  "userSteps",
3885
- "autonomy"
5690
+ "autonomy",
5691
+ "sliceType"
3886
5692
  ];
3887
5693
  function textHasTaskOps(text) {
3888
5694
  return text.includes(`"${TASK_OPS_ENVELOPE_KEY}"`);
@@ -3919,18 +5725,41 @@ function parseTaskOpsObject(json, text) {
3919
5725
  }
3920
5726
  return ops;
3921
5727
  }
3922
- function resolveRef(ref, tasks) {
3923
- if (typeof ref !== "string" && typeof ref !== "number") return void 0;
5728
+ function resolveRef(ref, originalScope, liveScope, handleOwner, handleId, opIndex) {
5729
+ if (typeof ref !== "string" && typeof ref !== "number") return { error: `invalid reference "${String(ref)}"` };
3924
5730
  const s = String(ref).trim();
3925
- const byId = tasks.find((t) => t.id === s);
3926
- if (byId) return byId;
5731
+ const ownerIdx = handleOwner.get(s);
5732
+ if (ownerIdx !== void 0) {
5733
+ if (ownerIdx >= opIndex) {
5734
+ return { error: `handle "${s}" is defined later in this batch (op ${ownerIdx + 1}) and cannot be referenced yet` };
5735
+ }
5736
+ const id = handleId.get(s);
5737
+ if (!id) return { error: `handle "${s}" was never resolved` };
5738
+ return { id };
5739
+ }
5740
+ const byLiveId = liveScope.find((t) => t.id === s);
5741
+ if (byLiveId) return { id: byLiveId.id };
3927
5742
  const orderStr = s.startsWith("#") ? s.slice(1) : s;
3928
5743
  if (/^\d+$/.test(orderStr)) {
3929
5744
  const order = Number(orderStr);
3930
- const byOrder = tasks.filter((t) => t.order === order);
3931
- if (byOrder.length === 1) return byOrder[0];
5745
+ const byOrder = originalScope.filter((t) => t.order === order);
5746
+ if (byOrder.length === 1) return { id: byOrder[0].id };
3932
5747
  }
3933
- return tasks.find((t) => t.title === s);
5748
+ const byTitle = originalScope.find((t) => t.title === s);
5749
+ if (byTitle) return { id: byTitle.id };
5750
+ return { error: `task "${s}" not found` };
5751
+ }
5752
+ function refCollidesWithExisting(name, originalFlatAll) {
5753
+ if (originalFlatAll.some((t) => t.id === name)) return true;
5754
+ const orderStr = name.startsWith("#") ? name.slice(1) : name;
5755
+ if (/^\d+$/.test(orderStr) && originalFlatAll.some((t) => t.order === Number(orderStr))) return true;
5756
+ return originalFlatAll.some((t) => t.title === name);
5757
+ }
5758
+ function findInTasks(tasks, id) {
5759
+ return flattenTasks(tasks).find((t) => t.id === id);
5760
+ }
5761
+ function findTopLevel(tasks, id) {
5762
+ return tasks.find((t) => t.id === id);
3934
5763
  }
3935
5764
  function detectCycle(tasks) {
3936
5765
  const byId = new Map(tasks.map((t) => [t.id, t]));
@@ -3954,6 +5783,55 @@ function detectCycle(tasks) {
3954
5783
  }
3955
5784
  return null;
3956
5785
  }
5786
+ function isPinned(t) {
5787
+ return t.status === "in_progress" || t.status === "completed";
5788
+ }
5789
+ function repairOrder(tasks) {
5790
+ const byId = new Map(tasks.map((t) => [t.id, t]));
5791
+ const placed = /* @__PURE__ */ new Set();
5792
+ const movable = tasks.filter((t) => !isPinned(t));
5793
+ const result = [];
5794
+ const blockerOf = (t) => {
5795
+ const id = t.dependencies.find((d) => byId.has(d) && !placed.has(d));
5796
+ return id ? byId.get(id) : void 0;
5797
+ };
5798
+ const place = (t) => {
5799
+ result.push(t);
5800
+ placed.add(t.id);
5801
+ };
5802
+ for (const occupant of tasks) {
5803
+ if (isPinned(occupant)) {
5804
+ const blocker = blockerOf(occupant);
5805
+ if (blocker) return { error: pinnedRefusal(blocker, occupant, "before") };
5806
+ place(occupant);
5807
+ continue;
5808
+ }
5809
+ const next = movable.findIndex((t) => !blockerOf(t));
5810
+ if (next < 0) {
5811
+ const [stuck, pin] = trailToPin(movable[0], blockerOf);
5812
+ return { error: pinnedRefusal(stuck, pin, "after") };
5813
+ }
5814
+ place(movable.splice(next, 1)[0]);
5815
+ }
5816
+ return { tasks: result };
5817
+ }
5818
+ function trailToPin(task, blockerOf) {
5819
+ let at = task;
5820
+ for (; ; ) {
5821
+ const blocker = blockerOf(at);
5822
+ if (!blocker || isPinned(blocker)) return [at, blocker ?? at];
5823
+ at = blocker;
5824
+ }
5825
+ }
5826
+ function describeMoves(before, after) {
5827
+ const wasAt = new Map(before.map((t, i) => [t.id, i + 1]));
5828
+ const moved = after.map((t, i) => ({ t, from: wasAt.get(t.id), to: i + 1 })).filter((m) => m.from !== m.to);
5829
+ return moved.length ? moved.map((m) => `"${m.t.title}" #${m.from}\u2192#${m.to}`).join(", ") : null;
5830
+ }
5831
+ function pinnedRefusal(other, pin, side) {
5832
+ const word = pin.status === "in_progress" ? "running" : "completed";
5833
+ return `"${other.title}" would have to run ${side} "${pin.title}", which is ${word} and cannot be moved`;
5834
+ }
3957
5835
  function canMergeTasks(tasks, selectedIds) {
3958
5836
  if (selectedIds.length < 2) return { ok: false, error: "Select at least two tasks to merge" };
3959
5837
  const idSet = new Set(selectedIds);
@@ -3970,15 +5848,6 @@ function canMergeTasks(tasks, selectedIds) {
3970
5848
  sim.push({ ...ordered[0], id: survivorId, title: "__merge_sim__", order: ordered[0].order, dependencies: unionDeps });
3971
5849
  const cycle = detectCycle(sim);
3972
5850
  if (cycle) return { ok: false, error: `Merging would create a dependency cycle involving "${cycle}"` };
3973
- const orderById = new Map(sim.map((t) => [t.id, t.order]));
3974
- for (const t of sim) {
3975
- for (const dep of t.dependencies) {
3976
- const depOrder = orderById.get(dep);
3977
- if (depOrder !== void 0 && depOrder >= t.order) {
3978
- return { ok: false, error: "Merging would break task ordering \u2014 a dependency of the merged set comes after it" };
3979
- }
3980
- }
3981
- }
3982
5851
  return { ok: true };
3983
5852
  }
3984
5853
  function dependentsOf(tasks, taskId) {
@@ -4010,17 +5879,46 @@ function canSplitTask(tasks, taskId) {
4010
5879
  if (target.status === "completed") return { ok: false, error: `"${target.title}" is completed and cannot be split` };
4011
5880
  return { ok: true };
4012
5881
  }
4013
- function applyTaskOps(currentTasks, ops, runners) {
5882
+ function applyTaskOps(currentTasks, ops, runners, catalog) {
4014
5883
  let tasks = JSON.parse(JSON.stringify(currentTasks));
4015
5884
  const errors = [];
4016
5885
  const summary = [];
4017
- const resolveDeps = (deps) => {
5886
+ const originalFlatAll = flattenTasks(currentTasks);
5887
+ const handleOwner = /* @__PURE__ */ new Map();
5888
+ const handleId = /* @__PURE__ */ new Map();
5889
+ for (const [i, op] of ops.entries()) {
5890
+ const handle = op.handle;
5891
+ if (handle === void 0) continue;
5892
+ if (typeof handle !== "string" || !handle.trim()) {
5893
+ errors.push(`op ${i + 1} (${op.op}): handle must be a non-empty string`);
5894
+ continue;
5895
+ }
5896
+ if (handleOwner.has(handle)) {
5897
+ errors.push(`op ${i + 1} (${op.op}): handle "${handle}" is already used earlier in this batch`);
5898
+ continue;
5899
+ }
5900
+ if (refCollidesWithExisting(handle, originalFlatAll)) {
5901
+ errors.push(`op ${i + 1} (${op.op}): handle "${handle}" collides with an existing task reference`);
5902
+ continue;
5903
+ }
5904
+ handleOwner.set(handle, i);
5905
+ }
5906
+ if (errors.length) return { ok: false, tasks: currentTasks, errors, summary: [] };
5907
+ const resolveDeps = (deps, opIndex) => {
4018
5908
  const ids = [];
4019
5909
  const bad = [];
5910
+ const liveFlat = flattenTasks(tasks);
4020
5911
  for (const d of Array.isArray(deps) ? deps : []) {
4021
- const t = resolveRef(d, flattenTasks(tasks));
4022
- if (t) ids.push(t.id);
4023
- else bad.push(String(d));
5912
+ const r = resolveRef(d, originalFlatAll, liveFlat, handleOwner, handleId, opIndex);
5913
+ if ("error" in r) {
5914
+ bad.push(`${String(d)} (${r.error})`);
5915
+ continue;
5916
+ }
5917
+ if (!liveFlat.some((t) => t.id === r.id)) {
5918
+ bad.push(`${String(d)} (no longer exists in this batch)`);
5919
+ continue;
5920
+ }
5921
+ ids.push(r.id);
4024
5922
  }
4025
5923
  return { ids, bad };
4026
5924
  };
@@ -4028,41 +5926,47 @@ function applyTaskOps(currentTasks, ops, runners) {
4028
5926
  const label = `op ${i + 1} (${op.op})`;
4029
5927
  switch (op.op) {
4030
5928
  case "update": {
4031
- const target = resolveRef(op.taskId, flattenTasks(tasks));
4032
- if (!target) {
4033
- errors.push(`${label}: task "${op.taskId}" not found`);
4034
- break;
4035
- }
4036
- if (target.status === "in_progress") {
4037
- errors.push(`${label}: "${target.title}" is running and cannot be modified`);
5929
+ const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
5930
+ if ("error" in ref) {
5931
+ errors.push(`${label}: ${ref.error}`);
4038
5932
  break;
4039
5933
  }
4040
- if (target.status === "completed") {
4041
- errors.push(`${label}: "${target.title}" is completed and cannot be modified`);
5934
+ const target = findInTasks(tasks, ref.id);
5935
+ if (!target) {
5936
+ errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
4042
5937
  break;
4043
5938
  }
4044
5939
  const changes = {};
4045
5940
  for (const key of UPDATABLE_FIELDS) {
4046
5941
  if (op.changes && key in op.changes) changes[key] = op.changes[key];
4047
5942
  }
5943
+ if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
5944
+ errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
5945
+ break;
5946
+ }
5947
+ const changesForCheck = { ...changes };
5948
+ delete changesForCheck.dependencies;
5949
+ const check = validateTaskEdit("planner", flattenTasks(tasks), target.id, changesForCheck, catalog);
5950
+ if (!check.ok) {
5951
+ errors.push(`${label}: ${check.error}`);
5952
+ break;
5953
+ }
5954
+ for (const field of check.clear ?? []) changes[field] = void 0;
4048
5955
  if ("dependencies" in changes) {
4049
- const { ids, bad } = resolveDeps(changes.dependencies);
5956
+ const { ids, bad } = resolveDeps(changes.dependencies, i);
4050
5957
  if (bad.length) {
4051
5958
  errors.push(`${label}: unknown dependencies: ${bad.join(", ")}`);
4052
5959
  break;
4053
5960
  }
4054
5961
  changes.dependencies = ids.filter((id) => id !== target.id);
4055
5962
  }
4056
- if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
4057
- errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
4058
- break;
4059
- }
4060
5963
  if (Object.keys(changes).length === 0) {
4061
5964
  errors.push(`${label}: no valid changes provided`);
4062
5965
  break;
4063
5966
  }
4064
5967
  tasks = updateTaskInPlan(tasks, target.id, changes);
4065
- summary.push(`Updated "${target.title}" (${Object.keys(changes).join(", ")})`);
5968
+ const clearedNote = check.clear?.length ? `; cleared ${check.clear.join(", ")}` : "";
5969
+ summary.push(`Updated "${target.title}" (${Object.keys(changes).join(", ")})${clearedNote}`);
4066
5970
  break;
4067
5971
  }
4068
5972
  case "add": {
@@ -4071,27 +5975,39 @@ function applyTaskOps(currentTasks, ops, runners) {
4071
5975
  errors.push(`${label}: new task needs a title`);
4072
5976
  break;
4073
5977
  }
4074
- const { ids, bad } = resolveDeps(spec.dependencies);
5978
+ const { ids, bad } = resolveDeps(spec.dependencies, i);
4075
5979
  if (bad.length) {
4076
5980
  errors.push(`${label}: unknown dependencies: ${bad.join(", ")}`);
4077
5981
  break;
4078
5982
  }
4079
5983
  const runner = spec.assignedRunner && runners.includes(spec.assignedRunner) ? spec.assignedRunner : runners[0];
5984
+ const validity = checkModelAndModeValidity(runner, spec.assignedModel, spec.taskMode, catalog);
5985
+ if (!validity.ok) {
5986
+ errors.push(`${label}: ${validity.error}`);
5987
+ break;
5988
+ }
5989
+ const newId = (0, import_uuid2.v4)();
4080
5990
  tasks = addTaskToPlan(tasks, {
4081
5991
  ...spec,
4082
- id: void 0,
5992
+ id: newId,
4083
5993
  dependencies: ids,
4084
5994
  assignedRunner: runner,
4085
5995
  description: spec.description ?? spec.title,
4086
5996
  prompt: spec.prompt ?? spec.description ?? spec.title
4087
5997
  });
5998
+ if (op.handle) handleId.set(op.handle, newId);
4088
5999
  summary.push(`Added "${spec.title}"`);
4089
6000
  break;
4090
6001
  }
4091
6002
  case "remove": {
4092
- const target = resolveRef(op.taskId, flattenTasks(tasks));
6003
+ const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
6004
+ if ("error" in ref) {
6005
+ errors.push(`${label}: ${ref.error}`);
6006
+ break;
6007
+ }
6008
+ const target = findInTasks(tasks, ref.id);
4093
6009
  if (!target) {
4094
- errors.push(`${label}: task "${op.taskId}" not found`);
6010
+ errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
4095
6011
  break;
4096
6012
  }
4097
6013
  if (target.status === "in_progress") {
@@ -4107,7 +6023,13 @@ function applyTaskOps(currentTasks, ops, runners) {
4107
6023
  break;
4108
6024
  }
4109
6025
  case "reorder": {
4110
- const resolved = (op.taskIds ?? []).map((r) => resolveRef(r, tasks));
6026
+ const refs = (op.taskIds ?? []).map((r) => resolveRef(r, currentTasks, tasks, handleOwner, handleId, i));
6027
+ const badRef = refs.find((r) => "error" in r);
6028
+ if (badRef) {
6029
+ errors.push(`${label}: ${badRef.error}`);
6030
+ break;
6031
+ }
6032
+ const resolved = refs.map((r) => findTopLevel(tasks, r.id));
4111
6033
  if (resolved.some((t) => !t)) {
4112
6034
  errors.push(`${label}: unknown task in reorder list`);
4113
6035
  break;
@@ -4123,9 +6045,15 @@ function applyTaskOps(currentTasks, ops, runners) {
4123
6045
  break;
4124
6046
  }
4125
6047
  case "merge": {
4126
- const resolved = (op.taskIds ?? []).map((r) => resolveRef(r, tasks));
6048
+ const refs = (op.taskIds ?? []).map((r) => resolveRef(r, currentTasks, tasks, handleOwner, handleId, i));
6049
+ const badRef = refs.find((r) => "error" in r);
6050
+ if (badRef) {
6051
+ errors.push(`${label}: ${badRef.error}`);
6052
+ break;
6053
+ }
6054
+ const resolved = refs.map((r) => findTopLevel(tasks, r.id));
4127
6055
  if (resolved.some((t) => !t)) {
4128
- errors.push(`${label}: unknown task in merge list`);
6056
+ errors.push(`${label}: unknown task in merge list (already removed, merged, or split by an earlier op)`);
4129
6057
  break;
4130
6058
  }
4131
6059
  const toMerge = resolved.map((t) => t).sort((a, b) => a.order - b.order);
@@ -4140,7 +6068,7 @@ function applyTaskOps(currentTasks, ops, runners) {
4140
6068
  }
4141
6069
  const idSet = new Set(toMerge.map((t) => t.id));
4142
6070
  const unionDepsRaw = [...new Set(toMerge.flatMap((t) => t.dependencies))].filter((d) => !idSet.has(d));
4143
- const { ids, bad } = resolveDeps(unionDepsRaw);
6071
+ const { ids, bad } = resolveDeps(unionDepsRaw, i);
4144
6072
  if (bad.length) {
4145
6073
  errors.push(`${label}: unknown dependencies: ${bad.join(", ")}`);
4146
6074
  break;
@@ -4181,11 +6109,17 @@ function applyTaskOps(currentTasks, ops, runners) {
4181
6109
  }
4182
6110
  if (!inserted) result.push(mergedTask);
4183
6111
  tasks = renumberTasks(result);
6112
+ if (op.handle) handleId.set(op.handle, mergedId);
4184
6113
  summary.push(`Merged ${toMerge.length} tasks into "${spec.title}"`);
4185
6114
  break;
4186
6115
  }
4187
6116
  case "split": {
4188
- const target = resolveRef(op.taskId, tasks);
6117
+ const ref = resolveRef(op.taskId, currentTasks, tasks, handleOwner, handleId, i);
6118
+ if ("error" in ref) {
6119
+ errors.push(`${label}: ${ref.error}`);
6120
+ break;
6121
+ }
6122
+ const target = findTopLevel(tasks, ref.id);
4189
6123
  if (!target) {
4190
6124
  errors.push(`${label}: task "${op.taskId}" not found`);
4191
6125
  break;
@@ -4206,16 +6140,16 @@ function applyTaskOps(currentTasks, ops, runners) {
4206
6140
  const newTasks = [];
4207
6141
  let prevId;
4208
6142
  let buildError = false;
4209
- for (let i2 = 0; i2 < parts.length; i2++) {
4210
- const spec = parts[i2];
6143
+ for (let p = 0; p < parts.length; p++) {
6144
+ const spec = parts[p];
4211
6145
  if (!spec || !spec.title) {
4212
- errors.push(`${label}: split part ${i2 + 1} needs a title`);
6146
+ errors.push(`${label}: split part ${p + 1} needs a title`);
4213
6147
  buildError = true;
4214
6148
  break;
4215
6149
  }
4216
6150
  let deps;
4217
- if (i2 === 0) {
4218
- const { ids, bad } = resolveDeps(target.dependencies);
6151
+ if (p === 0) {
6152
+ const { ids, bad } = resolveDeps(target.dependencies, i);
4219
6153
  if (bad.length) {
4220
6154
  errors.push(`${label}: split part 1 unknown dependencies: ${bad.join(", ")}`);
4221
6155
  buildError = true;
@@ -4253,9 +6187,60 @@ function applyTaskOps(currentTasks, ops, runners) {
4253
6187
  result.push({ ...t, dependencies: t.dependencies.map((d) => d === target.id ? tailId : d) });
4254
6188
  }
4255
6189
  tasks = renumberTasks(result);
6190
+ if (op.handle) handleId.set(op.handle, tailId);
4256
6191
  summary.push(`Split "${target.title}" into ${newTasks.length} tasks`);
4257
6192
  break;
4258
6193
  }
6194
+ case "rearm": {
6195
+ const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
6196
+ if ("error" in ref) {
6197
+ errors.push(`${label}: ${ref.error}`);
6198
+ break;
6199
+ }
6200
+ const target = findInTasks(tasks, ref.id);
6201
+ if (!target) {
6202
+ errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
6203
+ break;
6204
+ }
6205
+ if (target.status === "in_progress") {
6206
+ errors.push(`${label}: "${target.title}" is running and cannot be re-armed`);
6207
+ break;
6208
+ }
6209
+ const changes = {};
6210
+ for (const key of UPDATABLE_FIELDS) {
6211
+ if (op.changes && key in op.changes) changes[key] = op.changes[key];
6212
+ }
6213
+ if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
6214
+ errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
6215
+ break;
6216
+ }
6217
+ const changesForCheck = { ...changes };
6218
+ delete changesForCheck.dependencies;
6219
+ const check = validateTaskEdit("direct", flattenTasks(tasks), target.id, changesForCheck, catalog);
6220
+ if (!check.ok) {
6221
+ errors.push(`${label}: ${check.error}`);
6222
+ break;
6223
+ }
6224
+ for (const field of check.clear ?? []) changes[field] = void 0;
6225
+ if ("dependencies" in changes) {
6226
+ const { ids, bad } = resolveDeps(changes.dependencies, i);
6227
+ if (bad.length) {
6228
+ errors.push(`${label}: unknown dependencies: ${bad.join(", ")}`);
6229
+ break;
6230
+ }
6231
+ changes.dependencies = ids.filter((id) => id !== target.id);
6232
+ }
6233
+ tasks = updateTaskInPlan(tasks, target.id, { ...changes, status: "pending", verdict: void 0, outputSummary: void 0 });
6234
+ for (const dep of flattenTasks(tasks)) {
6235
+ if (dep.status === "blocked" && dep.dependencies.includes(target.id)) {
6236
+ tasks = updateTaskInPlan(tasks, dep.id, { status: "pending" });
6237
+ }
6238
+ }
6239
+ const clearedNote = check.clear?.length ? `; cleared ${check.clear.join(", ")}` : "";
6240
+ const changedNote = Object.keys(changes).length ? ` (${Object.keys(changes).join(", ")})` : "";
6241
+ summary.push(`Re-armed "${target.title}"${changedNote}${clearedNote}`);
6242
+ break;
6243
+ }
4259
6244
  default:
4260
6245
  errors.push(`${label}: unknown op "${op.op}"`);
4261
6246
  }
@@ -4271,13 +6256,14 @@ function applyTaskOps(currentTasks, ops, runners) {
4271
6256
  }
4272
6257
  const cycleAt = detectCycle(flat);
4273
6258
  if (cycleAt) errors.push(`Dependency cycle detected involving "${cycleAt}"`);
4274
- const orderById = new Map(tasks.map((t) => [t.id, t.order]));
4275
- for (const t of tasks) {
4276
- for (const dep of t.dependencies) {
4277
- const depOrder = orderById.get(dep);
4278
- if (depOrder !== void 0 && depOrder >= t.order) {
4279
- errors.push(`"${t.title}" is ordered before its dependency; move the dependency earlier`);
4280
- }
6259
+ if (!errors.length) {
6260
+ const repaired = repairOrder(tasks);
6261
+ if ("error" in repaired) {
6262
+ errors.push(repaired.error);
6263
+ } else {
6264
+ const moves = describeMoves(tasks, repaired.tasks);
6265
+ if (moves) summary.push(`Reordered to keep dependencies first: ${moves}`);
6266
+ tasks = renumberTasks(repaired.tasks);
4281
6267
  }
4282
6268
  }
4283
6269
  }
@@ -4307,6 +6293,9 @@ function truncatedPlanReEmitPrompt(historyCompacted) {
4307
6293
  function reEmitTaskOpsPrompt(detail) {
4308
6294
  return `Your task edits could not be applied: ${detail}. Re-emit ONLY a corrected {"${TASK_OPS_ENVELOPE_KEY}":[...]} JSON object \u2014 no prose, no code fences \u2014 or reply in prose if you did not intend to edit tasks.`;
4309
6295
  }
6296
+ function reEmitTaskQueryPrompt(detail) {
6297
+ return `Your task-detail request could not be read: ${detail}. Re-emit ONLY a corrected {"${TASK_QUERY_ENVELOPE_KEY}":{"tasks":["<id or #order>"],"fields"?:[...],"catalog"?:true}} JSON object \u2014 no prose, no code fences \u2014 or reply in prose if you did not mean to look anything up.`;
6298
+ }
4310
6299
  function taskOpsRejectedPrompt(errors) {
4311
6300
  return `Those task edits were rejected:
4312
6301
  - ${errors.join("\n- ")}
@@ -4333,6 +6322,16 @@ function classifyPlannerReply(text, opts) {
4333
6322
  }
4334
6323
  }
4335
6324
  }
6325
+ if (textHasTaskQuery(text)) {
6326
+ try {
6327
+ return { kind: "task_query", query: parseTaskQueryJson(text) };
6328
+ } catch (err) {
6329
+ if (!(err instanceof PlanParseError)) throw err;
6330
+ if (extractObjectsWithKey(text, TASK_QUERY_ENVELOPE_KEY).matches.length > 0) {
6331
+ return { kind: "broken_task_query", error: err };
6332
+ }
6333
+ }
6334
+ }
4336
6335
  if (text.includes(`"${PLAN_ENVELOPE_KEY}"`)) {
4337
6336
  try {
4338
6337
  return { kind: "plan", tasks: parsePlanJson(text, opts.runners, opts.runnerModes, opts.autonomousDefault) };
@@ -4976,7 +6975,7 @@ var BaseAiService = class _BaseAiService {
4976
6975
  const parallelIndexes = toolCalls.flatMap((tc, i) => PARALLEL_SAFE_TOOLS.has(tc.name) ? [i] : []);
4977
6976
  await Promise.all([
4978
6977
  spawnIndexes.length > 0 ? mapWithConcurrency(spawnIndexes, SUBAGENT_LIMITS.maxConcurrent, async (i) => {
4979
- const subagentId = (0, import_uuid2.v4)();
6978
+ const subagentId = (0, import_uuid3.v4)();
4980
6979
  spawnSubagentIds.set(i, subagentId);
4981
6980
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), subagentId, toolCallId: toolCalls[i].id });
4982
6981
  precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs10, onProgress, signal, subagentId));
@@ -5131,6 +7130,10 @@ ${llmOutput}
5131
7130
  switch (reply.kind) {
5132
7131
  case "task_ops":
5133
7132
  return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
7133
+ // A read is settled by the Session (it owns the plan and the catalog),
7134
+ // so it leaves this loop the same way a plan or an edit does.
7135
+ case "task_query":
7136
+ return { kind: "task_query", query: reply.query, text: turn.text, researchLog };
5134
7137
  case "plan":
5135
7138
  if (ctx.prdEnabled && !ctx.prdCaptured && !ctx.prdNudgeSent && !signal?.aborted) {
5136
7139
  ctx.prdNudgeSent = true;
@@ -5153,6 +7156,13 @@ ${llmOutput}
5153
7156
  continue;
5154
7157
  }
5155
7158
  break;
7159
+ case "broken_task_query":
7160
+ if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
7161
+ jsonRepairAttempts++;
7162
+ pending = reEmitTaskQueryPrompt(reply.error.message);
7163
+ continue;
7164
+ }
7165
+ break;
5156
7166
  case "broken_plan":
5157
7167
  if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
5158
7168
  jsonRepairAttempts++;
@@ -5955,11 +7965,11 @@ function composeAugmentedPrompt(task, allTasks, opts) {
5955
7965
  if (isHitlTask(task)) {
5956
7966
  blocks.push(renderCheckpointInstruction());
5957
7967
  }
5958
- const marker = renderCompletionMarker(task);
5959
- if (blocks.length === 0) return basePrompt + marker;
7968
+ const marker2 = renderCompletionMarker(task);
7969
+ if (blocks.length === 0) return basePrompt + marker2;
5960
7970
  return `${blocks.join("\n\n")}
5961
7971
 
5962
- ${basePrompt}${marker}`;
7972
+ ${basePrompt}${marker2}`;
5963
7973
  }
5964
7974
 
5965
7975
  // src/services/VerdictEngine.ts
@@ -7132,111 +9142,6 @@ ${output.slice(-3e3)}`);
7132
9142
  }
7133
9143
  };
7134
9144
 
7135
- // src/services/ModelAllowlistResolver.ts
7136
- function effectiveAllowlist(allowlist, runner, modelsByRunner) {
7137
- if (!allowlist || allowlist.length === 0) return void 0;
7138
- if (!modelsByRunner) return allowlist;
7139
- const own = new Set((modelsByRunner[runner] ?? []).map((m) => m.modelId));
7140
- const elsewhere = /* @__PURE__ */ new Set();
7141
- for (const [other, models] of Object.entries(modelsByRunner)) {
7142
- if (other === runner) continue;
7143
- for (const m of models ?? []) elsewhere.add(m.modelId);
7144
- }
7145
- const kept = allowlist.filter((id) => own.has(id) || !elsewhere.has(id));
7146
- return kept.length > 0 ? kept : void 0;
7147
- }
7148
- function filterModelsForPrompt(modelsByRunner, perRunnerAllowlist) {
7149
- const result = {};
7150
- for (const [runner, models] of Object.entries(modelsByRunner)) {
7151
- if (!models) continue;
7152
- const allowed = effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
7153
- if (!allowed) {
7154
- result[runner] = models;
7155
- continue;
7156
- }
7157
- const allowedSet = new Set(allowed);
7158
- const intersection = models.filter((m) => allowedSet.has(m.modelId));
7159
- const covered = new Set(intersection.map((m) => m.modelId));
7160
- const missing = allowed.filter((id) => !covered.has(id)).map((id) => ({ modelId: id, modelLabel: id.split("/").pop() || id, variants: [] }));
7161
- result[runner] = [...intersection, ...missing];
7162
- }
7163
- return result;
7164
- }
7165
- var EFFORT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
7166
- var EFFORT_ALIASES = { disabled: "none", adaptive: "medium" };
7167
- function clampThinkingEffort(effort, variants) {
7168
- if (!effort) return void 0;
7169
- if (variants.length === 0) return void 0;
7170
- const ids = new Set(variants.map((v) => v.id));
7171
- if (ids.has(effort)) return effort;
7172
- const canon = EFFORT_ALIASES[effort] ?? effort;
7173
- if (ids.has(canon)) return canon;
7174
- const idx = EFFORT_LADDER.indexOf(canon);
7175
- if (idx < 0) return void 0;
7176
- for (let d = 1; d < EFFORT_LADDER.length; d++) {
7177
- const lower = EFFORT_LADDER[idx - d];
7178
- if (lower && ids.has(lower)) return lower;
7179
- const higher = EFFORT_LADDER[idx + d];
7180
- if (higher && ids.has(higher)) return higher;
7181
- }
7182
- return void 0;
7183
- }
7184
- function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRunner) {
7185
- const clampVariant = (task) => {
7186
- if (!task.assignedModel || !modelsByRunner) return task;
7187
- const model = modelsByRunner[task.assignedRunner]?.find(
7188
- (m) => m.modelId === task.assignedModel.modelId
7189
- );
7190
- if (!model) return task;
7191
- const clamped = clampThinkingEffort(task.assignedModel.thinkingEffort, model.variants);
7192
- return {
7193
- ...task,
7194
- assignedModel: {
7195
- ...task.assignedModel,
7196
- thinkingEffort: clamped,
7197
- availableVariants: model.variants.map((v) => v.id)
7198
- }
7199
- };
7200
- };
7201
- const allowedFor = (runner) => effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
7202
- const assignmentFor = (runner, modelId) => {
7203
- const known = modelsByRunner?.[runner]?.find((m) => m.modelId === modelId);
7204
- return {
7205
- modelId,
7206
- modelLabel: known?.modelLabel ?? modelId,
7207
- thinkingEffort: void 0,
7208
- ...known ? { availableVariants: known.variants.map((v) => v.id) } : {}
7209
- };
7210
- };
7211
- return tasks.map((task) => {
7212
- if (task.type === "user") return task;
7213
- if (allowedRunners && !allowedRunners.includes(task.assignedRunner)) {
7214
- const fallbackRunner = allowedRunners[0];
7215
- const fallbackModels = allowedFor(fallbackRunner);
7216
- const fallbackModel = fallbackModels?.length ? assignmentFor(fallbackRunner, fallbackModels[0]) : modelsByRunner?.[fallbackRunner]?.[0] ? assignmentFor(fallbackRunner, modelsByRunner[fallbackRunner][0].modelId) : task.assignedModel;
7217
- return clampVariant({
7218
- ...task,
7219
- assignedRunner: fallbackRunner,
7220
- assignedModel: fallbackModel
7221
- });
7222
- }
7223
- const allowlist = allowedFor(task.assignedRunner);
7224
- if (!task.assignedModel) {
7225
- if (allowlist && allowlist.length > 0) {
7226
- return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) });
7227
- }
7228
- const discovered = modelsByRunner?.[task.assignedRunner]?.[0];
7229
- if (discovered) {
7230
- return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, discovered.modelId) });
7231
- }
7232
- return task;
7233
- }
7234
- if (!allowlist || allowlist.length === 0) return clampVariant(task);
7235
- if (allowlist.includes(task.assignedModel.modelId)) return clampVariant(task);
7236
- return { ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) };
7237
- });
7238
- }
7239
-
7240
9145
  // src/services/Planner.ts
7241
9146
  function plannerModesWith(req) {
7242
9147
  const modes = req.modes ?? DEFAULT_PLANNER_MODES;
@@ -8906,7 +10811,7 @@ function assertWorkspaceIsProject(workspace, deps = {}) {
8906
10811
  throw new WorkspaceNotAProjectError(workspace);
8907
10812
  }
8908
10813
  const hasMarker = [...PROJECT_MARKER_DIRS, ...PROJECT_MARKER_FILES].some(
8909
- (marker) => exists(path9.join(resolved, marker))
10814
+ (marker2) => exists(path9.join(resolved, marker2))
8910
10815
  );
8911
10816
  if (!hasMarker) throw new WorkspaceNotAProjectError(workspace);
8912
10817
  }
@@ -8948,12 +10853,19 @@ var StdioAgentAdapter = class {
8948
10853
  /** Set for the duration of a turn. Outside one, events are buffered rather than dropped. */
8949
10854
  turnEmit = null;
8950
10855
  /**
8951
- * Events the agent produced between turns — in practice the startup warnings
8952
- * that arrive during the handshake. Dropping them hid the one diagnostic
8953
- * that explains a planner which cannot read the workspace, so they are held
8954
- * until a turn exists to show them in.
10856
+ * Events the agent produced before its first turn — in practice the startup
10857
+ * warnings that arrive during the handshake. Dropping them hid the one
10858
+ * diagnostic that explains a planner which cannot read the workspace, so they
10859
+ * are held until a turn exists to show them in.
10860
+ *
10861
+ * Only before the *first* turn. Once one has run, anything arriving outside a
10862
+ * turn is that closed turn's straggling work — a backgrounded subagent
10863
+ * finishing, a late read — and replaying it into the next turn presents it as
10864
+ * research done for the user's new message.
8955
10865
  */
8956
10866
  betweenTurns = [];
10867
+ /** Set once a turn has ended: after that, out-of-turn events are stale, not startup. */
10868
+ hadTurn = false;
8957
10869
  disposed = false;
8958
10870
  /** Resolves when the process ends, so a handshake can lose the race instead of waiting out its timeout. */
8959
10871
  processEnded;
@@ -8988,7 +10900,7 @@ var StdioAgentAdapter = class {
8988
10900
  this.process.stdout?.on("data", (chunk) => {
8989
10901
  this.stdout.push(chunk.toString(), (line) => this.handleLine(line, (event) => {
8990
10902
  if (this.turnEmit) this.turnEmit(event);
8991
- else if (!TURN_SCOPED_EVENTS.has(event.type) && this.betweenTurns.length < BETWEEN_TURN_EVENT_CAP) {
10903
+ else if (!this.hadTurn && !TURN_SCOPED_EVENTS.has(event.type) && this.betweenTurns.length < BETWEEN_TURN_EVENT_CAP) {
8992
10904
  this.betweenTurns.push(event);
8993
10905
  }
8994
10906
  }));
@@ -9020,6 +10932,7 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS);
9020
10932
  if (settled) return;
9021
10933
  settled = true;
9022
10934
  this.turnEmit = null;
10935
+ this.hadTurn = true;
9023
10936
  signal?.removeEventListener("abort", onAbort);
9024
10937
  this.process?.removeListener("exit", onExit);
9025
10938
  this.process?.removeListener("error", onExit);
@@ -9098,6 +11011,7 @@ ${tail}` : ""}`;
9098
11011
 
9099
11012
  // src/services/harness/ClaudeCodeAdapter.ts
9100
11013
  var DISALLOWED_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit", "KillShell"];
11014
+ var ASYNC_LAUNCH_MARKER = "Async agent launched successfully";
9101
11015
  function flattenContent(content) {
9102
11016
  if (typeof content === "string") return content;
9103
11017
  if (Array.isArray(content)) {
@@ -9107,6 +11021,8 @@ function flattenContent(content) {
9107
11021
  }
9108
11022
  var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9109
11023
  agentId = "claude-code";
11024
+ /** Whether this turn has already emitted reply text — see {@link handleLine}. */
11025
+ turnHasText = false;
9110
11026
  spawnSpec(opts) {
9111
11027
  const args = [
9112
11028
  "-p",
@@ -9131,6 +11047,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9131
11047
  return { command: "claude", args };
9132
11048
  }
9133
11049
  turnPayload(message) {
11050
+ this.turnHasText = false;
9134
11051
  return `${JSON.stringify({
9135
11052
  type: "user",
9136
11053
  message: { role: "user", content: [{ type: "text", text: message }] }
@@ -9141,6 +11058,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9141
11058
  const msg = StdioAgentAdapter.parse(line);
9142
11059
  if (!msg) return;
9143
11060
  if (msg.session_id) this.sessionId = msg.session_id;
11061
+ if (msg.parent_tool_use_id) return;
9144
11062
  switch (msg.type) {
9145
11063
  // The control channel: Claude asks whether a tool may run when its mode
9146
11064
  // cannot decide alone. A read-only planner answers "deny", every time —
@@ -9165,8 +11083,12 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9165
11083
  }
9166
11084
  case "assistant":
9167
11085
  for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
9168
- if (block.type === "text" && block.text) emit({ type: "assistant_text", text: block.text });
9169
- else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
11086
+ if (block.type === "text" && block.text) {
11087
+ emit({ type: "assistant_text", text: this.turnHasText ? `
11088
+
11089
+ ${block.text}` : block.text });
11090
+ this.turnHasText = true;
11091
+ } else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
9170
11092
  else if (block.type === "tool_use" && block.name) {
9171
11093
  emit({ type: "tool_call", id: block.id ?? block.name, name: block.name, args: block.input ?? {} });
9172
11094
  }
@@ -9175,11 +11097,15 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
9175
11097
  case "user":
9176
11098
  for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
9177
11099
  if (block.type !== "tool_result") continue;
11100
+ const output = flattenContent(block.content);
11101
+ if (output.includes(ASYNC_LAUNCH_MARKER)) {
11102
+ emit({ type: "background_agent", id: block.tool_use_id ?? "" });
11103
+ }
9178
11104
  emit({
9179
11105
  type: "tool_result",
9180
11106
  id: block.tool_use_id ?? "",
9181
11107
  name: "",
9182
- output: flattenContent(block.content),
11108
+ output,
9183
11109
  success: block.is_error !== true
9184
11110
  });
9185
11111
  }
@@ -9599,6 +11525,8 @@ var OpenCodeAdapter = class {
9599
11525
  exited = false;
9600
11526
  disposed = false;
9601
11527
  opts = null;
11528
+ /** Whether this turn has already emitted reply text — see {@link emitPart}. */
11529
+ turnHasText = false;
9602
11530
  async start(opts) {
9603
11531
  this.opts = opts;
9604
11532
  assertWorkspaceExists(opts.cwd, { isDirectory: this.deps.isDirectory });
@@ -9666,6 +11594,7 @@ ${this.stderrTail.trim()}` : ""}`);
9666
11594
  return;
9667
11595
  }
9668
11596
  const seen = /* @__PURE__ */ new Set();
11597
+ this.turnHasText = false;
9669
11598
  const streamAbort = new AbortController();
9670
11599
  let connected = () => {
9671
11600
  };
@@ -9734,7 +11663,10 @@ ${this.stderrTail.trim()}` : ""}`);
9734
11663
  if (part.type === "text" && part.text) {
9735
11664
  if (seen.has(`text:${id}`)) return;
9736
11665
  seen.add(`text:${id}`);
9737
- onEvent({ type: "assistant_text", text: part.text });
11666
+ onEvent({ type: "assistant_text", text: this.turnHasText ? `
11667
+
11668
+ ${part.text}` : part.text });
11669
+ this.turnHasText = true;
9738
11670
  return;
9739
11671
  }
9740
11672
  if (part.type === "reasoning" && part.text) {
@@ -9901,6 +11833,7 @@ function normalizeAgentArgs(tool, args) {
9901
11833
 
9902
11834
  // src/services/harness/CliAgentAiService.ts
9903
11835
  var MAX_JSON_REPAIRS = 2;
11836
+ var MAX_AGENT_WAITS = 2;
9904
11837
  var LOG_MAX_CHARS = 1e4;
9905
11838
  function defaultAdapter(runner, deps) {
9906
11839
  switch (runner) {
@@ -10041,13 +11974,16 @@ var CliAgentAiService = class {
10041
11974
  let pending = message;
10042
11975
  let emptyNudgeSent = false;
10043
11976
  let jsonRepairAttempts = 0;
11977
+ let agentWaits = 0;
11978
+ const carried = [];
11979
+ const replyText = (text) => [...carried, text].filter((part) => part.trim()).join("\n\n");
10044
11980
  try {
10045
11981
  for (; ; ) {
10046
11982
  const turn = await this.runTurn(pending, onProgress, combined);
10047
11983
  researchLog.push(...turn.researchLog);
10048
11984
  if (turn.aborted || combined?.aborted) {
10049
11985
  onProgress({ type: "interrupted" });
10050
- return { kind: "message", text: turn.text, researchLog };
11986
+ return { kind: "message", text: replyText(turn.text), researchLog };
10051
11987
  }
10052
11988
  if (turn.error) {
10053
11989
  return { kind: "message", text: turn.error, researchLog };
@@ -10065,6 +12001,18 @@ var CliAgentAiService = class {
10065
12001
  researchLog
10066
12002
  };
10067
12003
  }
12004
+ if (turn.backgroundAgents > 0 && agentWaits < MAX_AGENT_WAITS && !combined?.aborted) {
12005
+ agentWaits++;
12006
+ carried.push(turn.text);
12007
+ pending = [
12008
+ `You ended your turn with ${turn.backgroundAgents} subagent(s) still running in the background.`,
12009
+ "Ordewell hands the conversation back to the user when your turn ends, so anything you say after it never reaches them \u2014",
12010
+ "the results you promised to report would be lost.",
12011
+ "Wait for those agents to finish NOW, in this reply, and do not end your turn until you have their results.",
12012
+ "Then give the user your synthesis. In future replies, await your agents inside the turn rather than backgrounding them."
12013
+ ].join(" ");
12014
+ continue;
12015
+ }
10068
12016
  if (extractPrdBlock(turn.text)) conversation.prdCaptured = true;
10069
12017
  const reply = classifyPlannerReply(turn.text, {
10070
12018
  runners: conversation.runners,
@@ -10073,7 +12021,11 @@ var CliAgentAiService = class {
10073
12021
  });
10074
12022
  switch (reply.kind) {
10075
12023
  case "task_ops":
10076
- return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
12024
+ return { kind: "task_ops", ops: reply.ops, text: replyText(turn.text), researchLog };
12025
+ // The read channel is a text envelope precisely so it reaches here
12026
+ // too: a harness planner has no Ordewell tool loop to call into.
12027
+ case "task_query":
12028
+ return { kind: "task_query", query: reply.query, text: replyText(turn.text), researchLog };
10077
12029
  case "plan":
10078
12030
  if (conversation.prdEnabled && !conversation.prdCaptured && !conversation.prdNudgeSent && !combined?.aborted) {
10079
12031
  conversation.prdNudgeSent = true;
@@ -10086,7 +12038,7 @@ var CliAgentAiService = class {
10086
12038
  continue;
10087
12039
  }
10088
12040
  this.conversation = null;
10089
- return { kind: "plan", tasks: reply.tasks, text: turn.text, researchLog };
12041
+ return { kind: "plan", tasks: reply.tasks, text: replyText(turn.text), researchLog };
10090
12042
  case "broken_task_ops":
10091
12043
  if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
10092
12044
  jsonRepairAttempts++;
@@ -10094,6 +12046,13 @@ var CliAgentAiService = class {
10094
12046
  continue;
10095
12047
  }
10096
12048
  break;
12049
+ case "broken_task_query":
12050
+ if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
12051
+ jsonRepairAttempts++;
12052
+ pending = reEmitTaskQueryPrompt(reply.error.message);
12053
+ continue;
12054
+ }
12055
+ break;
10097
12056
  case "broken_plan":
10098
12057
  if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
10099
12058
  jsonRepairAttempts++;
@@ -10104,7 +12063,7 @@ var CliAgentAiService = class {
10104
12063
  case "prose":
10105
12064
  break;
10106
12065
  }
10107
- return { kind: "message", text: turn.text, researchLog };
12066
+ return { kind: "message", text: replyText(turn.text), researchLog };
10108
12067
  }
10109
12068
  } finally {
10110
12069
  this.activeAbort = null;
@@ -10124,9 +12083,11 @@ var CliAgentAiService = class {
10124
12083
  let text = "";
10125
12084
  let error;
10126
12085
  let stepIndex = 0;
12086
+ let backgroundAgents = 0;
10127
12087
  const truncate = (value) => value.length > LOG_MAX_CHARS ? `${value.slice(0, LOG_MAX_CHARS)}
10128
12088
  [... truncated, total ${value.length} chars]` : value;
10129
- const settle = (id, output, success, outcome) => {
12089
+ const settle = (id, rawOutput, success, outcome) => {
12090
+ const output = redactSecrets(rawOutput);
10130
12091
  const call = pendingCalls.get(id);
10131
12092
  pendingCalls.delete(id);
10132
12093
  const step = {
@@ -10162,6 +12123,9 @@ var CliAgentAiService = class {
10162
12123
  case "tool_result":
10163
12124
  settle(event.id, event.output, event.success, event.success ? "success" : "failure");
10164
12125
  return;
12126
+ case "background_agent":
12127
+ backgroundAgents++;
12128
+ return;
10165
12129
  case "permission_request": {
10166
12130
  const mapped = mapAgentTool(event.name);
10167
12131
  const args = JSON.stringify({ detail: event.detail });
@@ -10192,7 +12156,7 @@ var CliAgentAiService = class {
10192
12156
  adapter.dispose();
10193
12157
  this.adapter = null;
10194
12158
  }
10195
- return { text, researchLog, error, aborted: signal?.aborted };
12159
+ return { text, researchLog, error, aborted: signal?.aborted, backgroundAgents };
10196
12160
  }
10197
12161
  // --- Process lifecycle ---
10198
12162
  async startAdapter(opts) {
@@ -10657,6 +12621,16 @@ function ensureDir(dir) {
10657
12621
  function getStateDir(baseDir) {
10658
12622
  return path10.join(baseDir ?? process.cwd(), STATE_DIR);
10659
12623
  }
12624
+ var IGNORE_FILE = ".gitignore";
12625
+ function ensureStateDirIgnored(baseDir) {
12626
+ const dir = getStateDir(baseDir);
12627
+ ensureDir(dir);
12628
+ try {
12629
+ fs4.writeFileSync(path10.join(dir, IGNORE_FILE), "*\n", { flag: "wx" });
12630
+ } catch (err) {
12631
+ if (err.code !== "EEXIST") throw err;
12632
+ }
12633
+ }
10660
12634
 
10661
12635
  // src/utils/sessionId.ts
10662
12636
  var import_crypto = require("crypto");
@@ -10686,6 +12660,7 @@ function sessionFilename(session) {
10686
12660
  function saveSession(plan, goal, baseDir, id) {
10687
12661
  const sessionsDir = getSessionsDir(baseDir);
10688
12662
  ensureDir(sessionsDir);
12663
+ ensureStateDirIgnored(baseDir);
10689
12664
  const now = (/* @__PURE__ */ new Date()).toISOString();
10690
12665
  const meta = {
10691
12666
  id: id || mintSessionId(),
@@ -10788,6 +12763,12 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
10788
12763
  }
10789
12764
 
10790
12765
  // src/services/createSession.ts
12766
+ var CATALOG_MODEL_CAP = 100;
12767
+ var MAX_TASK_QUERIES = 3;
12768
+ var MAX_TASK_QUERIES_HARD = 6;
12769
+ function freshReadBudget() {
12770
+ return { answered: 0, seen: /* @__PURE__ */ new Set() };
12771
+ }
10791
12772
  var PlanEditError = class extends Error {
10792
12773
  constructor(message) {
10793
12774
  super(message);
@@ -11024,6 +13005,21 @@ var Session = class {
11024
13005
  runnerModesFor(runners) {
11025
13006
  return runnerModesFrom(this.registry, runners);
11026
13007
  }
13008
+ /**
13009
+ * The catalog a model/task-mode edit is checked against — the same
13010
+ * discovered models and manifest modes the per-turn catalog block shows the
13011
+ * planner, so a refusal here can never name something invalid that the
13012
+ * planner was never told about. `modelsCache` is read live, not filtered by
13013
+ * the allowlist here — {@link checkModelAndModeValidity} narrows by
13014
+ * allowlist itself, the same way `coerceAssignments` does.
13015
+ */
13016
+ editCatalog() {
13017
+ return {
13018
+ modelsByRunner: this.modelsCache,
13019
+ runnerModes: this.runnerModesFor(this.plan?.runners ?? []),
13020
+ perRunnerAllowlist: this.settingsFn().modelAllowlist ?? this.currentAllowlist
13021
+ };
13022
+ }
11027
13023
  get planState() {
11028
13024
  return this.plan;
11029
13025
  }
@@ -11203,10 +13199,12 @@ var Session = class {
11203
13199
  async continueConversation(userMessage, options) {
11204
13200
  if (!this.plan) throw new Error("No planning conversation to continue");
11205
13201
  const priorHistory = this.plan.conversationHistory ?? [];
13202
+ const priorResearchLog = this.plan.researchLog ?? [];
11206
13203
  const now = (/* @__PURE__ */ new Date()).toISOString();
11207
13204
  this.appendTranscript("user", userMessage, now);
13205
+ const historyAfterOwnAppend = this.plan.conversationHistory;
11208
13206
  this.plan.researchLog = [...this.plan.researchLog ?? [], { id: `up-${Date.now()}`, type: "user_prompt", content: userMessage, timestamp: now }];
11209
- const contextBlock = this.planContextBlock();
13207
+ const contextBlock = [this.catalogBlock(), this.planContextBlock()].filter(Boolean).join("\n\n");
11210
13208
  const outgoing = contextBlock ? `${contextBlock}
11211
13209
 
11212
13210
  ${userMessage}` : userMessage;
@@ -11219,6 +13217,8 @@ ${userMessage}` : userMessage;
11219
13217
  (p) => this.translateProgress(p),
11220
13218
  options?.signal
11221
13219
  ) : await this.resumeConversation(outgoing, priorHistory, options);
13220
+ const reads = freshReadBudget();
13221
+ turn = await this.drainTaskQueries(turn, reads, options);
11222
13222
  if ((turn.kind === "task_ops" || turn.kind === "plan") && this.hasLiveWork) {
11223
13223
  this.queueMessage(userMessage);
11224
13224
  this.plan.queuedMessages = this.getQueuedMessages();
@@ -11228,11 +13228,74 @@ ${userMessage}` : userMessage;
11228
13228
  researchLog: turn.researchLog
11229
13229
  };
11230
13230
  }
11231
- return await this.settleTurn(turn, options);
13231
+ return await this.settleTurn(turn, options, reads);
13232
+ } catch (err) {
13233
+ if (this.plan && this.plan.conversationHistory === historyAfterOwnAppend) {
13234
+ this.plan.conversationHistory = priorHistory;
13235
+ this.plan.researchLog = priorResearchLog;
13236
+ }
13237
+ throw err;
11232
13238
  } finally {
11233
13239
  releaseAbort();
11234
13240
  }
11235
13241
  }
13242
+ /**
13243
+ * Answer every read the planner emits until it says something else.
13244
+ *
13245
+ * The channel is a text envelope rather than a registered tool because the
13246
+ * protocol has to be identical on both planner backends (ADR-0009): Ordewell
13247
+ * owns a tool loop only in the API case, and a harness planner running as a
13248
+ * coding-agent subprocess can only be reached this way.
13249
+ *
13250
+ * Draining here — outside {@link repairLoop} — is what keeps reads free of
13251
+ * the repair budget. A planner that looks a task up and *then* fumbles its
13252
+ * ops JSON still gets its two corrective retries; charging it for the read
13253
+ * would cost it the chance to fix the edit.
13254
+ */
13255
+ async drainTaskQueries(turn, reads, options) {
13256
+ const carried = [];
13257
+ let current = turn;
13258
+ while (current.kind === "task_query") {
13259
+ carried.push(...current.researchLog);
13260
+ if (reads.answered >= MAX_TASK_QUERIES_HARD || !this.aiService.hasActiveConversation() || options?.signal?.aborted) {
13261
+ return {
13262
+ kind: "message",
13263
+ text: "The planner kept asking to read tasks instead of answering. Nothing was changed \u2014 ask again, or be more specific about the edit you want.",
13264
+ researchLog: carried
13265
+ };
13266
+ }
13267
+ const signature = taskQuerySignature(current.query);
13268
+ const insist = reads.seen.has(signature) || reads.answered >= MAX_TASK_QUERIES;
13269
+ reads.seen.add(signature);
13270
+ reads.answered++;
13271
+ const answer = this.taskQueryAnswer(current.query);
13272
+ current = await this.aiService.continueConversation(
13273
+ insist ? `${answer}
13274
+
13275
+ ${TASK_QUERY_ANSWER_OR_OPS}` : answer,
13276
+ (p) => this.translateProgress(p),
13277
+ options?.signal
13278
+ );
13279
+ }
13280
+ return carried.length > 0 ? { ...current, researchLog: [...carried, ...current.researchLog] } : current;
13281
+ }
13282
+ /**
13283
+ * Render one read out of live state. Never persisted to the transcript: the
13284
+ * detail is context for the planner's next reply, and re-sending it on every
13285
+ * later turn is exactly the cost this channel exists to avoid.
13286
+ */
13287
+ taskQueryAnswer(query) {
13288
+ const runners = this.plan?.runners ?? [];
13289
+ const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
13290
+ return renderTaskQueryAnswer(query, this.store.planTasks, {
13291
+ runners,
13292
+ // Allowlist-filtered like the always-on block: a read must not offer a
13293
+ // model the planner is forbidden to assign.
13294
+ models: filterModelsForPrompt(this.modelsCache, allowlist),
13295
+ modes: this.runnerModesFor(runners),
13296
+ autonomousDefault: this.config.autonomousMode
13297
+ });
13298
+ }
11236
13299
  /**
11237
13300
  * Drive a planner turn to a persisted, broadcast outcome. Task edits apply
11238
13301
  * atomically; validation failures are fed back to the model for up to 2
@@ -11240,7 +13303,7 @@ ${userMessage}` : userMessage;
11240
13303
  * first turn (startPlanning) and every later turn route through here — one
11241
13304
  * path, not two.
11242
13305
  */
11243
- async settleTurn(turn, options) {
13306
+ async settleTurn(turn, options, reads = freshReadBudget()) {
11244
13307
  const invalidOps = (errors, researchLog) => ({
11245
13308
  turn: {
11246
13309
  kind: "message",
@@ -11252,11 +13315,15 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
11252
13315
  }
11253
13316
  });
11254
13317
  const settled = await repairLoop({
11255
- first: () => turn,
11256
- resend: (corrective) => this.aiService.continueConversation(
11257
- corrective,
11258
- (p) => this.translateProgress(p),
11259
- options?.signal
13318
+ first: () => this.drainTaskQueries(turn, reads, options),
13319
+ resend: async (corrective) => this.drainTaskQueries(
13320
+ await this.aiService.continueConversation(
13321
+ corrective,
13322
+ (p) => this.translateProgress(p),
13323
+ options?.signal
13324
+ ),
13325
+ reads,
13326
+ options
11260
13327
  ),
11261
13328
  interpret: (t) => {
11262
13329
  if (t.kind !== "task_ops") return { done: { turn: t } };
@@ -11276,6 +13343,52 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
11276
13343
  }
11277
13344
  return this.applyConversationTurn(settled.turn);
11278
13345
  }
13346
+ /**
13347
+ * The catalog the planner may actually draw from — model ids and task-mode
13348
+ * ids, per selected runner — emitted on EVERY turn, before any plan exists
13349
+ * or after. The system prompt shows this once at conversation start; a long
13350
+ * grill-me interview or PRD negotiation outlives that single showing and the
13351
+ * planner starts misquoting it, so this re-states it per turn instead.
13352
+ *
13353
+ * Built from the allowlist-filtered view (`filterModelsForPrompt`), the same
13354
+ * one the system prompt used — a restricted allowlist stays a hard bound on
13355
+ * every turn, not just the first. `this.modelsCache` itself is never
13356
+ * filtered: `coerceAssignments` needs the full discovered catalog alongside
13357
+ * the allowlist to resolve labels and clamp effort to real variants.
13358
+ *
13359
+ * Reads `this.plan.runners` live, so a runner admitted mid-session by a
13360
+ * retarget (`admitRunner`) is filtered and shown like every other — there is
13361
+ * no separate "originally selected" list to fall stale.
13362
+ */
13363
+ catalogBlock() {
13364
+ if (!this.plan) return null;
13365
+ const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
13366
+ const filteredModels = filterModelsForPrompt(this.modelsCache, allowlist);
13367
+ const runnerModes = this.runnerModesFor(this.plan.runners);
13368
+ const autonomousDefault = this.config.autonomousMode;
13369
+ const modelLines = this.plan.runners.map((runner) => {
13370
+ const models = filteredModels[runner] ?? [];
13371
+ const capped = models.slice(0, CATALOG_MODEL_CAP);
13372
+ const remainder = models.length - capped.length;
13373
+ const ids = capped.map((m) => m.modelId).join(", ") || "(no models discovered)";
13374
+ return `${runner}: ${ids}${remainder > 0 ? ` \u2026 +${remainder} more not shown` : ""}`;
13375
+ });
13376
+ const modeLines = this.plan.runners.map((runner) => {
13377
+ const modes = runnerModes[runner] ?? [];
13378
+ if (modes.length === 0) return `${runner}: (no modes declared)`;
13379
+ const defaultId = resolveDefaultMode(modes, autonomousDefault);
13380
+ const ids = modes.map((m) => `${m.id}${m.id === defaultId ? " (default)" : ""}`).join(", ");
13381
+ return `${runner}: ${ids}`;
13382
+ });
13383
+ return [
13384
+ "<available_models>",
13385
+ ...modelLines,
13386
+ "</available_models>",
13387
+ "<available_task_modes>",
13388
+ ...modeLines,
13389
+ "</available_task_modes>"
13390
+ ].join("\n");
13391
+ }
11279
13392
  /**
11280
13393
  * The "you are here" block for post-plan chat: current tasks with stable
11281
13394
  * references, plus the task-ops protocol. Injected per turn (never
@@ -11285,44 +13398,48 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
11285
13398
  planContextBlock() {
11286
13399
  if (!this.plan || this.store.planTasks.length === 0) return null;
11287
13400
  const orderOf = new Map(this.store.planTasks.map((t) => [t.id, `#${t.order}`]));
11288
- const lines = this.store.planTasks.map(
11289
- (t) => `#${t.order} id=${t.id} "${t.title}" [${t.status}] runner:${t.assignedRunner}${t.assignedModel ? ` model:${t.assignedModel.modelId}` : ""} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`
11290
- );
13401
+ const lines = this.store.planTasks.map((t) => {
13402
+ const isMan = t.type === "user";
13403
+ const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
13404
+ return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
13405
+ });
11291
13406
  const locked = this.store.planTasks.filter((t) => t.status === "in_progress");
11292
13407
  const execNote = this.hasLiveWork ? `
11293
13408
  Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.map((t) => `#${t.order}`).join(", ")}` : ""}. Any task edits you emit will be queued and applied between batches.` : "";
11294
- const modelLines = [];
11295
- for (const runner of this.plan.runners) {
11296
- const models = this.modelsCache[runner] ?? [];
11297
- const ids = models.map((m) => m.modelId).join(", ");
11298
- modelLines.push(`${runner}: ${ids || "(no models discovered)"}`);
11299
- }
11300
- const modelBlock = modelLines.length > 0 ? ["<available_models>", ...modelLines, "</available_models>"].join("\n") : "";
13409
+ const updateChanges = UPDATABLE_FIELDS.map((f) => f === "dependencies" ? '"dependencies"?:["<id or #order>"]' : `"${f}"?`).join(",");
11301
13410
  return [
11302
13411
  "<current_plan>",
11303
13412
  ...lines,
11304
13413
  "</current_plan>",
11305
- modelBlock,
11306
- "The block above is the CURRENT task plan. Choose how to respond:",
13414
+ "The block above is the CURRENT task plan \u2014 short fields only. Choose how to respond:",
11307
13415
  "- To answer a question or discuss, reply in plain prose (no JSON).",
13416
+ // The reminder text is owned by TaskQuery beside the parser, so the
13417
+ // protocol the planner reads and the one Ordewell accepts stay one thing.
13418
+ TASK_QUERY_REMINDER,
11308
13419
  "- To modify specific tasks, reply with ONLY this JSON object:",
11309
13420
  ' {"taskOps": [',
11310
- ' {"op":"update","taskId":"<id or #order>","changes":{"title"?,"description"?,"prompt"?,"dependencies"?:["<id or #order>"],"assignedRunner"?,"assignedModel"?,"taskMode"?}},',
11311
- ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?}},',
13421
+ ` {"op":"update","taskId":"<id or #order>","changes":{${updateChanges}}},`,
13422
+ ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?},"handle"?:"<name>"},',
11312
13423
  ' {"op":"remove","taskId":"<id or #order>"},',
11313
- ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]},',
11314
- ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}},',
11315
- ' {"op":"split","taskId":"<id or #order>","parts":[{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}, ...]}',
13424
+ ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]}, // only to re-prioritise INDEPENDENT tasks',
13425
+ ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...},"handle"?:"<name>"},',
13426
+ ' {"op":"split","taskId":"<id or #order>","parts":[{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}, ...],"handle"?:"<name>"},',
13427
+ ` {"op":"rearm","taskId":"<id or #order>","changes"?:{${updateChanges}}}`,
11316
13428
  " ]}",
11317
13429
  '- For sweeping changes, you may instead emit a full {"tasks":[...]} plan JSON.',
13430
+ 'Every "<id or #order>" ref in a batch resolves against the plan shown above, before any op in the batch runs \u2014 an earlier remove/merge/split never shifts what a later "#N" means.',
13431
+ 'Give "add", "merge", or "split" a "handle" (any name you choose, unused elsewhere in this batch) to let a LATER op in the same batch reference the task it produces \u2014 for "split", the handle names its last part. A handle used before its defining op is rejected.',
11318
13432
  'When creating or changing tasks, set "assignedRunner" and "assignedModel" ({"modelId","modelLabel","thinkingEffort"?}) using only the runners and models listed in <available_models>.',
11319
- "Keep dependencies consistent: no cycles, no references to removed tasks, dependencies must come before dependents, and never touch running or completed tasks." + execNote
13433
+ 'Keep dependencies consistent: no cycles, no references to removed tasks, and never touch running or completed tasks \u2014 "rearm" is the one exception, below.',
13434
+ 'Just declare the dependencies you want \u2014 display order is repaired for you afterwards, so a rewire or a newly added prerequisite never needs a "reorder" op. Only a task that is running or completed cannot be shifted, so an edit that would need one to move is rejected.' + execNote,
13435
+ 'Flipping "type" between "ai" and "user" is a content change, not just a label: an update to "user" needs "userSteps" in the SAME op, and an update to "ai" needs "prompt" in the SAME op \u2014 the model/mode/effort/autonomy fields (flipping to "user") or the userSteps (flipping to "ai") are cleared automatically.',
13436
+ '"rearm" puts a failed OR completed task back to pending \u2014 verdict and output summary are cleared, any dependents it had blocked are released, and it may carry field changes (e.g. a corrected "prompt") applied in the same op. A running task cannot be re-armed. Never rearm a task just to relabel it \u2014 only when it should actually run again.'
11320
13437
  ].filter(Boolean).join("\n");
11321
13438
  }
11322
13439
  /** Validate + commit a task_ops turn atomically. Returns the errors on rejection (plan untouched). */
11323
13440
  applyTaskOpsTurn(turn) {
11324
13441
  if (!this.plan) throw new Error("No active plan state");
11325
- const result = applyTaskOps(this.store.planTasks, turn.ops, this.plan.runners);
13442
+ const result = applyTaskOps(this.store.planTasks, turn.ops, this.plan.runners, this.editCatalog());
11326
13443
  if (!result.ok) return { errors: result.errors };
11327
13444
  const now = (/* @__PURE__ */ new Date()).toISOString();
11328
13445
  const content = `Tasks updated:
@@ -11589,15 +13706,22 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
11589
13706
  return plan;
11590
13707
  }
11591
13708
  /**
11592
- * Patch one task's fields. A hand-set dependency list is the one patch that
11593
- * can leave the plan unschedulable, so it goes through the same
11594
- * {@link canSetDependencies} guard the planner's pickers use rather than a
11595
- * second copy of the rule — and throws, so the surface can say why.
13709
+ * Patch one task's fields. A hand-set dependency list, or a type flip
13710
+ * between AI and MAN, are the patches that can leave a task incoherent
13711
+ * (unschedulable, or carrying fields that mean nothing for its new type),
13712
+ * so both go through the same {@link validateTaskEdit} guard the planner's
13713
+ * task-ops applier uses (as the 'direct' actor, which skips the lock rule)
13714
+ * rather than a second copy of the rules — and throws, so the surface can
13715
+ * say why. Gated on the task existing so an edit to an unknown id still
13716
+ * falls through to the no-op `store.update` below instead of throwing.
11596
13717
  */
11597
13718
  async updateTask(taskId, changes) {
11598
- if (changes.dependencies) {
11599
- const check = canSetDependencies(this.store.planTasks, taskId, changes.dependencies);
11600
- if (!check.ok) throw new PlanEditError(check.error ?? "Those dependencies are not valid");
13719
+ if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode) && this.store.get(taskId)) {
13720
+ const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
13721
+ if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
13722
+ if (check.clear?.length) {
13723
+ changes = { ...changes, ...Object.fromEntries(check.clear.map((f) => [f, void 0])) };
13724
+ }
11601
13725
  }
11602
13726
  return this.editPlan(
11603
13727
  () => Boolean(this.store.update(taskId, changes)),
@@ -13695,6 +15819,11 @@ var PlannerModelMemory = class {
13695
15819
  SettingsService,
13696
15820
  StdioAgentAdapter,
13697
15821
  TASK_OPS_ENVELOPE_KEY,
15822
+ TASK_QUERY_ANSWER_OR_OPS,
15823
+ TASK_QUERY_ENVELOPE_KEY,
15824
+ TASK_QUERY_FIELDS,
15825
+ TASK_QUERY_PROTOCOL,
15826
+ TASK_QUERY_REMINDER,
13698
15827
  TRUNCATED_PLAN_REPAIR_INSTRUCTION,
13699
15828
  TaskOrchestrator,
13700
15829
  TmuxRunner,
@@ -13765,6 +15894,7 @@ var PlannerModelMemory = class {
13765
15894
  emptyWarnings,
13766
15895
  enabledRunners,
13767
15896
  ensureDir,
15897
+ ensureStateDirIgnored,
13768
15898
  escapeControlCharsInStrings,
13769
15899
  executionLogBlock,
13770
15900
  executionSummary,
@@ -13814,6 +15944,7 @@ var PlannerModelMemory = class {
13814
15944
  parsePartialPlan,
13815
15945
  parsePlanJson,
13816
15946
  parseTaskOpsJson,
15947
+ parseTaskQueryJson,
13817
15948
  pendingEditRulesBlock,
13818
15949
  planDirectLaunch,
13819
15950
  planShellLaunch,
@@ -13822,11 +15953,13 @@ var PlannerModelMemory = class {
13822
15953
  providerForRunner,
13823
15954
  reEmitPlanPrompt,
13824
15955
  reEmitTaskOpsPrompt,
15956
+ reEmitTaskQueryPrompt,
13825
15957
  readDaemonToken,
13826
15958
  referencePattern,
13827
15959
  removeTaskFromPlan,
13828
15960
  renderPlanMap,
13829
15961
  renderPriorOutputs,
15962
+ renderTaskQueryAnswer,
13830
15963
  renumberTasks,
13831
15964
  repairLoop,
13832
15965
  researchShellWarning,
@@ -13860,7 +15993,9 @@ var PlannerModelMemory = class {
13860
15993
  summarizeOutput,
13861
15994
  summarizeToolCall,
13862
15995
  taskOpsRejectedPrompt,
15996
+ taskQuerySignature,
13863
15997
  textHasTaskOps,
15998
+ textHasTaskQuery,
13864
15999
  tmuxSessionName,
13865
16000
  tmuxSocketName,
13866
16001
  tmuxWindowName,