duaer-spec 0.1.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.
Files changed (75) hide show
  1. package/.cursor/rules/agents-workflow.mdc +50 -0
  2. package/.cursor/rules/ai-ui-copy.mdc +12 -0
  3. package/.cursor/rules/duaer-spec.mdc +33 -0
  4. package/.cursor/skills/duaer-analyze/SKILL.md +259 -0
  5. package/.cursor/skills/duaer-checklist/SKILL.md +383 -0
  6. package/.cursor/skills/duaer-clarify/SKILL.md +291 -0
  7. package/.cursor/skills/duaer-constitution/SKILL.md +177 -0
  8. package/.cursor/skills/duaer-converge/SKILL.md +277 -0
  9. package/.cursor/skills/duaer-git-commit/SKILL.md +68 -0
  10. package/.cursor/skills/duaer-git-feature/SKILL.md +94 -0
  11. package/.cursor/skills/duaer-git-initialize/SKILL.md +54 -0
  12. package/.cursor/skills/duaer-git-remote/SKILL.md +50 -0
  13. package/.cursor/skills/duaer-git-validate/SKILL.md +54 -0
  14. package/.cursor/skills/duaer-implement/SKILL.md +226 -0
  15. package/.cursor/skills/duaer-plan/SKILL.md +166 -0
  16. package/.cursor/skills/duaer-specify/SKILL.md +345 -0
  17. package/.cursor/skills/duaer-tasks/SKILL.md +214 -0
  18. package/.cursor/skills/duaer-taskstoissues/SKILL.md +109 -0
  19. package/.duaer/extensions/.registry +23 -0
  20. package/.duaer/extensions/git/README.md +119 -0
  21. package/.duaer/extensions/git/commands/duaer.git.commit.md +63 -0
  22. package/.duaer/extensions/git/commands/duaer.git.feature.md +82 -0
  23. package/.duaer/extensions/git/commands/duaer.git.initialize.md +49 -0
  24. package/.duaer/extensions/git/commands/duaer.git.remote.md +45 -0
  25. package/.duaer/extensions/git/commands/duaer.git.validate.md +49 -0
  26. package/.duaer/extensions/git/config-template.yml +79 -0
  27. package/.duaer/extensions/git/extension.yml +142 -0
  28. package/.duaer/extensions/git/git-config.yml +79 -0
  29. package/.duaer/extensions/git/scripts/bash/auto-commit.sh +211 -0
  30. package/.duaer/extensions/git/scripts/bash/create-new-feature-branch.sh +626 -0
  31. package/.duaer/extensions/git/scripts/bash/git-common.sh +56 -0
  32. package/.duaer/extensions/git/scripts/bash/initialize-repo.sh +54 -0
  33. package/.duaer/extensions/git/scripts/powershell/auto-commit.ps1 +230 -0
  34. package/.duaer/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +592 -0
  35. package/.duaer/extensions/git/scripts/powershell/git-common.ps1 +52 -0
  36. package/.duaer/extensions/git/scripts/powershell/initialize-repo.ps1 +69 -0
  37. package/.duaer/extensions/git/scripts/python/auto_commit.py +195 -0
  38. package/.duaer/extensions/git/scripts/python/create_new_feature_branch.py +634 -0
  39. package/.duaer/extensions/git/scripts/python/git_common.py +81 -0
  40. package/.duaer/extensions/git/scripts/python/initialize_repo.py +89 -0
  41. package/.duaer/extensions.yml +167 -0
  42. package/.duaer/init-options.json +9 -0
  43. package/.duaer/integration.json +15 -0
  44. package/.duaer/integrations/cursor-agent.manifest.json +17 -0
  45. package/.duaer/integrations/duaer.manifest.json +19 -0
  46. package/.duaer/memory/.constitution-template.json +4 -0
  47. package/.duaer/memory/constitution.md +37 -0
  48. package/.duaer/memory/project-context.md +24 -0
  49. package/.duaer/memory/testing.md +14 -0
  50. package/.duaer/scripts/bash/check-prerequisites.sh +243 -0
  51. package/.duaer/scripts/bash/common.sh +926 -0
  52. package/.duaer/scripts/bash/create-new-feature.sh +407 -0
  53. package/.duaer/scripts/bash/resolve-template.sh +57 -0
  54. package/.duaer/scripts/bash/setup-plan.sh +85 -0
  55. package/.duaer/scripts/bash/setup-tasks.sh +94 -0
  56. package/.duaer/templates/checklist-template.md +45 -0
  57. package/.duaer/templates/constitution-template.md +50 -0
  58. package/.duaer/templates/plan-template.md +113 -0
  59. package/.duaer/templates/spec-template.md +131 -0
  60. package/.duaer/templates/tasks-template.md +252 -0
  61. package/.duaer/workflows/duaer/workflow.yml +78 -0
  62. package/.duaer/workflows/workflow-registry.json +13 -0
  63. package/ADOPT.md +75 -0
  64. package/AGENTS.md +290 -0
  65. package/CHANGELOG.md +28 -0
  66. package/DUADER.md +57 -0
  67. package/LICENSE +21 -0
  68. package/README.md +74 -0
  69. package/bin/duaer.mjs +298 -0
  70. package/docs/agent/README.md +12 -0
  71. package/docs/agent/change-checklist.md +130 -0
  72. package/docs/agent/e2e-test-plan.md +33 -0
  73. package/docs/agent/workflow.md +127 -0
  74. package/docs/baseline.md +10 -0
  75. package/package.json +49 -0
@@ -0,0 +1,592 @@
1
+ #!/usr/bin/env pwsh
2
+ # Git extension: create-new-feature-branch.ps1
3
+ # Creates a git feature branch only. The feature directory and spec file
4
+ # are created by the core create-new-feature.ps1 script.
5
+ # Sources common.ps1 from the project's installed scripts, falling back to
6
+ # git-common.ps1 for minimal git helpers.
7
+ [CmdletBinding()]
8
+ param(
9
+ [switch]$Json,
10
+ [switch]$AllowExistingBranch,
11
+ [switch]$DryRun,
12
+ [string]$ShortName,
13
+ [Parameter()]
14
+ [long]$Number = 0,
15
+ [switch]$Timestamp,
16
+ [switch]$Help,
17
+ [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
18
+ [string[]]$FeatureDescription
19
+ )
20
+ $ErrorActionPreference = 'Stop'
21
+
22
+ if ($Help) {
23
+ Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
24
+ Write-Host ""
25
+ Write-Host "Options:"
26
+ Write-Host " -Json Output in JSON format"
27
+ Write-Host " -DryRun Compute branch name without creating the branch"
28
+ Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
29
+ Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
30
+ Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
31
+ Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
32
+ Write-Host " -Help Show this help message"
33
+ Write-Host ""
34
+ Write-Host "Environment variables:"
35
+ Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
36
+ Write-Host ""
37
+ Write-Host "Configuration:"
38
+ Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
39
+ Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
40
+ Write-Host ""
41
+ exit 0
42
+ }
43
+
44
+ # -Number is [long], so PowerShell binds "-5" as -5 rather than rejecting it
45
+ # the way the bash/Python twins do (`^[0-9]+$`). A negative value would format
46
+ # via '{0:000}' to e.g. "-005" and produce a branch name starting with "-",
47
+ # which git refuses (refs cannot begin with a dash). Reject it here, before the
48
+ # description check, matching the bash twin's parse-time validation order.
49
+ if ($Number -lt 0) {
50
+ Write-Error 'Error: --number must be a non-negative integer'
51
+ exit 1
52
+ }
53
+
54
+ if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
55
+ Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
56
+ exit 1
57
+ }
58
+
59
+ $featureDesc = ($FeatureDescription -join ' ').Trim()
60
+
61
+ if ([string]::IsNullOrWhiteSpace($featureDesc)) {
62
+ Write-Error "Error: Feature description cannot be empty or contain only whitespace"
63
+ exit 1
64
+ }
65
+
66
+ function Get-HighestNumberFromSpecs {
67
+ param([string]$SpecsDir)
68
+
69
+ [long]$highest = 0
70
+ if (Test-Path $SpecsDir) {
71
+ Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
72
+ if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
73
+ [long]$num = 0
74
+ if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
75
+ $highest = $num
76
+ }
77
+ }
78
+ }
79
+ }
80
+ return $highest
81
+ }
82
+
83
+ function Get-HighestNumberFromNames {
84
+ param(
85
+ [string[]]$Names,
86
+ [string]$ScopePrefix = ''
87
+ )
88
+
89
+ [long]$highest = 0
90
+ foreach ($name in $Names) {
91
+ if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
92
+ continue
93
+ }
94
+ if ($ScopePrefix) {
95
+ $name = $name.Substring($ScopePrefix.Length)
96
+ }
97
+ $name = ($name -split '/')[-1]
98
+ $hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
99
+ $hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
100
+ if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
101
+ [long]$num = 0
102
+ if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
103
+ $highest = $num
104
+ }
105
+ }
106
+ }
107
+ return $highest
108
+ }
109
+
110
+ function Get-HighestNumberFromBranches {
111
+ param([string]$ScopePrefix = '')
112
+
113
+ try {
114
+ $branches = git branch -a 2>$null
115
+ if ($LASTEXITCODE -eq 0 -and $branches) {
116
+ $cleanNames = $branches | ForEach-Object {
117
+ $_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
118
+ }
119
+ return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
120
+ }
121
+ } catch {
122
+ Write-Verbose "Could not check Git branches: $_"
123
+ }
124
+ return 0
125
+ }
126
+
127
+ function Get-HighestNumberFromRemoteRefs {
128
+ param([string]$ScopePrefix = '')
129
+
130
+ [long]$highest = 0
131
+ try {
132
+ $remotes = git remote 2>$null
133
+ if ($remotes) {
134
+ foreach ($remote in $remotes) {
135
+ $env:GIT_TERMINAL_PROMPT = '0'
136
+ $refs = git ls-remote --heads $remote 2>$null
137
+ $env:GIT_TERMINAL_PROMPT = $null
138
+ if ($LASTEXITCODE -eq 0 -and $refs) {
139
+ $refNames = $refs | ForEach-Object {
140
+ if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
141
+ } | Where-Object { $_ }
142
+ $remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
143
+ if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
144
+ }
145
+ }
146
+ }
147
+ } catch {
148
+ Write-Verbose "Could not query remote refs: $_"
149
+ }
150
+ return $highest
151
+ }
152
+
153
+ function Get-NextBranchNumber {
154
+ param(
155
+ [string]$SpecsDir,
156
+ [switch]$SkipFetch,
157
+ [string]$ScopePrefix = ''
158
+ )
159
+
160
+ if ($SkipFetch) {
161
+ $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
162
+ $highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
163
+ $highestBranch = [Math]::Max($highestBranch, $highestRemote)
164
+ } else {
165
+ try {
166
+ git fetch --all --prune 2>$null | Out-Null
167
+ } catch { }
168
+ $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
169
+ }
170
+
171
+ $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
172
+ $maxNum = [Math]::Max($highestBranch, $highestSpec)
173
+ return $maxNum + 1
174
+ }
175
+
176
+ function ConvertTo-CleanBranchName {
177
+ param([string]$Name)
178
+ return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
179
+ }
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Source common.ps1 from the project's installed scripts.
183
+ # Search locations in priority order:
184
+ # 1. .duaer/scripts/powershell/common.ps1 under the project root
185
+ # 2. scripts/powershell/common.ps1 under the project root (source checkout)
186
+ # 3. git-common.ps1 next to this script (minimal fallback)
187
+ # ---------------------------------------------------------------------------
188
+ function Find-ProjectRoot {
189
+ param([string]$StartDir)
190
+ $current = Resolve-Path $StartDir
191
+ while ($true) {
192
+ foreach ($marker in @('.duaer', '.git')) {
193
+ if (Test-Path (Join-Path $current $marker)) {
194
+ return $current
195
+ }
196
+ }
197
+ $parent = Split-Path $current -Parent
198
+ if ($parent -eq $current) { return $null }
199
+ $current = $parent
200
+ }
201
+ }
202
+
203
+ $projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
204
+ $commonLoaded = $false
205
+
206
+ if ($projectRoot) {
207
+ $candidates = @(
208
+ (Join-Path $projectRoot ".duaer/scripts/powershell/common.ps1"),
209
+ (Join-Path $projectRoot "scripts/powershell/common.ps1")
210
+ )
211
+ foreach ($candidate in $candidates) {
212
+ if (Test-Path $candidate) {
213
+ . $candidate
214
+ $commonLoaded = $true
215
+ break
216
+ }
217
+ }
218
+ }
219
+
220
+ if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
221
+ . "$PSScriptRoot/git-common.ps1"
222
+ $commonLoaded = $true
223
+ }
224
+
225
+ if (-not $commonLoaded) {
226
+ throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
227
+ }
228
+
229
+ # SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the
230
+ # minimal git-common.ps1 was loaded, or an older core common.ps1 without the
231
+ # resolver was loaded, refuse rather than silently falling back to the wrong root.
232
+ if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -CommandType Function -ErrorAction SilentlyContinue)) {
233
+ throw "SPECIFY_INIT_DIR requires updated Duaer core scripts (common.ps1 with Resolve-SpecifyInitDir), which were not found."
234
+ }
235
+
236
+ # Resolve repository root. When the core scripts are present, Get-RepoRoot
237
+ # honors SPECIFY_INIT_DIR (the explicit project override for non-interactive /
238
+ # CI use) and hard-fails on an invalid value with no silent fallback.
239
+ if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
240
+ $repoRoot = Get-RepoRoot
241
+ } elseif ($projectRoot) {
242
+ $repoRoot = $projectRoot
243
+ } else {
244
+ throw "Could not determine repository root."
245
+ }
246
+
247
+ # Check if git is available
248
+ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
249
+ # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
250
+ # and git-common.ps1 (has -RepoRoot param with default).
251
+ $hasGit = Test-HasGit
252
+ } else {
253
+ try {
254
+ git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
255
+ $hasGit = ($LASTEXITCODE -eq 0)
256
+ } catch {
257
+ $hasGit = $false
258
+ }
259
+ }
260
+
261
+ Set-Location $repoRoot
262
+
263
+ $specsDir = Join-Path $repoRoot 'specs'
264
+ $configFile = Join-Path $repoRoot ".duaer/extensions/git/git-config.yml"
265
+
266
+ function Read-GitConfigValue {
267
+ param([string]$Key)
268
+
269
+ if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
270
+ $escapedKey = [regex]::Escape($Key)
271
+ foreach ($line in Get-Content -LiteralPath $configFile) {
272
+ if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
273
+ $val = ($matches[1] -replace '\s+#.*$', '').Trim()
274
+ $val = $val -replace '^["'']', '' -replace '["'']$', ''
275
+ return $val
276
+ }
277
+ }
278
+ return ''
279
+ }
280
+
281
+ function ConvertTo-BranchToken {
282
+ param(
283
+ [string]$Value,
284
+ [string]$Fallback
285
+ )
286
+
287
+ $cleaned = ConvertTo-CleanBranchName -Name $Value
288
+ if ($cleaned) { return $cleaned }
289
+ return $Fallback
290
+ }
291
+
292
+ function Get-GitAuthorToken {
293
+ $author = ''
294
+ if (Get-Command git -ErrorAction SilentlyContinue) {
295
+ try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
296
+ if (-not $author) {
297
+ try {
298
+ $email = (git config user.email 2>$null | Out-String).Trim()
299
+ if ($email) { $author = ($email -split '@')[0] }
300
+ } catch {}
301
+ }
302
+ }
303
+ if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
304
+ return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
305
+ }
306
+
307
+ function Get-AppToken {
308
+ return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
309
+ }
310
+
311
+ function Resolve-BranchTemplate {
312
+ $template = Read-GitConfigValue -Key 'branch_template'
313
+ if ($template) { return $template }
314
+
315
+ $prefix = Read-GitConfigValue -Key 'branch_prefix'
316
+ if (-not $prefix) { return '' }
317
+ if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
318
+ return "$prefix/{number}-{slug}"
319
+ }
320
+
321
+ function Expand-BranchTemplate {
322
+ param(
323
+ [string]$Template,
324
+ [string]$FeatureNum,
325
+ [string]$BranchSuffix
326
+ )
327
+
328
+ $rendered = $Template.Replace('{author}', $authorToken)
329
+ $rendered = $rendered.Replace('{app}', $appToken)
330
+ $rendered = $rendered.Replace('{number}', $FeatureNum)
331
+ $rendered = $rendered.Replace('{slug}', $BranchSuffix)
332
+ return $rendered
333
+ }
334
+
335
+ function Assert-BranchTemplateValid {
336
+ param([string]$Template)
337
+
338
+ if ($Template -and -not $Template.Contains('{number}')) {
339
+ throw "branch_template must include the {number} token so generated branches remain valid feature branches."
340
+ }
341
+ if ($Template) {
342
+ $numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
343
+ $slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
344
+ if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
345
+ throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
346
+ }
347
+ $featureSegment = ($Template -split '/')[-1]
348
+ if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
349
+ throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
350
+ }
351
+ }
352
+ }
353
+
354
+ function New-BranchName {
355
+ param(
356
+ [string]$FeatureNum,
357
+ [string]$BranchSuffix
358
+ )
359
+
360
+ if ($branchTemplate) {
361
+ return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
362
+ }
363
+ return "$FeatureNum-$BranchSuffix"
364
+ }
365
+
366
+ function Get-BranchScopePrefix {
367
+ param(
368
+ [string]$Template,
369
+ [string]$BranchSuffix
370
+ )
371
+
372
+ if (-not $Template) { return '' }
373
+ $numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
374
+ $slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
375
+ $indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
376
+ if (-not $indexes) { return '' }
377
+ $prefix = $Template.Substring(0, $indexes[0])
378
+ return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
379
+ }
380
+
381
+ function Get-FeatureNumberFromBranchName {
382
+ param([string]$BranchName)
383
+
384
+ $featureSegment = ($BranchName -split '/')[-1]
385
+ if ($featureSegment -match '^(\d{8}-\d{6})-') {
386
+ return $matches[1]
387
+ }
388
+ if ($featureSegment -match '^(\d+)-') {
389
+ return $matches[1]
390
+ }
391
+ return $BranchName
392
+ }
393
+
394
+ function Get-Utf8ByteCount {
395
+ param([string]$Value)
396
+ return [System.Text.Encoding]::UTF8.GetByteCount($Value)
397
+ }
398
+
399
+ $authorToken = Get-GitAuthorToken
400
+ $appToken = Get-AppToken
401
+ $branchTemplate = Resolve-BranchTemplate
402
+ Assert-BranchTemplateValid -Template $branchTemplate
403
+
404
+ function Get-BranchName {
405
+ param([string]$Description)
406
+
407
+ $stopWords = @(
408
+ 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
409
+ 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
410
+ 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
411
+ 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
412
+ 'want', 'need', 'add', 'get', 'set'
413
+ )
414
+
415
+ $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
416
+ $words = $cleanName -split '\s+' | Where-Object { $_ }
417
+
418
+ $meaningfulWords = @()
419
+ foreach ($word in $words) {
420
+ if ($stopWords -contains $word) { continue }
421
+ if ($word.Length -ge 3) {
422
+ $meaningfulWords += $word
423
+ } elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
424
+ # Case-sensitive (-cmatch) to mirror the bash twin's case-sensitive
425
+ # whole-word acronym match: keep a short word only when its UPPERCASE
426
+ # form appears in the original (an acronym). -match is case-insensitive
427
+ # and would keep every short word.
428
+ $meaningfulWords += $word
429
+ }
430
+ }
431
+
432
+ if ($meaningfulWords.Count -gt 0) {
433
+ $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
434
+ $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
435
+ return $result
436
+ } else {
437
+ $result = ConvertTo-CleanBranchName -Name $Description
438
+ $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
439
+ return [string]::Join('-', $fallbackWords)
440
+ }
441
+ }
442
+
443
+ # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
444
+ if ($env:GIT_BRANCH_NAME) {
445
+ $branchName = $env:GIT_BRANCH_NAME
446
+ # Check 244-byte limit (UTF-8) for override names
447
+ $branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
448
+ if ($branchNameUtf8ByteCount -gt 244) {
449
+ throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
450
+ }
451
+ $featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
452
+ } else {
453
+ if ($ShortName) {
454
+ $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
455
+ } else {
456
+ $branchSuffix = Get-BranchName -Description $featureDesc
457
+ }
458
+
459
+ # Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
460
+ # `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
461
+ # `[ -n "$BRANCH_NUMBER" ]` check.
462
+ if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
463
+ Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
464
+ $Number = 0
465
+ }
466
+
467
+ if ($Timestamp) {
468
+ $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
469
+ $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
470
+ } else {
471
+ $branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
472
+ # Auto-detect the next number only when -Number was not supplied; an
473
+ # explicit value (including 0) is honored, matching the bash twin's
474
+ # `[ -z "$BRANCH_NUMBER" ]` check.
475
+ if (-not $PSBoundParameters.ContainsKey('Number')) {
476
+ if ($DryRun -and $hasGit) {
477
+ $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
478
+ } elseif ($DryRun) {
479
+ $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
480
+ } elseif ($hasGit) {
481
+ $Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
482
+ } else {
483
+ $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
484
+ }
485
+ }
486
+
487
+ $featureNum = ('{0:000}' -f $Number)
488
+ $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
489
+ }
490
+ }
491
+
492
+ $maxBranchLength = 244
493
+ if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
494
+ $originalBranchName = $branchName
495
+ $truncatedSuffix = $branchSuffix
496
+ while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
497
+ $truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
498
+ $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
499
+ }
500
+ if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
501
+ throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
502
+ }
503
+
504
+ Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
505
+ Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
506
+ Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
507
+ }
508
+
509
+ if (-not $DryRun) {
510
+ if ($hasGit) {
511
+ $branchCreated = $false
512
+ $branchCreateError = ''
513
+ try {
514
+ $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
515
+ if ($LASTEXITCODE -eq 0) {
516
+ $branchCreated = $true
517
+ }
518
+ } catch {
519
+ $branchCreateError = $_.Exception.Message
520
+ }
521
+
522
+ if (-not $branchCreated) {
523
+ $currentBranch = ''
524
+ try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
525
+ $existingBranch = git branch --list $branchName 2>$null
526
+ if ($existingBranch) {
527
+ if ($AllowExistingBranch) {
528
+ if ($currentBranch -eq $branchName) {
529
+ # Already on the target branch
530
+ } else {
531
+ $switchBranchError = git checkout -q $branchName 2>&1 | Out-String
532
+ if ($LASTEXITCODE -ne 0) {
533
+ if ($switchBranchError) {
534
+ Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
535
+ } else {
536
+ Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
537
+ }
538
+ exit 1
539
+ }
540
+ }
541
+ } elseif ($Timestamp) {
542
+ Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
543
+ exit 1
544
+ } else {
545
+ Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
546
+ exit 1
547
+ }
548
+ } else {
549
+ if ($branchCreateError) {
550
+ Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
551
+ } else {
552
+ Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
553
+ }
554
+ exit 1
555
+ }
556
+ }
557
+ } else {
558
+ if ($Json) {
559
+ [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
560
+ } else {
561
+ Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
562
+ }
563
+ }
564
+
565
+ $env:SPECIFY_FEATURE = $branchName
566
+ }
567
+
568
+ # Build the PowerShell-idiomatic persist hint, mirroring the core
569
+ # create-new-feature.ps1 twin (and the bash/python twins of this script), which
570
+ # all emit "# To persist in your shell: ...".
571
+ $quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
572
+ $featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
573
+
574
+ if ($Json) {
575
+ $obj = [PSCustomObject]@{
576
+ BRANCH_NAME = $branchName
577
+ FEATURE_NUM = $featureNum
578
+ }
579
+ # $hasGit is computed for branch-creation logic only; it is intentionally not
580
+ # emitted so this output contract matches the bash twin: BRANCH_NAME and
581
+ # FEATURE_NUM, plus DRY_RUN (added just below) on dry runs.
582
+ if ($DryRun) {
583
+ $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
584
+ }
585
+ $obj | ConvertTo-Json -Compress
586
+ } else {
587
+ Write-Output "BRANCH_NAME: $branchName"
588
+ Write-Output "FEATURE_NUM: $featureNum"
589
+ if (-not $DryRun) {
590
+ Write-Output "# To persist in your shell: $featureAssignment"
591
+ }
592
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env pwsh
2
+ # Git-specific common functions for the git extension.
3
+ # Extracted from scripts/powershell/common.ps1 -- contains only git-specific
4
+ # branch validation and detection logic.
5
+
6
+ function Test-HasGit {
7
+ param([string]$RepoRoot = (Get-Location))
8
+ try {
9
+ if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
10
+ if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
11
+ git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
12
+ return ($LASTEXITCODE -eq 0)
13
+ } catch {
14
+ return $false
15
+ }
16
+ }
17
+
18
+ function Get-SpecKitEffectiveBranchName {
19
+ param([string]$Branch)
20
+ if ($Branch -match '^([^/]+)/([^/]+)$') {
21
+ return $Matches[2]
22
+ }
23
+ return $Branch
24
+ }
25
+
26
+ function Test-FeatureBranch {
27
+ param(
28
+ [string]$Branch,
29
+ [bool]$HasGit = $true
30
+ )
31
+
32
+ # For non-git repos, we can't enforce branch naming but still provide output
33
+ if (-not $HasGit) {
34
+ Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
35
+ return $true
36
+ }
37
+
38
+ $raw = $Branch
39
+ $Branch = Get-SpecKitEffectiveBranchName $raw
40
+ $featureSegment = ($Branch -split '/')[-1]
41
+
42
+ # Accept sequential prefix (3+ digits), at the start or after namespace
43
+ # segments, but exclude malformed timestamps.
44
+ $hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
45
+ $isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
46
+ if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
47
+ [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
48
+ [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
49
+ return $false
50
+ }
51
+ return $true
52
+ }