@danmoisan/drm-copilot-mcp 1.1.11 → 1.1.14

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 (66) hide show
  1. package/out/mcp-server.js +368 -209
  2. package/package.json +2 -2
  3. package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +1 -1
  4. package/resources/claude-customizations/.claude/agents/epic-planner.md +4 -1
  5. package/resources/claude-customizations/.claude/agents/parallel-planner.md +18 -1
  6. package/resources/claude-customizations/.claude/agents/pr-author.md +15 -0
  7. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +16 -8
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-authorization.ps1 +443 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +33 -37
  10. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +120 -5
  11. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +127 -0
  12. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +172 -24
  13. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +14 -27
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +66 -5
  15. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +134 -10
  16. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +37 -8
  17. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +3 -1
  18. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner-helpers.ps1 +332 -0
  19. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +211 -182
  20. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +43 -6
  21. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +486 -0
  22. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +5 -2
  23. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusWriteIntent.psm1 +446 -0
  24. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +1 -0
  25. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeReadiness.psm1 +176 -0
  26. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +369 -0
  27. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +392 -0
  28. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeResolution.psm1 +500 -0
  29. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeTargetResolution.psm1 +347 -0
  30. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +27 -0
  31. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +150 -2
  32. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +5 -5
  33. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +12 -0
  34. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +14 -1
  35. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +15 -0
  36. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +4 -1
  37. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +32 -17
  38. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +39 -6
  39. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +39 -10
  40. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +15 -0
  41. package/resources/claude-customizations/.claude/skills/quota-throttling/SKILL.md +221 -0
  42. package/resources/claude-customizations/config/blast-radius.json +14 -1
  43. package/resources/claude-customizations/pack-manifests/core.json +11 -0
  44. package/resources/codex-and-agents-customizations/.agents/skills/codex-model-routing/SKILL.md +13 -14
  45. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  46. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +193 -0
  47. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +15 -5
  48. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-resolution.ps1 +493 -0
  49. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +189 -0
  50. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +172 -24
  51. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +14 -27
  52. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexDeployment.ps1 +87 -0
  53. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexTopology.ps1 +96 -0
  54. package/resources/codex-and-agents-customizations/.codex/scripts/codex-routing-cli-common.ps1 +370 -0
  55. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-contract.ps1 +475 -0
  56. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-runtime.ps1 +445 -0
  57. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-persistence-runtime.ps1 +118 -0
  58. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-sandbox-preflight.ps1 +69 -0
  59. package/resources/codex-and-agents-customizations/.codex/scripts/launch-epic-child-wave.ps1 +471 -0
  60. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +280 -0
  61. package/resources/codex-and-agents-customizations/.codex/scripts/resume-epic-child.ps1 +263 -0
  62. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +2 -1
  63. package/resources/codex-and-agents-customizations/pack-manifests/core.json +20 -1
  64. package/resources/lib/codex-routing/CodexDeployment.psm1 +315 -0
  65. package/resources/lib/codex-routing/CodexTopology.psm1 +394 -0
  66. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +37 -0
@@ -12,7 +12,18 @@
12
12
  'atomic-planner'.
13
13
 
14
14
  Feature folder resolution order:
15
- 1. Scan the prompt text for any path matching
15
+ 1. Resolve the worktree this call pertains to from the assembled prompt and
16
+ description text, by portable identity only: the canonical issue-number
17
+ line, and a branch signal. The resolution belongs to the
18
+ worktree-resolution library and is not re-implemented here. No path
19
+ signal selects a worktree, because a merged feature folder exists in
20
+ every checkout branched from main and so places a call in many at once.
21
+ When the call carries no identity, or an identity the library cannot
22
+ narrow to one live worktree, the gate denies at once with that library's
23
+ own reason code -- the no-target code or the ambiguity code -- before any
24
+ document probe runs. A probe at that point would validate the call
25
+ against whichever root happened to be current.
26
+ 2. Scan the prompt text for any path matching
16
27
  docs/features/active/<token>, accepting both forward-slash and
17
28
  backslash separators. Truncate every match to two segments past the
18
29
  docs/features/active/ prefix -- that is, to exactly four path segments:
@@ -22,16 +33,30 @@
22
33
  the same folder. A match that truncates to fewer than four segments is
23
34
  rejected. Candidates are deduplicated preserving first-occurrence
24
35
  order.
25
- 2. Select among the distinct candidates: one candidate is used directly;
26
- otherwise the candidate equal to the checkpoint's feature-folder field
27
- is preferred, because the checkpoint is the orchestrator's own record of
28
- which feature is in flight; otherwise the earliest-occurring candidate
29
- in the prompt wins, because the orchestrator names the active feature
30
- folder before citing artifacts inside it.
31
- 3. If no candidate was found in the prompt, read the feature-folder field
32
- from artifacts/orchestration/orchestrator-state.json.
33
- 4. If neither yields a folder, block with a reason instructing the caller
34
- to reference a feature folder explicitly.
36
+ 3. Choose among the distinct candidates by asking which one the resolved
37
+ worktree's own orchestrator checkpoint names. That checkpoint is the only
38
+ disambiguator, and neither prompt position nor a foreign session's record
39
+ takes that role: position would make selection an accident of wording,
40
+ and another session's checkpoint records its own unrelated work.
41
+ 4. When more than one folder was cited and that checkpoint names none of
42
+ them, the tie is unresolved and the gate denies with the ambiguity
43
+ reason code rather than selecting any one of the candidates.
44
+ 5. When the prompt named no folder at all, the resolved worktree's
45
+ checkpoint supplies one. It is the item's own checkpoint by
46
+ construction, because identity selected the worktree it sits in, so the
47
+ conditional guard that used to protect this step is unnecessary and has
48
+ been removed.
49
+ 6. If no folder is resolved by any of the steps above, block with a reason
50
+ instructing the caller to reference a feature folder explicitly.
51
+ 7. Once a folder is resolved, anchor every document probe to the resolved
52
+ target root, and only when that root differs from the session root. When
53
+ the two coincide the bare repo-relative spelling is kept, because
54
+ prefixing unconditionally would break every call that legitimately
55
+ resolves where it runs. A folder that is absent under the resolved target
56
+ root therefore denies on the missing-document reason for that root. It
57
+ never denies on the marker-is-broken reason, which is reachable only when
58
+ the folder does exist under that root and its work-mode marker cannot be
59
+ read.
35
60
 
36
61
  Known limitation: resolution stops at the feature-folder segment, so it does
37
62
  not descend into a version folder (v1/, v2/). No versioned folder exists
@@ -76,6 +101,16 @@ param()
76
101
 
77
102
 
78
103
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
104
+ # Issue #669 owns worktree location and path normalisation; issue #673 owns the
105
+ # portable-identity resolution this gate now selects a worktree with. Both imports are
106
+ # unguarded on purpose: a resolution module that cannot be loaded is itself the
107
+ # target-not-resolvable state, and the gate must fail closed on it rather than degrade to
108
+ # a permissive path. Both are required rather than one: the absolute-path join the probe
109
+ # step below calls is exported only by the first, because a module whose exports are pinned
110
+ # by an explicit list does not re-export a sibling's functions to its own importer.
111
+ Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/WorktreeTargetResolution.psm1') -Force
112
+ Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/WorktreeItemResolution.psm1') -Force
113
+ . (Join-Path $PSScriptRoot 'enforce-prd-feature-before-planner-helpers.ps1')
79
114
  function Get-PrdFeatureFileExistence {
80
115
  <#
81
116
  .SYNOPSIS
@@ -117,75 +152,6 @@ function Get-PrdFeatureIssueContent {
117
152
  }
118
153
  }
119
154
 
120
- function Resolve-PrdFeatureWorkMode {
121
- <#
122
- .SYNOPSIS
123
- Parses the persisted `- Work Mode: ...` marker out of issue.md content
124
- and returns the canonical mode, or $null when the marker is absent,
125
- unreadable, or unrecognized.
126
- .DESCRIPTION
127
- Recognizes minor-audit, full-feature, full-bug, and the legacy full
128
- marker (normalized to full-feature), mirroring the regex convention
129
- used by scripts/dev_tools/prompt_mode_contract.py so both runtimes
130
- agree on what counts as a valid marker line.
131
- #>
132
- [CmdletBinding()]
133
- [OutputType([string])]
134
- param(
135
- [AllowNull()]
136
- [string] $IssueContent
137
- )
138
-
139
- if ([string]::IsNullOrWhiteSpace($IssueContent)) {
140
- return $null
141
- }
142
-
143
- $match = [regex]::Match($IssueContent, '(?im)^-\s*Work Mode:\s*(minor-audit|full-feature|full-bug|full)\s*$')
144
- if (-not $match.Success) {
145
- return $null
146
- }
147
-
148
- $rawMode = $match.Groups[1].Value
149
- if ($rawMode -eq 'full') {
150
- return 'full-feature'
151
- }
152
- return $rawMode
153
- }
154
-
155
- function Get-PrdFeatureRequiredFile {
156
- <#
157
- .SYNOPSIS
158
- Maps a resolved work mode to the set of prd-feature output files the
159
- target folder must contain before an atomic-planner delegation is
160
- allowed.
161
- .DESCRIPTION
162
- full-feature requires spec.md and user-story.md; full-bug requires
163
- spec.md only; minor-audit requires neither.
164
-
165
- The default arm returns spec.md alone. It is not reached from the
166
- decision path for an undeterminable mode, which denies on its own branch
167
- without probing at all; the arm exists so a direct caller passing a $null
168
- or unrecognized mode never receives a permissive empty set, and it must
169
- not name user-story.md, because that document is required to be ABSENT
170
- for full-bug and minor-audit work.
171
- #>
172
- [CmdletBinding()]
173
- [OutputType([string[]])]
174
- param(
175
- [AllowNull()]
176
- [string] $WorkMode
177
- )
178
-
179
- # Route on the canonical mode; anything outside the three known values
180
- # (including $null) falls through to the fail-closed default case.
181
- switch ($WorkMode) {
182
- 'full-feature' { return [string[]]@('spec.md', 'user-story.md') }
183
- 'full-bug' { return [string[]]@('spec.md') }
184
- 'minor-audit' { return [string[]]@() }
185
- default { return [string[]]@('spec.md') }
186
- }
187
- }
188
-
189
155
  function Get-PrdFeatureCheckpointFolder {
190
156
  <#
191
157
  .SYNOPSIS
@@ -195,7 +161,8 @@ function Get-PrdFeatureCheckpointFolder {
195
161
  [CmdletBinding()]
196
162
  [OutputType([string])]
197
163
  param(
198
- [string] $CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
164
+ [Parameter(Mandatory)]
165
+ [string] $CheckpointPath
199
166
  )
200
167
 
201
168
  if (-not (Test-Path -LiteralPath $CheckpointPath -PathType Leaf)) {
@@ -216,133 +183,138 @@ function Get-PrdFeatureCheckpointFolder {
216
183
  return $null
217
184
  }
218
185
 
219
- function Find-PrdFeatureFolderFromPrompt {
186
+ function Get-PrdFeatureTargetDecision {
220
187
  <#
221
188
  .SYNOPSIS
222
- Scans a prompt string for docs/features/active/<...> path tokens,
223
- truncates each to exactly four path segments, and returns the selected
224
- feature folder. Returns $null when no token truncates to four segments.
189
+ Builds a target-resolution deny, carrying the caller's reason code.
225
190
  .DESCRIPTION
226
- Truncation to four segments -- docs, features, active, and the
227
- feature-folder name -- is two segments past the docs/features/active/
228
- prefix, so the depth at which an artifact is cited cannot change which
229
- folder is resolved. Candidates are deduplicated preserving
230
- first-occurrence order; selection among two or more distinct candidates
231
- prefers the checkpoint's feature-folder field and otherwise takes the
232
- earliest occurrence in the prompt.
233
-
234
- The return value is a repo-relative path normalized to forward slashes,
235
- or $null. The function reads no file except through the existing
236
- checkpoint seam, and it is deterministic for a given prompt and
237
- checkpoint value.
191
+ The code is supplied by the caller rather than chosen here, because two are
192
+ now reachable: the no-target code when the call carries no identity or an
193
+ identity that places in no live worktree, and the ambiguity code when
194
+ several live worktrees match or two identities disagree. Both are read from
195
+ the resolution library's accessors, so the gate and the library can never
196
+ disagree about either spelling.
197
+
198
+ Every reason built here is distinct from the missing-document and marker
199
+ reasons, which name a resolved folder rather than a resolution failure.
200
+
201
+ The remedy sentence names the two identity lines rather than an absolute
202
+ path. Citing a path is no longer an action that selects anything, so the
203
+ previous remedy would have instructed the caller to do something inert.
204
+ .PARAMETER ReasonCode
205
+ The resolution reason code to carry, from one of the library's accessors.
206
+ .PARAMETER Detail
207
+ The library's own detail text for the unresolved state.
238
208
  #>
239
209
  [CmdletBinding()]
240
- [OutputType([string])]
210
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
241
211
  param(
242
212
  [Parameter(Mandatory)]
243
213
  [AllowEmptyString()]
244
- [string] $Prompt
245
- )
214
+ [string] $ReasonCode,
246
215
 
247
- if (-not $Prompt) {
248
- return $null
249
- }
250
-
251
- # Allow forward or backslash separators inside the matched path token.
252
- $pattern = 'docs[\\/]+features[\\/]+active[\\/]+[^\s"''`]+'
253
- $matchList = [regex]::Matches($Prompt, $pattern)
254
- if ($matchList.Count -eq 0) {
255
- return $null
256
- }
257
-
258
- # Deduplicate preserving FIRST-OCCURRENCE order. A [hashtable] must not be
259
- # used here: PowerShell hashtable key enumeration order is unspecified, so a
260
- # first-occurrence selection rule fed by a hashtable is not deterministic.
261
- [System.Collections.Generic.List[string]] $candidates = [System.Collections.Generic.List[string]]::new()
262
- foreach ($m in $matchList) {
263
- $normalized = ($m.Value -replace '\\', '/').TrimEnd('/')
264
-
265
- # Truncate to exactly two segments past the docs/features/active/ prefix,
266
- # that is, to the four segments docs, features, active, and the feature
267
- # folder name. Truncation is depth-insensitive, so a folder path, a
268
- # spec.md path, a research/ artifact path, and an evidence/ artifact path
269
- # all reduce to the same value. A '.' component is a path no-op and is
270
- # discarded first, so a degenerate token such as docs/features/active/.
271
- # yields three segments and is rejected rather than resolved.
272
- $segments = @($normalized -split '/' | Where-Object { $_ -ne '' -and $_ -ne '.' })
273
- if ($segments.Count -lt 4) {
274
- continue
275
- }
276
-
277
- $truncated = ($segments[0..3] -join '/')
278
- if (-not $candidates.Contains($truncated)) {
279
- $candidates.Add($truncated)
280
- }
281
- }
282
-
283
- if ($candidates.Count -eq 0) {
284
- return $null
285
- }
286
-
287
- # One distinct candidate is used directly, so the common case never consults
288
- # the checkpoint.
289
- if ($candidates.Count -eq 1) {
290
- return $candidates[0]
291
- }
216
+ [Parameter(Mandatory)]
217
+ [AllowEmptyString()]
218
+ [string] $Detail
219
+ )
292
220
 
293
- # More than one distinct feature folder was cited. Prefer the folder the
294
- # orchestrator itself records as in flight: the checkpoint is the
295
- # authoritative disambiguator, and it reuses a seam this hook already owns.
296
- $checkpointFolder = Get-PrdFeatureCheckpointFolder
297
- if ($checkpointFolder) {
298
- $checkpointNormalized = ($checkpointFolder -replace '\\', '/').TrimEnd('/')
299
- if ($candidates.Contains($checkpointNormalized)) {
300
- return $checkpointNormalized
221
+ return [ordered]@{
222
+ hookSpecificOutput = [ordered]@{
223
+ hookEventName = 'PreToolUse'
224
+ permissionDecision = 'deny'
225
+ permissionDecisionReason = "PRD_FEATURE_BLOCKED: $ReasonCode - $Detail. " +
226
+ "Put the line 'Canonical issue number for this feature is <N>.' and a " +
227
+ "'branch: <item branch>' label in the delegation prompt so the gate can identify the item."
301
228
  }
302
229
  }
303
-
304
- # Tiebreak of last resort: the orchestrator supplies the active feature folder
305
- # among its delegation inputs and names it before citing artifacts inside it,
306
- # so a cross-reference to another feature appears later in the prompt.
307
- return $candidates[0]
308
230
  }
309
231
 
310
- function Get-PrdFeatureMissingFile {
232
+ function Resolve-PrdFeatureWorktreeTarget {
311
233
  <#
312
234
  .SYNOPSIS
313
- Returns the subset of $RequiredFile that is missing from the target
314
- folder.
235
+ Resolve the delegation's target worktree. Tests mock this function (seam).
236
+ .DESCRIPTION
237
+ A seam rather than a direct call because the resolver reads real git state: the
238
+ worktree list, each registration's branch, and the filesystem. A test that drove
239
+ it directly would depend on whichever worktrees exist on the machine running it.
240
+ .PARAMETER Text
241
+ The assembled prompt and description text to read identity from.
242
+ .OUTPUTS
243
+ The target result object from Resolve-WorktreeItemTarget.
315
244
  #>
316
245
  [CmdletBinding()]
317
- [OutputType([string[]])]
246
+ [OutputType([pscustomobject])]
318
247
  param(
319
248
  [Parameter(Mandatory)]
320
- [string] $FeatureFolder,
249
+ [AllowEmptyString()]
250
+ [string] $Text
251
+ )
321
252
 
322
- [Parameter(Mandatory)]
323
- [AllowEmptyCollection()]
324
- [string[]] $RequiredFile
253
+ return (Resolve-WorktreeItemTarget -Text $Text -SessionRoot (Get-Location).Path)
254
+ }
255
+
256
+ function Get-PrdFeatureCallTarget {
257
+ <#
258
+ .SYNOPSIS
259
+ Resolves the worktree this tool call pertains to, or $null when the call
260
+ supplies no text to resolve from.
261
+ .DESCRIPTION
262
+ The resolution itself belongs to the worktree-resolution library and is not
263
+ re-implemented here: this function only assembles the text it reads, from the
264
+ envelope root and the nested tool_input, and supplies the session root from
265
+ the envelope's own cwd field when the runtime sets one.
266
+
267
+ The assembled text is handed to the derivation unconditionally. Which
268
+ citations are eligible to be placed is the derivation's decision and not
269
+ this hook's: it answers NoTarget for a call it cannot place at all, and
270
+ Ambiguous for a placed signal that resolves to no worktree or to several.
271
+ A repo-relative citation therefore has a real placement channel, because
272
+ the derivation keeps only the worktrees under which that repo-relative
273
+ path exists. The outcome the caller sees is fail-closed:
274
+ a call that places in no worktree, or in several, denies before any probe
275
+ runs, rather than being validated against whichever root happened to be
276
+ current.
277
+ #>
278
+ [CmdletBinding()]
279
+ [OutputType([object])]
280
+ param(
281
+ [AllowNull()]
282
+ [object] $ToolInput
325
283
  )
326
284
 
327
- [System.Collections.Generic.List[string]] $missing = [System.Collections.Generic.List[string]]::new()
328
- foreach ($name in $RequiredFile) {
329
- $candidate = "$FeatureFolder/$name"
330
- if (-not (Get-PrdFeatureFileExistence -Path $candidate)) {
331
- $missing.Add($name)
332
- }
285
+ $prompt = Get-ClaudeHookToolInputString -ToolInput $ToolInput -Name 'prompt'
286
+ $description = Get-ClaudeHookToolInputString -ToolInput $ToolInput -Name 'description'
287
+ $text = (@($prompt, $description) | Where-Object { $_ }) -join ' '
288
+
289
+ # An empty call resolves nothing and, importantly, performs no enumeration and no
290
+ # file read. The caller treats the null as the no-target state.
291
+ if (-not $text) {
292
+ return $null
333
293
  }
334
- return [string[]] $missing.ToArray()
294
+
295
+ return (Resolve-PrdFeatureWorktreeTarget -Text $text)
335
296
  }
336
297
 
337
298
  function Invoke-PrdFeatureBeforePlannerDecision {
338
299
  <#
339
300
  .SYNOPSIS
340
301
  Parses the envelope's nested tool_input and returns an allow-or-block decision.
302
+ .PARAMETER ToolInputRaw
303
+ The raw PreToolUse payload.
304
+ .PARAMETER ResolvedTarget
305
+ Test-only injection seam for the derived call target, following the
306
+ -CheckpointRaw precedent in enforce-orchestration-preimplementation-gate.ps1.
307
+ Binding is decided with $PSBoundParameters.ContainsKey rather than a
308
+ truthiness test, so an explicitly supplied empty value suppresses the
309
+ derivation seam instead of falling through to it.
341
310
  #>
342
311
  [CmdletBinding()]
343
312
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
344
313
  param(
345
- [string] $ToolInputRaw
314
+ [string] $ToolInputRaw,
315
+
316
+ [AllowNull()]
317
+ [object] $ResolvedTarget
346
318
  )
347
319
 
348
320
  $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
@@ -364,9 +336,47 @@ function Invoke-PrdFeatureBeforePlannerDecision {
364
336
  }
365
337
 
366
338
  $prompt = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'prompt'
367
- $folder = Find-PrdFeatureFolderFromPrompt -Prompt $prompt
339
+
340
+ $target = if ($PSBoundParameters.ContainsKey('ResolvedTarget')) {
341
+ $ResolvedTarget
342
+ }
343
+ else {
344
+ Get-PrdFeatureCallTarget -ToolInput $envelope.Value
345
+ }
346
+
347
+ # Unresolved identity denies before any document probe runs; a probe here would
348
+ # validate the call against whichever root happened to be current. A null target has
349
+ # two origins that mean the same thing -- the injection seam bound to null, and a call
350
+ # carrying no text at all -- so both take the no-target code.
351
+ if ($null -eq $target -or $target.Status -eq 'NoTarget' -or $target.Status -eq 'Ambiguous') {
352
+ $code = if ($null -eq $target) { Get-WorktreeResolutionNoTargetReasonCode } else { $target.ReasonCode }
353
+ $detail = if ($null -eq $target) { 'the call target was not resolved' } else { $target.Detail }
354
+ return (Get-PrdFeatureTargetDecision -ReasonCode $code -Detail $detail)
355
+ }
356
+
357
+ $candidates = @(Find-PrdFeatureFolderCandidate -Prompt $prompt)
358
+
359
+ # The checkpoint is read only when it is needed. Exactly one cited candidate is used
360
+ # directly, so the common case performs no checkpoint read at all; zero or several
361
+ # candidates need the resolved worktree's own checkpoint, which is read once here.
362
+ $checkpointFolder = $null
363
+ if ($candidates.Count -ne 1) {
364
+ $checkpointFolder = Get-PrdFeatureCheckpointFolder -CheckpointPath (Get-WorktreeItemCheckpointPath -WorktreeRoot $target.WorktreeRoot)
365
+ }
366
+
367
+ $folder = Find-PrdFeatureFolderFromPrompt -Prompt $prompt -CheckpointFolder $checkpointFolder
368
+
369
+ # More than one folder was cited and the resolved worktree's checkpoint names none of
370
+ # them. The tie is unresolved, which is a resolution failure rather than a document one.
371
+ if (-not $folder -and $candidates.Count -gt 1) {
372
+ return (Get-PrdFeatureTargetDecision -ReasonCode (Get-WorktreeResolutionAmbiguityReasonCode) -Detail ("the call cites $($candidates.Count) feature folders ($($candidates -join ', ')) and the resolved worktree's checkpoint chooses between none of them"))
373
+ }
374
+
375
+ # The prompt named no folder, so the resolved worktree's checkpoint supplies one. It is
376
+ # the item's own checkpoint by construction, because identity selected the worktree it
377
+ # sits in, so no conditional guard is needed before trusting it.
368
378
  if (-not $folder) {
369
- $folder = Get-PrdFeatureCheckpointFolder
379
+ $folder = $checkpointFolder
370
380
  }
371
381
 
372
382
  if (-not $folder) {
@@ -374,18 +384,37 @@ function Invoke-PrdFeatureBeforePlannerDecision {
374
384
  hookSpecificOutput = [ordered]@{
375
385
  hookEventName = 'PreToolUse'
376
386
  permissionDecision = 'deny'
377
- permissionDecisionReason = "PRD_FEATURE_BLOCKED: atomic-planner delegation must reference a feature folder (either in the prompt or via orchestrator-state.json) so spec.md and user-story.md prerequisites can be verified."
387
+ permissionDecisionReason = "PRD_FEATURE_BLOCKED: atomic-planner delegation must reference a feature folder, either in the prompt or in the orchestrator checkpoint of the worktree the call resolves to, so spec.md and user-story.md prerequisites can be verified."
378
388
  }
379
389
  }
380
390
  }
381
391
 
382
392
  $folderNormalized = ($folder -replace '\\', '/').TrimEnd('/')
383
393
 
394
+ # Anchor every probe to the resolved target root, and only when that root differs
395
+ # from the session root. When the two coincide the bare repo-relative spelling is
396
+ # kept: prefixing unconditionally would break every call that legitimately resolves
397
+ # where it runs.
398
+ $probeFolder = $folderNormalized
399
+ if ($null -ne $target -and $target.Status -eq 'OtherWorktree' -and $target.WorktreeRoot) {
400
+ $probeFolder = Join-WorktreeResolutionPath -WorktreeRoot $target.WorktreeRoot -RepoRelativePath $folderNormalized
401
+ }
402
+
384
403
  # Derive the prerequisite set from the persisted work-mode marker rather
385
404
  # than a fixed spec.md/user-story.md pair. A marker that cannot be read or
386
405
  # recognized must fail closed, not fail open: it denies on its own branch
387
406
  # below, naming no prerequisite set and probing for no required file.
388
- $issueContent = Get-PrdFeatureIssueContent -FeatureFolder $folderNormalized
407
+ $issueContent = Get-PrdFeatureIssueContent -FeatureFolder $probeFolder
408
+
409
+ # A folder that is absent from the resolved target root is a resolution failure,
410
+ # not a broken marker. Reporting it as a broken marker would describe a folder the
411
+ # gate probed at a root the call never named. The conjunct on the guard above is
412
+ # load-bearing: without it, an unreadable issue.md would take this branch on every
413
+ # session-root call instead of falling through to the work-mode branch.
414
+ if ($null -eq $issueContent -and $probeFolder -ne $folderNormalized) {
415
+ return (Get-PrdFeatureTargetDecision -ReasonCode (Get-WorktreeResolutionAmbiguityReasonCode) -Detail ("the resolved feature folder '$probeFolder' does not exist under the resolved target worktree, so the call's target cannot be confirmed"))
416
+ }
417
+
389
418
  $workMode = Resolve-PrdFeatureWorkMode -IssueContent $issueContent
390
419
 
391
420
  # An indeterminate mode is its own decision path, and it deliberately does NOT
@@ -400,8 +429,8 @@ function Invoke-PrdFeatureBeforePlannerDecision {
400
429
  hookSpecificOutput = [ordered]@{
401
430
  hookEventName = 'PreToolUse'
402
431
  permissionDecision = 'deny'
403
- permissionDecisionReason = "PRD_FEATURE_BLOCKED: resolved feature folder '$folderNormalized', " +
404
- "but its work mode could not be determined from '$folderNormalized/issue.md' " +
432
+ permissionDecisionReason = "PRD_FEATURE_BLOCKED: resolved feature folder '$probeFolder', " +
433
+ "but its work mode could not be determined from '$probeFolder/issue.md' " +
405
434
  '(the ''- Work Mode:'' marker is absent, unreadable, or unrecognized). ' +
406
435
  'Confirm that is the intended feature folder, then add or correct the ' +
407
436
  '''- Work Mode:'' marker in that file so the prerequisite set can be derived.'
@@ -414,7 +443,7 @@ function Invoke-PrdFeatureBeforePlannerDecision {
414
443
  # -RequiredFile parameter on Get-PrdFeatureMissingFile for minor-audit mode.
415
444
  $required = @(Get-PrdFeatureRequiredFile -WorkMode $workMode)
416
445
 
417
- $missing = Get-PrdFeatureMissingFile -FeatureFolder $folderNormalized -RequiredFile $required
446
+ $missing = Get-PrdFeatureMissingFile -FeatureFolder $probeFolder -RequiredFile $required
418
447
  if ($missing.Count -eq 0) {
419
448
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
420
449
  }
@@ -423,7 +452,7 @@ function Invoke-PrdFeatureBeforePlannerDecision {
423
452
  # folder they did not intend diagnoses a path problem immediately instead of
424
453
  # re-running a step that has already completed correctly.
425
454
  $list = ($missing -join ', ')
426
- $reason = "PRD_FEATURE_BLOCKED: resolved feature folder '$folderNormalized' is missing: " +
455
+ $reason = "PRD_FEATURE_BLOCKED: resolved feature folder '$probeFolder' is missing: " +
427
456
  "$list (work mode: $workMode). Confirm that is the intended feature folder, then " +
428
457
  'invoke the prd-feature subagent to produce the missing output(s).'
429
458
 
@@ -18,6 +18,17 @@
18
18
  - Test-BlastRadius port of validate_blast_radius
19
19
  - Test-BlastRadiusConflict port of conflicts
20
20
 
21
+ It also re-exports two scheduling functions of BlastRadiusScheduling.psm1
22
+ (issue #722); each takes the relation as -Relation, so a caller passes
23
+ ${function:Test-BlastRadiusConflict} from this facade:
24
+
25
+ - Get-BlastRadiusConflictEdge port of schedule_conflict_edges
26
+ - Get-BlastRadiusPairDecision port of decide_pair
27
+
28
+ It also re-exports the six functions of BlastRadiusWriteIntent.psm1 (issue
29
+ #722); Get-BlastRadius and Get-NormalizedDeclaredRadius delegate their
30
+ write_intent_extraction flag branch to that module.
31
+
21
32
  The Python modules remain the authoritative reference implementation. This
22
33
  module is one half of a two-language mirror; it never imports validator
23
34
  logic. Every function is pure: no filesystem, subprocess, network, or
@@ -60,6 +71,8 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1'
60
71
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop
61
72
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force -ErrorAction Stop
62
73
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConflict.psm1') -Force -ErrorAction Stop
74
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusScheduling.psm1') -Force -ErrorAction Stop
75
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusWriteIntent.psm1') -Force -ErrorAction Stop
63
76
 
64
77
  # Feature-folder handling. Every radius contains its own feature folder, and a
65
78
  # caller may pass either a bare folder name or an already-qualified path.
@@ -169,10 +182,19 @@ function Get-BlastRadius {
169
182
  # derived radius always passes V1 and V2 against its own plan (issue #452).
170
183
  $rootSurface = [string[]]@(Get-ConfigRootSurface -Config $Config)
171
184
 
172
- $specLine = [string[]]@(ConvertTo-NormalizedLine -Text $SpecText)
185
+ # Write-intent mode (issue #722) delegates to BlastRadiusWriteIntent.psm1:
186
+ # plan paths come from the selector Test-BlastRadius also uses and the spec
187
+ # contributes contracts only.
173
188
  $entry = [System.Collections.Generic.List[string]]::new()
174
- $entry.AddRange([string[]]@(Get-PlanPaths -PlanText $PlanText -RootSurface $rootSurface))
175
- $entry.AddRange([string[]]@(Get-PathFromLine -Line $specLine -RootSurface $rootSurface))
189
+ if (Test-WriteIntentExtractionEnabled -Config $Config) {
190
+ $entry.AddRange([string[]]@(Get-PlanPathForConfig -PlanText $PlanText -Config $Config -RootSurface $rootSurface))
191
+ $contract = @(Get-WriteIntentSpecContract -SpecText $SpecText)
192
+ } else {
193
+ $specLine = [string[]]@(ConvertTo-NormalizedLine -Text $SpecText)
194
+ $entry.AddRange([string[]]@(Get-PlanPaths -PlanText $PlanText -RootSurface $rootSurface))
195
+ $entry.AddRange([string[]]@(Get-PathFromLine -Line $specLine -RootSurface $rootSurface))
196
+ $contract = @(Get-ContractIdentifier -SpecText $SpecText)
197
+ }
176
198
 
177
199
  # Read-by-mandate citations are dropped before the feature folder is added: a
178
200
  # plan cites the policy rules because its author was told to read them, not
@@ -193,7 +215,7 @@ function Get-BlastRadius {
193
215
  paths = $paths
194
216
  modules = @(Resolve-BlastRadiusModule -PathEntry $paths -Config $Config)
195
217
  shared_surfaces = @(Resolve-BlastRadiusSharedSurface -ConcretePath $concrete -Config $Config)
196
- contracts = @(Get-ContractIdentifier -SpecText $SpecText)
218
+ contracts = $contract
197
219
  source = $Source
198
220
  computed_at = $ComputedAt
199
221
  }
@@ -264,7 +286,14 @@ function Get-NormalizedDeclaredRadius {
264
286
  }
265
287
  }
266
288
 
267
- $paths = [string[]]@(Get-NonMandateReadEntry -Entry $accepted.ToArray() `
289
+ # Write-intent mode (issue #722) applies the token-level rules W1, W4, and W6
290
+ # before the mandate-read filter; the feature-folder glob is kept.
291
+ $acceptedEntry = [string[]]$accepted.ToArray()
292
+ if (Test-WriteIntentExtractionEnabled -Config $Config) {
293
+ $acceptedEntry = [string[]]@(Select-WriteIntentPathEntry -Entry $acceptedEntry -RootSurface $rootSurface `
294
+ -PathRoot ([string[]]@(Get-ConfigPathRoot -Config $Config)))
295
+ }
296
+ $paths = [string[]]@(Get-NonMandateReadEntry -Entry $acceptedEntry `
268
297
  -MandateRead ([string[]]@(Get-ConfigMandateRead -Config $Config)))
269
298
  $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths)
270
299
 
@@ -435,4 +464,12 @@ Export-ModuleMember -Function `
435
464
  Get-NormalizedDeclaredRadius, `
436
465
  Get-BlastRadiusFromObservedPaths, `
437
466
  Test-BlastRadius, `
438
- Test-BlastRadiusConflict
467
+ Test-BlastRadiusConflict, `
468
+ Get-BlastRadiusConflictEdge, `
469
+ Get-BlastRadiusPairDecision, `
470
+ Test-WriteIntentExtractionEnabled, `
471
+ Get-ConfigPathRoot, `
472
+ Get-WriteIntentPlanPath, `
473
+ Get-WriteIntentSpecContract, `
474
+ Select-WriteIntentPathEntry, `
475
+ Get-PlanPathForConfig