gwqadd 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -19,3 +19,34 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ---
24
+
25
+ This package embeds word lists derived from third-party sources:
26
+
27
+ glitchdotcom/friendly-words — https://github.com/glitchdotcom/friendly-words
28
+
29
+ MIT License
30
+
31
+ Copyright (c) 2018 Glitch
32
+
33
+ Permission is hereby granted, free of charge, to any person obtaining a copy
34
+ of this software and associated documentation files (the "Software"), to deal
35
+ in the Software without restriction, including without limitation the rights
36
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
37
+ copies of the Software, and to permit persons to whom the Software is
38
+ furnished to do so, subject to the following conditions:
39
+
40
+ The above copyright notice and this permission notice shall be included in all
41
+ copies or substantial portions of the Software.
42
+
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
46
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
47
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
48
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
49
+ SOFTWARE.
50
+
51
+ dariusk/corpora — https://github.com/dariusk/corpora — released under CC0
52
+ (public domain). No attribution is required; it is given anyway.
package/README.md CHANGED
@@ -99,6 +99,23 @@ So the fix is one flag:
99
99
  gwqadd feat/logout --from main
100
100
  ```
101
101
 
102
+ ## No name? Take a random one
103
+
104
+ Run `gwqadd` with no branch name and it rolls one before it asks you anything:
105
+
106
+ ```
107
+ │ plume-melting-bearskin off main
108
+ │ create it? [Y]es · [n]o, name it properly · [e]dit · [r]eroll
109
+ ```
110
+
111
+ Three words, no prefix, no waiting — nothing has been sent anywhere and nothing
112
+ created. `r` rolls again, `e` edits it, and `n` drops you into the naming help
113
+ below, where an AI names the branch in your repository's own style.
114
+
115
+ `--random` skips the confirmation and is the only naming path that works without
116
+ a terminal, which makes it the one scripts and agents should use. `--no-random`
117
+ (or `GWQADD_RANDOM=off`) starts at the description prompt instead.
118
+
102
119
  ## Naming help
103
120
 
104
121
  One question, one confirmation:
@@ -180,6 +197,8 @@ gwqadd [options] [<branch>]
180
197
  | `--expires <dur>` | hand gwq an expiry (`1h`, `7d`, …) for a throwaway worktree |
181
198
  | `--ai <cmd>` | AI CLI used to suggest names (default: autodetected) |
182
199
  | `--no-ai` | never ask an AI, even when one is installed |
200
+ | `--random` | skip the questions and generate a name |
201
+ | `--no-random` | start by describing the work instead of rolling a name |
183
202
  | `--no-submodules` | skip `git submodule update --init --recursive` |
184
203
  | `-f`, `--force` | move a colliding worktree directory aside instead of failing |
185
204
  | `-n`, `--no-cd` | do the work and report the path, but do not move the shell |
package/bin/gwqadd.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync, spawn } from 'node:child_process';
3
+ import { randomBytes } from 'node:crypto';
3
4
  import { parseArgs } from 'node:util';
4
5
  import { Buffer } from 'node:buffer';
5
6
  import {
@@ -34,6 +35,8 @@ OPTIONS
34
35
  --expires <dur> hand gwq an expiry (1h, 7d, …) for a throwaway worktree
35
36
  --ai <cmd> AI CLI used to suggest names (default: autodetected)
36
37
  --no-ai never ask an AI, even when one is installed
38
+ --random skip the questions and generate a name
39
+ --no-random start by describing the work instead of rolling a name
37
40
  --no-submodules skip \`git submodule update --init --recursive\`
38
41
  -f, --force move a colliding worktree directory aside instead of failing
39
42
  -n, --no-cd do the work and report the path, but do not move the shell
@@ -47,12 +50,19 @@ EXAMPLES
47
50
  ${PKG} feat/login branch off HEAD, worktree, cd
48
51
  ${PKG} feat/login --from main branch off main wherever you happen to be
49
52
  ${PKG} hotfix/x --expires 1d gwq will mark it expired after a day
50
- ${PKG} describe the work, confirm, done
53
+ ${PKG} roll a name, confirm, done
54
+ ${PKG} --random roll a name for a script, no questions
51
55
  ${PKG} -n --json feat/login machine-readable, shell stays put
52
56
 
53
57
  NAMING HELP
54
- Run ${PKG} with no branch name and it asks one questionwhat you want to do,
55
- in any language. An AI CLI turns that into a branch name and you confirm once:
58
+ Run ${PKG} with no branch name and it rolls one immediatelythree words, no
59
+ prefix, no waiting:
60
+
61
+ Y create it n name it properly e edit the name r reroll
62
+
63
+ Nothing has been sent anywhere and nothing created at that point. Press n and
64
+ it asks what you want to do, in any language, and hands that to an AI CLI
65
+ which answers with one name in this repository's own style:
56
66
 
57
67
  Y create it n describe it again e edit the name
58
68
 
@@ -67,6 +77,10 @@ NAMING HELP
67
77
  GWQADD_AI='<cmd>'; disable with --no-ai or GWQADD_AI=off, which leaves a plain
68
78
  prompt for an ASCII name.
69
79
 
80
+ --no-random (or GWQADD_RANDOM=off) starts at the description prompt instead.
81
+ --random goes the other way and never asks; it is the only naming path that
82
+ works without a terminal, which is what scripts and agents should use.
83
+
70
84
  None of this happens with a branch name on the command line, or without a
71
85
  terminal — scripts and agents keep the plain, silent contract.
72
86
 
@@ -135,6 +149,8 @@ try {
135
149
  expires: { type: 'string' },
136
150
  ai: { type: 'string' },
137
151
  'no-ai': { type: 'boolean' },
152
+ random: { type: 'boolean' },
153
+ 'no-random': { type: 'boolean' },
138
154
  'no-submodules': { type: 'boolean' },
139
155
  force: { type: 'boolean', short: 'f' },
140
156
  'no-cd': { type: 'boolean', short: 'n' },
@@ -403,6 +419,17 @@ const doSubmodules = !values['no-submodules'];
403
419
  const force = !!values.force;
404
420
  const stayOut = !!values['no-cd'];
405
421
 
422
+ // The random-first prompt is the default; --no-random restores the 0.3.x flow
423
+ // of describing the work to an AI straight away.
424
+ const randomOff =
425
+ !!values['no-random'] ||
426
+ ['off', '0', 'false', 'none'].includes(process.env.GWQADD_RANDOM ?? '');
427
+ const randomFirst = !randomOff;
428
+
429
+ if (values.random && values['no-random']) {
430
+ die('E_VALIDATION', '--random and --no-random cannot both be given');
431
+ }
432
+
406
433
  // ── interactivity ────────────────────────────────────────────────────────────
407
434
 
408
435
  const stdinTTY = !!process.stdin.isTTY;
@@ -598,6 +625,161 @@ function defaultBranch(dir) {
598
625
  return head ? head.replace(/^refs\/remotes\/origin\//, '') : '';
599
626
  }
600
627
 
628
+ // ── word lists (generated — do not hand-edit) ────────────────────────────────
629
+ //
630
+ // Regenerate with `node tools/build-words.mjs`. Editing these by hand drifts
631
+ // the counts from the recipe and discards the licence trail (I23).
632
+ //
633
+ // Adjectives and nouns: glitchdotcom/friendly-words, MIT (c) 2018 Glitch.
634
+ // Gerunds: dariusk/corpora, CC0.
635
+ //
636
+ // The counts match `claude -w` (216 * 109 * 407 = 9,582,408 names); the words
637
+ // deliberately do not. See the design doc for why they were not copied.
638
+
639
+ const ADJECTIVES = [
640
+ 'aback', 'abrupt', 'achieved', 'adaptable', 'aerial', 'airy', 'alike',
641
+ 'alpine', 'amplified', 'apricot', 'ash', 'available', 'azure', 'basalt',
642
+ 'bejeweled', 'bevel', 'bloom', 'bold', 'boundless', 'branched', 'brawny',
643
+ 'bright', 'bronzed', 'burly', 'butternut', 'cactus', 'candied', 'caramel',
644
+ 'carpal', 'celestial', 'chambray', 'checkered', 'chestnut', 'chisel',
645
+ 'citrine', 'clean', 'cloudy', 'coconut', 'colossal', 'concise', 'cookie',
646
+ 'cord', 'creative', 'cuddly', 'cyber', 'daily', 'darkened', 'decorous',
647
+ 'delicious', 'dented', 'diamond', 'dolomite', 'dune', 'early', 'educated',
648
+ 'elated', 'elite', 'endurable', 'equinox', 'evergreen', 'expensive',
649
+ 'faceted', 'familiar', 'fantastic', 'fearless', 'field', 'first', 'flannel',
650
+ 'flax', 'flower', 'foremost', 'fortune', 'freckle', 'frill', 'furtive',
651
+ 'garrulous', 'geode', 'ginger', 'glib', 'glorious', 'goldenrod', 'graceful',
652
+ 'grass', 'grey', 'guiltless', 'half', 'happy', 'heathered', 'helpful',
653
+ 'hill', 'honorable', 'humane', 'hurricane', 'icy', 'important', 'innate',
654
+ 'iodized', 'island', 'jet', 'judicious', 'juvenile', 'knotty', 'languid',
655
+ 'lavender', 'learned', 'level', 'lime', 'lively', 'lopsided', 'lumbar',
656
+ 'luxurious', 'magical', 'malleable', 'marble', 'marred', 'massive', 'mellow',
657
+ 'mercurial', 'mica', 'military', 'mire', 'modest', 'mousy', 'narrow',
658
+ 'nebula', 'nice', 'ninth', 'numerous', 'occipital', 'olivine', 'orchid',
659
+ 'ossified', 'pale', 'past', 'pear', 'perfect', 'petalite', 'picayune',
660
+ 'pineapple', 'plaid', 'platinum', 'plume', 'polarized', 'polyester',
661
+ 'prairie', 'private', 'proximal', 'pyrite', 'quickest', 'quilted', 'radical',
662
+ 'raspy', 'regal', 'repeated', 'respected', 'ringed', 'road', 'romantic',
663
+ 'round', 'rustic', 'salt', 'sapphire', 'scented', 'season', 'sedate',
664
+ 'separate', 'shaded', 'sheer', 'shiny', 'shrub', 'silky', 'sincere',
665
+ 'skitter', 'slimy', 'smooth', 'solar', 'southern', 'speckle', 'spiced',
666
+ 'spiral', 'spotless', 'spurious', 'steel', 'stone', 'strong', 'subdued',
667
+ 'sugar', 'sumptuous', 'superb', 'sweet', 'tame', 'tartan', 'temporal',
668
+ 'thankful', 'thoracic', 'tide', 'tiny', 'torpid', 'tropical', 'tundra',
669
+ 'typhoon', 'unique', 'upbeat', 'valiant', 'vast', 'verbose', 'violet',
670
+ 'volcano', 'water', 'west', 'wholesale', 'winter', 'wobbly', 'woolen',
671
+ 'young', 'zest'
672
+ ];
673
+
674
+ const GERUNDS = [
675
+ 'abiding', 'adding', 'affording', 'amazing', 'appearing', 'asking',
676
+ 'attracting', 'baring', 'behaving', 'blotting', 'booking', 'boxing',
677
+ 'brushing', 'bustling', 'carrying', 'charming', 'chopping', 'clipping',
678
+ 'colouring', 'completing', 'continuing', 'counting', 'curing', 'daring',
679
+ 'delighting', 'deserving', 'doubling', 'dropping', 'employing', 'escaping',
680
+ 'exercising', 'exploding', 'fastening', 'filling', 'floating', 'flying',
681
+ 'forming', 'gathering', 'gluing', 'guessing', 'hanging', 'heating',
682
+ 'hopping', 'hunting', 'including', 'intending', 'joining', 'kicking',
683
+ 'knowing', 'launching', 'licking', 'listing', 'loving', 'matching',
684
+ 'melting', 'mining', 'muddling', 'nesting', 'obeying', 'offering', 'owning',
685
+ 'parting', 'pedaling', 'phoning', 'planning', 'poking', 'pouring',
686
+ 'preferring', 'printing', 'pulling', 'pushing', 'raining', 'recording',
687
+ 'rejoicing', 'reminding', 'replying', 'returning', 'rotating', 'sailing',
688
+ 'scorching', 'sealing', 'shading', 'shining', 'signing', 'slapping',
689
+ 'smiling', 'snoring', 'sparing', 'spotting', 'squeaking', 'standing',
690
+ 'stepping', 'stretching', 'suggesting', 'surprising', 'taming', 'testing',
691
+ 'ticking', 'touching', 'trading', 'trusting', 'tying', 'unpacking',
692
+ 'wailing', 'warming', 'weighing', 'whistling', 'wondering', 'yawning'
693
+ ];
694
+
695
+ const NOUNS = [
696
+ 'aardvark', 'acorn', 'actress', 'aftermath', 'air', 'airport', 'alibi',
697
+ 'almanac', 'aluminum', 'amp', 'ancient', 'anise', 'antimony', 'apology',
698
+ 'appliance', 'archduke', 'armchair', 'article', 'asterisk', 'attempt',
699
+ 'author', 'axolotl', 'bag', 'ball', 'barbecue', 'barn', 'barracuda',
700
+ 'basket', 'bathtub', 'beak', 'bearskin', 'bedbug', 'beginner', 'beluga',
701
+ 'bicycle', 'birch', 'bit', 'blarney', 'blouse', 'boat', 'bongo', 'booth',
702
+ 'bow', 'braid', 'brass', 'breath', 'broccoli', 'brow', 'buckaroo', 'buffet',
703
+ 'bun', 'butter', 'cabbage', 'cafe', 'camera', 'candytuft', 'cap', 'caption',
704
+ 'carbon', 'caribou', 'carpet', 'carver', 'cat', 'catmint', 'ceder', 'cello',
705
+ 'centipede', 'chair', 'change', 'chauffeur', 'chemistry', 'chevre', 'chill',
706
+ 'chive', 'cicada', 'cirrus', 'clarinet', 'click', 'clock', 'clover', 'coat',
707
+ 'cockroach', 'cold', 'colossus', 'comfort', 'concrete', 'conifer', 'copy',
708
+ 'corn', 'couch', 'course', 'cowl', 'crate', 'creature', 'cricket', 'crow',
709
+ 'cub', 'cupcake', 'curve', 'cylinder', 'dancer', 'dataset', 'decade', 'den',
710
+ 'desk', 'dewberry', 'dichondra', 'dinghy', 'discovery', 'dogwood', 'donut',
711
+ 'drain', 'drifter', 'drizzle', 'duckling', 'durian', 'earth', 'echo',
712
+ 'education', 'elbow', 'elm', 'energy', 'entree', 'ermine', 'evergreen',
713
+ 'eyebrow', 'falcon', 'farm', 'feather', 'femur', 'ferret', 'fibre', 'figure',
714
+ 'fine', 'flag', 'flavor', 'flood', 'flyaway', 'football', 'form', 'foxtail',
715
+ 'freedom', 'friction', 'frog', 'function', 'galley', 'garage', 'garnet',
716
+ 'gauge', 'gemini', 'gerbera', 'ginger', 'glasses', 'glow', 'golf', 'gouda',
717
+ 'gram', 'grey', 'group', 'guarantee', 'gull', 'haddock', 'hallway',
718
+ 'handsaw', 'hare', 'hawthorn', 'health', 'heaven', 'hellebore', 'herring',
719
+ 'hiss', 'homegrown', 'hoof', 'hose', 'hourglass', 'humerus', 'hydrangea',
720
+ 'hyphen', 'icon', 'income', 'ink', 'iron', 'jacket', 'jasmine', 'jellyfish',
721
+ 'jodhpur', 'judge', 'juniper', 'kayak', 'keyboard', 'king', 'knee', 'krill',
722
+ 'lake', 'land', 'larkspur', 'launch', 'lead', 'legal', 'lemur', 'letter',
723
+ 'license', 'lighter', 'limpet', 'lion', 'liver', 'lobster', 'logic', 'lunch',
724
+ 'lychee', 'macaw', 'magician', 'mailbox', 'mallow', 'mandible', 'manta',
725
+ 'march', 'market', 'mars', 'mastodon', 'may', 'medallion', 'memory',
726
+ 'meteoroid', 'midnight', 'mine', 'mirror', 'molasses', 'money', 'moon',
727
+ 'mosquito', 'mountain', 'muenster', 'museum', 'mustang', 'napkin', 'nebula',
728
+ 'neon', 'net', 'newt', 'nitrogen', 'nurse', 'oatmeal', 'octagon', 'office',
729
+ 'onion', 'opinion', 'orca', 'origami', 'ounce', 'owl', 'pail', 'pan',
730
+ 'panther', 'papyrus', 'park', 'particle', 'passive', 'path', 'pea', 'pear',
731
+ 'pencil', 'perch', 'pet', 'pharaoh', 'piccolo', 'pig', 'pin', 'piper',
732
+ 'place', 'plant', 'platypus', 'plot', 'plutonium', 'polyester', 'porter',
733
+ 'potato', 'prawn', 'prince', 'process', 'proof', 'ptarmigan', 'puppet',
734
+ 'pyramid', 'quart', 'quill', 'quotation', 'radiator', 'raft', 'rainstorm',
735
+ 'range', 'reaction', 'recess', 'region', 'repair', 'research', 'reward',
736
+ 'riddle', 'riverbed', 'rock', 'rook', 'rosemary', 'rubidium', 'runner',
737
+ 'saguaro', 'salary', 'salute', 'sapphire', 'saturn', 'saxophone', 'scapula',
738
+ 'school', 'scooter', 'screen', 'seaplane', 'second', 'seer', 'server',
739
+ 'shallot', 'shear', 'shift', 'shop', 'shroud', 'silence', 'silver', 'skull',
740
+ 'slice', 'slipper', 'smoke', 'sneeze', 'snowman', 'soarer', 'sodalite',
741
+ 'sole', 'soul', 'soy', 'spark', 'spectrum', 'spider', 'split', 'sprint',
742
+ 'spy', 'stage', 'station', 'step', 'sting', 'stocking', 'story', 'streetcar',
743
+ 'subject', 'suit', 'sundial', 'sunspot', 'surgeon', 'sweater', 'swordfish',
744
+ 'system', 'tailor', 'tangelo', 'target', 'tartan', 'teal', 'tellurium',
745
+ 'tent', 'textbook', 'thorium', 'throne', 'tick', 'tile', 'tip', 'toast',
746
+ 'topaz', 'town', 'traffic', 'traveler', 'tricorne', 'trouser', 'trust',
747
+ 'tugboat', 'turkey', 'turret', 'twine', 'uncle', 'vacation', 'variety',
748
+ 'vein', 'vertebra', 'viola', 'viscose', 'voice', 'walk', 'walleye',
749
+ 'warbler', 'wasp', 'wave', 'weaver', 'whale', 'whitefish', 'wineberry',
750
+ 'wisteria', 'wolfsbane', 'woolen', 'wrinkle', 'xylophone', 'yarrow', 'zebra',
751
+ 'zinnia'
752
+ ];
753
+
754
+ // ── random names ─────────────────────────────────────────────────────────────
755
+
756
+ // crypto, not Math.random: two shells started in the same second must not be
757
+ // able to agree on a branch name. The modulo biases the first few words of each
758
+ // list upward by about 407 / 2^32, which is invisible at any number of branches
759
+ // a person will ever create.
760
+ const pick = (a) => a[randomBytes(4).readUInt32BE(0) % a.length];
761
+ const randomName = () => `${pick(ADJECTIVES)}-${pick(GERUNDS)}-${pick(NOUNS)}`;
762
+
763
+ const RANDOM_TRIES = 10;
764
+
765
+ // `claude -w` lets a collision become a hard error; we reroll instead. The
766
+ // check has to happen before the name is shown, so the confirmation prompt can
767
+ // never offer something that cannot be created (I24). Ten failures in a
768
+ // 9,582,408-name space means our randomness is broken, not the user's luck.
769
+ //
770
+ // The branch check alone is enough, and a `worktreePath()` call beside it would
771
+ // be unreachable: `git worktree list --porcelain` only prints `branch
772
+ // refs/heads/<name>` for a worktree that has that branch checked out, and a
773
+ // worktree with no branch prints `detached` instead. So a name a worktree holds
774
+ // is always a name a branch holds. Verified against git before this was cut.
775
+ function freeRandomName(dir) {
776
+ for (let i = 0; i < RANDOM_TRIES; i++) {
777
+ const name = randomName();
778
+ if (!hasLocalBranch(dir, name)) return name;
779
+ }
780
+ return '';
781
+ }
782
+
601
783
  // ── naming (interactive only) ────────────────────────────────────────────────
602
784
 
603
785
  // Everything about the repository worth telling a model that is choosing a
@@ -837,13 +1019,16 @@ async function typeItYourself(initial = '') {
837
1019
 
838
1020
  // The single checkpoint before anything is created. `n` sends the user back to
839
1021
  // describing the work, which is what they asked for; `e` is there because a
840
- // suggestion that is one word off should not cost another round trip.
841
- async function confirmCreate(name, base) {
1022
+ // suggestion that is one word off should not cost another round trip; `r` only
1023
+ // appears for a random name, where rerolling costs nothing at all.
1024
+ async function confirmCreate(name, base, { reroll = false } = {}) {
842
1025
  log(`${dim('│')}`);
843
1026
  log(`${dim('│')} ${bold(cyan(name))} ${dim(`off ${base}`)}`);
1027
+ const no = reroll ? '[n]o, name it properly' : '[n]o, describe again';
1028
+ const extra = reroll ? `${dim('·')} ${dim('[r]eroll')} ` : '';
844
1029
  stderr.write(
845
- `${dim('│')} create it? ${dim('[Y]es')} ${dim('·')} ${dim('[n]o, describe again')} ` +
846
- `${dim('·')} ${dim('[e]dit the name')} `,
1030
+ `${dim('│')} create it? ${dim('[Y]es')} ${dim('·')} ${dim(no)} ` +
1031
+ `${dim('·')} ${dim('[e]dit the name')} ${extra}`,
847
1032
  );
848
1033
  for (;;) {
849
1034
  const buf = await waitForKey();
@@ -852,14 +1037,40 @@ async function confirmCreate(name, base) {
852
1037
  if (c === 0x79 || c === 0x59 || c === 0x0d || c === 0x0a) { stderr.write('\n'); return { create: true }; }
853
1038
  if (c === 0x6e || c === 0x4e) { stderr.write('\n'); return { again: true }; }
854
1039
  if (c === 0x65 || c === 0x45) { stderr.write('\n'); return { edit: true }; }
1040
+ if (reroll && (c === 0x72 || c === 0x52)) { stderr.write('\n'); return { reroll: true }; }
1041
+ }
1042
+ }
1043
+
1044
+ // The fast half of the naming layer: a name appears with no prompt, no
1045
+ // subprocess and no network, and the expensive path costs one keystroke.
1046
+ // Returns null when the user wants to describe the work to an AI instead.
1047
+ async function offerRandom(dir, baseRef) {
1048
+ for (;;) {
1049
+ const name = freeRandomName(dir);
1050
+ if (!name) {
1051
+ warn(`could not find an unused random name in ${RANDOM_TRIES} tries — name it yourself instead`);
1052
+ return { branch: await typeItYourself(), named: 'manual' };
1053
+ }
1054
+ const choice = await confirmCreate(name, baseRef, { reroll: true });
1055
+ if (choice.create) return { branch: name, named: 'random' };
1056
+ if (choice.edit) return { branch: await typeItYourself(name), named: 'manual' };
1057
+ if (choice.reroll) continue;
1058
+ return null; // `n` — fall through to describing the work
855
1059
  }
856
1060
  }
857
1061
 
858
1062
  // The whole interactive path. Returns a branch name git has already accepted;
859
1063
  // never runs unless there is a terminal and no positional was given.
860
1064
  async function composeBranchName(dir, repo, base) {
1065
+ // Random first: it is free, and a user who wanted to think about the name is
1066
+ // one keystroke away from the prompt that lets them.
1067
+ if (randomFirst || values.random) {
1068
+ const chosen = await offerRandom(dir, base.ref);
1069
+ if (chosen) return chosen;
1070
+ }
1071
+
861
1072
  const ai = detectAi();
862
- if (!ai) return typeItYourself();
1073
+ if (!ai) return { branch: await typeItYourself(), named: 'manual' };
863
1074
 
864
1075
  const ctx = repoContext(dir, repo, base);
865
1076
  const rejected = [];
@@ -870,24 +1081,24 @@ async function composeBranchName(dir, repo, base) {
870
1081
  `${dim('│')} what do you want to do? ${dim('(any language)')}\n${dim('│')} ${dim('>')} `,
871
1082
  );
872
1083
  // An empty answer is the escape hatch out of the AI entirely.
873
- if (!description) return typeItYourself();
1084
+ if (!description) return { branch: await typeItYourself(), named: 'manual' };
874
1085
 
875
1086
  const res = await askAi(ai, namingPrompt(ctx, description, rejected));
876
1087
  if (!res.ok) {
877
1088
  warn(`${aiLabel(ai)} failed — name it yourself instead`);
878
1089
  const first = (res.err || '').trim().split('\n')[0];
879
1090
  if (first) log(`${dim('│')} ${dim(first.slice(0, 120))}`);
880
- return typeItYourself();
1091
+ return { branch: await typeItYourself(), named: 'manual' };
881
1092
  }
882
1093
  const candidates = parseCandidates(res.out);
883
1094
  if (candidates.length === 0) {
884
1095
  warn(`${aiLabel(ai)} returned nothing usable — name it yourself instead`);
885
- return typeItYourself();
1096
+ return { branch: await typeItYourself(), named: 'manual' };
886
1097
  }
887
1098
 
888
1099
  const choice = await confirmCreate(candidates[0], ctx.base);
889
- if (choice.create) return candidates[0];
890
- if (choice.edit) return typeItYourself(candidates[0]);
1100
+ if (choice.create) return { branch: candidates[0], named: 'ai' };
1101
+ if (choice.edit) return { branch: await typeItYourself(candidates[0]), named: 'manual' };
891
1102
  // Rejected: remember every suggestion from this round so the next prompt
892
1103
  // cannot come back with a near-identical name.
893
1104
  rejected.push(...candidates);
@@ -1051,17 +1262,32 @@ async function main() {
1051
1262
  }
1052
1263
 
1053
1264
  let branch = positionals[0];
1265
+ let named = 'argument';
1054
1266
  if (branch) {
1267
+ // A name on the command line is the user speaking; never second-guess it.
1268
+ if (values.random) {
1269
+ die('E_VALIDATION', '--random cannot be combined with a branch name');
1270
+ }
1055
1271
  // git would reject this later with a less obvious message.
1056
1272
  if (!validBranchName(branch)) {
1057
1273
  die('E_VALIDATION', `'${branch}' is not a valid branch name`);
1058
1274
  }
1059
- } else {
1060
- if (isNonInteractive) {
1275
+ } else if (isNonInteractive) {
1276
+ // --random is the one naming path that needs no terminal: it is arithmetic
1277
+ // over a constant array, so nothing is triggered that the caller did not
1278
+ // ask for by name (I15).
1279
+ if (!values.random) {
1061
1280
  die('E_VALIDATION', 'a branch name is required — `gwqadd <branch>`');
1062
1281
  }
1282
+ branch = freeRandomName(cwd);
1283
+ if (!branch) {
1284
+ die('E_VALIDATION',
1285
+ `could not find an unused random name in ${RANDOM_TRIES} tries — pass a branch name`);
1286
+ }
1287
+ named = 'random';
1288
+ } else {
1063
1289
  // composeBranchName only ever returns a name git has already accepted.
1064
- branch = await composeBranchName(cwd, repo, base);
1290
+ ({ branch, named } = await composeBranchName(cwd, repo, base));
1065
1291
  }
1066
1292
 
1067
1293
  const branchExisted = hasLocalBranch(cwd, branch);
@@ -1076,7 +1302,7 @@ async function main() {
1076
1302
  if (existing && existsSync(existing)) {
1077
1303
  log(`${dim('│')} ${dim('worktree already exists')}`);
1078
1304
  log(`${dim('└')} ${green('✓')} ${cyan(branch)} ${dim('→')} ${existing}`);
1079
- return finish({ repo, branch, base, path: existing, created: 'none' });
1305
+ return finish({ repo, branch, base, path: existing, created: 'none', named });
1080
1306
  }
1081
1307
 
1082
1308
  // Two ways in. Without --from, `gwq add -b` creates branch and worktree in
@@ -1167,14 +1393,14 @@ async function main() {
1167
1393
 
1168
1394
  log(`${dim('└')} ${green('✓')} ${cyan(branch)} ${dim('→')} ${created}`);
1169
1395
  return finish({
1170
- repo, branch, base, path: created,
1396
+ repo, branch, base, path: created, named,
1171
1397
  created: branchExisted ? 'worktree' : 'branch+worktree',
1172
1398
  });
1173
1399
  }
1174
1400
 
1175
1401
  // ── output ───────────────────────────────────────────────────────────────────
1176
1402
 
1177
- async function finish({ repo, branch, base, path, created }) {
1403
+ async function finish({ repo, branch, base, path, created, named }) {
1178
1404
  if (isJson) {
1179
1405
  process.stdout.write(JSON.stringify({
1180
1406
  schemaVersion: SCHEMA_VERSION,
@@ -1183,6 +1409,9 @@ async function finish({ repo, branch, base, path, created }) {
1183
1409
  base: { ref: base.ref, sha: base.sha },
1184
1410
  repo: { root: repo.root, name: repo.name },
1185
1411
  created,
1412
+ // How the name was chosen, so a caller can tell a name it picked from one
1413
+ // the tool invented. Adding a field does not bump schemaVersion (I10).
1414
+ named,
1186
1415
  cd: !stayOut,
1187
1416
  }) + '\n');
1188
1417
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gwqadd",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Create a branch and its gwq worktree in the repository you are in, and cd there.",
5
5
  "type": "module",
6
6
  "bin": {