@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.mjs CHANGED
@@ -13,23 +13,30 @@ import {
13
13
  resolveTaskMode,
14
14
  runnerModesFrom,
15
15
  validatePlanModification
16
- } from "./chunk-YXAIQKVE.mjs";
16
+ } from "./chunk-TUGJXD4G.mjs";
17
17
  import {
18
+ UPDATABLE_FIELDS,
18
19
  applyTaskOps,
19
20
  canMergeTasks,
20
21
  canSetDependencies,
21
22
  canSplitTask,
23
+ clampThinkingEffort,
22
24
  classifyOutcome,
25
+ coerceAssignments,
23
26
  dependencyCandidates,
24
27
  dependentsOf,
28
+ effectiveAllowlist,
29
+ filterModelsForPrompt,
25
30
  parseTaskOpsJson,
26
31
  summarizeToolCall,
27
- textHasTaskOps
28
- } from "./chunk-4KIWPW4K.mjs";
32
+ textHasTaskOps,
33
+ validateTaskEdit
34
+ } from "./chunk-YI4ZDZEE.mjs";
29
35
  import {
30
36
  PLAN_ENVELOPE_KEY,
31
37
  PlanParseError,
32
38
  TASK_OPS_ENVELOPE_KEY,
39
+ TASK_QUERY_ENVELOPE_KEY,
33
40
  addTaskToPlan,
34
41
  createEmptyPlan,
35
42
  createTask,
@@ -49,7 +56,7 @@ import {
49
56
  updateTaskInPlan,
50
57
  validateModifiedPlan,
51
58
  warningsText
52
- } from "./chunk-SRGQBGI5.mjs";
59
+ } from "./chunk-IO6PEEN6.mjs";
53
60
 
54
61
  // src/interfaces/IFileSystem.ts
55
62
  var GREP_DEFAULT_HEAD_LIMIT = 100;
@@ -118,13 +125,1233 @@ var AUTO_COMMANDS = [
118
125
  "jq",
119
126
  "yq"
120
127
  ];
128
+ var UNIVERSAL_FLAGS = ["--help", "--version"];
129
+ var COUNT_FLAG = /^-\d+$/;
130
+ var FLAG_POLICY = {
131
+ ls: {
132
+ cluster: true,
133
+ booleans: [
134
+ "-1",
135
+ "-a",
136
+ "-A",
137
+ "-b",
138
+ "-c",
139
+ "-C",
140
+ "-d",
141
+ "-D",
142
+ "-f",
143
+ "-F",
144
+ "-g",
145
+ "-G",
146
+ "-h",
147
+ "-H",
148
+ "-i",
149
+ "-k",
150
+ "-l",
151
+ "-L",
152
+ "-m",
153
+ "-n",
154
+ "-N",
155
+ "-o",
156
+ "-p",
157
+ "-q",
158
+ "-Q",
159
+ "-r",
160
+ "-R",
161
+ "-s",
162
+ "-S",
163
+ "-t",
164
+ "-u",
165
+ "-U",
166
+ "-v",
167
+ "-x",
168
+ "-X",
169
+ "-Z",
170
+ "--all",
171
+ "--almost-all",
172
+ "--author",
173
+ "--escape",
174
+ "--classify",
175
+ "--file-type",
176
+ "--full-time",
177
+ "--no-group",
178
+ "--human-readable",
179
+ "--si",
180
+ "--dereference-command-line",
181
+ "--inode",
182
+ "--kibibytes",
183
+ "--dereference",
184
+ "--numeric-uid-gid",
185
+ "--literal",
186
+ "--hide-control-chars",
187
+ "--show-control-chars",
188
+ "--quote-name",
189
+ "--reverse",
190
+ "--recursive",
191
+ "--size",
192
+ "--directory",
193
+ "--group-directories-first",
194
+ "--context",
195
+ "--zero"
196
+ ],
197
+ values: [
198
+ "-w",
199
+ "-I",
200
+ "-T",
201
+ "--width",
202
+ "--ignore",
203
+ "--hide",
204
+ "--block-size",
205
+ "--color",
206
+ "--format",
207
+ "--sort",
208
+ "--time",
209
+ "--time-style",
210
+ "--indicator-style",
211
+ "--quoting-style",
212
+ "--tabsize"
213
+ ]
214
+ },
215
+ tree: {
216
+ cluster: true,
217
+ // `-o` is absent on purpose: it is tree's write-to-a-file flag, which the
218
+ // redirect check never sees because there is no redirect.
219
+ booleans: [
220
+ "-a",
221
+ "-d",
222
+ "-f",
223
+ "-i",
224
+ "-l",
225
+ "-x",
226
+ "-C",
227
+ "-n",
228
+ "-p",
229
+ "-s",
230
+ "-h",
231
+ "-u",
232
+ "-g",
233
+ "-D",
234
+ "-t",
235
+ "-c",
236
+ "-r",
237
+ "-F",
238
+ "-q",
239
+ "-N",
240
+ "-Q",
241
+ "-v",
242
+ "-J",
243
+ "-X",
244
+ "-Z",
245
+ "--noreport",
246
+ "--dirsfirst",
247
+ "--du",
248
+ "--prune",
249
+ "--matchdirs",
250
+ "--inodes",
251
+ "--device",
252
+ "--si",
253
+ "--gitignore",
254
+ "--ignore-case",
255
+ "--filesfirst",
256
+ "--nolinks",
257
+ "--hintro"
258
+ ],
259
+ values: [
260
+ "-L",
261
+ "-P",
262
+ "-I",
263
+ "-H",
264
+ "--filelimit",
265
+ "--timefmt",
266
+ "--charset",
267
+ "--sort",
268
+ "--gitfile",
269
+ "--info",
270
+ "--prefix"
271
+ ]
272
+ },
273
+ git: {
274
+ cluster: true,
275
+ // Absent on purpose, and each for a reason that was demonstrated rather than
276
+ // guessed at:
277
+ // -c, --config-env set configuration inline, and several configuration
278
+ // keys name a program to run (`core.pager`, `core.editor`,
279
+ // `diff.external`), so this is an execution flag with one
280
+ // indirection in front of it.
281
+ // --exec-path redirects where git finds its helper programs; a
282
+ // fabricated helper ran during an ordinary remote listing.
283
+ // -O, --open-files-in-pager runs an arbitrary named program over search hits.
284
+ // --upload-pack, --receive-pack name the program run on the other end.
285
+ // --textconv, --ext-diff run filters the repository itself configures.
286
+ // --git-dir, --work-tree repoint git at a tree whose configuration is not
287
+ // the one the developer is looking at.
288
+ // -o, --output write a file. `git ls-files -o` is a legitimate spelling
289
+ // of `--others`, but `git archive -o` writes, and the
290
+ // long spelling stays available for the read.
291
+ booleans: [
292
+ "-a",
293
+ "-b",
294
+ "-E",
295
+ "-F",
296
+ "-h",
297
+ "-H",
298
+ "-i",
299
+ "-I",
300
+ "-l",
301
+ "-p",
302
+ "-P",
303
+ "-q",
304
+ "-r",
305
+ "-R",
306
+ "-s",
307
+ "-t",
308
+ "-u",
309
+ "-v",
310
+ "-w",
311
+ "-z",
312
+ "--oneline",
313
+ "--graph",
314
+ "--stat",
315
+ "--shortstat",
316
+ "--numstat",
317
+ "--dirstat",
318
+ "--summary",
319
+ "--name-only",
320
+ "--name-status",
321
+ "--raw",
322
+ "--patch",
323
+ "--no-patch",
324
+ "--patch-with-stat",
325
+ "--cached",
326
+ "--staged",
327
+ "--porcelain",
328
+ "--short",
329
+ "--long",
330
+ "--branch",
331
+ "--show-stash",
332
+ "--show-current",
333
+ "--all",
334
+ "--tags",
335
+ "--heads",
336
+ "--remotes",
337
+ "--list",
338
+ "--verbose",
339
+ "--quiet",
340
+ "--abbrev-ref",
341
+ "--count",
342
+ "--reverse",
343
+ "--first-parent",
344
+ "--no-merges",
345
+ "--merges",
346
+ "--follow",
347
+ "--full-history",
348
+ "--simplify-merges",
349
+ "--sparse",
350
+ "--dense",
351
+ "--ancestry-path",
352
+ "--left-right",
353
+ "--cherry",
354
+ "--cherry-pick",
355
+ "--cherry-mark",
356
+ "--boundary",
357
+ "--topo-order",
358
+ "--date-order",
359
+ "--author-date-order",
360
+ "--relative-date",
361
+ "--parents",
362
+ "--children",
363
+ "--timestamp",
364
+ "--no-walk",
365
+ "--do-walk",
366
+ "--decorate",
367
+ "--no-decorate",
368
+ "--abbrev-commit",
369
+ "--no-abbrev-commit",
370
+ "--abbrev",
371
+ "--no-abbrev",
372
+ "--color",
373
+ "--no-color",
374
+ "--pretty",
375
+ "--format",
376
+ "--expand-tabs",
377
+ "--no-expand-tabs",
378
+ "--numbered",
379
+ "--email",
380
+ "--no-notes",
381
+ "--notes",
382
+ "--show-signature",
383
+ "--find-renames",
384
+ "--find-copies",
385
+ "--find-copies-harder",
386
+ "--break-rewrites",
387
+ "--no-renames",
388
+ "--irreversible-delete",
389
+ "--full-index",
390
+ "--binary",
391
+ "--text",
392
+ "--check",
393
+ "--exit-code",
394
+ "--word-diff",
395
+ "--function-context",
396
+ "--ignore-all-space",
397
+ "--ignore-space-change",
398
+ "--ignore-space-at-eol",
399
+ "--ignore-blank-lines",
400
+ "--ignore-cr-at-eol",
401
+ "--no-prefix",
402
+ "--default-prefix",
403
+ "--no-textconv",
404
+ "--no-ext-diff",
405
+ "--submodule",
406
+ "--ignore-submodules",
407
+ "--recurse-submodules",
408
+ "--no-recurse-submodules",
409
+ "--others",
410
+ "--deleted",
411
+ "--modified",
412
+ "--unmerged",
413
+ "--stage",
414
+ "--killed",
415
+ "--ignored",
416
+ "--exclude-standard",
417
+ "--error-unmatch",
418
+ "--full-name",
419
+ "--directory",
420
+ "--no-empty-directory",
421
+ "--untracked-files",
422
+ "--no-optional-locks",
423
+ "--no-pager",
424
+ "--paginate",
425
+ "--bare",
426
+ "--literal-pathspecs",
427
+ "--glob-pathspecs",
428
+ "--noglob-pathspecs",
429
+ "--icase-pathspecs",
430
+ "--line-number",
431
+ "--column",
432
+ "--heading",
433
+ "--no-index",
434
+ "--untracked",
435
+ "--no-recursive",
436
+ "--name-rev",
437
+ "--root",
438
+ "--dirty",
439
+ "--broken",
440
+ "--always",
441
+ "--merged",
442
+ "--no-merged",
443
+ "--show-toplevel",
444
+ "--show-cdup",
445
+ "--git-common-dir",
446
+ "--absolute-git-dir",
447
+ "--is-inside-work-tree",
448
+ "--is-inside-git-dir",
449
+ "--is-bare-repository",
450
+ "--symbolic",
451
+ "--symbolic-full-name",
452
+ "--verify",
453
+ "--short-hash",
454
+ "--textual",
455
+ "--batch",
456
+ "--batch-check",
457
+ "--batch-all-objects",
458
+ "--buffer",
459
+ "--follow-symlinks",
460
+ "--use-mailmap"
461
+ ],
462
+ values: [
463
+ "-n",
464
+ "-C",
465
+ "-B",
466
+ "-M",
467
+ "-L",
468
+ "-S",
469
+ "-G",
470
+ "-U",
471
+ "-e",
472
+ "-m",
473
+ "-f",
474
+ "-g",
475
+ "-x",
476
+ "-X",
477
+ "-W",
478
+ "--max-count",
479
+ "--skip",
480
+ "--since",
481
+ "--after",
482
+ "--until",
483
+ "--before",
484
+ "--author",
485
+ "--grep",
486
+ "--grep-reflog",
487
+ "--committer",
488
+ "--date",
489
+ "--diff-filter",
490
+ "--unified",
491
+ "--stat-width",
492
+ "--stat-name-width",
493
+ "--stat-count",
494
+ "--word-diff-regex",
495
+ "--line-prefix",
496
+ "--src-prefix",
497
+ "--dst-prefix",
498
+ "--pickaxe-regex",
499
+ "--anchored",
500
+ "--color-moved",
501
+ "--color-words",
502
+ "--inter-hunk-context",
503
+ "--max-parents",
504
+ "--min-parents",
505
+ "--simplify-by-decoration",
506
+ "--glob",
507
+ "--exclude",
508
+ "--sort",
509
+ "--contains",
510
+ "--points-at",
511
+ "--decorate-refs",
512
+ "--decorate-refs-exclude",
513
+ "--encoding",
514
+ "--context",
515
+ "--max-depth",
516
+ // `--filters` is absent from this list on purpose: it runs the clean and
517
+ // smudge filters the repository under research configures, which is the
518
+ // same execution vector as the configuration flag above with a different
519
+ // spelling.
520
+ "--and",
521
+ "--or",
522
+ "--not",
523
+ "--all-match",
524
+ "--threads",
525
+ "--path"
526
+ ],
527
+ patterns: [COUNT_FLAG]
528
+ },
529
+ wc: {
530
+ cluster: true,
531
+ booleans: [
532
+ "-c",
533
+ "-l",
534
+ "-m",
535
+ "-w",
536
+ "-L",
537
+ "--bytes",
538
+ "--chars",
539
+ "--lines",
540
+ "--words",
541
+ "--max-line-length",
542
+ "--total"
543
+ ]
544
+ },
545
+ du: {
546
+ cluster: true,
547
+ booleans: [
548
+ "-0",
549
+ "-a",
550
+ "-b",
551
+ "-c",
552
+ "-D",
553
+ "-h",
554
+ "-H",
555
+ "-k",
556
+ "-l",
557
+ "-L",
558
+ "-m",
559
+ "-P",
560
+ "-s",
561
+ "-S",
562
+ "-x",
563
+ "--all",
564
+ "--apparent-size",
565
+ "--bytes",
566
+ "--total",
567
+ "--dereference-args",
568
+ "--human-readable",
569
+ "--si",
570
+ "--inodes",
571
+ "--count-links",
572
+ "--dereference",
573
+ "--no-dereference",
574
+ "--separate-dirs",
575
+ "--summarize",
576
+ "--one-file-system",
577
+ "--null"
578
+ ],
579
+ values: [
580
+ "-d",
581
+ "-t",
582
+ "-B",
583
+ "-X",
584
+ "--max-depth",
585
+ "--threshold",
586
+ "--block-size",
587
+ "--exclude",
588
+ "--exclude-from",
589
+ "--time",
590
+ "--time-style",
591
+ "--files0-from"
592
+ ]
593
+ },
594
+ df: {
595
+ cluster: true,
596
+ booleans: [
597
+ "-a",
598
+ "-h",
599
+ "-H",
600
+ "-i",
601
+ "-k",
602
+ "-l",
603
+ "-m",
604
+ "-P",
605
+ "-T",
606
+ "-v",
607
+ "--all",
608
+ "--human-readable",
609
+ "--si",
610
+ "--inodes",
611
+ "--local",
612
+ "--portability",
613
+ "--print-type",
614
+ "--total",
615
+ "--sync",
616
+ "--no-sync"
617
+ ],
618
+ values: ["-B", "-t", "-x", "--block-size", "--type", "--exclude-type"]
619
+ },
620
+ file: {
621
+ cluster: true,
622
+ // `-C`/`--compile` is absent: it writes a compiled magic file.
623
+ booleans: [
624
+ "-b",
625
+ "-c",
626
+ "-h",
627
+ "-i",
628
+ "-k",
629
+ "-L",
630
+ "-n",
631
+ "-N",
632
+ "-p",
633
+ "-r",
634
+ "-s",
635
+ "-S",
636
+ "-v",
637
+ "-z",
638
+ "-0",
639
+ "--brief",
640
+ "--mime",
641
+ "--mime-type",
642
+ "--mime-encoding",
643
+ "--dereference",
644
+ "--no-dereference",
645
+ "--keep-going",
646
+ "--no-buffer",
647
+ "--no-pad",
648
+ "--raw",
649
+ "--special-files",
650
+ "--uncompress",
651
+ "--print0",
652
+ "--checking-printout"
653
+ ],
654
+ values: [
655
+ "-e",
656
+ "-m",
657
+ "-P",
658
+ "-F",
659
+ "--exclude",
660
+ "--magic-file",
661
+ "--separator",
662
+ "--parameter",
663
+ "--extension",
664
+ "--files-from"
665
+ ]
666
+ },
667
+ head: {
668
+ cluster: true,
669
+ booleans: ["-q", "-v", "-z", "--quiet", "--silent", "--verbose", "--zero-terminated"],
670
+ values: ["-c", "-n", "--bytes", "--lines"],
671
+ patterns: [COUNT_FLAG]
672
+ },
673
+ tail: {
674
+ cluster: true,
675
+ booleans: [
676
+ "-f",
677
+ "-F",
678
+ "-q",
679
+ "-v",
680
+ "-z",
681
+ "--follow",
682
+ "--retry",
683
+ "--quiet",
684
+ "--silent",
685
+ "--verbose",
686
+ "--zero-terminated"
687
+ ],
688
+ values: [
689
+ "-c",
690
+ "-n",
691
+ "-s",
692
+ "--bytes",
693
+ "--lines",
694
+ "--sleep-interval",
695
+ "--pid",
696
+ "--max-unchanged-stats"
697
+ ],
698
+ patterns: [COUNT_FLAG]
699
+ },
700
+ cat: {
701
+ cluster: true,
702
+ booleans: [
703
+ "-A",
704
+ "-b",
705
+ "-e",
706
+ "-E",
707
+ "-n",
708
+ "-s",
709
+ "-t",
710
+ "-T",
711
+ "-u",
712
+ "-v",
713
+ "--show-all",
714
+ "--number-nonblank",
715
+ "--show-ends",
716
+ "--number",
717
+ "--squeeze-blank",
718
+ "--show-tabs",
719
+ "--show-nonprinting"
720
+ ]
721
+ },
722
+ sort: {
723
+ cluster: true,
724
+ // Absent on purpose: `-o`/`--output` writes a file with no redirect for the
725
+ // redirect check to see, and `--compress-program` names a program that sort
726
+ // runs over its own temporary files — a permitted binary as an interpreter.
727
+ booleans: [
728
+ "-b",
729
+ "-c",
730
+ "-C",
731
+ "-d",
732
+ "-f",
733
+ "-g",
734
+ "-h",
735
+ "-i",
736
+ "-M",
737
+ "-n",
738
+ "-r",
739
+ "-R",
740
+ "-s",
741
+ "-u",
742
+ "-V",
743
+ "-z",
744
+ "--ignore-leading-blanks",
745
+ "--check",
746
+ "--dictionary-order",
747
+ "--ignore-case",
748
+ "--general-numeric-sort",
749
+ "--human-numeric-sort",
750
+ "--ignore-nonprinting",
751
+ "--month-sort",
752
+ "--numeric-sort",
753
+ "--random-sort",
754
+ "--reverse",
755
+ "--stable",
756
+ "--unique",
757
+ "--version-sort",
758
+ "--zero-terminated",
759
+ "--debug"
760
+ ],
761
+ values: [
762
+ "-k",
763
+ "-t",
764
+ "-S",
765
+ "-T",
766
+ "--key",
767
+ "--field-separator",
768
+ "--buffer-size",
769
+ "--temporary-directory",
770
+ "--random-source",
771
+ "--batch-size",
772
+ "--parallel",
773
+ "--sort"
774
+ ]
775
+ },
776
+ uniq: {
777
+ cluster: true,
778
+ booleans: [
779
+ "-c",
780
+ "-d",
781
+ "-D",
782
+ "-i",
783
+ "-u",
784
+ "-z",
785
+ "--count",
786
+ "--repeated",
787
+ "--all-repeated",
788
+ "--ignore-case",
789
+ "--unique",
790
+ "--zero-terminated"
791
+ ],
792
+ values: ["-f", "-s", "-w", "--skip-fields", "--skip-chars", "--check-chars", "--group"]
793
+ },
794
+ // No flag on either runs a program or writes a file, and the planner's
795
+ // commonest use of `echo` is a section marker — `echo --- files changed ---`
796
+ // is three tokens that all begin with a dash and none of them is a flag.
797
+ echo: { freeform: true },
798
+ printf: { freeform: true },
799
+ date: {
800
+ cluster: true,
801
+ // `-s`/`--set` is absent: it sets the system clock.
802
+ booleans: [
803
+ "-u",
804
+ "-R",
805
+ "-I",
806
+ "--utc",
807
+ "--universal",
808
+ "--rfc-email",
809
+ "--rfc-3339",
810
+ "--iso-8601",
811
+ "--debug"
812
+ ],
813
+ values: ["-d", "-f", "-r", "--date", "--file", "--reference"]
814
+ },
815
+ stat: {
816
+ cluster: true,
817
+ booleans: ["-f", "-L", "-t", "--file-system", "--terse", "--dereference"],
818
+ values: ["-c", "--format", "--printf", "--cached"]
819
+ },
820
+ basename: {
821
+ cluster: true,
822
+ booleans: ["-a", "-z", "--multiple", "--zero"],
823
+ values: ["-s", "--suffix"]
824
+ },
825
+ dirname: {
826
+ cluster: true,
827
+ booleans: ["-z", "--zero"]
828
+ },
829
+ realpath: {
830
+ cluster: true,
831
+ booleans: [
832
+ "-e",
833
+ "-m",
834
+ "-L",
835
+ "-P",
836
+ "-q",
837
+ "-s",
838
+ "-z",
839
+ "--canonicalize-existing",
840
+ "--canonicalize-missing",
841
+ "--logical",
842
+ "--physical",
843
+ "--quiet",
844
+ "--strip",
845
+ "--no-symlinks",
846
+ "--zero"
847
+ ],
848
+ values: ["--relative-to", "--relative-base"]
849
+ },
850
+ pwd: { cluster: true, booleans: ["-L", "-P", "--logical", "--physical"] },
851
+ which: { cluster: true, booleans: ["-a", "-s", "--all"] },
852
+ type: { cluster: true, booleans: ["-a", "-f", "-p", "-P", "-t"] },
853
+ uname: {
854
+ cluster: true,
855
+ booleans: [
856
+ "-a",
857
+ "-i",
858
+ "-m",
859
+ "-n",
860
+ "-o",
861
+ "-p",
862
+ "-r",
863
+ "-s",
864
+ "-v",
865
+ "--all",
866
+ "--kernel-name",
867
+ "--nodename",
868
+ "--kernel-release",
869
+ "--kernel-version",
870
+ "--machine",
871
+ "--processor",
872
+ "--hardware-platform",
873
+ "--operating-system"
874
+ ]
875
+ },
876
+ whoami: {},
877
+ cut: {
878
+ cluster: true,
879
+ // `--output-delimiter` names a separator string, not a file.
880
+ booleans: ["-n", "-s", "-z", "--only-delimited", "--complement", "--zero-terminated"],
881
+ values: [
882
+ "-b",
883
+ "-c",
884
+ "-d",
885
+ "-f",
886
+ "--bytes",
887
+ "--characters",
888
+ "--delimiter",
889
+ "--fields",
890
+ "--output-delimiter"
891
+ ]
892
+ },
893
+ nl: {
894
+ cluster: true,
895
+ booleans: ["-p", "--no-renumber"],
896
+ values: [
897
+ "-b",
898
+ "-d",
899
+ "-f",
900
+ "-h",
901
+ "-i",
902
+ "-l",
903
+ "-n",
904
+ "-s",
905
+ "-v",
906
+ "-w",
907
+ "--body-numbering",
908
+ "--section-delimiter",
909
+ "--footer-numbering",
910
+ "--header-numbering",
911
+ "--join-blank-lines",
912
+ "--line-increment",
913
+ "--number-format",
914
+ "--number-separator",
915
+ "--starting-line-number",
916
+ "--number-width"
917
+ ]
918
+ },
919
+ rg: {
920
+ cluster: true,
921
+ // `--pre` runs a named preprocessor over every file searched and
922
+ // `--hostname-bin` runs a named program to resolve the hostname; both are
923
+ // absent, and `--pre-glob` with them.
924
+ booleans: [
925
+ "-a",
926
+ "-c",
927
+ "-F",
928
+ "-h",
929
+ "-H",
930
+ "-i",
931
+ "-I",
932
+ "-l",
933
+ "-L",
934
+ "-n",
935
+ "-N",
936
+ "-o",
937
+ "-p",
938
+ "-P",
939
+ "-q",
940
+ "-s",
941
+ "-S",
942
+ "-u",
943
+ "-uu",
944
+ "-uuu",
945
+ "-U",
946
+ "-v",
947
+ "-V",
948
+ "-w",
949
+ "-x",
950
+ "-z",
951
+ "--text",
952
+ "--count",
953
+ "--count-matches",
954
+ "--fixed-strings",
955
+ "--files",
956
+ "--files-with-matches",
957
+ "--files-without-match",
958
+ "--follow",
959
+ "--hidden",
960
+ "--no-hidden",
961
+ "--ignore-case",
962
+ "--case-sensitive",
963
+ "--smart-case",
964
+ "--invert-match",
965
+ "--line-number",
966
+ "--no-line-number",
967
+ "--with-filename",
968
+ "--no-filename",
969
+ "--heading",
970
+ "--no-heading",
971
+ "--column",
972
+ "--no-column",
973
+ "--byte-offset",
974
+ "--vimgrep",
975
+ "--json",
976
+ "--stats",
977
+ "--trim",
978
+ "--pretty",
979
+ "--passthru",
980
+ "--word-regexp",
981
+ "--line-regexp",
982
+ "--multiline",
983
+ "--multiline-dotall",
984
+ "--no-multiline",
985
+ "--crlf",
986
+ "--null",
987
+ "--null-data",
988
+ "--binary",
989
+ "--search-zip",
990
+ "--no-ignore",
991
+ "--no-ignore-dot",
992
+ "--no-ignore-exclude",
993
+ "--no-ignore-files",
994
+ "--no-ignore-global",
995
+ "--no-ignore-parent",
996
+ "--no-ignore-vcs",
997
+ "--no-ignore-messages",
998
+ "--no-messages",
999
+ "--no-require-git",
1000
+ "--require-git",
1001
+ "--one-file-system",
1002
+ "--pcre2",
1003
+ "--no-pcre2",
1004
+ "--auto-hybrid-regex",
1005
+ "--line-buffered",
1006
+ "--block-buffered",
1007
+ "--unicode",
1008
+ "--no-unicode",
1009
+ "--unrestricted",
1010
+ "--type-list",
1011
+ "--debug",
1012
+ "--trace",
1013
+ "--include-zero",
1014
+ "--no-context-separator",
1015
+ "--stop-on-nonmatch",
1016
+ "--quiet",
1017
+ "--no-binary"
1018
+ ],
1019
+ values: [
1020
+ "-A",
1021
+ "-B",
1022
+ "-C",
1023
+ "-d",
1024
+ "-e",
1025
+ "-E",
1026
+ "-f",
1027
+ "-g",
1028
+ "-j",
1029
+ "-m",
1030
+ "-M",
1031
+ "-r",
1032
+ "-t",
1033
+ "-T",
1034
+ "--after-context",
1035
+ "--before-context",
1036
+ "--context",
1037
+ "--context-separator",
1038
+ "--field-context-separator",
1039
+ "--field-match-separator",
1040
+ "--regexp",
1041
+ "--file",
1042
+ "--glob",
1043
+ "--iglob",
1044
+ "--glob-case-insensitive",
1045
+ "--type",
1046
+ "--type-not",
1047
+ "--type-add",
1048
+ "--type-clear",
1049
+ "--max-count",
1050
+ "--max-columns",
1051
+ "--max-depth",
1052
+ "--maxdepth",
1053
+ "--max-filesize",
1054
+ "--replace",
1055
+ "--threads",
1056
+ "--encoding",
1057
+ "--engine",
1058
+ "--sort",
1059
+ "--sortr",
1060
+ "--color",
1061
+ "--colors",
1062
+ "--path-separator",
1063
+ "--ignore-file",
1064
+ "--dfa-size-limit",
1065
+ "--regex-size-limit",
1066
+ "--binary-files",
1067
+ "--max-columns-preview"
1068
+ ]
1069
+ },
1070
+ grep: {
1071
+ cluster: true,
1072
+ // `-o` here is `--only-matching`, which writes nothing. It is the same two
1073
+ // characters as `sort -o`, which writes a file — the reason these sets are
1074
+ // per-binary rather than one shared list.
1075
+ booleans: [
1076
+ "-a",
1077
+ "-b",
1078
+ "-c",
1079
+ "-E",
1080
+ "-F",
1081
+ "-G",
1082
+ "-H",
1083
+ "-h",
1084
+ "-i",
1085
+ "-I",
1086
+ "-l",
1087
+ "-L",
1088
+ "-n",
1089
+ "-o",
1090
+ "-P",
1091
+ "-q",
1092
+ "-r",
1093
+ "-R",
1094
+ "-s",
1095
+ "-T",
1096
+ "-U",
1097
+ "-v",
1098
+ "-w",
1099
+ "-x",
1100
+ "-y",
1101
+ "-z",
1102
+ "-Z",
1103
+ "--extended-regexp",
1104
+ "--fixed-strings",
1105
+ "--basic-regexp",
1106
+ "--perl-regexp",
1107
+ "--ignore-case",
1108
+ "--no-ignore-case",
1109
+ "--invert-match",
1110
+ "--word-regexp",
1111
+ "--line-regexp",
1112
+ "--count",
1113
+ "--files-with-matches",
1114
+ "--files-without-match",
1115
+ "--line-number",
1116
+ "--with-filename",
1117
+ "--no-filename",
1118
+ "--only-matching",
1119
+ "--quiet",
1120
+ "--silent",
1121
+ "--no-messages",
1122
+ "--text",
1123
+ "--binary",
1124
+ "--line-buffered",
1125
+ "--null",
1126
+ "--null-data",
1127
+ "--byte-offset",
1128
+ "--recursive",
1129
+ "--dereference-recursive",
1130
+ "--initial-tab",
1131
+ "--unix-byte-offsets",
1132
+ "--no-group-separator",
1133
+ "--color",
1134
+ "--colour"
1135
+ ],
1136
+ values: [
1137
+ "-A",
1138
+ "-B",
1139
+ "-C",
1140
+ "-d",
1141
+ "-D",
1142
+ "-e",
1143
+ "-f",
1144
+ "-m",
1145
+ "--after-context",
1146
+ "--before-context",
1147
+ "--context",
1148
+ "--regexp",
1149
+ "--file",
1150
+ "--max-count",
1151
+ "--devices",
1152
+ "--directories",
1153
+ "--binary-files",
1154
+ "--include",
1155
+ "--exclude",
1156
+ "--exclude-dir",
1157
+ "--exclude-from",
1158
+ "--label",
1159
+ "--group-separator"
1160
+ ]
1161
+ },
1162
+ find: {
1163
+ // No clustering: find spells its flags as single-dash words, so walking the
1164
+ // characters of `-name` would read it as five short flags. And its arguments
1165
+ // are an expression, so `--` does not end them.
1166
+ expressionArgs: true,
1167
+ // Absent on purpose: `-exec`, `-execdir`, `-ok` and `-okdir` run an inner
1168
+ // command (also caught by the named guard below, which says so more
1169
+ // precisely), `-delete` removes files, and `-fprint`, `-fprint0`, `-fprintf`
1170
+ // and `-fls` are find's write family — the same write as `> file` with no
1171
+ // redirect for the redirect check to see.
1172
+ booleans: [
1173
+ "-H",
1174
+ "-L",
1175
+ "-P",
1176
+ "-a",
1177
+ "-and",
1178
+ "-o",
1179
+ "-or",
1180
+ "-not",
1181
+ "-d",
1182
+ "-depth",
1183
+ "-daystart",
1184
+ "-empty",
1185
+ "-executable",
1186
+ "-false",
1187
+ "-follow",
1188
+ "-help",
1189
+ "--help",
1190
+ "-ignore_readdir_race",
1191
+ "-noignore_readdir_race",
1192
+ "-ls",
1193
+ "-mount",
1194
+ "-nogroup",
1195
+ "-noleaf",
1196
+ "-nouser",
1197
+ "-print",
1198
+ "-print0",
1199
+ "-prune",
1200
+ "-quit",
1201
+ "-readable",
1202
+ "-true",
1203
+ "-version",
1204
+ "-writable",
1205
+ "-xdev"
1206
+ ],
1207
+ values: [
1208
+ "-maxdepth",
1209
+ "-mindepth",
1210
+ "-name",
1211
+ "-iname",
1212
+ "-path",
1213
+ "-ipath",
1214
+ "-wholename",
1215
+ "-iwholename",
1216
+ "-regex",
1217
+ "-iregex",
1218
+ "-lname",
1219
+ "-ilname",
1220
+ "-type",
1221
+ "-xtype",
1222
+ "-size",
1223
+ "-newer",
1224
+ "-anewer",
1225
+ "-cnewer",
1226
+ "-mtime",
1227
+ "-mmin",
1228
+ "-ctime",
1229
+ "-cmin",
1230
+ "-atime",
1231
+ "-amin",
1232
+ "-used",
1233
+ "-uid",
1234
+ "-gid",
1235
+ "-user",
1236
+ "-group",
1237
+ "-perm",
1238
+ "-links",
1239
+ "-inum",
1240
+ "-samefile",
1241
+ "-fstype",
1242
+ "-regextype",
1243
+ "-files0-from",
1244
+ "-printf",
1245
+ "-context",
1246
+ "-D"
1247
+ ],
1248
+ // `find -O2` glues the optimisation level on, and `-newermt`/`-newerat` and
1249
+ // friends spell the two comparison fields into the flag name.
1250
+ patterns: [/^-O\d$/, /^-newer[acmBt][acmBt]$/]
1251
+ },
1252
+ jq: {
1253
+ cluster: true,
1254
+ booleans: [
1255
+ "-a",
1256
+ "-c",
1257
+ "-C",
1258
+ "-e",
1259
+ "-h",
1260
+ "-j",
1261
+ "-M",
1262
+ "-n",
1263
+ "-r",
1264
+ "-R",
1265
+ "-s",
1266
+ "-S",
1267
+ "--ascii-output",
1268
+ "--compact-output",
1269
+ "--color-output",
1270
+ "--monochrome-output",
1271
+ "--exit-status",
1272
+ "--join-output",
1273
+ "--null-input",
1274
+ "--raw-input",
1275
+ "--raw-output",
1276
+ "--raw-output0",
1277
+ "--slurp",
1278
+ "--sort-keys",
1279
+ "--seq",
1280
+ "--stream",
1281
+ "--stream-errors",
1282
+ "--tab",
1283
+ "--unbuffered",
1284
+ "--args",
1285
+ "--jsonargs"
1286
+ ],
1287
+ values: [
1288
+ "-f",
1289
+ "-L",
1290
+ "--arg",
1291
+ "--argjson",
1292
+ "--slurpfile",
1293
+ "--rawfile",
1294
+ "--indent",
1295
+ "--from-file"
1296
+ ]
1297
+ },
1298
+ yq: {
1299
+ cluster: true,
1300
+ // `-i`/`--inplace`/`--in-place` rewrite the file, and `-s`/`--split-exp`
1301
+ // writes one file per document. All absent — and all bare or near-bare
1302
+ // booleans, which is why exempting booleans was not an option.
1303
+ booleans: [
1304
+ "-C",
1305
+ "-e",
1306
+ "-h",
1307
+ "-j",
1308
+ "-M",
1309
+ "-n",
1310
+ "-N",
1311
+ "-P",
1312
+ "-r",
1313
+ "-S",
1314
+ "-v",
1315
+ "-y",
1316
+ "-Y",
1317
+ "--colors",
1318
+ "--no-colors",
1319
+ "--exit-status",
1320
+ "--no-doc",
1321
+ "--null-input",
1322
+ "--prettyPrint",
1323
+ "--raw-output",
1324
+ "--sort-keys",
1325
+ "--tojson",
1326
+ "--yaml-output",
1327
+ "--yaml-roundtrip",
1328
+ "--unwrapScalar",
1329
+ "--header-preprocess",
1330
+ "--no-exit-status",
1331
+ "--verbose",
1332
+ "--xml-strict-mode"
1333
+ ],
1334
+ values: [
1335
+ "-I",
1336
+ "-o",
1337
+ "-p",
1338
+ "-L",
1339
+ "--indent",
1340
+ "--output-format",
1341
+ "--input-format",
1342
+ "--expression",
1343
+ "--width",
1344
+ "--xml-attribute-prefix",
1345
+ "--xml-content-name"
1346
+ ]
1347
+ }
1348
+ };
121
1349
  var GIT_READONLY_SUBCOMMANDS = [
122
1350
  "log",
123
1351
  "status",
124
1352
  "diff",
125
1353
  "show",
126
1354
  "ls-files",
127
- "ls-remote",
128
1355
  "ls-tree",
129
1356
  "branch",
130
1357
  "tag",
@@ -239,6 +1466,26 @@ var REFUSED_SUBCOMMANDS = {
239
1466
  az: ["login", "logout"],
240
1467
  terraform: ["apply", "destroy"]
241
1468
  };
1469
+ var SHELL_KEYWORDS = [
1470
+ "{",
1471
+ "}",
1472
+ "if",
1473
+ "then",
1474
+ "elif",
1475
+ "else",
1476
+ "fi",
1477
+ "for",
1478
+ "while",
1479
+ "until",
1480
+ "do",
1481
+ "done",
1482
+ "case",
1483
+ "esac",
1484
+ "select",
1485
+ "function",
1486
+ "time",
1487
+ "export"
1488
+ ];
242
1489
  var INTERPRETERS = [
243
1490
  "sh",
244
1491
  "bash",
@@ -682,16 +1929,79 @@ function pathLikeArgs(command, opts = {}) {
682
1929
  return !a.startsWith("-") && looksLikePath(a) ? [a] : [];
683
1930
  }));
684
1931
  }
1932
+ function matchFlag(spec, booleans, values, token) {
1933
+ if (token.startsWith("--")) {
1934
+ const eq = token.indexOf("=");
1935
+ const name = eq > 0 ? token.slice(0, eq) : token;
1936
+ if (booleans.has(name)) return "boolean";
1937
+ if (values.has(name)) return eq > 0 ? "boolean" : "value";
1938
+ return void 0;
1939
+ }
1940
+ if (booleans.has(token)) return "boolean";
1941
+ if (values.has(token)) return "value";
1942
+ if (spec.patterns?.some((p) => p.test(token))) return "boolean";
1943
+ if (!spec.cluster) return void 0;
1944
+ for (let i = 1; i < token.length; i++) {
1945
+ const short = `-${token[i]}`;
1946
+ if (booleans.has(short)) continue;
1947
+ if (values.has(short)) return i === token.length - 1 ? "value" : "boolean";
1948
+ return void 0;
1949
+ }
1950
+ return "boolean";
1951
+ }
1952
+ function takesNextToken(next) {
1953
+ if (next === void 0) return false;
1954
+ return !/^-./.test(next) || /^-\d/.test(next);
1955
+ }
1956
+ function flagLabel(token) {
1957
+ const eq = token.startsWith("--") ? token.indexOf("=") : -1;
1958
+ return eq > 0 ? token.slice(0, eq) : token;
1959
+ }
1960
+ function scanFlags(seg) {
1961
+ const spec = FLAG_POLICY[seg.binary];
1962
+ if (!spec || spec.freeform) return void 0;
1963
+ const booleans = /* @__PURE__ */ new Set([...spec.booleans ?? [], ...UNIVERSAL_FLAGS]);
1964
+ const values = new Set(spec.values ?? []);
1965
+ const operands = [];
1966
+ let i = 0;
1967
+ while (i < seg.args.length) {
1968
+ const token = seg.args[i];
1969
+ if (token === "--" && !spec.expressionArgs) {
1970
+ operands.push(...seg.args.slice(i + 1));
1971
+ break;
1972
+ }
1973
+ if (!/^-./.test(token)) {
1974
+ operands.push(token);
1975
+ i++;
1976
+ continue;
1977
+ }
1978
+ const shape = matchFlag(spec, booleans, values, token);
1979
+ if (shape === void 0) return { unknown: flagLabel(token), operands };
1980
+ i += shape === "value" && takesNextToken(seg.args[i + 1]) ? 2 : 1;
1981
+ }
1982
+ return { operands };
1983
+ }
1984
+ function subcommandOf(seg) {
1985
+ const scan = scanFlags(seg);
1986
+ if (scan) return scan.operands[0];
1987
+ return seg.args.find((a) => !a.startsWith("-"));
1988
+ }
1989
+ var SCOPE_LEAD_ARGS = 2;
685
1990
  function scopeFor(seg) {
686
1991
  if (!MULTIPLEXERS.includes(seg.binary)) return seg.binary;
687
- const sub = seg.args.find((a) => !a.startsWith("-"));
688
- return sub ? `${seg.binary} ${sub}` : seg.binary;
1992
+ const lead = [];
1993
+ for (const arg of seg.args) {
1994
+ if (arg.startsWith("-")) break;
1995
+ lead.push(arg);
1996
+ if (lead.length === SCOPE_LEAD_ARGS) break;
1997
+ }
1998
+ return [seg.binary, ...lead].join(" ");
689
1999
  }
690
2000
  function isAuto(seg) {
691
2001
  if (!AUTO_COMMANDS.includes(seg.binary)) return false;
692
2002
  if (seg.expandable) return false;
693
2003
  if (seg.binary !== "git") return true;
694
- const sub = seg.args.find((a) => !a.startsWith("-"));
2004
+ const sub = subcommandOf(seg);
695
2005
  return sub !== void 0 && GIT_READONLY_SUBCOMMANDS.includes(sub);
696
2006
  }
697
2007
  function refusalFor(seg) {
@@ -701,29 +2011,31 @@ function refusalFor(seg) {
701
2011
  if (seg.binary === "eval" || seg.binary === "exec") {
702
2012
  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.`;
703
2013
  }
2014
+ if (seg.binary === "source" || seg.binary === ".") {
2015
+ 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.`;
2016
+ }
2017
+ if (SHELL_KEYWORDS.includes(seg.binary)) {
2018
+ 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.`;
2019
+ }
704
2020
  const subs = REFUSED_SUBCOMMANDS[seg.binary];
705
2021
  if (subs) {
706
- const sub = seg.args.find((a) => !a.startsWith("-"));
2022
+ const sub = subcommandOf(seg);
707
2023
  if (sub && subs.includes(sub)) {
708
2024
  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.`;
709
2025
  }
710
2026
  }
711
2027
  if (seg.binary === "git") {
712
- const sub = seg.args.find((a) => !a.startsWith("-"));
2028
+ const sub = subcommandOf(seg);
713
2029
  if (sub === "branch" || sub === "tag") {
714
2030
  const flag = seg.args.find((a) => ["-D", "-d", "-m", "-M", "--delete", "--move"].includes(a));
715
2031
  if (flag) {
716
2032
  return `"git ${sub} ${flag}" mutates refs. You are a read-only planner \u2014 describe the change as a task instead.`;
717
2033
  }
718
- }
719
- const execPathFlag = seg.args.find((a) => a === "--exec-path" || a.startsWith("--exec-path="));
720
- if (execPathFlag) {
721
- 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.`;
722
- }
723
- if (sub === "grep") {
724
- const pagerFlag = seg.args.find((a) => a === "-O" || a === "--open-files-in-pager" || a.startsWith("--open-files-in-pager="));
725
- if (pagerFlag) {
726
- 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.`;
2034
+ const listing = seg.args.includes("-l") || seg.args.includes("--list");
2035
+ const scan2 = scanFlags(seg);
2036
+ const target = scan2?.operands[1];
2037
+ if (!listing && target) {
2038
+ return `"git ${sub} ${target}" creates or moves a ref. You are a read-only planner \u2014 describe the change as a task instead.`;
727
2039
  }
728
2040
  }
729
2041
  }
@@ -751,6 +2063,13 @@ function refusalFor(seg) {
751
2063
  if (seg.assignments.length > 0) {
752
2064
  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.`;
753
2065
  }
2066
+ const scan = scanFlags(seg);
2067
+ if (scan?.unknown) {
2068
+ 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.`;
2069
+ }
2070
+ if (seg.binary === "uniq" && scan && scan.operands.length > 1) {
2071
+ 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.`;
2072
+ }
754
2073
  return void 0;
755
2074
  }
756
2075
  function withWrapperNote(reason, wrappers) {
@@ -1137,6 +2456,139 @@ It is outside the auto-allowed read-only set (${scope}). Continue with the read-
1137
2456
  }
1138
2457
  };
1139
2458
 
2459
+ // src/utils/redactSecrets.ts
2460
+ var REDACTION_MARKER = "REDACTED";
2461
+ function marker(kind, length) {
2462
+ return `[${REDACTION_MARKER} ${kind}: ${length} chars]`;
2463
+ }
2464
+ var LINE_PREFIX = String.raw`(?:[ \t]*\d+\||[^\s:]*:\d+:|)[ \t]*`;
2465
+ var LINE_PREFIX_ANCHORED = new RegExp("^" + LINE_PREFIX);
2466
+ var CREDENTIAL_WORD = String.raw`(?:api[_-]?keys?|secrets?|passwords?|passwd|passphrase|tokens?|access[_-]?keys?|private[_-]?keys?|client[_-]?secrets?|credentials?|auth)(?![a-z])`;
2467
+ var NAMED_CREDENTIAL = new RegExp(
2468
+ 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`,
2469
+ "gi"
2470
+ );
2471
+ var DOTENV_CREDENTIAL = new RegExp(
2472
+ 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]*$`,
2473
+ "gm"
2474
+ );
2475
+ var PROVIDER_KEY_PATTERNS = [
2476
+ // OpenAI / Anthropic / OpenRouter and the many `sk-` derivatives
2477
+ /(?<![A-Za-z0-9])sk-(?:ant-|proj-|or-v1-|live-|test-)?[A-Za-z0-9_-]{20,}/g,
2478
+ // GitHub personal-access, OAuth, app and refresh tokens
2479
+ /(?<![A-Za-z0-9])(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{22,}/g,
2480
+ // Slack bot / user / app tokens
2481
+ /(?<![A-Za-z0-9])xox[abprse]-[A-Za-z0-9-]{10,}/g,
2482
+ // Stripe secret and restricted keys
2483
+ /(?<![A-Za-z0-9])[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g,
2484
+ // Google API keys and OAuth access tokens
2485
+ /(?<![A-Za-z0-9])AIza[A-Za-z0-9_-]{30,}/g,
2486
+ /(?<![A-Za-z0-9])ya29\.[A-Za-z0-9_-]{20,}/g,
2487
+ // npm, Hugging Face, Groq, GitLab, DigitalOcean, Shopify
2488
+ /(?<![A-Za-z0-9])(?:npm|hf|gsk|glpat|dop_v1|shpat|shpss)[_-][A-Za-z0-9]{20,}/g,
2489
+ /(?<![A-Za-z0-9])xai-[A-Za-z0-9]{20,}/g,
2490
+ // Three-segment JWTs, whose payload segment is base64 for `{"`
2491
+ /(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
2492
+ ];
2493
+ 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;
2494
+ var PEM_BEGIN = /-----BEGIN ((?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?)-----/;
2495
+ var PEM_BODY_LINE = /^[A-Za-z0-9+/=:,.\- \t]*$/;
2496
+ function redactPrivateKeyBlocks(text) {
2497
+ if (!PEM_BEGIN.test(text)) return text;
2498
+ const out = [];
2499
+ let label = null;
2500
+ let bodyChars = 0;
2501
+ const closeBlock = () => {
2502
+ if (label === null) return;
2503
+ out.push(marker("private key", bodyChars));
2504
+ label = null;
2505
+ bodyChars = 0;
2506
+ };
2507
+ for (const line of text.split("\n")) {
2508
+ if (label === null) {
2509
+ out.push(line);
2510
+ const begun = PEM_BEGIN.exec(line);
2511
+ if (begun) label = begun[1];
2512
+ continue;
2513
+ }
2514
+ const content = line.replace(LINE_PREFIX_ANCHORED, "");
2515
+ if (content.includes(`-----END ${label}-----`)) {
2516
+ closeBlock();
2517
+ out.push(line);
2518
+ } else if (PEM_BODY_LINE.test(content)) {
2519
+ bodyChars += content.trim().length;
2520
+ } else {
2521
+ closeBlock();
2522
+ out.push(line);
2523
+ }
2524
+ }
2525
+ closeBlock();
2526
+ return out.join("\n");
2527
+ }
2528
+ var NUMERIC = /^[-+]?\d+(?:\.\d+)?$/;
2529
+ var IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
2530
+ var MEMBER_EXPRESSION = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$/;
2531
+ var SLUG = /^[a-z]+(?:[-_][a-z]+)+$/;
2532
+ var PATHISH = /^(?:\.{0,2}\/|~\/)/;
2533
+ var URLISH = /^[a-z][a-z0-9+.-]*:\/\//i;
2534
+ var CODE_KEYWORDS = /* @__PURE__ */ new Set([
2535
+ "true",
2536
+ "false",
2537
+ "null",
2538
+ "none",
2539
+ "nil",
2540
+ "undefined",
2541
+ "void",
2542
+ "string",
2543
+ "number",
2544
+ "boolean",
2545
+ "object",
2546
+ "any",
2547
+ "unknown",
2548
+ "bool",
2549
+ "str",
2550
+ "int"
2551
+ ]);
2552
+ var UNAMBIGUOUS_CREDENTIAL_NAME = /(?:password|passwd|passphrase|secret|credentials?|private[_-]?key)/i;
2553
+ function looksLikeKeyMaterial(value) {
2554
+ const hasDigit = /\d/.test(value);
2555
+ const hasAlpha = /[A-Za-z]/.test(value);
2556
+ const mixedCase = /[a-z]/.test(value) && /[A-Z]/.test(value);
2557
+ return hasDigit && hasAlpha || mixedCase || value.length >= 24;
2558
+ }
2559
+ function looksLikeCode(value) {
2560
+ return NUMERIC.test(value) || CODE_KEYWORDS.has(value.toLowerCase()) || MEMBER_EXPRESSION.test(value) || PATHISH.test(value) || URLISH.test(value);
2561
+ }
2562
+ function trimTrailingPunctuation(value) {
2563
+ const match = /[.:!]+$/.exec(value);
2564
+ if (!match || match.index === 0) return { value, tail: "" };
2565
+ return { value: value.slice(0, match.index), tail: match[0] };
2566
+ }
2567
+ function redactSecrets(text) {
2568
+ if (!text) return text;
2569
+ let out = redactPrivateKeyBlocks(text);
2570
+ out = out.replace(DOTENV_CREDENTIAL, (full, head, quote, value) => {
2571
+ if (looksLikeCode(value) || value.startsWith("$")) return full;
2572
+ return `${head}${quote}${marker("credential", value.length)}${quote}`;
2573
+ });
2574
+ out = out.replace(NAMED_CREDENTIAL, (full, head, quote, raw) => {
2575
+ const { value, tail } = trimTrailingPunctuation(raw);
2576
+ if (value.length < 8) return full;
2577
+ if (looksLikeCode(value) || value.startsWith("$")) return full;
2578
+ if (UNAMBIGUOUS_CREDENTIAL_NAME.test(head) && /\d/.test(value)) {
2579
+ return `${head}${quote}${marker("credential", value.length)}${quote}${tail}`;
2580
+ }
2581
+ if (IDENTIFIER.test(value) || SLUG.test(value)) return full;
2582
+ if (!UNAMBIGUOUS_CREDENTIAL_NAME.test(head) && !looksLikeKeyMaterial(value)) return full;
2583
+ return `${head}${quote}${marker("credential", value.length)}${quote}${tail}`;
2584
+ });
2585
+ for (const pattern of PROVIDER_KEY_PATTERNS) {
2586
+ out = out.replace(pattern, (hit) => marker("api key", hit.length));
2587
+ }
2588
+ out = out.replace(CLOUD_ACCESS_KEY_ID, (hit) => marker("access key id", hit.length));
2589
+ return out;
2590
+ }
2591
+
1140
2592
  // src/services/executeTool.ts
1141
2593
  var GREP_OUTPUT_MODES = ["content", "files", "count"];
1142
2594
  function num(value) {
@@ -1163,6 +2615,10 @@ function grepOptions(args) {
1163
2615
  }
1164
2616
  var STOPPED_TOOL_RESULT = "Stopped by the user \u2014 this call was not executed, and no further tool calls will run this turn.";
1165
2617
  async function executeTool(name, args, fs10, fetcher, signal) {
2618
+ const outcome = await runTool(name, args, fs10, fetcher, signal);
2619
+ return { ...outcome, output: redactSecrets(outcome.output) };
2620
+ }
2621
+ async function runTool(name, args, fs10, fetcher, signal) {
1166
2622
  if (signal?.aborted) return { success: false, output: STOPPED_TOOL_RESULT, truncated: false };
1167
2623
  switch (name) {
1168
2624
  case "read_file": {
@@ -2317,6 +3773,177 @@ function modesFor(scope, modes) {
2317
3773
  return scoped;
2318
3774
  }
2319
3775
 
3776
+ // src/services/TaskQuery.ts
3777
+ var TASK_QUERY_FIELDS = [
3778
+ "description",
3779
+ "prompt",
3780
+ "userSteps",
3781
+ "verdict",
3782
+ "outputSummary",
3783
+ "userStoriesCovered"
3784
+ ];
3785
+ function textHasTaskQuery(text) {
3786
+ return text.includes(`"${TASK_QUERY_ENVELOPE_KEY}"`);
3787
+ }
3788
+ function parseTaskQueryJson(text) {
3789
+ const { matches, fallback } = extractObjectsWithKey(text, TASK_QUERY_ENVELOPE_KEY);
3790
+ const candidates = matches.length > 0 ? [...matches].reverse() : [fallback.json];
3791
+ let lastError = null;
3792
+ for (const json of candidates) {
3793
+ try {
3794
+ return parseTaskQueryObject(json, text);
3795
+ } catch (err) {
3796
+ if (!(err instanceof PlanParseError)) throw err;
3797
+ if (!lastError) lastError = err;
3798
+ }
3799
+ }
3800
+ throw lastError ?? new PlanParseError("No JSON object found in task-query reply", text);
3801
+ }
3802
+ function parseTaskQueryObject(json, text) {
3803
+ let parsed;
3804
+ try {
3805
+ parsed = JSON.parse(escapeControlCharsInStrings(stripTrailingCommas(json)));
3806
+ } catch (err) {
3807
+ throw new PlanParseError(`Task-query JSON is invalid: ${err instanceof Error ? err.message : String(err)}`, text);
3808
+ }
3809
+ const raw = parsed.taskQuery;
3810
+ const body = Array.isArray(raw) ? { tasks: raw } : raw;
3811
+ if (typeof body !== "object" || body === null) {
3812
+ throw new PlanParseError('"taskQuery" must be an object (or an array of task references)', text);
3813
+ }
3814
+ const { tasks: rawTasks, fields: rawFields, catalog: rawCatalog } = body;
3815
+ const tasks = [];
3816
+ if (rawTasks !== void 0) {
3817
+ if (!Array.isArray(rawTasks)) throw new PlanParseError('"tasks" in a taskQuery must be an array of task references', text);
3818
+ for (const ref of rawTasks) {
3819
+ if (typeof ref !== "string" && typeof ref !== "number") {
3820
+ throw new PlanParseError(`Invalid task reference "${String(ref)}" in taskQuery`, text);
3821
+ }
3822
+ tasks.push(String(ref).trim());
3823
+ }
3824
+ }
3825
+ let fields;
3826
+ if (rawFields !== void 0) {
3827
+ if (!Array.isArray(rawFields)) throw new PlanParseError('"fields" in a taskQuery must be an array of field names', text);
3828
+ fields = [];
3829
+ for (const f of rawFields) {
3830
+ if (typeof f !== "string" || !TASK_QUERY_FIELDS.includes(f)) {
3831
+ throw new PlanParseError(`Unknown taskQuery field "${String(f)}" \u2014 choose from ${TASK_QUERY_FIELDS.join(", ")}`, text);
3832
+ }
3833
+ fields.push(f);
3834
+ }
3835
+ }
3836
+ const catalog = rawCatalog === true;
3837
+ if (tasks.length === 0 && !catalog) {
3838
+ throw new PlanParseError('A taskQuery must name at least one task or set "catalog": true', text);
3839
+ }
3840
+ return { tasks, fields, catalog };
3841
+ }
3842
+ function taskQuerySignature(query) {
3843
+ return JSON.stringify([query.tasks, query.fields ?? null, query.catalog]);
3844
+ }
3845
+ 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.";
3846
+ var TASK_QUERY_PROTOCOL = [
3847
+ "READING A TASK BEFORE YOU EDIT IT:",
3848
+ "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:",
3849
+ ` {"${TASK_QUERY_ENVELOPE_KEY}":{"tasks":["<id or #order>", "..."],"fields":[${TASK_QUERY_FIELDS.map((f) => `"${f}"`).join(", ")}],"catalog":true}}`,
3850
+ '- "tasks": one or more task references. Ask for everything you need in ONE query \u2014 each query costs a round-trip.',
3851
+ '- "fields": optional. Omit it to get every field above.',
3852
+ '- "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.',
3853
+ "Ordewell answers immediately with the detail and changes nothing. Then reply again with your taskOps JSON, or with prose for the user.",
3854
+ "Three queries per user message; after that every answer also tells you to land the turn. Do not ask the same question twice."
3855
+ ];
3856
+ 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.`;
3857
+ function findTask(tasks, ref) {
3858
+ const byId = tasks.find((t) => t.id === ref);
3859
+ if (byId) return byId;
3860
+ const orderStr = ref.startsWith("#") ? ref.slice(1) : ref;
3861
+ if (/^\d+$/.test(orderStr)) {
3862
+ const byOrder = tasks.filter((t) => t.order === Number(orderStr));
3863
+ if (byOrder.length === 1) return byOrder[0];
3864
+ }
3865
+ return tasks.find((t) => t.title === ref);
3866
+ }
3867
+ function renderField(task, field) {
3868
+ switch (field) {
3869
+ case "description":
3870
+ return [`description: ${task.description || "(none)"}`];
3871
+ case "prompt":
3872
+ return task.prompt ? ["prompt:", task.prompt] : ["prompt: (none)"];
3873
+ case "userSteps": {
3874
+ const steps = task.userSteps ?? [];
3875
+ if (steps.length === 0) return ["userSteps: (none)"];
3876
+ return ["userSteps:", ...steps.map((s) => ` ${s.order}. [${s.completed ? "x" : " "}] ${s.instruction}`)];
3877
+ }
3878
+ case "verdict": {
3879
+ const v = task.verdict;
3880
+ if (!v) return ["verdict: (none)"];
3881
+ const checks = v.checks.map((c) => `${c.name}=${c.skipped ? "skipped" : c.passed ? "pass" : "fail"}`).join(", ");
3882
+ return [`verdict: ${v.outcome.toUpperCase()} \u2014 ${v.reason}${checks ? ` [${checks}]` : ""}`];
3883
+ }
3884
+ case "outputSummary": {
3885
+ const o = task.outputSummary;
3886
+ if (!o) return ["outputSummary: (none)"];
3887
+ return [`outputSummary: ${o.reviewReason}`, ...o.logTail ? ["log tail:", o.logTail] : []];
3888
+ }
3889
+ case "userStoriesCovered": {
3890
+ const stories = task.userStoriesCovered ?? [];
3891
+ if (stories.length === 0) return ["userStoriesCovered: (none)"];
3892
+ return ["userStoriesCovered:", ...stories.map((s) => ` - ${s}`)];
3893
+ }
3894
+ }
3895
+ }
3896
+ function renderTask(task, fields) {
3897
+ return [
3898
+ `#${task.order} id=${task.id} "${task.title}" [${task.status}] type:${task.type === "user" ? "MAN" : "AI"}`,
3899
+ ...fields.flatMap((f) => renderField(task, f)),
3900
+ ""
3901
+ ];
3902
+ }
3903
+ function renderCatalog(catalog) {
3904
+ const lines = [];
3905
+ for (const runner of catalog.runners) {
3906
+ const models = catalog.models[runner] ?? [];
3907
+ lines.push(`${runner} models:`);
3908
+ if (models.length === 0) lines.push(" (none discovered)");
3909
+ for (const m of models) {
3910
+ const variants = m.variants?.length ? ` variants: ${m.variants.map((v) => `${v.id}${v.label && v.label !== v.id ? ` (${v.label})` : ""}`).join(", ")}` : " variants: none";
3911
+ lines.push(` - ${m.modelId} \u2014 ${m.modelLabel}${variants}`);
3912
+ }
3913
+ const modes = catalog.modes[runner] ?? [];
3914
+ lines.push(`${runner} task modes:`);
3915
+ if (modes.length === 0) lines.push(" (none declared)");
3916
+ const defaultId = resolveDefaultMode(modes, catalog.autonomousDefault);
3917
+ for (const mode of modes) {
3918
+ lines.push(` - ${mode.id} \u2014 ${mode.label}: ${mode.description}${mode.id === defaultId ? " (default)" : ""}`);
3919
+ }
3920
+ }
3921
+ return lines;
3922
+ }
3923
+ function renderTaskQueryAnswer(query, tasks, catalog) {
3924
+ const fields = query.fields ?? TASK_QUERY_FIELDS;
3925
+ const blocks = [];
3926
+ if (query.tasks.length > 0) {
3927
+ const body = [];
3928
+ for (const ref of query.tasks) {
3929
+ const task = findTask(tasks, ref);
3930
+ if (!task) {
3931
+ body.push(`${ref}: no task matches this reference in the current plan.`, "");
3932
+ continue;
3933
+ }
3934
+ body.push(...renderTask(task, fields));
3935
+ }
3936
+ blocks.push(["<task_detail>", ...body, "</task_detail>"].join("\n"));
3937
+ }
3938
+ if (query.catalog) {
3939
+ blocks.push(["<runner_catalog>", ...renderCatalog(catalog), "</runner_catalog>"].join("\n"));
3940
+ }
3941
+ blocks.push(
3942
+ "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."
3943
+ );
3944
+ return blocks.join("\n\n");
3945
+ }
3946
+
2320
3947
  // src/services/PlanPrompts.ts
2321
3948
  function buildResearchToolsPrompt(subagentsEnabled = false) {
2322
3949
  const lines = [
@@ -2406,7 +4033,8 @@ function researchPhaseBlock(harnessMode) {
2406
4033
  ];
2407
4034
  const toolSpecific = harnessMode ? [
2408
4035
  "- Explore the repository with your own tools until you understand its architecture",
2409
- "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace"
4036
+ "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace",
4037
+ "- 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."
2410
4038
  ] : [
2411
4039
  "- Use list_dir with depth=2-3 for a tree overview",
2412
4040
  "- Use glob and grep to find relevant source files",
@@ -2571,6 +4199,11 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
2571
4199
  "TASK MODE:",
2572
4200
  modeGuide,
2573
4201
  "",
4202
+ // Shared by both variants on purpose: the read channel is a text envelope
4203
+ // exactly so a harness planner, which Ordewell cannot hand tools to, speaks
4204
+ // the same protocol as an API-backed one (ADR-0009).
4205
+ ...TASK_QUERY_PROTOCOL,
4206
+ "",
2574
4207
  context ? `PROJECT CONTEXT:
2575
4208
  ${context}
2576
4209
  ` : "",
@@ -2878,7 +4511,8 @@ function buildMergePrompt(taskIds, tasks) {
2878
4511
  "The merged task takes the union of their dependencies (excluding the merged tasks themselves) and preserves their user stories.",
2879
4512
  '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.',
2880
4513
  'Reply with ONLY a taskOps JSON object using a single "merge" op:',
2881
- ` {"taskOps":[{"op":"merge","taskIds":[${selected.map((t) => `"${t.id}"`).join(", ")}],"merged":{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}}}]}`
4514
+ ` {"taskOps":[{"op":"merge","taskIds":[${selected.map((t) => `"${t.id}"`).join(", ")}],"merged":{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}}}]}`,
4515
+ `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.`
2882
4516
  ].join("\n");
2883
4517
  }
2884
4518
  function buildSplitPrompt(taskId, tasks) {
@@ -2890,7 +4524,8 @@ function buildSplitPrompt(taskId, tasks) {
2890
4524
  "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.",
2891
4525
  '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).',
2892
4526
  'Reply with ONLY a taskOps JSON object using a single "split" op:',
2893
- ` {"taskOps":[{"op":"split","taskId":"${task.id}","parts":[{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}},...]}]}`
4527
+ ` {"taskOps":[{"op":"split","taskId":"${task.id}","parts":[{"title":"...","description":"...","prompt":"...","assignedRunner":"...","assignedModel":{"modelId":"...","modelLabel":"..."}},...]}]}`,
4528
+ `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.`
2894
4529
  ].join("\n");
2895
4530
  }
2896
4531
 
@@ -2917,6 +4552,9 @@ function truncatedPlanReEmitPrompt(historyCompacted) {
2917
4552
  function reEmitTaskOpsPrompt(detail) {
2918
4553
  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.`;
2919
4554
  }
4555
+ function reEmitTaskQueryPrompt(detail) {
4556
+ 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.`;
4557
+ }
2920
4558
  function taskOpsRejectedPrompt(errors) {
2921
4559
  return `Those task edits were rejected:
2922
4560
  - ${errors.join("\n- ")}
@@ -2943,6 +4581,16 @@ function classifyPlannerReply(text, opts) {
2943
4581
  }
2944
4582
  }
2945
4583
  }
4584
+ if (textHasTaskQuery(text)) {
4585
+ try {
4586
+ return { kind: "task_query", query: parseTaskQueryJson(text) };
4587
+ } catch (err) {
4588
+ if (!(err instanceof PlanParseError)) throw err;
4589
+ if (extractObjectsWithKey(text, TASK_QUERY_ENVELOPE_KEY).matches.length > 0) {
4590
+ return { kind: "broken_task_query", error: err };
4591
+ }
4592
+ }
4593
+ }
2946
4594
  if (text.includes(`"${PLAN_ENVELOPE_KEY}"`)) {
2947
4595
  try {
2948
4596
  return { kind: "plan", tasks: parsePlanJson(text, opts.runners, opts.runnerModes, opts.autonomousDefault) };
@@ -3672,6 +5320,10 @@ ${llmOutput}
3672
5320
  switch (reply.kind) {
3673
5321
  case "task_ops":
3674
5322
  return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
5323
+ // A read is settled by the Session (it owns the plan and the catalog),
5324
+ // so it leaves this loop the same way a plan or an edit does.
5325
+ case "task_query":
5326
+ return { kind: "task_query", query: reply.query, text: turn.text, researchLog };
3675
5327
  case "plan":
3676
5328
  if (ctx.prdEnabled && !ctx.prdCaptured && !ctx.prdNudgeSent && !signal?.aborted) {
3677
5329
  ctx.prdNudgeSent = true;
@@ -3694,6 +5346,13 @@ ${llmOutput}
3694
5346
  continue;
3695
5347
  }
3696
5348
  break;
5349
+ case "broken_task_query":
5350
+ if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
5351
+ jsonRepairAttempts++;
5352
+ pending = reEmitTaskQueryPrompt(reply.error.message);
5353
+ continue;
5354
+ }
5355
+ break;
3697
5356
  case "broken_plan":
3698
5357
  if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
3699
5358
  jsonRepairAttempts++;
@@ -4498,11 +6157,11 @@ function composeAugmentedPrompt(task, allTasks, opts) {
4498
6157
  if (isHitlTask(task)) {
4499
6158
  blocks.push(renderCheckpointInstruction());
4500
6159
  }
4501
- const marker = renderCompletionMarker(task);
4502
- if (blocks.length === 0) return basePrompt + marker;
6160
+ const marker2 = renderCompletionMarker(task);
6161
+ if (blocks.length === 0) return basePrompt + marker2;
4503
6162
  return `${blocks.join("\n\n")}
4504
6163
 
4505
- ${basePrompt}${marker}`;
6164
+ ${basePrompt}${marker2}`;
4506
6165
  }
4507
6166
 
4508
6167
  // src/services/VerdictEngine.ts
@@ -5675,111 +7334,6 @@ ${output.slice(-3e3)}`);
5675
7334
  }
5676
7335
  };
5677
7336
 
5678
- // src/services/ModelAllowlistResolver.ts
5679
- function effectiveAllowlist(allowlist, runner, modelsByRunner) {
5680
- if (!allowlist || allowlist.length === 0) return void 0;
5681
- if (!modelsByRunner) return allowlist;
5682
- const own = new Set((modelsByRunner[runner] ?? []).map((m) => m.modelId));
5683
- const elsewhere = /* @__PURE__ */ new Set();
5684
- for (const [other, models] of Object.entries(modelsByRunner)) {
5685
- if (other === runner) continue;
5686
- for (const m of models ?? []) elsewhere.add(m.modelId);
5687
- }
5688
- const kept = allowlist.filter((id) => own.has(id) || !elsewhere.has(id));
5689
- return kept.length > 0 ? kept : void 0;
5690
- }
5691
- function filterModelsForPrompt(modelsByRunner, perRunnerAllowlist) {
5692
- const result = {};
5693
- for (const [runner, models] of Object.entries(modelsByRunner)) {
5694
- if (!models) continue;
5695
- const allowed = effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
5696
- if (!allowed) {
5697
- result[runner] = models;
5698
- continue;
5699
- }
5700
- const allowedSet = new Set(allowed);
5701
- const intersection = models.filter((m) => allowedSet.has(m.modelId));
5702
- const covered = new Set(intersection.map((m) => m.modelId));
5703
- const missing = allowed.filter((id) => !covered.has(id)).map((id) => ({ modelId: id, modelLabel: id.split("/").pop() || id, variants: [] }));
5704
- result[runner] = [...intersection, ...missing];
5705
- }
5706
- return result;
5707
- }
5708
- var EFFORT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
5709
- var EFFORT_ALIASES = { disabled: "none", adaptive: "medium" };
5710
- function clampThinkingEffort(effort, variants) {
5711
- if (!effort) return void 0;
5712
- if (variants.length === 0) return void 0;
5713
- const ids = new Set(variants.map((v) => v.id));
5714
- if (ids.has(effort)) return effort;
5715
- const canon = EFFORT_ALIASES[effort] ?? effort;
5716
- if (ids.has(canon)) return canon;
5717
- const idx = EFFORT_LADDER.indexOf(canon);
5718
- if (idx < 0) return void 0;
5719
- for (let d = 1; d < EFFORT_LADDER.length; d++) {
5720
- const lower = EFFORT_LADDER[idx - d];
5721
- if (lower && ids.has(lower)) return lower;
5722
- const higher = EFFORT_LADDER[idx + d];
5723
- if (higher && ids.has(higher)) return higher;
5724
- }
5725
- return void 0;
5726
- }
5727
- function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRunner) {
5728
- const clampVariant = (task) => {
5729
- if (!task.assignedModel || !modelsByRunner) return task;
5730
- const model = modelsByRunner[task.assignedRunner]?.find(
5731
- (m) => m.modelId === task.assignedModel.modelId
5732
- );
5733
- if (!model) return task;
5734
- const clamped = clampThinkingEffort(task.assignedModel.thinkingEffort, model.variants);
5735
- return {
5736
- ...task,
5737
- assignedModel: {
5738
- ...task.assignedModel,
5739
- thinkingEffort: clamped,
5740
- availableVariants: model.variants.map((v) => v.id)
5741
- }
5742
- };
5743
- };
5744
- const allowedFor = (runner) => effectiveAllowlist(perRunnerAllowlist[runner], runner, modelsByRunner);
5745
- const assignmentFor = (runner, modelId) => {
5746
- const known = modelsByRunner?.[runner]?.find((m) => m.modelId === modelId);
5747
- return {
5748
- modelId,
5749
- modelLabel: known?.modelLabel ?? modelId,
5750
- thinkingEffort: void 0,
5751
- ...known ? { availableVariants: known.variants.map((v) => v.id) } : {}
5752
- };
5753
- };
5754
- return tasks.map((task) => {
5755
- if (task.type === "user") return task;
5756
- if (allowedRunners && !allowedRunners.includes(task.assignedRunner)) {
5757
- const fallbackRunner = allowedRunners[0];
5758
- const fallbackModels = allowedFor(fallbackRunner);
5759
- const fallbackModel = fallbackModels?.length ? assignmentFor(fallbackRunner, fallbackModels[0]) : modelsByRunner?.[fallbackRunner]?.[0] ? assignmentFor(fallbackRunner, modelsByRunner[fallbackRunner][0].modelId) : task.assignedModel;
5760
- return clampVariant({
5761
- ...task,
5762
- assignedRunner: fallbackRunner,
5763
- assignedModel: fallbackModel
5764
- });
5765
- }
5766
- const allowlist = allowedFor(task.assignedRunner);
5767
- if (!task.assignedModel) {
5768
- if (allowlist && allowlist.length > 0) {
5769
- return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) });
5770
- }
5771
- const discovered = modelsByRunner?.[task.assignedRunner]?.[0];
5772
- if (discovered) {
5773
- return clampVariant({ ...task, assignedModel: assignmentFor(task.assignedRunner, discovered.modelId) });
5774
- }
5775
- return task;
5776
- }
5777
- if (!allowlist || allowlist.length === 0) return clampVariant(task);
5778
- if (allowlist.includes(task.assignedModel.modelId)) return clampVariant(task);
5779
- return { ...task, assignedModel: assignmentFor(task.assignedRunner, allowlist[0]) };
5780
- });
5781
- }
5782
-
5783
7337
  // src/services/Planner.ts
5784
7338
  function plannerModesWith(req) {
5785
7339
  const modes = req.modes ?? DEFAULT_PLANNER_MODES;
@@ -7449,7 +9003,7 @@ function assertWorkspaceIsProject(workspace, deps = {}) {
7449
9003
  throw new WorkspaceNotAProjectError(workspace);
7450
9004
  }
7451
9005
  const hasMarker = [...PROJECT_MARKER_DIRS, ...PROJECT_MARKER_FILES].some(
7452
- (marker) => exists(path9.join(resolved, marker))
9006
+ (marker2) => exists(path9.join(resolved, marker2))
7453
9007
  );
7454
9008
  if (!hasMarker) throw new WorkspaceNotAProjectError(workspace);
7455
9009
  }
@@ -7491,12 +9045,19 @@ var StdioAgentAdapter = class {
7491
9045
  /** Set for the duration of a turn. Outside one, events are buffered rather than dropped. */
7492
9046
  turnEmit = null;
7493
9047
  /**
7494
- * Events the agent produced between turns — in practice the startup warnings
7495
- * that arrive during the handshake. Dropping them hid the one diagnostic
7496
- * that explains a planner which cannot read the workspace, so they are held
7497
- * until a turn exists to show them in.
9048
+ * Events the agent produced before its first turn — in practice the startup
9049
+ * warnings that arrive during the handshake. Dropping them hid the one
9050
+ * diagnostic that explains a planner which cannot read the workspace, so they
9051
+ * are held until a turn exists to show them in.
9052
+ *
9053
+ * Only before the *first* turn. Once one has run, anything arriving outside a
9054
+ * turn is that closed turn's straggling work — a backgrounded subagent
9055
+ * finishing, a late read — and replaying it into the next turn presents it as
9056
+ * research done for the user's new message.
7498
9057
  */
7499
9058
  betweenTurns = [];
9059
+ /** Set once a turn has ended: after that, out-of-turn events are stale, not startup. */
9060
+ hadTurn = false;
7500
9061
  disposed = false;
7501
9062
  /** Resolves when the process ends, so a handshake can lose the race instead of waiting out its timeout. */
7502
9063
  processEnded;
@@ -7531,7 +9092,7 @@ var StdioAgentAdapter = class {
7531
9092
  this.process.stdout?.on("data", (chunk) => {
7532
9093
  this.stdout.push(chunk.toString(), (line) => this.handleLine(line, (event) => {
7533
9094
  if (this.turnEmit) this.turnEmit(event);
7534
- else if (!TURN_SCOPED_EVENTS.has(event.type) && this.betweenTurns.length < BETWEEN_TURN_EVENT_CAP) {
9095
+ else if (!this.hadTurn && !TURN_SCOPED_EVENTS.has(event.type) && this.betweenTurns.length < BETWEEN_TURN_EVENT_CAP) {
7535
9096
  this.betweenTurns.push(event);
7536
9097
  }
7537
9098
  }));
@@ -7563,6 +9124,7 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS);
7563
9124
  if (settled) return;
7564
9125
  settled = true;
7565
9126
  this.turnEmit = null;
9127
+ this.hadTurn = true;
7566
9128
  signal?.removeEventListener("abort", onAbort);
7567
9129
  this.process?.removeListener("exit", onExit);
7568
9130
  this.process?.removeListener("error", onExit);
@@ -7641,6 +9203,7 @@ ${tail}` : ""}`;
7641
9203
 
7642
9204
  // src/services/harness/ClaudeCodeAdapter.ts
7643
9205
  var DISALLOWED_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit", "KillShell"];
9206
+ var ASYNC_LAUNCH_MARKER = "Async agent launched successfully";
7644
9207
  function flattenContent(content) {
7645
9208
  if (typeof content === "string") return content;
7646
9209
  if (Array.isArray(content)) {
@@ -7650,6 +9213,8 @@ function flattenContent(content) {
7650
9213
  }
7651
9214
  var ClaudeCodeAdapter = class extends StdioAgentAdapter {
7652
9215
  agentId = "claude-code";
9216
+ /** Whether this turn has already emitted reply text — see {@link handleLine}. */
9217
+ turnHasText = false;
7653
9218
  spawnSpec(opts) {
7654
9219
  const args = [
7655
9220
  "-p",
@@ -7674,6 +9239,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
7674
9239
  return { command: "claude", args };
7675
9240
  }
7676
9241
  turnPayload(message) {
9242
+ this.turnHasText = false;
7677
9243
  return `${JSON.stringify({
7678
9244
  type: "user",
7679
9245
  message: { role: "user", content: [{ type: "text", text: message }] }
@@ -7684,6 +9250,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
7684
9250
  const msg = StdioAgentAdapter.parse(line);
7685
9251
  if (!msg) return;
7686
9252
  if (msg.session_id) this.sessionId = msg.session_id;
9253
+ if (msg.parent_tool_use_id) return;
7687
9254
  switch (msg.type) {
7688
9255
  // The control channel: Claude asks whether a tool may run when its mode
7689
9256
  // cannot decide alone. A read-only planner answers "deny", every time —
@@ -7708,8 +9275,12 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
7708
9275
  }
7709
9276
  case "assistant":
7710
9277
  for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
7711
- if (block.type === "text" && block.text) emit({ type: "assistant_text", text: block.text });
7712
- else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
9278
+ if (block.type === "text" && block.text) {
9279
+ emit({ type: "assistant_text", text: this.turnHasText ? `
9280
+
9281
+ ${block.text}` : block.text });
9282
+ this.turnHasText = true;
9283
+ } else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
7713
9284
  else if (block.type === "tool_use" && block.name) {
7714
9285
  emit({ type: "tool_call", id: block.id ?? block.name, name: block.name, args: block.input ?? {} });
7715
9286
  }
@@ -7718,11 +9289,15 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
7718
9289
  case "user":
7719
9290
  for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
7720
9291
  if (block.type !== "tool_result") continue;
9292
+ const output = flattenContent(block.content);
9293
+ if (output.includes(ASYNC_LAUNCH_MARKER)) {
9294
+ emit({ type: "background_agent", id: block.tool_use_id ?? "" });
9295
+ }
7721
9296
  emit({
7722
9297
  type: "tool_result",
7723
9298
  id: block.tool_use_id ?? "",
7724
9299
  name: "",
7725
- output: flattenContent(block.content),
9300
+ output,
7726
9301
  success: block.is_error !== true
7727
9302
  });
7728
9303
  }
@@ -8142,6 +9717,8 @@ var OpenCodeAdapter = class {
8142
9717
  exited = false;
8143
9718
  disposed = false;
8144
9719
  opts = null;
9720
+ /** Whether this turn has already emitted reply text — see {@link emitPart}. */
9721
+ turnHasText = false;
8145
9722
  async start(opts) {
8146
9723
  this.opts = opts;
8147
9724
  assertWorkspaceExists(opts.cwd, { isDirectory: this.deps.isDirectory });
@@ -8209,6 +9786,7 @@ ${this.stderrTail.trim()}` : ""}`);
8209
9786
  return;
8210
9787
  }
8211
9788
  const seen = /* @__PURE__ */ new Set();
9789
+ this.turnHasText = false;
8212
9790
  const streamAbort = new AbortController();
8213
9791
  let connected = () => {
8214
9792
  };
@@ -8277,7 +9855,10 @@ ${this.stderrTail.trim()}` : ""}`);
8277
9855
  if (part.type === "text" && part.text) {
8278
9856
  if (seen.has(`text:${id}`)) return;
8279
9857
  seen.add(`text:${id}`);
8280
- onEvent({ type: "assistant_text", text: part.text });
9858
+ onEvent({ type: "assistant_text", text: this.turnHasText ? `
9859
+
9860
+ ${part.text}` : part.text });
9861
+ this.turnHasText = true;
8281
9862
  return;
8282
9863
  }
8283
9864
  if (part.type === "reasoning" && part.text) {
@@ -8444,6 +10025,7 @@ function normalizeAgentArgs(tool, args) {
8444
10025
 
8445
10026
  // src/services/harness/CliAgentAiService.ts
8446
10027
  var MAX_JSON_REPAIRS = 2;
10028
+ var MAX_AGENT_WAITS = 2;
8447
10029
  var LOG_MAX_CHARS = 1e4;
8448
10030
  function defaultAdapter(runner, deps) {
8449
10031
  switch (runner) {
@@ -8584,13 +10166,16 @@ var CliAgentAiService = class {
8584
10166
  let pending = message;
8585
10167
  let emptyNudgeSent = false;
8586
10168
  let jsonRepairAttempts = 0;
10169
+ let agentWaits = 0;
10170
+ const carried = [];
10171
+ const replyText = (text) => [...carried, text].filter((part) => part.trim()).join("\n\n");
8587
10172
  try {
8588
10173
  for (; ; ) {
8589
10174
  const turn = await this.runTurn(pending, onProgress, combined);
8590
10175
  researchLog.push(...turn.researchLog);
8591
10176
  if (turn.aborted || combined?.aborted) {
8592
10177
  onProgress({ type: "interrupted" });
8593
- return { kind: "message", text: turn.text, researchLog };
10178
+ return { kind: "message", text: replyText(turn.text), researchLog };
8594
10179
  }
8595
10180
  if (turn.error) {
8596
10181
  return { kind: "message", text: turn.error, researchLog };
@@ -8608,6 +10193,18 @@ var CliAgentAiService = class {
8608
10193
  researchLog
8609
10194
  };
8610
10195
  }
10196
+ if (turn.backgroundAgents > 0 && agentWaits < MAX_AGENT_WAITS && !combined?.aborted) {
10197
+ agentWaits++;
10198
+ carried.push(turn.text);
10199
+ pending = [
10200
+ `You ended your turn with ${turn.backgroundAgents} subagent(s) still running in the background.`,
10201
+ "Ordewell hands the conversation back to the user when your turn ends, so anything you say after it never reaches them \u2014",
10202
+ "the results you promised to report would be lost.",
10203
+ "Wait for those agents to finish NOW, in this reply, and do not end your turn until you have their results.",
10204
+ "Then give the user your synthesis. In future replies, await your agents inside the turn rather than backgrounding them."
10205
+ ].join(" ");
10206
+ continue;
10207
+ }
8611
10208
  if (extractPrdBlock(turn.text)) conversation.prdCaptured = true;
8612
10209
  const reply = classifyPlannerReply(turn.text, {
8613
10210
  runners: conversation.runners,
@@ -8616,7 +10213,11 @@ var CliAgentAiService = class {
8616
10213
  });
8617
10214
  switch (reply.kind) {
8618
10215
  case "task_ops":
8619
- return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
10216
+ return { kind: "task_ops", ops: reply.ops, text: replyText(turn.text), researchLog };
10217
+ // The read channel is a text envelope precisely so it reaches here
10218
+ // too: a harness planner has no Ordewell tool loop to call into.
10219
+ case "task_query":
10220
+ return { kind: "task_query", query: reply.query, text: replyText(turn.text), researchLog };
8620
10221
  case "plan":
8621
10222
  if (conversation.prdEnabled && !conversation.prdCaptured && !conversation.prdNudgeSent && !combined?.aborted) {
8622
10223
  conversation.prdNudgeSent = true;
@@ -8629,7 +10230,7 @@ var CliAgentAiService = class {
8629
10230
  continue;
8630
10231
  }
8631
10232
  this.conversation = null;
8632
- return { kind: "plan", tasks: reply.tasks, text: turn.text, researchLog };
10233
+ return { kind: "plan", tasks: reply.tasks, text: replyText(turn.text), researchLog };
8633
10234
  case "broken_task_ops":
8634
10235
  if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
8635
10236
  jsonRepairAttempts++;
@@ -8637,6 +10238,13 @@ var CliAgentAiService = class {
8637
10238
  continue;
8638
10239
  }
8639
10240
  break;
10241
+ case "broken_task_query":
10242
+ if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
10243
+ jsonRepairAttempts++;
10244
+ pending = reEmitTaskQueryPrompt(reply.error.message);
10245
+ continue;
10246
+ }
10247
+ break;
8640
10248
  case "broken_plan":
8641
10249
  if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
8642
10250
  jsonRepairAttempts++;
@@ -8647,7 +10255,7 @@ var CliAgentAiService = class {
8647
10255
  case "prose":
8648
10256
  break;
8649
10257
  }
8650
- return { kind: "message", text: turn.text, researchLog };
10258
+ return { kind: "message", text: replyText(turn.text), researchLog };
8651
10259
  }
8652
10260
  } finally {
8653
10261
  this.activeAbort = null;
@@ -8667,9 +10275,11 @@ var CliAgentAiService = class {
8667
10275
  let text = "";
8668
10276
  let error;
8669
10277
  let stepIndex = 0;
10278
+ let backgroundAgents = 0;
8670
10279
  const truncate = (value) => value.length > LOG_MAX_CHARS ? `${value.slice(0, LOG_MAX_CHARS)}
8671
10280
  [... truncated, total ${value.length} chars]` : value;
8672
- const settle = (id, output, success, outcome) => {
10281
+ const settle = (id, rawOutput, success, outcome) => {
10282
+ const output = redactSecrets(rawOutput);
8673
10283
  const call = pendingCalls.get(id);
8674
10284
  pendingCalls.delete(id);
8675
10285
  const step = {
@@ -8705,6 +10315,9 @@ var CliAgentAiService = class {
8705
10315
  case "tool_result":
8706
10316
  settle(event.id, event.output, event.success, event.success ? "success" : "failure");
8707
10317
  return;
10318
+ case "background_agent":
10319
+ backgroundAgents++;
10320
+ return;
8708
10321
  case "permission_request": {
8709
10322
  const mapped = mapAgentTool(event.name);
8710
10323
  const args = JSON.stringify({ detail: event.detail });
@@ -8735,7 +10348,7 @@ var CliAgentAiService = class {
8735
10348
  adapter.dispose();
8736
10349
  this.adapter = null;
8737
10350
  }
8738
- return { text, researchLog, error, aborted: signal?.aborted };
10351
+ return { text, researchLog, error, aborted: signal?.aborted, backgroundAgents };
8739
10352
  }
8740
10353
  // --- Process lifecycle ---
8741
10354
  async startAdapter(opts) {
@@ -9200,6 +10813,16 @@ function ensureDir(dir) {
9200
10813
  function getStateDir(baseDir) {
9201
10814
  return path10.join(baseDir ?? process.cwd(), STATE_DIR);
9202
10815
  }
10816
+ var IGNORE_FILE = ".gitignore";
10817
+ function ensureStateDirIgnored(baseDir) {
10818
+ const dir = getStateDir(baseDir);
10819
+ ensureDir(dir);
10820
+ try {
10821
+ fs4.writeFileSync(path10.join(dir, IGNORE_FILE), "*\n", { flag: "wx" });
10822
+ } catch (err) {
10823
+ if (err.code !== "EEXIST") throw err;
10824
+ }
10825
+ }
9203
10826
 
9204
10827
  // src/utils/sessionId.ts
9205
10828
  import { randomBytes } from "crypto";
@@ -9229,6 +10852,7 @@ function sessionFilename(session) {
9229
10852
  function saveSession(plan, goal, baseDir, id) {
9230
10853
  const sessionsDir = getSessionsDir(baseDir);
9231
10854
  ensureDir(sessionsDir);
10855
+ ensureStateDirIgnored(baseDir);
9232
10856
  const now = (/* @__PURE__ */ new Date()).toISOString();
9233
10857
  const meta = {
9234
10858
  id: id || mintSessionId(),
@@ -9331,6 +10955,12 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
9331
10955
  }
9332
10956
 
9333
10957
  // src/services/createSession.ts
10958
+ var CATALOG_MODEL_CAP = 100;
10959
+ var MAX_TASK_QUERIES = 3;
10960
+ var MAX_TASK_QUERIES_HARD = 6;
10961
+ function freshReadBudget() {
10962
+ return { answered: 0, seen: /* @__PURE__ */ new Set() };
10963
+ }
9334
10964
  var PlanEditError = class extends Error {
9335
10965
  constructor(message) {
9336
10966
  super(message);
@@ -9567,6 +11197,21 @@ var Session = class {
9567
11197
  runnerModesFor(runners) {
9568
11198
  return runnerModesFrom(this.registry, runners);
9569
11199
  }
11200
+ /**
11201
+ * The catalog a model/task-mode edit is checked against — the same
11202
+ * discovered models and manifest modes the per-turn catalog block shows the
11203
+ * planner, so a refusal here can never name something invalid that the
11204
+ * planner was never told about. `modelsCache` is read live, not filtered by
11205
+ * the allowlist here — {@link checkModelAndModeValidity} narrows by
11206
+ * allowlist itself, the same way `coerceAssignments` does.
11207
+ */
11208
+ editCatalog() {
11209
+ return {
11210
+ modelsByRunner: this.modelsCache,
11211
+ runnerModes: this.runnerModesFor(this.plan?.runners ?? []),
11212
+ perRunnerAllowlist: this.settingsFn().modelAllowlist ?? this.currentAllowlist
11213
+ };
11214
+ }
9570
11215
  get planState() {
9571
11216
  return this.plan;
9572
11217
  }
@@ -9746,10 +11391,12 @@ var Session = class {
9746
11391
  async continueConversation(userMessage, options) {
9747
11392
  if (!this.plan) throw new Error("No planning conversation to continue");
9748
11393
  const priorHistory = this.plan.conversationHistory ?? [];
11394
+ const priorResearchLog = this.plan.researchLog ?? [];
9749
11395
  const now = (/* @__PURE__ */ new Date()).toISOString();
9750
11396
  this.appendTranscript("user", userMessage, now);
11397
+ const historyAfterOwnAppend = this.plan.conversationHistory;
9751
11398
  this.plan.researchLog = [...this.plan.researchLog ?? [], { id: `up-${Date.now()}`, type: "user_prompt", content: userMessage, timestamp: now }];
9752
- const contextBlock = this.planContextBlock();
11399
+ const contextBlock = [this.catalogBlock(), this.planContextBlock()].filter(Boolean).join("\n\n");
9753
11400
  const outgoing = contextBlock ? `${contextBlock}
9754
11401
 
9755
11402
  ${userMessage}` : userMessage;
@@ -9762,6 +11409,8 @@ ${userMessage}` : userMessage;
9762
11409
  (p) => this.translateProgress(p),
9763
11410
  options?.signal
9764
11411
  ) : await this.resumeConversation(outgoing, priorHistory, options);
11412
+ const reads = freshReadBudget();
11413
+ turn = await this.drainTaskQueries(turn, reads, options);
9765
11414
  if ((turn.kind === "task_ops" || turn.kind === "plan") && this.hasLiveWork) {
9766
11415
  this.queueMessage(userMessage);
9767
11416
  this.plan.queuedMessages = this.getQueuedMessages();
@@ -9771,11 +11420,74 @@ ${userMessage}` : userMessage;
9771
11420
  researchLog: turn.researchLog
9772
11421
  };
9773
11422
  }
9774
- return await this.settleTurn(turn, options);
11423
+ return await this.settleTurn(turn, options, reads);
11424
+ } catch (err) {
11425
+ if (this.plan && this.plan.conversationHistory === historyAfterOwnAppend) {
11426
+ this.plan.conversationHistory = priorHistory;
11427
+ this.plan.researchLog = priorResearchLog;
11428
+ }
11429
+ throw err;
9775
11430
  } finally {
9776
11431
  releaseAbort();
9777
11432
  }
9778
11433
  }
11434
+ /**
11435
+ * Answer every read the planner emits until it says something else.
11436
+ *
11437
+ * The channel is a text envelope rather than a registered tool because the
11438
+ * protocol has to be identical on both planner backends (ADR-0009): Ordewell
11439
+ * owns a tool loop only in the API case, and a harness planner running as a
11440
+ * coding-agent subprocess can only be reached this way.
11441
+ *
11442
+ * Draining here — outside {@link repairLoop} — is what keeps reads free of
11443
+ * the repair budget. A planner that looks a task up and *then* fumbles its
11444
+ * ops JSON still gets its two corrective retries; charging it for the read
11445
+ * would cost it the chance to fix the edit.
11446
+ */
11447
+ async drainTaskQueries(turn, reads, options) {
11448
+ const carried = [];
11449
+ let current = turn;
11450
+ while (current.kind === "task_query") {
11451
+ carried.push(...current.researchLog);
11452
+ if (reads.answered >= MAX_TASK_QUERIES_HARD || !this.aiService.hasActiveConversation() || options?.signal?.aborted) {
11453
+ return {
11454
+ kind: "message",
11455
+ 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.",
11456
+ researchLog: carried
11457
+ };
11458
+ }
11459
+ const signature = taskQuerySignature(current.query);
11460
+ const insist = reads.seen.has(signature) || reads.answered >= MAX_TASK_QUERIES;
11461
+ reads.seen.add(signature);
11462
+ reads.answered++;
11463
+ const answer = this.taskQueryAnswer(current.query);
11464
+ current = await this.aiService.continueConversation(
11465
+ insist ? `${answer}
11466
+
11467
+ ${TASK_QUERY_ANSWER_OR_OPS}` : answer,
11468
+ (p) => this.translateProgress(p),
11469
+ options?.signal
11470
+ );
11471
+ }
11472
+ return carried.length > 0 ? { ...current, researchLog: [...carried, ...current.researchLog] } : current;
11473
+ }
11474
+ /**
11475
+ * Render one read out of live state. Never persisted to the transcript: the
11476
+ * detail is context for the planner's next reply, and re-sending it on every
11477
+ * later turn is exactly the cost this channel exists to avoid.
11478
+ */
11479
+ taskQueryAnswer(query) {
11480
+ const runners = this.plan?.runners ?? [];
11481
+ const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
11482
+ return renderTaskQueryAnswer(query, this.store.planTasks, {
11483
+ runners,
11484
+ // Allowlist-filtered like the always-on block: a read must not offer a
11485
+ // model the planner is forbidden to assign.
11486
+ models: filterModelsForPrompt(this.modelsCache, allowlist),
11487
+ modes: this.runnerModesFor(runners),
11488
+ autonomousDefault: this.config.autonomousMode
11489
+ });
11490
+ }
9779
11491
  /**
9780
11492
  * Drive a planner turn to a persisted, broadcast outcome. Task edits apply
9781
11493
  * atomically; validation failures are fed back to the model for up to 2
@@ -9783,7 +11495,7 @@ ${userMessage}` : userMessage;
9783
11495
  * first turn (startPlanning) and every later turn route through here — one
9784
11496
  * path, not two.
9785
11497
  */
9786
- async settleTurn(turn, options) {
11498
+ async settleTurn(turn, options, reads = freshReadBudget()) {
9787
11499
  const invalidOps = (errors, researchLog) => ({
9788
11500
  turn: {
9789
11501
  kind: "message",
@@ -9795,11 +11507,15 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
9795
11507
  }
9796
11508
  });
9797
11509
  const settled = await repairLoop({
9798
- first: () => turn,
9799
- resend: (corrective) => this.aiService.continueConversation(
9800
- corrective,
9801
- (p) => this.translateProgress(p),
9802
- options?.signal
11510
+ first: () => this.drainTaskQueries(turn, reads, options),
11511
+ resend: async (corrective) => this.drainTaskQueries(
11512
+ await this.aiService.continueConversation(
11513
+ corrective,
11514
+ (p) => this.translateProgress(p),
11515
+ options?.signal
11516
+ ),
11517
+ reads,
11518
+ options
9803
11519
  ),
9804
11520
  interpret: (t) => {
9805
11521
  if (t.kind !== "task_ops") return { done: { turn: t } };
@@ -9819,6 +11535,52 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
9819
11535
  }
9820
11536
  return this.applyConversationTurn(settled.turn);
9821
11537
  }
11538
+ /**
11539
+ * The catalog the planner may actually draw from — model ids and task-mode
11540
+ * ids, per selected runner — emitted on EVERY turn, before any plan exists
11541
+ * or after. The system prompt shows this once at conversation start; a long
11542
+ * grill-me interview or PRD negotiation outlives that single showing and the
11543
+ * planner starts misquoting it, so this re-states it per turn instead.
11544
+ *
11545
+ * Built from the allowlist-filtered view (`filterModelsForPrompt`), the same
11546
+ * one the system prompt used — a restricted allowlist stays a hard bound on
11547
+ * every turn, not just the first. `this.modelsCache` itself is never
11548
+ * filtered: `coerceAssignments` needs the full discovered catalog alongside
11549
+ * the allowlist to resolve labels and clamp effort to real variants.
11550
+ *
11551
+ * Reads `this.plan.runners` live, so a runner admitted mid-session by a
11552
+ * retarget (`admitRunner`) is filtered and shown like every other — there is
11553
+ * no separate "originally selected" list to fall stale.
11554
+ */
11555
+ catalogBlock() {
11556
+ if (!this.plan) return null;
11557
+ const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
11558
+ const filteredModels = filterModelsForPrompt(this.modelsCache, allowlist);
11559
+ const runnerModes = this.runnerModesFor(this.plan.runners);
11560
+ const autonomousDefault = this.config.autonomousMode;
11561
+ const modelLines = this.plan.runners.map((runner) => {
11562
+ const models = filteredModels[runner] ?? [];
11563
+ const capped = models.slice(0, CATALOG_MODEL_CAP);
11564
+ const remainder = models.length - capped.length;
11565
+ const ids = capped.map((m) => m.modelId).join(", ") || "(no models discovered)";
11566
+ return `${runner}: ${ids}${remainder > 0 ? ` \u2026 +${remainder} more not shown` : ""}`;
11567
+ });
11568
+ const modeLines = this.plan.runners.map((runner) => {
11569
+ const modes = runnerModes[runner] ?? [];
11570
+ if (modes.length === 0) return `${runner}: (no modes declared)`;
11571
+ const defaultId = resolveDefaultMode(modes, autonomousDefault);
11572
+ const ids = modes.map((m) => `${m.id}${m.id === defaultId ? " (default)" : ""}`).join(", ");
11573
+ return `${runner}: ${ids}`;
11574
+ });
11575
+ return [
11576
+ "<available_models>",
11577
+ ...modelLines,
11578
+ "</available_models>",
11579
+ "<available_task_modes>",
11580
+ ...modeLines,
11581
+ "</available_task_modes>"
11582
+ ].join("\n");
11583
+ }
9822
11584
  /**
9823
11585
  * The "you are here" block for post-plan chat: current tasks with stable
9824
11586
  * references, plus the task-ops protocol. Injected per turn (never
@@ -9828,44 +11590,48 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
9828
11590
  planContextBlock() {
9829
11591
  if (!this.plan || this.store.planTasks.length === 0) return null;
9830
11592
  const orderOf = new Map(this.store.planTasks.map((t) => [t.id, `#${t.order}`]));
9831
- const lines = this.store.planTasks.map(
9832
- (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(", ")}]`
9833
- );
11593
+ const lines = this.store.planTasks.map((t) => {
11594
+ const isMan = t.type === "user";
11595
+ const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
11596
+ 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(", ")}]`;
11597
+ });
9834
11598
  const locked = this.store.planTasks.filter((t) => t.status === "in_progress");
9835
11599
  const execNote = this.hasLiveWork ? `
9836
11600
  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.` : "";
9837
- const modelLines = [];
9838
- for (const runner of this.plan.runners) {
9839
- const models = this.modelsCache[runner] ?? [];
9840
- const ids = models.map((m) => m.modelId).join(", ");
9841
- modelLines.push(`${runner}: ${ids || "(no models discovered)"}`);
9842
- }
9843
- const modelBlock = modelLines.length > 0 ? ["<available_models>", ...modelLines, "</available_models>"].join("\n") : "";
11601
+ const updateChanges = UPDATABLE_FIELDS.map((f) => f === "dependencies" ? '"dependencies"?:["<id or #order>"]' : `"${f}"?`).join(",");
9844
11602
  return [
9845
11603
  "<current_plan>",
9846
11604
  ...lines,
9847
11605
  "</current_plan>",
9848
- modelBlock,
9849
- "The block above is the CURRENT task plan. Choose how to respond:",
11606
+ "The block above is the CURRENT task plan \u2014 short fields only. Choose how to respond:",
9850
11607
  "- To answer a question or discuss, reply in plain prose (no JSON).",
11608
+ // The reminder text is owned by TaskQuery beside the parser, so the
11609
+ // protocol the planner reads and the one Ordewell accepts stay one thing.
11610
+ TASK_QUERY_REMINDER,
9851
11611
  "- To modify specific tasks, reply with ONLY this JSON object:",
9852
11612
  ' {"taskOps": [',
9853
- ' {"op":"update","taskId":"<id or #order>","changes":{"title"?,"description"?,"prompt"?,"dependencies"?:["<id or #order>"],"assignedRunner"?,"assignedModel"?,"taskMode"?}},',
9854
- ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?}},',
11613
+ ` {"op":"update","taskId":"<id or #order>","changes":{${updateChanges}}},`,
11614
+ ' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?},"handle"?:"<name>"},',
9855
11615
  ' {"op":"remove","taskId":"<id or #order>"},',
9856
- ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]},',
9857
- ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}},',
9858
- ' {"op":"split","taskId":"<id or #order>","parts":[{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}, ...]}',
11616
+ ' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]}, // only to re-prioritise INDEPENDENT tasks',
11617
+ ' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...},"handle"?:"<name>"},',
11618
+ ' {"op":"split","taskId":"<id or #order>","parts":[{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...}, ...],"handle"?:"<name>"},',
11619
+ ` {"op":"rearm","taskId":"<id or #order>","changes"?:{${updateChanges}}}`,
9859
11620
  " ]}",
9860
11621
  '- For sweeping changes, you may instead emit a full {"tasks":[...]} plan JSON.',
11622
+ '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.',
11623
+ '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.',
9861
11624
  'When creating or changing tasks, set "assignedRunner" and "assignedModel" ({"modelId","modelLabel","thinkingEffort"?}) using only the runners and models listed in <available_models>.',
9862
- "Keep dependencies consistent: no cycles, no references to removed tasks, dependencies must come before dependents, and never touch running or completed tasks." + execNote
11625
+ 'Keep dependencies consistent: no cycles, no references to removed tasks, and never touch running or completed tasks \u2014 "rearm" is the one exception, below.',
11626
+ '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,
11627
+ '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.',
11628
+ '"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.'
9863
11629
  ].filter(Boolean).join("\n");
9864
11630
  }
9865
11631
  /** Validate + commit a task_ops turn atomically. Returns the errors on rejection (plan untouched). */
9866
11632
  applyTaskOpsTurn(turn) {
9867
11633
  if (!this.plan) throw new Error("No active plan state");
9868
- const result = applyTaskOps(this.store.planTasks, turn.ops, this.plan.runners);
11634
+ const result = applyTaskOps(this.store.planTasks, turn.ops, this.plan.runners, this.editCatalog());
9869
11635
  if (!result.ok) return { errors: result.errors };
9870
11636
  const now = (/* @__PURE__ */ new Date()).toISOString();
9871
11637
  const content = `Tasks updated:
@@ -10132,15 +11898,22 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
10132
11898
  return plan;
10133
11899
  }
10134
11900
  /**
10135
- * Patch one task's fields. A hand-set dependency list is the one patch that
10136
- * can leave the plan unschedulable, so it goes through the same
10137
- * {@link canSetDependencies} guard the planner's pickers use rather than a
10138
- * second copy of the rule — and throws, so the surface can say why.
11901
+ * Patch one task's fields. A hand-set dependency list, or a type flip
11902
+ * between AI and MAN, are the patches that can leave a task incoherent
11903
+ * (unschedulable, or carrying fields that mean nothing for its new type),
11904
+ * so both go through the same {@link validateTaskEdit} guard the planner's
11905
+ * task-ops applier uses (as the 'direct' actor, which skips the lock rule)
11906
+ * rather than a second copy of the rules — and throws, so the surface can
11907
+ * say why. Gated on the task existing so an edit to an unknown id still
11908
+ * falls through to the no-op `store.update` below instead of throwing.
10139
11909
  */
10140
11910
  async updateTask(taskId, changes) {
10141
- if (changes.dependencies) {
10142
- const check = canSetDependencies(this.store.planTasks, taskId, changes.dependencies);
10143
- if (!check.ok) throw new PlanEditError(check.error ?? "Those dependencies are not valid");
11911
+ if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode) && this.store.get(taskId)) {
11912
+ const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
11913
+ if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
11914
+ if (check.clear?.length) {
11915
+ changes = { ...changes, ...Object.fromEntries(check.clear.map((f) => [f, void 0])) };
11916
+ }
10144
11917
  }
10145
11918
  return this.editPlan(
10146
11919
  () => Boolean(this.store.update(taskId, changes)),
@@ -10781,7 +12554,7 @@ import { execFile as execFile2 } from "child_process";
10781
12554
  import { promisify as promisify4 } from "util";
10782
12555
  import { tmpdir } from "os";
10783
12556
  import { join as join10 } from "path";
10784
- import { existsSync as existsSync6, readFileSync as readFileSync4, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
12557
+ import { existsSync as existsSync6, readFileSync as readFileSync4, unlinkSync as unlinkSync2, writeFileSync as writeFileSync6 } from "fs";
10785
12558
 
10786
12559
  // src/utils/tmux.ts
10787
12560
  import { execSync as execSync2 } from "child_process";
@@ -10872,7 +12645,7 @@ var TmuxSession = class extends AbstractTerminalSession {
10872
12645
  * lets the user keep poking at a finished task's terminal.
10873
12646
  */
10874
12647
  async start(command, args, cwd, env) {
10875
- writeFileSync5(this.logPath, "");
12648
+ writeFileSync6(this.logPath, "");
10876
12649
  const envAssignments = Object.entries(env ?? {}).map(([k, v]) => `${k}=${posixShellQuote(v)}`).join(" ");
10877
12650
  const inner = [command, ...args].map(posixShellQuote).join(" ");
10878
12651
  const prefix = envAssignments ? `env ${envAssignments} ` : "";
@@ -12175,6 +13948,11 @@ export {
12175
13948
  SettingsService,
12176
13949
  StdioAgentAdapter,
12177
13950
  TASK_OPS_ENVELOPE_KEY,
13951
+ TASK_QUERY_ANSWER_OR_OPS,
13952
+ TASK_QUERY_ENVELOPE_KEY,
13953
+ TASK_QUERY_FIELDS,
13954
+ TASK_QUERY_PROTOCOL,
13955
+ TASK_QUERY_REMINDER,
12178
13956
  TRUNCATED_PLAN_REPAIR_INSTRUCTION,
12179
13957
  TaskOrchestrator,
12180
13958
  TmuxRunner,
@@ -12245,6 +14023,7 @@ export {
12245
14023
  emptyWarnings,
12246
14024
  enabledRunners,
12247
14025
  ensureDir,
14026
+ ensureStateDirIgnored,
12248
14027
  escapeControlCharsInStrings,
12249
14028
  executionLogBlock,
12250
14029
  executionSummary,
@@ -12294,6 +14073,7 @@ export {
12294
14073
  parsePartialPlan,
12295
14074
  parsePlanJson,
12296
14075
  parseTaskOpsJson,
14076
+ parseTaskQueryJson,
12297
14077
  pendingEditRulesBlock,
12298
14078
  planDirectLaunch,
12299
14079
  planShellLaunch,
@@ -12302,11 +14082,13 @@ export {
12302
14082
  providerForRunner,
12303
14083
  reEmitPlanPrompt,
12304
14084
  reEmitTaskOpsPrompt,
14085
+ reEmitTaskQueryPrompt,
12305
14086
  readDaemonToken,
12306
14087
  referencePattern,
12307
14088
  removeTaskFromPlan,
12308
14089
  renderPlanMap,
12309
14090
  renderPriorOutputs,
14091
+ renderTaskQueryAnswer,
12310
14092
  renumberTasks,
12311
14093
  repairLoop,
12312
14094
  researchShellWarning,
@@ -12340,7 +14122,9 @@ export {
12340
14122
  summarizeOutput,
12341
14123
  summarizeToolCall,
12342
14124
  taskOpsRejectedPrompt,
14125
+ taskQuerySignature,
12343
14126
  textHasTaskOps,
14127
+ textHasTaskQuery,
12344
14128
  tmuxSessionName,
12345
14129
  tmuxSocketName,
12346
14130
  tmuxWindowName,