@danmoisan/drm-copilot-mcp 0.0.5 → 1.0.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 (96) hide show
  1. package/out/mcp-server.js +10268 -380
  2. package/package.json +2 -2
  3. package/resources/claude-customizations/.claude/agents/orchestrator.md +6 -2
  4. package/resources/claude-customizations/.claude/agents/pr-author.md +78 -0
  5. package/resources/claude-customizations/.claude/agents/task-researcher.md +10 -6
  6. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +68 -10
  7. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +148 -11
  8. package/resources/claude-customizations/.claude/hooks/enforce-completion-helpers.ps1 +163 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +40 -14
  10. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +198 -0
  11. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +153 -10
  12. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +68 -1
  13. package/resources/claude-customizations/.claude/hooks/validate-pr-author-output.ps1 +136 -0
  14. package/resources/claude-customizations/.claude/hooks/validate-task-researcher-output.ps1 +22 -6
  15. package/resources/claude-customizations/.claude/settings.json +22 -0
  16. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +0 -1
  17. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +76 -2
  18. package/resources/claude-customizations/.claude/skills/research-issue/SKILL.md +5 -3
  19. package/resources/claude-customizations/.claude-variants/csharp-legacy/agents/csharp-typed-engineer.md +69 -0
  20. package/resources/claude-customizations/.claude-variants/csharp-legacy/rules/csharp.md +96 -0
  21. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +77 -0
  22. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +64 -0
  23. package/resources/claude-customizations/pack-manifests/core.json +65 -0
  24. package/resources/claude-customizations/pack-manifests/csharp-legacy.json +11 -0
  25. package/resources/claude-customizations/pack-manifests/csharp-modern.json +12 -0
  26. package/resources/claude-customizations/pack-manifests/powershell.json +14 -0
  27. package/resources/claude-customizations/pack-manifests/python.json +14 -0
  28. package/resources/claude-customizations/pack-manifests/typescript.json +9 -0
  29. package/resources/codex-and-agents-customizations/.agents/skills/feature-promotion-lifecycle/SKILL.md +30 -13
  30. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +113 -10
  31. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +33 -8
  32. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +12 -7
  33. package/resources/codex-and-agents-customizations/.codex/config.toml +65 -6
  34. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-checkpoint-monotonic.ps1 +303 -0
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +300 -0
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +148 -0
  37. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +336 -0
  38. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +83 -17
  39. package/resources/config/orchestration-routing.json +5 -13
  40. package/resources/customizations/.github/agents/pr-author.agent.md +25 -0
  41. package/resources/customizations/.github/agents/task-researcher.agent.md +4 -4
  42. package/resources/customizations/.github/prompts/fillout-prd-feature.prompt.md +1 -1
  43. package/resources/customizations/.github/prompts/research-issue.prompt.md +4 -3
  44. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +0 -5
  45. package/resources/scripts/dev_tools/__init__.py +0 -0
  46. package/resources/scripts/dev_tools/_orchestrator_state_human_interaction.py +0 -127
  47. package/resources/scripts/dev_tools/_orchestrator_state_routing.py +0 -216
  48. package/resources/scripts/dev_tools/agentic_sync.py +0 -819
  49. package/resources/scripts/dev_tools/codex_native_converter/__init__.py +0 -11
  50. package/resources/scripts/dev_tools/codex_native_converter/__main__.py +0 -6
  51. package/resources/scripts/dev_tools/codex_native_converter/cli.py +0 -11
  52. package/resources/scripts/dev_tools/new_active_feature_folder.py +0 -79
  53. package/resources/scripts/dev_tools/new_active_feature_folder_docs.py +0 -268
  54. package/resources/scripts/dev_tools/new_active_feature_folder_flow.py +0 -366
  55. package/resources/scripts/dev_tools/new_active_feature_folder_io.py +0 -306
  56. package/resources/scripts/dev_tools/new_active_feature_folder_markdown.py +0 -252
  57. package/resources/scripts/dev_tools/new_active_feature_folder_models.py +0 -136
  58. package/resources/scripts/dev_tools/new_potential_bug_entry.py +0 -465
  59. package/resources/scripts/dev_tools/potential_to_issue.py +0 -421
  60. package/resources/scripts/dev_tools/potential_to_issue_content.py +0 -212
  61. package/resources/scripts/dev_tools/pr_context/__init__.py +0 -0
  62. package/resources/scripts/dev_tools/pr_context/collector.py +0 -619
  63. package/resources/scripts/dev_tools/pr_context/feature_docs.py +0 -349
  64. package/resources/scripts/dev_tools/pr_context/git.py +0 -153
  65. package/resources/scripts/dev_tools/pr_context/github.py +0 -549
  66. package/resources/scripts/dev_tools/pr_context/models.py +0 -198
  67. package/resources/scripts/dev_tools/pr_context/render.py +0 -342
  68. package/resources/scripts/dev_tools/pr_context/render_feature_excerpts.py +0 -256
  69. package/resources/scripts/dev_tools/pr_context/render_pr_helpers.py +0 -291
  70. package/resources/scripts/dev_tools/pr_context/summary_helpers.py +0 -386
  71. package/resources/scripts/dev_tools/pr_context/verification_evidence.py +0 -171
  72. package/resources/scripts/dev_tools/prompt_mode_contract.py +0 -152
  73. package/resources/scripts/dev_tools/push_down_claude_customizations.py +0 -374
  74. package/resources/scripts/dev_tools/push_down_codex_and_agents_customizations.py +0 -139
  75. package/resources/scripts/dev_tools/push_down_copilot_customizations.py +0 -504
  76. package/resources/scripts/dev_tools/push_down_copilot_customizations_filesystem.py +0 -217
  77. package/resources/scripts/dev_tools/push_down_copilot_customizations_rewrites.py +0 -293
  78. package/resources/scripts/dev_tools/resolve_file_prompt.py +0 -457
  79. package/resources/scripts/dev_tools/resolve_hard_lock_prompt.py +0 -444
  80. package/resources/scripts/dev_tools/validate_orchestration_artifacts.py +0 -246
  81. package/resources/scripts/dev_tools/validate_orchestration_review_artifacts.py +0 -107
  82. package/resources/scripts/dev_tools/validate_orchestrator_state.py +0 -428
  83. package/resources/scripts/dev_tools/validate_policy_audit_artifact.py +0 -448
  84. package/resources/templates/codex_native_converter.py +0 -35
  85. package/resources/templates/collect_commit_context.py +0 -212
  86. package/resources/templates/collect_pr_context.py +0 -74
  87. package/resources/templates/hello_python.py +0 -11
  88. package/resources/templates/new_active_feature_folder.py +0 -67
  89. package/resources/templates/new_potential_bug_entry.py +0 -54
  90. package/resources/templates/potential_to_issue.py +0 -55
  91. package/resources/templates/push_down_claude_customizations.py +0 -409
  92. package/resources/templates/push_down_codex_and_agents_customizations.py +0 -95
  93. package/resources/templates/push_down_copilot_customizations.py +0 -124
  94. package/resources/templates/resolve_atomic_plan_prompt.py +0 -75
  95. package/resources/templates/resolve_hard_lock_prompt.py +0 -65
  96. package/resources/templates/validate_orchestration_artifacts.py +0 -55
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "0.0.5",
3
+ "version": "1.0.0",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -35,7 +35,7 @@
35
35
  "author": "Dan Moisan",
36
36
  "scripts": {
37
37
  "build": "node esbuild-mcp-server.cjs",
38
- "prepack": "node -e \"const{cpSync}=require('fs');cpSync('../../extensions/drm-copilot/resources','./resources',{recursive:true,force:true})\""
38
+ "prepack": "node prepack.cjs"
39
39
  },
40
40
  "overrides": {
41
41
  "fast-uri": "^3.1.2",
@@ -2,7 +2,7 @@
2
2
  name: orchestrator
3
3
  description: Deterministic repository orchestrator that estimates change budget, selects small or large workflow path, delegates to specialist subagents, persists checkpoint state, and enforces completion gates proactively.
4
4
  tools:
5
- - "Agent(atomic-planner,atomic-executor,feature-review,task-researcher,prd-feature,staged-review,epic-review,status-updater,python-typed-engineer,powershell-typed-engineer,csharp-typed-engineer,typescript-engineer)"
5
+ - "Agent(atomic-planner,atomic-executor,feature-review,task-researcher,prd-feature,staged-review,epic-review,status-updater,pr-author,python-typed-engineer,powershell-typed-engineer,csharp-typed-engineer,typescript-engineer)"
6
6
  - Read
7
7
  - Grep
8
8
  - Glob
@@ -69,10 +69,14 @@ Delegate exclusively through configured subagents:
69
69
  - `atomic-planner` — generates phased implementation plans (planning only)
70
70
  - `atomic-executor` — executes approved plans task-by-task (execution only)
71
71
  - `feature-review` — produces policy, code, and feature audit artifacts
72
- - `task-researcher` — performs deep research and writes findings to `artifacts/research/`
72
+ - `task-researcher` — performs deep research and writes findings to the research path the orchestrator resolves before delegating: `docs/features/<feature>/research/` when an active `feature-folder` is in scope in `orchestrator-state.json`, otherwise `docs/research/` for one-off research. The orchestrator passes the resolved path in the delegation prompt.
73
73
 
74
74
  For required delegated steps, delegation is mandatory. If a handoff cannot be started, resumed, or completed, stop execution and record blocked state. Do not perform the step locally.
75
75
 
76
+ ## PR Creation Delegation
77
+
78
+ PR creation and PR body edits must be delegated to `Agent(pr-author)`. The orchestrator must not call `gh pr create` or `gh pr edit --body*` directly from the main thread; those commands are blocked by the `enforce-pr-author-skill.ps1` PreToolUse hook unless a valid authorization sentinel issued by the `pr-author` agent is present. The orchestrator first produces the PR-context artifact via `mcp__drm-copilot__collect_pr_context`, then delegates to `Agent(pr-author)`, which authors the PR body via the `pr-author` skill, writes and deletes the authorization sentinel around the `gh` command, and reports the resulting PR URL or PR number.
79
+
76
80
  ## Checkpoint Persistence
77
81
 
78
82
  Update `artifacts/orchestration/orchestrator-state.json` after every completed step with:
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: pr-author
3
+ description: Project-scoped agent that runs the pr-author skill to produce a GitHub-ready PR body from the canonical PR-context bundle, then opens or updates the pull request. Sole authorized caller of gh pr create and gh pr edit --body*. Writes a short-lived authorization sentinel immediately before each gh command and deletes it afterward.
4
+ model: sonnet
5
+ skills:
6
+ - pr-author
7
+ memory: project
8
+ tools:
9
+ - Read
10
+ - "Bash(git log *)"
11
+ - "Bash(git rev-parse *)"
12
+ - "Bash(gh pr create *)"
13
+ - "Bash(gh pr edit *)"
14
+ - "Write(/artifacts/**)"
15
+ hooks:
16
+ SubagentStop:
17
+ - matcher: "pr-author"
18
+ hooks:
19
+ - type: command
20
+ command: pwsh -NoProfile -File .claude/hooks/validate-pr-author-output.ps1
21
+ ---
22
+
23
+ # PR Author Agent
24
+
25
+ You are the dedicated pull-request authoring agent. Your sole responsibility is to consume the
26
+ canonical PR-context bundle, produce the PR body using the `pr-author` skill, and open or update the
27
+ pull request. You are the only agent authorized to run `gh pr create` and `gh pr edit --body*`. The
28
+ orchestrator and all other agents are blocked from these commands by the
29
+ `enforce-pr-author-skill.ps1` PreToolUse hook.
30
+
31
+ ## Skill
32
+
33
+ Apply the `pr-author` skill (`.claude/skills/pr-author/SKILL.md`) as the canonical workflow for
34
+ authoring the PR body. The skill produces the PR body text from the PR-context bundle
35
+ (`artifacts/pr_context.summary.txt` and `artifacts/pr_context.appendix.txt`) and its enumerated
36
+ additional context files. The skill itself only authors body text; opening and editing the pull
37
+ request via `gh pr create` / `gh pr edit` is this agent's responsibility, not the skill's.
38
+
39
+ ## Authorization Sentinel Write/Delete Protocol
40
+
41
+ Before any `gh pr create` or `gh pr edit --body*` command, you MUST perform these steps in order:
42
+
43
+ 1. Run `git rev-parse HEAD` to obtain the current `head_sha`.
44
+ 2. Write `artifacts/pr_author_authorization.json` with exactly these fields:
45
+ - `issued_by`: `"pr-author"`
46
+ - `issued_at`: the current time as a UTC ISO-8601 timestamp (for example `2026-06-24T16:00:00Z`)
47
+ - `head_sha`: the value from step 1
48
+ - `ttl_seconds`: `120`
49
+ 3. Issue the `gh pr create` or `gh pr edit --body-file ...` command immediately, within the 120-second
50
+ TTL. The PreToolUse hook verifies that the sentinel is present, that `issued_by` is exactly
51
+ `pr-author`, and that the sentinel has not expired before allowing the command.
52
+ 4. Delete `artifacts/pr_author_authorization.json` after the `gh` command completes, on both success
53
+ and failure. The TTL also expires abandoned sentinels, but explicit deletion is required.
54
+
55
+ The sentinel filename is exactly `artifacts/pr_author_authorization.json`. The PR body must be passed
56
+ via `--body-file`; inline `--body` is blocked by the hook (Case A).
57
+
58
+ ## Final Output Requirement
59
+
60
+ After opening or updating the pull request, report the resulting PR URL
61
+ (`https://github.com/<owner>/<repo>/pull/<n>`) or the PR number (`PR #<n>`) in your final output. The
62
+ `validate-pr-author-output.ps1` SubagentStop hook blocks completion when the final output contains no
63
+ PR URL or PR number.
64
+
65
+ ## Enforcement Strength (Honest Disclosure)
66
+
67
+ The authorization sentinel is a **policy guardrail, not a cryptographic or security control.** Any
68
+ actor with `Write(/artifacts/**)` access can forge `artifacts/pr_author_authorization.json`, because
69
+ all agents share the same filesystem and the runtime exposes no native agent-identity signal at Bash
70
+ PreToolUse time. The mechanism prevents accidental bypass (such as the PR #228 pattern where the
71
+ orchestrator wrote the body file and called `gh pr create` directly) and requires a deliberate,
72
+ documented act to circumvent. It is not tamper-proof and is not a security boundary.
73
+
74
+ ## Standing Rules
75
+
76
+ Repository tone is defined in `CLAUDE.md` and `.claude/rules/tonality.md`. Reference only files
77
+ listed under "Additional context files" in the PR-context bundle; do not cite or summarize files
78
+ outside that enumeration. Do not invent issue or PR numbers.
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: task-researcher
3
- description: Research specialist that performs deep investigation and writes structured findings exclusively to artifacts/research/.
3
+ description: Research specialist that performs deep investigation and writes structured findings to the orchestrator-supplied research path under docs/features/<feature>/research/ (feature-associated) or docs/research/ (one-off).
4
4
  model: sonnet
5
5
  tools:
6
6
  - Read
7
7
  - Grep
8
8
  - Glob
9
9
  - WebFetch
10
- - "Write(/artifacts/research/**)"
10
+ - "Write(/docs/features/**/research/**)"
11
+ - "Write(/docs/research/**)"
11
12
  - evidence-and-timestamp-conventions
12
13
  memory: project
13
14
  hooks:
@@ -20,13 +21,16 @@ hooks:
20
21
 
21
22
  # Task Researcher Agent
22
23
 
23
- You are a research-only specialist. You perform deep analysis for task planning and write structured research notes. You do not make changes to source code, configurations, or project files outside `artifacts/research/`.
24
+ You are a research-only specialist. You perform deep analysis for task planning and write structured research notes. You do not make changes to source code, configurations, or project files outside the research root the orchestrator supplies.
24
25
 
25
26
  ## Output Location
26
27
 
27
- Write all research artifacts to `artifacts/research/` using the filename convention:
28
+ Write each research artifact to the research path the orchestrator supplies in the delegation prompt. There are two tracked research roots:
28
29
 
29
- - `artifacts/research/<timestamp>-<short-name>-research.md`
30
+ - Feature-associated research: `docs/features/<feature>/research/<timestamp>-<short-name>-research.md` (for example `docs/features/active/<feature>/research/<timestamp>-<short-name>-research.md`).
31
+ - One-off research not tied to a feature: `docs/research/<timestamp>-<short-name>-research.md`.
32
+
33
+ The orchestrator resolves which root to use from whether an active feature folder is in scope and passes the exact path in the delegation prompt; do not infer the feature folder independently. The filename convention `<timestamp>-<short-name>-research.md` is unchanged.
30
34
 
31
35
  ## Core Principles
32
36
 
@@ -67,7 +71,7 @@ Write all research artifacts to `artifacts/research/` using the filename convent
67
71
 
68
72
  ## Constraints
69
73
 
70
- - Write only to `artifacts/research/`. Do not modify source code or configurations.
74
+ - Write only to the orchestrator-supplied research path under `docs/features/<feature>/research/` (feature-associated) or `docs/research/` (one-off). Do not modify source code or configurations.
71
75
  - Ground all findings in verified evidence.
72
76
  - Keep discussion of non-selected approaches brief.
73
77
  - Do not claim nested worker delegation.
@@ -134,6 +134,53 @@ function Get-OutOfOrderPair {
134
134
  return $null
135
135
  }
136
136
 
137
+ function Test-StepHasPrefix {
138
+ [CmdletBinding()]
139
+ [OutputType([bool])]
140
+ param(
141
+ [Parameter(Mandatory)]
142
+ [string] $StepEntry,
143
+
144
+ [Parameter(Mandatory)]
145
+ [string] $Prefix
146
+ )
147
+
148
+ return $StepEntry -eq $Prefix -or $StepEntry.StartsWith("$Prefix" + '_') -or $StepEntry.StartsWith("$Prefix.") -or $StepEntry.StartsWith("$Prefix-")
149
+ }
150
+
151
+ function Get-MissingPrerequisiteForAdvancedStep {
152
+ [CmdletBinding()]
153
+ param(
154
+ [Parameter(Mandatory)]
155
+ [AllowEmptyCollection()]
156
+ [string[]] $CompletedSteps
157
+ )
158
+
159
+ $hasPromotion = $false
160
+ $hasPlanning = $false
161
+ foreach ($step in $CompletedSteps) {
162
+ if (Test-StepHasPrefix -StepEntry $step -Prefix 'S3_promotion') {
163
+ $hasPromotion = $true
164
+ }
165
+ if (Test-StepHasPrefix -StepEntry $step -Prefix 'S4_atomic_planning') {
166
+ $hasPlanning = $true
167
+ }
168
+ }
169
+
170
+ foreach ($step in $CompletedSteps) {
171
+ $index = Get-CanonicalStepIndex -StepEntry $step
172
+ if ($index -ge 5 -and (-not $hasPromotion -or -not $hasPlanning)) {
173
+ return [pscustomobject]@{
174
+ Step = $step
175
+ MissingPromotion = -not $hasPromotion
176
+ MissingPlanning = -not $hasPlanning
177
+ }
178
+ }
179
+ }
180
+
181
+ return $null
182
+ }
183
+
137
184
  function Test-IsCheckpointPath {
138
185
  [CmdletBinding()]
139
186
  [OutputType([bool])]
@@ -204,10 +251,6 @@ function Invoke-CheckpointMonotonicDecision {
204
251
  }
205
252
  }
206
253
 
207
- if ($steps.Count -lt 2) {
208
- return [ordered]@{ decision = 'allow' }
209
- }
210
-
211
254
  $rollbackHistory = $null
212
255
  if ($payload.PSObject.Properties.Name -contains 'rollback_history') {
213
256
  $rollbackHistory = $payload.rollback_history
@@ -216,15 +259,30 @@ function Invoke-CheckpointMonotonicDecision {
216
259
  return [ordered]@{ decision = 'allow' }
217
260
  }
218
261
 
219
- $pair = Get-OutOfOrderPair -CompletedSteps $steps
220
- if ($null -eq $pair) {
221
- return [ordered]@{ decision = 'allow' }
262
+ $pair = if ($steps.Count -ge 2) { Get-OutOfOrderPair -CompletedSteps $steps } else { $null }
263
+ if ($null -ne $pair) {
264
+ return [ordered]@{
265
+ decision = 'block'
266
+ reason = "CHECKPOINT_ORDER_BLOCKED: completed_steps lists '$($pair.EarlierEntry)' at position $($pair.EarlierPos) before '$($pair.LaterEntry)' at position $($pair.LaterPos), but the canonical orchestrator workflow requires the later step to follow the earlier one. Reorder completed_steps or, if a rollback occurred, record it in rollback_history."
267
+ }
222
268
  }
223
269
 
224
- return [ordered]@{
225
- decision = 'block'
226
- reason = "CHECKPOINT_ORDER_BLOCKED: completed_steps lists '$($pair.EarlierEntry)' at position $($pair.EarlierPos) before '$($pair.LaterEntry)' at position $($pair.LaterPos), but the canonical orchestrator workflow requires the later step to follow the earlier one. Reorder completed_steps or, if a rollback occurred, record it in rollback_history."
270
+ $missingPrerequisite = Get-MissingPrerequisiteForAdvancedStep -CompletedSteps $steps
271
+ if ($null -ne $missingPrerequisite) {
272
+ $missing = @()
273
+ if ($missingPrerequisite.MissingPromotion) {
274
+ $missing += 'S3_promotion'
275
+ }
276
+ if ($missingPrerequisite.MissingPlanning) {
277
+ $missing += 'S4_atomic_planning'
278
+ }
279
+ return [ordered]@{
280
+ decision = 'block'
281
+ reason = "CHECKPOINT_ORDER_BLOCKED: completed_steps lists '$($missingPrerequisite.Step)' before required prerequisite step(s): $($missing -join ', '). Record promotion and planning completion before implementation, review, PR, CI, or DONE steps."
282
+ }
227
283
  }
284
+
285
+ return [ordered]@{ decision = 'allow' }
228
286
  }
229
287
 
230
288
  # Guard allows dot-sourcing in tests without executing the entrypoint.
@@ -39,6 +39,11 @@
39
39
  [CmdletBinding()]
40
40
  param()
41
41
 
42
+ # Dot-source the shared validation helpers. Guarded so a missing file produces a
43
+ # clear error and so dot-sourcing this hook in tests loads the helpers too.
44
+ $script:CompletionHelpersPath = Join-Path $PSScriptRoot 'enforce-completion-helpers.ps1'
45
+ . $script:CompletionHelpersPath
46
+
42
47
  function ConvertFrom-CheckpointJson {
43
48
  <#
44
49
  .SYNOPSIS
@@ -53,6 +58,29 @@ function ConvertFrom-CheckpointJson {
53
58
  return $Json | ConvertFrom-Json -ErrorAction Stop
54
59
  }
55
60
 
61
+ function Get-CheckpointFileContent {
62
+ <#
63
+ .SYNOPSIS
64
+ Reads the on-disk checkpoint content for the read-then-validate Edit path.
65
+ .DESCRIPTION
66
+ Returns the full file text when the path resolves to a file on disk, or
67
+ $null when the file does not exist. Tests inject a CheckpointReader
68
+ scriptblock instead of mocking this function so no temporary files are
69
+ required.
70
+ #>
71
+ [CmdletBinding()]
72
+ [OutputType([string])]
73
+ param(
74
+ [Parameter(Mandatory)]
75
+ [string] $Path
76
+ )
77
+
78
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
79
+ return $null
80
+ }
81
+ return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
82
+ }
83
+
56
84
  function Test-IsCheckpointPath {
57
85
  [CmdletBinding()]
58
86
  [OutputType([bool])]
@@ -149,7 +177,13 @@ function Get-MissingCompletionEvidence {
149
177
  param(
150
178
  [Parameter(Mandatory)]
151
179
  [AllowNull()]
152
- $Payload
180
+ $Payload,
181
+
182
+ [Parameter(Mandatory = $false)]
183
+ [scriptblock] $FolderExistsCheck = { param($p) Test-Path -LiteralPath $p -PathType Container },
184
+
185
+ [Parameter(Mandatory = $false)]
186
+ [scriptblock] $RoutingMatrixReader
153
187
  )
154
188
 
155
189
  $missing = @()
@@ -158,16 +192,17 @@ function Get-MissingCompletionEvidence {
158
192
  if (-not $issueNum -and $null -ne $Payload -and ($Payload.PSObject.Properties.Name -contains 'variables')) {
159
193
  $issueNum = Get-CheckpointStringValue -Payload $Payload.variables -Name 'issue-num'
160
194
  }
161
- if (-not $issueNum) {
162
- $missing += 'issue-num'
195
+ if (-not (Test-IsValidIssueNum -Value $issueNum)) {
196
+ # Name the offending value so sentinel/placeholder inputs are explicit.
197
+ $missing += "issue-num value '$issueNum' is not a valid issue number (must be digits-only)"
163
198
  }
164
199
 
165
200
  $featureFolder = Get-CheckpointStringValue -Payload $Payload -Name 'feature-folder'
166
201
  if (-not $featureFolder -and $null -ne $Payload -and ($Payload.PSObject.Properties.Name -contains 'variables')) {
167
202
  $featureFolder = Get-CheckpointStringValue -Payload $Payload.variables -Name 'feature-folder'
168
203
  }
169
- if (-not $featureFolder) {
170
- $missing += 'feature-folder'
204
+ if (-not (Test-IsValidFeatureFolder -Value $featureFolder -FolderExistsCheck $FolderExistsCheck)) {
205
+ $missing += "feature-folder value '$featureFolder' is not a valid feature folder (must be under docs/features/active/ and exist)"
171
206
  }
172
207
 
173
208
  $ciGate = $null
@@ -188,9 +223,92 @@ function Get-MissingCompletionEvidence {
188
223
  }
189
224
  }
190
225
 
226
+ # PR-gate evidence is required only when the checkpoint's selected route
227
+ # opts into it via requires_pr_gate in the routing matrix. This replaces the
228
+ # former issue-number special-casing with route-driven enforcement.
229
+ $prGateArgs = @{ Payload = $Payload }
230
+ if ($PSBoundParameters.ContainsKey('RoutingMatrixReader') -and $null -ne $RoutingMatrixReader) {
231
+ $prGateArgs['RoutingMatrixReader'] = $RoutingMatrixReader
232
+ }
233
+ if (Test-RouteRequiresPrGate @prGateArgs) {
234
+ $prGate = $null
235
+ if ($null -ne $Payload -and ($Payload.PSObject.Properties.Name -contains 'pr_gate')) {
236
+ $prGate = $Payload.pr_gate
237
+ }
238
+ if ($null -eq $prGate -or $prGate -isnot [System.Management.Automation.PSCustomObject]) {
239
+ $missing += 'pr_gate (object with pr_number, pr_url, head_branch, and head_sha)'
240
+ }
241
+ else {
242
+ foreach ($field in @('pr_number', 'pr_url', 'head_branch', 'head_sha')) {
243
+ if (-not (Get-CheckpointStringValue -Payload $prGate -Name $field)) {
244
+ $missing += "pr_gate.$field"
245
+ }
246
+ }
247
+ $prHeadSha = Get-CheckpointStringValue -Payload $prGate -Name 'head_sha'
248
+ $ciHeadSha = Get-CheckpointStringValue -Payload $ciGate -Name 'head_sha'
249
+ if ($prHeadSha -and $ciHeadSha -and $prHeadSha -ne $ciHeadSha) {
250
+ $missing += 'ci_gate.head_sha matching pr_gate.head_sha'
251
+ }
252
+ }
253
+ }
254
+
191
255
  return [string[]]$missing
192
256
  }
193
257
 
258
+ function Resolve-EditedCheckpointContent {
259
+ <#
260
+ .SYNOPSIS
261
+ Returns the patched checkpoint content for an Edit-tool call, or $null
262
+ when the patch cannot be applied against the on-disk checkpoint.
263
+ .DESCRIPTION
264
+ Implements the read-then-validate Edit path. When the tool input carries
265
+ an old_string (an Edit patch), the on-disk checkpoint is read through the
266
+ injectable CheckpointReader seam and the old_string -> new_string
267
+ replacement is applied in memory (no on-disk mutation). Returns $null
268
+ when there is no old_string, the on-disk file does not exist, or the
269
+ old_string is not present in the on-disk content, signalling the caller
270
+ to allow (defer).
271
+ #>
272
+ [CmdletBinding()]
273
+ [OutputType([string])]
274
+ param(
275
+ [Parameter(Mandatory)]
276
+ [AllowNull()]
277
+ $ToolInput,
278
+
279
+ [Parameter(Mandatory)]
280
+ [scriptblock] $CheckpointReader
281
+ )
282
+
283
+ $oldString = $null
284
+ if ($null -ne $ToolInput -and ($ToolInput.PSObject.Properties.Name -contains 'old_string')) {
285
+ $oldString = [string]$ToolInput.old_string
286
+ }
287
+ if ([string]::IsNullOrEmpty($oldString)) {
288
+ return $null
289
+ }
290
+
291
+ $newString = ''
292
+ if ($ToolInput.PSObject.Properties.Name -contains 'new_string') {
293
+ $newString = [string]$ToolInput.new_string
294
+ }
295
+
296
+ $onDisk = & $CheckpointReader 'artifacts/orchestration/orchestrator-state.json'
297
+ if ([string]::IsNullOrEmpty([string]$onDisk)) {
298
+ # The on-disk checkpoint does not exist (or is empty); cannot patch.
299
+ return $null
300
+ }
301
+
302
+ $onDiskText = [string]$onDisk
303
+ if (-not $onDiskText.Contains($oldString)) {
304
+ # The old_string is not present, so the patch does not apply here.
305
+ return $null
306
+ }
307
+
308
+ # Apply the patch in memory using a literal (non-regex) replacement.
309
+ return $onDiskText.Replace($oldString, $newString)
310
+ }
311
+
194
312
  function Invoke-CompletionConsistencyDecision {
195
313
  <#
196
314
  .SYNOPSIS
@@ -200,7 +318,16 @@ function Invoke-CompletionConsistencyDecision {
200
318
  [CmdletBinding()]
201
319
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
202
320
  param(
203
- [string] $ToolInputRaw
321
+ [string] $ToolInputRaw,
322
+
323
+ [Parameter(Mandatory = $false)]
324
+ [scriptblock] $FolderExistsCheck = { param($p) Test-Path -LiteralPath $p -PathType Container },
325
+
326
+ [Parameter(Mandatory = $false)]
327
+ [scriptblock] $CheckpointReader = { param($Path) Get-CheckpointFileContent -Path $Path },
328
+
329
+ [Parameter(Mandatory = $false)]
330
+ [scriptblock] $RoutingMatrixReader
204
331
  )
205
332
 
206
333
  if (-not $ToolInputRaw) {
@@ -224,11 +351,17 @@ function Invoke-CompletionConsistencyDecision {
224
351
  return [ordered]@{ decision = 'allow' }
225
352
  }
226
353
 
227
- # Write tool: validate the content payload. Edit tool: partial new_string is
228
- # not reliable without the full target file content, so allow.
354
+ # Write tool: validate the content payload directly. Edit tool: no content is
355
+ # supplied, so read the on-disk checkpoint through the injectable seam and
356
+ # apply the old_string -> new_string patch in memory (read-then-validate).
229
357
  $content = $toolInput.content
230
358
  if (-not $content) {
231
- return [ordered]@{ decision = 'allow' }
359
+ $content = Resolve-EditedCheckpointContent -ToolInput $toolInput -CheckpointReader $CheckpointReader
360
+ if (-not $content) {
361
+ # No content, and the Edit could not be resolved against on-disk
362
+ # state (missing file or non-matching patch): defer and allow.
363
+ return [ordered]@{ decision = 'allow' }
364
+ }
232
365
  }
233
366
 
234
367
  try {
@@ -244,14 +377,18 @@ function Invoke-CompletionConsistencyDecision {
244
377
  return [ordered]@{ decision = 'allow' }
245
378
  }
246
379
 
247
- $missing = Get-MissingCompletionEvidence -Payload $payload
380
+ $missingArgs = @{ Payload = $payload; FolderExistsCheck = $FolderExistsCheck }
381
+ if ($PSBoundParameters.ContainsKey('RoutingMatrixReader') -and $null -ne $RoutingMatrixReader) {
382
+ $missingArgs['RoutingMatrixReader'] = $RoutingMatrixReader
383
+ }
384
+ $missing = Get-MissingCompletionEvidence @missingArgs
248
385
  if ($missing.Count -eq 0) {
249
386
  return [ordered]@{ decision = 'allow' }
250
387
  }
251
388
 
252
389
  return [ordered]@{
253
390
  decision = 'block'
254
- reason = "COMPLETION_CONSISTENCY_BLOCKED: the checkpoint asserts completion but is missing required completion evidence: $($missing -join ', '). A completion-asserting checkpoint must include a non-empty issue-num, a non-empty feature-folder, and a ci_gate object with conclusion == 'success' and a non-empty head_sha. Supply the missing evidence or remove the completion assertion."
391
+ reason = "COMPLETION_CONSISTENCY_BLOCKED: the checkpoint asserts completion but is missing required completion evidence: $($missing -join ', '). A completion-asserting checkpoint must include a non-empty issue-num, a non-empty feature-folder, and a ci_gate object with conclusion == 'success' and a non-empty head_sha; routes whose requires_pr_gate is true must also include pr_gate evidence with a matching head_sha. Supply the missing evidence or remove the completion assertion."
255
392
  }
256
393
  }
257
394
 
@@ -0,0 +1,163 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Dot-sourced helper functions for enforce-completion-consistency.ps1.
4
+
5
+ .DESCRIPTION
6
+ Provides testable validation helpers used by the completion-consistency
7
+ PreToolUse hook:
8
+
9
+ - Test-IsValidIssueNum: rejects sentinel/placeholder and non-digit issue
10
+ numbers; accepts digits-only strings.
11
+ - Test-IsValidFeatureFolder: rejects sentinel/placeholder feature folders
12
+ and folders not anchored under docs/features/active/<segment>; optionally
13
+ verifies on-disk existence through an injectable scriptblock seam.
14
+
15
+ This script is dot-sourced by enforce-completion-consistency.ps1. It contains
16
+ no entrypoint logic, so dot-sourcing it in tests has no side effects.
17
+
18
+ .NOTES
19
+ Compatible with PowerShell 7+.
20
+ #>
21
+ [CmdletBinding()]
22
+ param()
23
+
24
+ # Sentinel/placeholder values that must never satisfy a presence check.
25
+ $script:CompletionEvidenceSentinels = @('n/a', 'none', 'tbd')
26
+
27
+ function Test-IsValidIssueNum {
28
+ <#
29
+ .SYNOPSIS
30
+ Returns $true only for a digits-only issue number.
31
+ .DESCRIPTION
32
+ Returns $false when the value is empty, whitespace-only, a sentinel
33
+ (n/a, none, tbd; case-insensitive), or contains any non-digit character.
34
+ Returns $true only when the trimmed value matches ^\d+$.
35
+ #>
36
+ [CmdletBinding()]
37
+ [OutputType([bool])]
38
+ param(
39
+ [Parameter(Mandatory = $true)]
40
+ [AllowNull()]
41
+ [AllowEmptyString()]
42
+ [string] $Value
43
+ )
44
+
45
+ if ([string]::IsNullOrWhiteSpace($Value)) {
46
+ return $false
47
+ }
48
+ $trimmed = $Value.Trim()
49
+ # Sentinel placeholders are explicitly rejected even though they are
50
+ # non-empty strings; the comparison is case-insensitive.
51
+ if ($script:CompletionEvidenceSentinels -contains $trimmed.ToLowerInvariant()) {
52
+ return $false
53
+ }
54
+ return $trimmed -match '^\d+$'
55
+ }
56
+
57
+ function Test-IsValidFeatureFolder {
58
+ <#
59
+ .SYNOPSIS
60
+ Returns $true only for a sentinel-free feature folder anchored under
61
+ docs/features/active/ with a non-empty trailing segment that exists.
62
+ .DESCRIPTION
63
+ Returns $false when the value is empty, whitespace-only, or a sentinel
64
+ (n/a, none, tbd; case-insensitive). Requires the value to start with
65
+ 'docs/features/active/' and to carry at least one additional non-empty
66
+ path segment after that prefix. Invokes the injectable FolderExistsCheck
67
+ scriptblock (default Test-Path -PathType Container) and returns $false
68
+ when it reports the folder does not exist.
69
+ #>
70
+ [CmdletBinding()]
71
+ [OutputType([bool])]
72
+ param(
73
+ [Parameter(Mandatory = $true)]
74
+ [AllowNull()]
75
+ [AllowEmptyString()]
76
+ [string] $Value,
77
+
78
+ [Parameter(Mandatory = $false)]
79
+ [scriptblock] $FolderExistsCheck = { param($p) Test-Path -LiteralPath $p -PathType Container }
80
+ )
81
+
82
+ if ([string]::IsNullOrWhiteSpace($Value)) {
83
+ return $false
84
+ }
85
+ $trimmed = $Value.Trim()
86
+ if ($script:CompletionEvidenceSentinels -contains $trimmed.ToLowerInvariant()) {
87
+ return $false
88
+ }
89
+
90
+ $prefix = 'docs/features/active/'
91
+ $normalized = $trimmed -replace '\\', '/'
92
+ if (-not $normalized.StartsWith($prefix)) {
93
+ return $false
94
+ }
95
+
96
+ # Require a non-empty segment after the active/ prefix so the bare prefix is
97
+ # not accepted as a valid folder.
98
+ $suffix = $normalized.Substring($prefix.Length).TrimEnd('/')
99
+ if ([string]::IsNullOrWhiteSpace($suffix)) {
100
+ return $false
101
+ }
102
+
103
+ return [bool](& $FolderExistsCheck $normalized)
104
+ }
105
+
106
+ function Test-RouteRequiresPrGate {
107
+ <#
108
+ .SYNOPSIS
109
+ Returns $true when the payload's selected route opts into the PR gate.
110
+ .DESCRIPTION
111
+ Resolves the route id from the payload (route_id, falling back to
112
+ path_selected), looks it up in the routing matrix returned by the
113
+ injectable RoutingMatrixReader seam, and returns $true only when that
114
+ route's requires_pr_gate value is the boolean $true. A missing route id,
115
+ an unknown route, a matrix without routes, or a missing/false
116
+ requires_pr_gate returns $false. This generalizes the former issue-232
117
+ special-casing into a route-driven check.
118
+ #>
119
+ [CmdletBinding()]
120
+ [OutputType([bool])]
121
+ param(
122
+ [Parameter(Mandatory)]
123
+ [AllowNull()]
124
+ $Payload,
125
+
126
+ [Parameter(Mandatory = $false)]
127
+ [scriptblock] $RoutingMatrixReader = {
128
+ $configPath = Join-Path $PSScriptRoot '../../config/orchestration-routing.json'
129
+ if (-not (Test-Path -LiteralPath $configPath)) { return $null }
130
+ Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
131
+ }
132
+ )
133
+
134
+ if ($null -eq $Payload) {
135
+ return $false
136
+ }
137
+
138
+ # Resolve the selected route id, preferring route_id over path_selected.
139
+ $routeId = ''
140
+ if ($Payload.PSObject.Properties.Name -contains 'route_id') {
141
+ $routeId = ([string]$Payload.route_id).Trim()
142
+ }
143
+ if (-not $routeId -and ($Payload.PSObject.Properties.Name -contains 'path_selected')) {
144
+ $routeId = ([string]$Payload.path_selected).Trim()
145
+ }
146
+ if (-not $routeId) {
147
+ return $false
148
+ }
149
+
150
+ $matrix = & $RoutingMatrixReader
151
+ if ($null -eq $matrix -or -not ($matrix.PSObject.Properties.Name -contains 'routes')) {
152
+ return $false
153
+ }
154
+ $routes = $matrix.routes
155
+ if ($null -eq $routes -or -not ($routes.PSObject.Properties.Name -contains $routeId)) {
156
+ return $false
157
+ }
158
+ $route = $routes.$routeId
159
+ if ($null -eq $route -or -not ($route.PSObject.Properties.Name -contains 'requires_pr_gate')) {
160
+ return $false
161
+ }
162
+ return ([bool]$route.requires_pr_gate -eq $true)
163
+ }