@danmoisan/drm-copilot-mcp 1.1.5 → 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/mcp-server.js +49 -14
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/atomic-planner.md +2 -0
- package/resources/claude-customizations/.claude/agents/prd-feature.md +5 -0
- package/resources/claude-customizations/.claude/agents/task-researcher.md +3 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +163 -18
- package/resources/claude-customizations/.claude/hooks/validate-planner-output.ps1 +122 -0
- package/resources/claude-customizations/.claude/hooks/validate-prd-feature-output.ps1 +91 -0
- package/resources/claude-customizations/.claude/hooks/validate-task-researcher-output.ps1 +69 -1
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +11 -0
- package/resources/claude-customizations/.claude/lib/requirements/GeneratedDocumentCounters.psm1 +32 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +1 -0
- package/resources/claude-customizations/.claude/settings.json +9 -0
- package/resources/claude-customizations/.claude/skills/acceptance-criteria-tracking/SKILL.md +2 -0
- package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +32 -0
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +136 -4
- package/resources/claude-customizations/.claude/skills/fill-feature-docs/SKILL.md +3 -0
- package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +5 -1
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +18 -9
- package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +10 -1
- package/resources/claude-customizations/.claude/skills/pr-context-artifacts/SKILL.md +22 -0
- package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +12 -0
- package/resources/claude-customizations/.claude/skills/research-issue/SKILL.md +3 -0
- package/resources/claude-customizations/pack-manifests/core.json +2 -0
- package/resources/codex-and-agents-customizations/.agents/skills/pr-context-artifacts/SKILL.md +22 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/customizations/.github/skills/pr-context-artifacts/SKILL.md +22 -0
package/out/mcp-server.js
CHANGED
|
@@ -31365,10 +31365,16 @@ function parseSection3(markdown, heading) {
|
|
|
31365
31365
|
function formatDiffPath2(pathText) {
|
|
31366
31366
|
return pathText !== null ? formatDiffPath(pathText) : "";
|
|
31367
31367
|
}
|
|
31368
|
-
|
|
31368
|
+
var GENERATED_CONTEXT_SECTION_TITLE = "Context generated";
|
|
31369
|
+
var HEAD_SHA_LABEL = "Head SHA:";
|
|
31370
|
+
var UNKNOWN_HEAD_SHA_PLACEHOLDER = "(unknown)";
|
|
31371
|
+
function appendGenerationTimestamp(clock = () => /* @__PURE__ */ new Date(), headSha = null) {
|
|
31369
31372
|
const now = clock();
|
|
31370
31373
|
const timestamp = formatUtcTimestamp(now);
|
|
31371
|
-
|
|
31374
|
+
const shaText = headSha !== null && headSha !== "" ? headSha : UNKNOWN_HEAD_SHA_PLACEHOLDER;
|
|
31375
|
+
return section(GENERATED_CONTEXT_SECTION_TITLE) + "\n" + timestamp + `
|
|
31376
|
+
${HEAD_SHA_LABEL} ${shaText}
|
|
31377
|
+
`;
|
|
31372
31378
|
}
|
|
31373
31379
|
function formatUtcTimestamp(date3) {
|
|
31374
31380
|
const year = date3.getUTCFullYear().toString().padStart(4, "0");
|
|
@@ -31716,7 +31722,7 @@ function renderVerificationEvidenceSection(fs10, resolvedRoot, featureDocs) {
|
|
|
31716
31722
|
}
|
|
31717
31723
|
return lines.join("\n");
|
|
31718
31724
|
}
|
|
31719
|
-
function buildSummaryText(collected, fs10, appendixPath) {
|
|
31725
|
+
function buildSummaryText(collected, fs10, appendixPath, generatedSection) {
|
|
31720
31726
|
const ctx = collected.contextResult;
|
|
31721
31727
|
const ghStatusText = resolveGhStatusText(collected);
|
|
31722
31728
|
const intentBlock = [
|
|
@@ -31727,6 +31733,7 @@ function buildSummaryText(collected, fs10, appendixPath) {
|
|
|
31727
31733
|
"Author-asserted autoclose issues:"
|
|
31728
31734
|
].join("\n");
|
|
31729
31735
|
const summarySections = [
|
|
31736
|
+
generatedSection,
|
|
31730
31737
|
section("GitHub CLI status"),
|
|
31731
31738
|
ghStatusText,
|
|
31732
31739
|
intentBlock,
|
|
@@ -31804,7 +31811,7 @@ function buildSummaryText(collected, fs10, appendixPath) {
|
|
|
31804
31811
|
}
|
|
31805
31812
|
return summaryText;
|
|
31806
31813
|
}
|
|
31807
|
-
function buildAppendixText(collected,
|
|
31814
|
+
function buildAppendixText(collected, generatedSection) {
|
|
31808
31815
|
const featureBlock = collected.featureDocs.map((doc) => doc.excerpt).join("\n");
|
|
31809
31816
|
const issueSections = collected.issueDetails.map(
|
|
31810
31817
|
(detail) => issueAppendix(detail)
|
|
@@ -31813,7 +31820,7 @@ function buildAppendixText(collected, clock) {
|
|
|
31813
31820
|
(detail) => prAppendix(detail)
|
|
31814
31821
|
);
|
|
31815
31822
|
const appendixParts = [
|
|
31816
|
-
|
|
31823
|
+
generatedSection,
|
|
31817
31824
|
collected.contextResult.text,
|
|
31818
31825
|
"",
|
|
31819
31826
|
section("Issue details"),
|
|
@@ -31846,17 +31853,23 @@ function writeOutput(fs10, text, outPath, append2) {
|
|
|
31846
31853
|
function collectAndWrite(options) {
|
|
31847
31854
|
const clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
31848
31855
|
const collected = collectPrContext(options);
|
|
31856
|
+
const generatedSection = appendGenerationTimestamp(
|
|
31857
|
+
clock,
|
|
31858
|
+
collected.contextResult.headSha
|
|
31859
|
+
);
|
|
31849
31860
|
const summaryText = buildSummaryText(
|
|
31850
31861
|
collected,
|
|
31851
31862
|
options.fs,
|
|
31852
|
-
options.appendixOut
|
|
31863
|
+
options.appendixOut,
|
|
31864
|
+
generatedSection
|
|
31853
31865
|
);
|
|
31854
|
-
const appendixText = buildAppendixText(collected,
|
|
31866
|
+
const appendixText = buildAppendixText(collected, generatedSection);
|
|
31855
31867
|
writeOutput(options.fs, summaryText, options.out, options.append);
|
|
31856
31868
|
writeOutput(options.fs, appendixText, options.appendixOut, options.append);
|
|
31857
31869
|
const log = options.log ?? (() => void 0);
|
|
31858
31870
|
log(`Wrote context summary to: ${options.out}`);
|
|
31859
31871
|
log(`Wrote context appendix to: ${options.appendixOut}`);
|
|
31872
|
+
return { summaryText, appendixText };
|
|
31860
31873
|
}
|
|
31861
31874
|
function resolveGhStatusText(collected) {
|
|
31862
31875
|
let ghStatusText = collected.ghStatusOverride || collected.ghStatusMessage || "GitHub CLI authenticated.";
|
|
@@ -31928,26 +31941,48 @@ function parentDir(path11) {
|
|
|
31928
31941
|
// ../../extensions/drm-copilot/src/lib/pr-context/pr-context-service-call.ts
|
|
31929
31942
|
var SUMMARY_OUT = "artifacts/pr_context.summary.txt";
|
|
31930
31943
|
var APPENDIX_OUT = "artifacts/pr_context.appendix.txt";
|
|
31944
|
+
function verifyWrittenArtifact(fileSystem, artifactPath, expected) {
|
|
31945
|
+
let actual;
|
|
31946
|
+
try {
|
|
31947
|
+
actual = fileSystem.readTextFile(artifactPath);
|
|
31948
|
+
} catch (error2) {
|
|
31949
|
+
const detail = error2 instanceof Error ? error2.message : String(error2);
|
|
31950
|
+
throw new Error(
|
|
31951
|
+
`Failed to verify PR context artifact '${artifactPath}': the file could not be read back after writing (${detail}).`,
|
|
31952
|
+
{ cause: error2 }
|
|
31953
|
+
);
|
|
31954
|
+
}
|
|
31955
|
+
if (actual !== expected) {
|
|
31956
|
+
throw new Error(
|
|
31957
|
+
`Failed to verify PR context artifact '${artifactPath}': the content read back is not the content this invocation rendered (expected ${String(expected.length)} characters, read back ${String(actual.length)}).`
|
|
31958
|
+
);
|
|
31959
|
+
}
|
|
31960
|
+
}
|
|
31931
31961
|
function collectPrContextServiceCall(input) {
|
|
31932
|
-
|
|
31962
|
+
const summaryOut = normalizeGeneratedPath(
|
|
31963
|
+
(0, import_node_path.join)(input.workspaceRoot, SUMMARY_OUT)
|
|
31964
|
+
);
|
|
31965
|
+
const appendixOut = normalizeGeneratedPath(
|
|
31966
|
+
(0, import_node_path.join)(input.workspaceRoot, APPENDIX_OUT)
|
|
31967
|
+
);
|
|
31968
|
+
const rendered = collectAndWrite({
|
|
31933
31969
|
base: input.base,
|
|
31934
31970
|
repoRoot: input.workspaceRoot,
|
|
31935
|
-
out:
|
|
31936
|
-
appendixOut
|
|
31971
|
+
out: summaryOut,
|
|
31972
|
+
appendixOut,
|
|
31937
31973
|
append: false,
|
|
31938
31974
|
includeUntracked: true,
|
|
31939
31975
|
fs: input.fileSystem,
|
|
31940
31976
|
runner: input.runner,
|
|
31941
31977
|
...input.log === void 0 ? {} : { log: input.log }
|
|
31942
31978
|
});
|
|
31979
|
+
verifyWrittenArtifact(input.fileSystem, summaryOut, rendered.summaryText);
|
|
31980
|
+
verifyWrittenArtifact(input.fileSystem, appendixOut, rendered.appendixText);
|
|
31943
31981
|
return {
|
|
31944
31982
|
tool: "collect_pr_context",
|
|
31945
31983
|
workspaceRoot: input.workspaceRoot,
|
|
31946
31984
|
summary: `Collected PR context against base '${input.base}'.`,
|
|
31947
|
-
artifacts: [
|
|
31948
|
-
normalizeGeneratedPath((0, import_node_path.join)(input.workspaceRoot, SUMMARY_OUT)),
|
|
31949
|
-
normalizeGeneratedPath((0, import_node_path.join)(input.workspaceRoot, APPENDIX_OUT))
|
|
31950
|
-
]
|
|
31985
|
+
artifacts: [summaryOut, appendixOut]
|
|
31951
31986
|
};
|
|
31952
31987
|
}
|
|
31953
31988
|
|
package/package.json
CHANGED
|
@@ -57,6 +57,8 @@ Generate plans using the atomic plan contract defined in the `atomic-plan-contra
|
|
|
57
57
|
|
|
58
58
|
## Preflight Validation
|
|
59
59
|
|
|
60
|
+
Before handing the plan to executor preflight, return the exact bounded line record defined in `atomic-plan-contract`, starting with `PLANNER-INTERNAL-REVIEW: PASS` and ending with the required `PREFLIGHT:` signal. The bounded record must include exactly one passing `CITATION-TO-TREE`, `AC-TRACEABILITY`, and `SCOPE-BOUNDARY` declaration; one or more current-tree `CITATION: <repository-relative path> | <locator>` records; one complete unique `AC-INVENTORY:`; one complete `AC-MAPPING: <ID> | IMPLEMENTATION: <identifier> | TESTS: <identifier> | EVIDENCE: <identifier>` for every and only inventory ID; and exactly one `UNRESOLVED-GAPS: NONE`. A missing, duplicate, malformed, failed, blocked, or out-of-bounds record declaration requires `SELF-REVIEW: BLOCKED` and stops handoff.
|
|
61
|
+
|
|
60
62
|
Return the finalized plan for validation-only preflight through `atomic-executor` and preserve the same target file path across revision loops. Do not claim nested worker delegation from within planner execution.
|
|
61
63
|
|
|
62
64
|
## Output
|
|
@@ -16,6 +16,8 @@ hooks:
|
|
|
16
16
|
hooks:
|
|
17
17
|
- type: command
|
|
18
18
|
command: pwsh -NoProfile -File .claude/hooks/validate-required-artifact-output.ps1 -AgentName prd-feature -RequiredArtifact 'spec-path|^docs/features/active/.+/spec\.md$|feature spec artifact' -RequiredArtifact 'user-story-path|^docs/features/active/.+/user-story\.md$|feature user story artifact'
|
|
19
|
+
- type: command
|
|
20
|
+
command: pwsh -NoProfile -File .claude/hooks/validate-prd-feature-output.ps1
|
|
19
21
|
---
|
|
20
22
|
|
|
21
23
|
# PRD Feature Agent
|
|
@@ -27,12 +29,15 @@ Produce feature-document outputs for the active feature folder.
|
|
|
27
29
|
- `docs/features/active/<feature>/spec.md`
|
|
28
30
|
- `docs/features/active/<feature>/user-story.md`
|
|
29
31
|
|
|
32
|
+
When an approved `spec.md` acceptance criterion contains a numeric count, enumeration, or population, require the supplied research record to include complete `## Numeric Derivation Evidence`: `Complete Family`, `Exhaustive Search Scope`, `Inclusion Rules`, `Exclusion Rules`, `Primary Search Strategy or Query Expression`, `Primary Member Set`, `Primary Count`, `Cross-check Search Strategy or Query Expression`, `Cross-check Member Set`, `Cross-check Count`, and `Member-set Comparison`. The two derivations must be non-empty, independently constructed, use distinct search strategies or query expressions, independently enumerate member sets, and explicitly compare those sets. The exhaustive scope must cover the complete family, including all relevant overloads and members. Omit the numeric assertion when the record is missing, repeated, incomplete, non-exhaustive, narrow, or disagrees; equal totals, distinct query text, or equal member sets alone are insufficient.
|
|
33
|
+
|
|
30
34
|
## Output Reporting
|
|
31
35
|
|
|
32
36
|
Report the final artifact paths as:
|
|
33
37
|
|
|
34
38
|
- `spec-path: docs/features/active/<feature>/spec.md`
|
|
35
39
|
- `user-story-path: docs/features/active/<feature>/user-story.md`
|
|
40
|
+
- `research-path: docs/features/active/<feature>/research/<timestamp>-<short-name>-research.md` when numeric acceptance criteria are present
|
|
36
41
|
|
|
37
42
|
## Evidence Location Invariant
|
|
38
43
|
|
|
@@ -64,6 +64,8 @@ The orchestrator resolves which root to use from whether an active feature folde
|
|
|
64
64
|
### 4. Requirements Mapping
|
|
65
65
|
|
|
66
66
|
- Map acceptance criteria into a concrete design with proposed state model, transitions, and required file changes.
|
|
67
|
+
- Before a numeric count, enumeration, or population can be proposed for an approved `spec.md` acceptance criterion, add a complete `## Numeric Derivation Evidence` section. For each numeric claim, record `Complete Family`, `Exhaustive Search Scope`, `Inclusion Rules`, `Exclusion Rules`, `Primary Search Strategy or Query Expression`, `Primary Member Set`, `Primary Count`, `Cross-check Search Strategy or Query Expression`, `Cross-check Member Set`, `Cross-check Count`, and `Member-set Comparison`.
|
|
68
|
+
- The primary and cross-check records must both be non-empty, independently enumerate their member sets, and name distinct search strategies or query expressions. The exhaustive scope must cover the complete family, including every relevant overload or member; a single grep or query that matches only one named pattern in the declared family is insufficient even if it yields the same count. Explicitly compare the normalized primary and cross-check member sets before proposing the numeric assertion. Withhold the assertion if either record is missing, incomplete, duplicated, non-exhaustive, narrow, or disagrees.
|
|
67
69
|
|
|
68
70
|
### 5. Testing Implications
|
|
69
71
|
|
|
@@ -75,6 +77,7 @@ The orchestrator resolves which root to use from whether an active feature folde
|
|
|
75
77
|
- Ground all findings in verified evidence.
|
|
76
78
|
- Keep discussion of non-selected approaches brief.
|
|
77
79
|
- Do not claim nested worker delegation.
|
|
80
|
+
- Do not present a numeric `spec.md` acceptance criterion when the required numeric derivation evidence is absent, incomplete, or has disagreeing counts.
|
|
78
81
|
|
|
79
82
|
## Evidence Location Invariant
|
|
80
83
|
|
package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1
CHANGED
|
@@ -4,14 +4,51 @@
|
|
|
4
4
|
|
|
5
5
|
.DESCRIPTION
|
|
6
6
|
Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
|
|
7
|
-
command runs. Regex-matches git worktree remove against the envelope's
|
|
8
|
-
extracts the target worktree path argument,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
command runs. Regex-matches git worktree remove against the envelope's
|
|
8
|
+
tool_input.command, extracts the target worktree path argument, and allows the
|
|
9
|
+
removal only when one of two checkpoint-only conditions holds. The branches are
|
|
10
|
+
evaluated in this order and are ORed, not ANDed: the first that authorizes wins and
|
|
11
|
+
the second is not consulted.
|
|
12
|
+
|
|
13
|
+
1. Epic path: artifacts/orchestration/epic-orchestrator-state.json carries a
|
|
14
|
+
features[] record whose worktree_path matches the removal target and whose
|
|
15
|
+
merge_status is merged or worktree_removed.
|
|
16
|
+
2. Parallel path: artifacts/orchestration/parallel-orchestrator-state.json has
|
|
17
|
+
route_id == "parallel" and carries an items[] entry whose worktree_path matches
|
|
18
|
+
the removal target and whose merge_status is merged or worktree_removed.
|
|
19
|
+
|
|
20
|
+
Otherwise the command is denied with reason EPIC_WORKTREE_REMOVAL_BLOCKED. The gate
|
|
21
|
+
is fail-closed in every failure mode: neither checkpoint present, either checkpoint
|
|
22
|
+
unparseable, route_id absent or not "parallel", no matching worktree_path, a matched
|
|
23
|
+
record whose merge_status is not in the allowed set, or a matched record carrying no
|
|
24
|
+
merge_status key. The cascade is a disjunction of two positive predicates; no
|
|
25
|
+
negative path returns an allow. An envelope-level anomaly is checked first and denies
|
|
26
|
+
before either checkpoint is read.
|
|
27
|
+
|
|
28
|
+
Both branches key on the worktree PATH because the command names a path, and both
|
|
29
|
+
normalize separators and trim a trailing slash on each side of the comparison, so
|
|
30
|
+
Windows- and POSIX-style paths compare equal. Path is also why the two branches are
|
|
31
|
+
mutually exclusive in practice: an epic run and a parallel run allocate worktrees
|
|
32
|
+
under distinct per-run, per-item paths, so a path recorded in one checkpoint is not a
|
|
33
|
+
path recorded in the other, and adding branch 2 cannot widen what branch 1 authorizes.
|
|
34
|
+
The authorization is a property of the path rather than of the caller, and the safety
|
|
35
|
+
property this gate protects - do not destroy unmerged work - is likewise a property of
|
|
36
|
+
the path's recorded merge state.
|
|
37
|
+
|
|
38
|
+
Accepted residual: artifacts/ is gitignored, so the parallel checkpoint persists after
|
|
39
|
+
a run ends and a stale document remains readable here. For a stale document to
|
|
40
|
+
authorize a removal it should not, a worktree would have to exist at a path
|
|
41
|
+
byte-identical, after normalization, to one it records with merge_status merged or
|
|
42
|
+
worktree_removed. Worktree paths carry a session or timestamp component, so a
|
|
43
|
+
collision is implausible, and where the recorded status is worktree_removed the path
|
|
44
|
+
was already deleted. This is a documented accepted trade, not an unexamined gap; the
|
|
45
|
+
route_id check does not reduce it, because a stale parallel checkpoint legitimately
|
|
46
|
+
declares that route.
|
|
47
|
+
|
|
48
|
+
The sibling gate enforce-parallel-worktree-removal-gate.ps1 fires on the same command
|
|
49
|
+
and owns its own reason prefix. PreToolUse denials are conjunctive, so both gates must
|
|
50
|
+
allow for a removal to proceed; this gate keeps the EPIC_WORKTREE_REMOVAL_BLOCKED
|
|
51
|
+
prefix for both of its branches so transcript attribution stays unambiguous.
|
|
15
52
|
|
|
16
53
|
.NOTES
|
|
17
54
|
Compatible with PowerShell 7+. No external module dependencies. Filesystem reads go
|
|
@@ -24,6 +61,7 @@ param()
|
|
|
24
61
|
|
|
25
62
|
Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
|
|
26
63
|
$script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
|
|
64
|
+
$script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
|
|
27
65
|
$script:AllowedMergeStatuses = @('merged', 'worktree_removed')
|
|
28
66
|
|
|
29
67
|
function Get-EpicWorktreeGateCheckpointContent {
|
|
@@ -44,6 +82,52 @@ function Get-EpicWorktreeGateCheckpointContent {
|
|
|
44
82
|
return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw)
|
|
45
83
|
}
|
|
46
84
|
|
|
85
|
+
function Get-EpicWorktreeGateParallelCheckpointContent {
|
|
86
|
+
<#
|
|
87
|
+
.SYNOPSIS
|
|
88
|
+
Read the raw JSON text of the parallel-orchestrator checkpoint. Tests mock
|
|
89
|
+
this function (read seam).
|
|
90
|
+
.OUTPUTS
|
|
91
|
+
System.String or $null
|
|
92
|
+
#>
|
|
93
|
+
[CmdletBinding()]
|
|
94
|
+
[OutputType([string])]
|
|
95
|
+
param()
|
|
96
|
+
|
|
97
|
+
if (-not (Test-Path -LiteralPath $script:ParallelCheckpointPath -PathType Leaf)) {
|
|
98
|
+
return $null
|
|
99
|
+
}
|
|
100
|
+
return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ConvertFrom-EpicWorktreeGateJson {
|
|
104
|
+
<#
|
|
105
|
+
.SYNOPSIS
|
|
106
|
+
Parse checkpoint JSON text, returning $null on unreadable/invalid content.
|
|
107
|
+
.DESCRIPTION
|
|
108
|
+
Shared by both authorization branches so the two checkpoints are parsed by
|
|
109
|
+
one implementation rather than by duplicated inline logic.
|
|
110
|
+
.PARAMETER Raw
|
|
111
|
+
Raw checkpoint text, or $null when the file does not exist.
|
|
112
|
+
.OUTPUTS
|
|
113
|
+
System.Object or $null
|
|
114
|
+
#>
|
|
115
|
+
[CmdletBinding()]
|
|
116
|
+
param(
|
|
117
|
+
[AllowNull()]
|
|
118
|
+
[string] $Raw
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if ([string]::IsNullOrWhiteSpace($Raw)) {
|
|
122
|
+
return $null
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return ($Raw | ConvertFrom-Json -ErrorAction Stop)
|
|
126
|
+
} catch {
|
|
127
|
+
return $null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
47
131
|
function Get-EpicWorktreeRemovalCommandPath {
|
|
48
132
|
<#
|
|
49
133
|
.SYNOPSIS
|
|
@@ -138,6 +222,70 @@ function Test-EpicWorktreeRemovalAllowed {
|
|
|
138
222
|
return $script:AllowedMergeStatuses -contains ([string]$FeatureRecord.merge_status)
|
|
139
223
|
}
|
|
140
224
|
|
|
225
|
+
function Test-ParallelCheckpointAllowsWorktreeRemoval {
|
|
226
|
+
<#
|
|
227
|
+
.SYNOPSIS
|
|
228
|
+
Decision logic for the parallel-orchestrator checkpoint path (branch 2).
|
|
229
|
+
.DESCRIPTION
|
|
230
|
+
Allows only when the checkpoint declares the parallel route identity and
|
|
231
|
+
carries an items[] entry whose worktree_path matches the removal target and
|
|
232
|
+
whose merge_status is in the allowed set. Every other shape returns $false,
|
|
233
|
+
so the branch is a positive predicate with no negative path to an allow.
|
|
234
|
+
.PARAMETER Checkpoint
|
|
235
|
+
Parsed parallel-orchestrator checkpoint, or $null when absent/unreadable.
|
|
236
|
+
.PARAMETER WorktreePath
|
|
237
|
+
The target worktree path extracted from the command text.
|
|
238
|
+
.OUTPUTS
|
|
239
|
+
System.Boolean
|
|
240
|
+
#>
|
|
241
|
+
[CmdletBinding()]
|
|
242
|
+
[OutputType([bool])]
|
|
243
|
+
param(
|
|
244
|
+
[AllowNull()]
|
|
245
|
+
$Checkpoint,
|
|
246
|
+
|
|
247
|
+
[AllowNull()]
|
|
248
|
+
[string] $WorktreePath
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($WorktreePath)) {
|
|
252
|
+
return $false
|
|
253
|
+
}
|
|
254
|
+
$props = @($Checkpoint.PSObject.Properties.Name)
|
|
255
|
+
# Route identity is orchestrator invariant 2, so a document at the parallel path
|
|
256
|
+
# that does not declare it is malformed by the rule's own definition and inert here.
|
|
257
|
+
if ($props -notcontains 'route_id' -or ([string]$Checkpoint.route_id) -ne 'parallel') {
|
|
258
|
+
return $false
|
|
259
|
+
}
|
|
260
|
+
if ($props -notcontains 'items' -or $null -eq $Checkpoint.items) {
|
|
261
|
+
return $false
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
$normalizedTarget = ($WorktreePath -replace '\\', '/').TrimEnd('/')
|
|
265
|
+
|
|
266
|
+
# Scan every recorded item for a worktree_path that matches the removal target;
|
|
267
|
+
# path separators are normalized exactly as the epic branch normalizes them.
|
|
268
|
+
foreach ($item in @($Checkpoint.items)) {
|
|
269
|
+
if ($null -eq $item) {
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
$itemProps = @($item.PSObject.Properties.Name)
|
|
273
|
+
if ($itemProps -notcontains 'worktree_path') {
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
$normalizedItemPath = (([string]$item.worktree_path) -replace '\\', '/').TrimEnd('/')
|
|
277
|
+
if ($normalizedItemPath -ne $normalizedTarget) {
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
if ($itemProps -notcontains 'merge_status') {
|
|
281
|
+
return $false
|
|
282
|
+
}
|
|
283
|
+
return $script:AllowedMergeStatuses -contains ([string]$item.merge_status)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return $false
|
|
287
|
+
}
|
|
288
|
+
|
|
141
289
|
function Get-EpicWorktreeGateAllowDecision {
|
|
142
290
|
[CmdletBinding()]
|
|
143
291
|
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
@@ -202,22 +350,19 @@ function Invoke-EpicWorktreeRemovalGateDecision {
|
|
|
202
350
|
|
|
203
351
|
$worktreePath = Get-EpicWorktreeRemovalCommandPath -CommandText $commandText
|
|
204
352
|
|
|
205
|
-
$
|
|
206
|
-
$checkpoint = $null
|
|
207
|
-
if (-not [string]::IsNullOrWhiteSpace($checkpointRaw)) {
|
|
208
|
-
try {
|
|
209
|
-
$checkpoint = $checkpointRaw | ConvertFrom-Json -ErrorAction Stop
|
|
210
|
-
} catch {
|
|
211
|
-
$checkpoint = $null
|
|
212
|
-
}
|
|
213
|
-
}
|
|
353
|
+
$checkpoint = ConvertFrom-EpicWorktreeGateJson -Raw (Get-EpicWorktreeGateCheckpointContent)
|
|
214
354
|
|
|
215
355
|
$featureRecord = Find-EpicWorktreeFeatureRecord -Checkpoint $checkpoint -WorktreePath $worktreePath
|
|
216
356
|
if (Test-EpicWorktreeRemovalAllowed -FeatureRecord $featureRecord) {
|
|
217
357
|
return Get-EpicWorktreeGateAllowDecision
|
|
218
358
|
}
|
|
219
359
|
|
|
220
|
-
|
|
360
|
+
$parallelCheckpoint = ConvertFrom-EpicWorktreeGateJson -Raw (Get-EpicWorktreeGateParallelCheckpointContent)
|
|
361
|
+
if (Test-ParallelCheckpointAllowsWorktreeRemoval -Checkpoint $parallelCheckpoint -WorktreePath $worktreePath) {
|
|
362
|
+
return Get-EpicWorktreeGateAllowDecision
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return Get-EpicWorktreeGateBlockDecision -Reason "EPIC_WORKTREE_REMOVAL_BLOCKED: git worktree remove for '$worktreePath' requires either an epic checkpoint features[] record with merge_status in {merged, worktree_removed}, or a parallel-orchestrator checkpoint with route_id == ""parallel"" whose matching items[] record (matched by worktree_path) has merge_status in {merged, worktree_removed}. No checkpoint authorized this removal."
|
|
221
366
|
}
|
|
222
367
|
|
|
223
368
|
function Invoke-EpicWorktreeRemovalGateEntryPoint {
|
|
@@ -110,6 +110,123 @@ function Test-HasPreflightSignal {
|
|
|
110
110
|
)
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
function Get-PlannerInternalReviewValidation {
|
|
114
|
+
[CmdletBinding()]
|
|
115
|
+
[OutputType([hashtable])]
|
|
116
|
+
param([Parameter(Mandatory = $true)][string] $AgentOutput)
|
|
117
|
+
|
|
118
|
+
$lines = @($AgentOutput -split "`r?`n")
|
|
119
|
+
$labelPattern = '^\s*(?:PLANNER-INTERNAL-REVIEW|CITATION-TO-TREE|AC-TRACEABILITY|SCOPE-BOUNDARY|CITATION|AC-INVENTORY|AC-MAPPING|UNRESOLVED-GAPS|PREFLIGHT)\s*:'
|
|
120
|
+
$headerPattern = '^\s*PLANNER-INTERNAL-REVIEW\s*:\s*(?<Value>.*?)\s*$'
|
|
121
|
+
$preflightPattern = '^\s*PREFLIGHT\s*:\s*(?:ALL CLEAR|REVISIONS REQUIRED)\s*$'
|
|
122
|
+
$labelIndexes = [System.Collections.Generic.List[int]]::new()
|
|
123
|
+
$headerIndexes = [System.Collections.Generic.List[int]]::new()
|
|
124
|
+
$preflightIndexes = [System.Collections.Generic.List[int]]::new()
|
|
125
|
+
|
|
126
|
+
for ($index = 0; $index -lt $lines.Count; $index++) {
|
|
127
|
+
if ($lines[$index] -match $labelPattern) {
|
|
128
|
+
$labelIndexes.Add($index)
|
|
129
|
+
}
|
|
130
|
+
if ($lines[$index] -match $headerPattern) {
|
|
131
|
+
$headerIndexes.Add($index)
|
|
132
|
+
}
|
|
133
|
+
if ($lines[$index] -match $preflightPattern) {
|
|
134
|
+
$preflightIndexes.Add($index)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if ($headerIndexes.Count -ne 1) {
|
|
139
|
+
return @{ Ok = $false; Message = 'planner internal review must contain exactly one `PLANNER-INTERNAL-REVIEW:` declaration.' }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
$headerIndex = $headerIndexes[0]
|
|
143
|
+
$headerMatch = [regex]::Match($lines[$headerIndex], $headerPattern)
|
|
144
|
+
if ($headerMatch.Groups['Value'].Value.Trim() -ne 'PASS') {
|
|
145
|
+
return @{ Ok = $false; Message = 'planner internal review header must be exactly `PLANNER-INTERNAL-REVIEW: PASS`.' }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
$preflightAfterHeader = @($preflightIndexes | Where-Object { $_ -gt $headerIndex })
|
|
149
|
+
if ($preflightAfterHeader.Count -ne 1) {
|
|
150
|
+
return @{ Ok = $false; Message = 'planner internal review must terminate at exactly one required `PREFLIGHT:` signal after its header.' }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
$preflightIndex = $preflightAfterHeader[0]
|
|
154
|
+
foreach ($labelIndex in $labelIndexes) {
|
|
155
|
+
if ($labelIndex -lt $headerIndex -or $labelIndex -gt $preflightIndex) {
|
|
156
|
+
return @{ Ok = $false; Message = 'planner internal review declarations must not occur outside the bounded record.' }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
$recordLines = @($lines[$headerIndex..$preflightIndex])
|
|
161
|
+
$requiredDeclarations = @('CITATION-TO-TREE', 'AC-TRACEABILITY', 'SCOPE-BOUNDARY')
|
|
162
|
+
foreach ($declaration in $requiredDeclarations) {
|
|
163
|
+
$declarationMatches = @($recordLines | Where-Object { $_ -match "^\s*$declaration\s*:" })
|
|
164
|
+
if ($declarationMatches.Count -ne 1) {
|
|
165
|
+
return @{ Ok = $false; Message = "planner internal review must contain exactly one `$declaration: PASS` declaration." }
|
|
166
|
+
}
|
|
167
|
+
if ($declarationMatches[0] -notmatch "^\s*$declaration\s*:\s*PASS\s*$") {
|
|
168
|
+
return @{ Ok = $false; Message = "planner internal review declaration `$declaration must be exactly PASS." }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
$citationLines = @($recordLines | Where-Object { $_ -match '^\s*CITATION\s*:' })
|
|
173
|
+
if ($citationLines.Count -eq 0) {
|
|
174
|
+
return @{ Ok = $false; Message = 'planner internal review must contain at least one `CITATION:` record.' }
|
|
175
|
+
}
|
|
176
|
+
foreach ($citationLine in $citationLines) {
|
|
177
|
+
$citation = [regex]::Match($citationLine, '^\s*CITATION\s*:\s*(?<Path>[^|\s]+)\s*\|\s*(?<Locator>.+?\S)\s*$')
|
|
178
|
+
if (-not $citation.Success -or $citation.Groups['Path'].Value -notmatch '^(?![A-Za-z]:)(?!/)(?!\\)(?:\.?[^/\\|\s]+)(?:/[^/\\|\s]+)+$') {
|
|
179
|
+
return @{ Ok = $false; Message = 'planner internal review citations require a repository-relative path and nonblank locator.' }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
$inventoryLines = @($recordLines | Where-Object { $_ -match '^\s*AC-INVENTORY\s*:' })
|
|
184
|
+
if ($inventoryLines.Count -ne 1) {
|
|
185
|
+
return @{ Ok = $false; Message = 'planner internal review must contain exactly one nonblank `AC-INVENTORY:` declaration.' }
|
|
186
|
+
}
|
|
187
|
+
$inventoryValue = ($inventoryLines[0] -replace '^\s*AC-INVENTORY\s*:\s*', '').Trim()
|
|
188
|
+
if ([string]::IsNullOrWhiteSpace($inventoryValue)) {
|
|
189
|
+
return @{ Ok = $false; Message = 'planner internal review `AC-INVENTORY:` must contain nonblank unique IDs.' }
|
|
190
|
+
}
|
|
191
|
+
$inventoryIds = @($inventoryValue -split ',' | ForEach-Object { $_.Trim() })
|
|
192
|
+
if ((@($inventoryIds | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) -or
|
|
193
|
+
(@($inventoryIds | Select-Object -Unique).Count -ne $inventoryIds.Count)) {
|
|
194
|
+
return @{ Ok = $false; Message = 'planner internal review `AC-INVENTORY:` must contain nonblank unique IDs.' }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
$mappingLines = @($recordLines | Where-Object { $_ -match '^\s*AC-MAPPING\s*:' })
|
|
198
|
+
if ($mappingLines.Count -eq 0) {
|
|
199
|
+
return @{ Ok = $false; Message = 'planner internal review must contain one `AC-MAPPING:` record for every inventory ID.' }
|
|
200
|
+
}
|
|
201
|
+
$mappingIds = [System.Collections.Generic.List[string]]::new()
|
|
202
|
+
foreach ($mappingLine in $mappingLines) {
|
|
203
|
+
$mapping = [regex]::Match($mappingLine, '^\s*AC-MAPPING\s*:\s*(?<Id>[^|]*?)\s*\|\s*IMPLEMENTATION\s*:\s*(?<Implementation>[^|]*?)\s*\|\s*TESTS\s*:\s*(?<Tests>[^|]*?)\s*\|\s*EVIDENCE\s*:\s*(?<Evidence>.*?)\s*$')
|
|
204
|
+
if (-not $mapping.Success -or
|
|
205
|
+
[string]::IsNullOrWhiteSpace($mapping.Groups['Id'].Value) -or
|
|
206
|
+
[string]::IsNullOrWhiteSpace($mapping.Groups['Implementation'].Value) -or
|
|
207
|
+
[string]::IsNullOrWhiteSpace($mapping.Groups['Tests'].Value) -or
|
|
208
|
+
[string]::IsNullOrWhiteSpace($mapping.Groups['Evidence'].Value)) {
|
|
209
|
+
return @{ Ok = $false; Message = 'planner internal review `AC-MAPPING:` requires nonblank ID, IMPLEMENTATION, TESTS, and EVIDENCE fields.' }
|
|
210
|
+
}
|
|
211
|
+
$mappingIds.Add($mapping.Groups['Id'].Value.Trim())
|
|
212
|
+
}
|
|
213
|
+
if (@($mappingIds | Select-Object -Unique).Count -ne $mappingIds.Count) {
|
|
214
|
+
return @{ Ok = $false; Message = 'planner internal review `AC-MAPPING:` identifiers must be unique.' }
|
|
215
|
+
}
|
|
216
|
+
if ($inventoryIds.Count -ne $mappingIds.Count -or
|
|
217
|
+
(@($inventoryIds | Where-Object { $_ -notin $mappingIds }).Count -gt 0) -or
|
|
218
|
+
(@($mappingIds | Where-Object { $_ -notin $inventoryIds }).Count -gt 0)) {
|
|
219
|
+
return @{ Ok = $false; Message = 'planner internal review AC inventory and mapping identifiers must match exactly.' }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
$gapLines = @($recordLines | Where-Object { $_ -match '^\s*UNRESOLVED-GAPS\s*:' })
|
|
223
|
+
if ($gapLines.Count -ne 1 -or $gapLines[0] -notmatch '^\s*UNRESOLVED-GAPS\s*:\s*NONE\s*$') {
|
|
224
|
+
return @{ Ok = $false; Message = 'planner internal review must contain exactly one `UNRESOLVED-GAPS: NONE` declaration.' }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return @{ Ok = $true; Message = $null }
|
|
228
|
+
}
|
|
229
|
+
|
|
113
230
|
function Get-PlanStructureValidationReport {
|
|
114
231
|
[CmdletBinding()]
|
|
115
232
|
[OutputType([string[]])]
|
|
@@ -272,6 +389,11 @@ function Invoke-PlannerOutputValidation {
|
|
|
272
389
|
return @{ Ok = $false; Message = $message }
|
|
273
390
|
}
|
|
274
391
|
|
|
392
|
+
$review = Get-PlannerInternalReviewValidation -AgentOutput $agentOutput
|
|
393
|
+
if (-not $review.Ok) {
|
|
394
|
+
return @{ Ok = $false; Message = "atomic-planner hook: $($review.Message)" }
|
|
395
|
+
}
|
|
396
|
+
|
|
275
397
|
return @{ Ok = $true; Message = $null }
|
|
276
398
|
}
|
|
277
399
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Validates numeric acceptance criteria produced by the prd-feature worker.
|
|
4
|
+
#>
|
|
5
|
+
|
|
6
|
+
[CmdletBinding()]
|
|
7
|
+
param()
|
|
8
|
+
|
|
9
|
+
Set-StrictMode -Version Latest
|
|
10
|
+
$ErrorActionPreference = 'Stop'
|
|
11
|
+
|
|
12
|
+
function Get-ArtifactPathFromOutput {
|
|
13
|
+
[CmdletBinding()]
|
|
14
|
+
[OutputType([string])]
|
|
15
|
+
param([Parameter(Mandatory = $true)][string] $Output, [Parameter(Mandatory = $true)][string] $Label)
|
|
16
|
+
|
|
17
|
+
$match = [regex]::Match($Output, "(?im)^\s*$([regex]::Escape($Label))\s*:\s*(?<path>\S+)")
|
|
18
|
+
if ($match.Success) { return $match.Groups['path'].Value }
|
|
19
|
+
return $null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Test-NumericDerivationEvidence {
|
|
23
|
+
[CmdletBinding()]
|
|
24
|
+
[OutputType([hashtable])]
|
|
25
|
+
param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Content)
|
|
26
|
+
|
|
27
|
+
$section = [regex]::Match($Content, '(?ims)^##\s+Numeric\s+Derivation\s+Evidence\s*$.*?(?=^##\s|\z)')
|
|
28
|
+
if (-not $section.Success) { return @{ Ok = $false; Message = 'prd-feature hook: numeric criterion requires ## Numeric Derivation Evidence.' } }
|
|
29
|
+
$requiredLabels = @(
|
|
30
|
+
'Complete Family', 'Exhaustive Search Scope', 'Inclusion Rules', 'Exclusion Rules',
|
|
31
|
+
'Primary Search Strategy or Query Expression', 'Primary Member Set', 'Primary Count',
|
|
32
|
+
'Cross-check Search Strategy or Query Expression', 'Cross-check Member Set', 'Cross-check Count',
|
|
33
|
+
'Member-set Comparison'
|
|
34
|
+
)
|
|
35
|
+
$values = @{}
|
|
36
|
+
foreach ($label in $requiredLabels) {
|
|
37
|
+
$match = [regex]::Match($section.Value, "(?im)^[\t ]*[-*]?[\t ]*$([regex]::Escape($label))[\t ]*:[\t ]*(?<value>\S(?:.*\S)?)[\t ]*$")
|
|
38
|
+
if (-not $match.Success) { return @{ Ok = $false; Message = "prd-feature hook: numeric derivation evidence is missing $label." } }
|
|
39
|
+
$values[$label] = $match.Groups['value'].Value.Trim()
|
|
40
|
+
}
|
|
41
|
+
if ($values['Exhaustive Search Scope'] -notmatch '(?i)\b(entire|all|complete)\b.*\b(repository|repo|source tree|tree)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence does not declare an exhaustive repository search scope.' } }
|
|
42
|
+
$primaryStrategy = $values['Primary Search Strategy or Query Expression']
|
|
43
|
+
$crossCheckStrategy = $values['Cross-check Search Strategy or Query Expression']
|
|
44
|
+
if ($primaryStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b' -or $crossCheckStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence uses a narrow named-pattern search.' } }
|
|
45
|
+
if ([regex]::Replace($primaryStrategy, '\s+', '').ToLowerInvariant() -eq [regex]::Replace($crossCheckStrategy, '\s+', '').ToLowerInvariant()) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation cross-check repeats the primary search strategy or query expression.' } }
|
|
46
|
+
$familyMembers = @($values['Complete Family'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
|
47
|
+
foreach ($familyMember in $familyMembers) {
|
|
48
|
+
if ($primaryStrategy -notmatch [regex]::Escape($familyMember) -or $crossCheckStrategy -notmatch [regex]::Escape($familyMember)) { return @{ Ok = $false; Message = "prd-feature hook: numeric derivation search does not cover complete family member '$familyMember'." } }
|
|
49
|
+
}
|
|
50
|
+
if ($values['Primary Count'] -notmatch '^\d+$' -or $values['Cross-check Count'] -notmatch '^\d+$') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation counts must be numeric.' } }
|
|
51
|
+
$primaryMembers = @($values['Primary Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
|
52
|
+
$crossCheckMembers = @($values['Cross-check Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
|
53
|
+
if ([int]$values['Primary Count'] -ne $primaryMembers.Count -or [int]$values['Cross-check Count'] -ne $crossCheckMembers.Count) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation count does not match its independently enumerated member set.' } }
|
|
54
|
+
$normalizedPrimaryMembers = @($primaryMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|'
|
|
55
|
+
$normalizedCrossCheckMembers = @($crossCheckMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|'
|
|
56
|
+
if ($normalizedPrimaryMembers -ne $normalizedCrossCheckMembers) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation primary and cross-check member sets disagree.' } }
|
|
57
|
+
if ($values['Member-set Comparison'] -notmatch '(?i)\b(equal|match|identical)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence is missing an explicit member-set comparison.' } }
|
|
58
|
+
return @{ Ok = $true; Message = $null }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Test-SpecNumericCriterion {
|
|
62
|
+
[CmdletBinding()]
|
|
63
|
+
[OutputType([bool])]
|
|
64
|
+
param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $SpecContent)
|
|
65
|
+
|
|
66
|
+
$section = [regex]::Match($SpecContent, '(?ims)^##\s+Acceptance\s+Criteria\s*$.*?(?=^##\s|\z)')
|
|
67
|
+
return $section.Success -and [regex]::IsMatch($section.Value, '(?m)^\s*[-*]\s*\[.\].*\b\d+\b')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function Invoke-PrdFeatureOutputValidation {
|
|
71
|
+
[CmdletBinding()]
|
|
72
|
+
[OutputType([hashtable])]
|
|
73
|
+
param([string] $RawPayload)
|
|
74
|
+
|
|
75
|
+
if ([string]::IsNullOrWhiteSpace($RawPayload)) { return @{ Ok = $false; Message = 'prd-feature hook: CLAUDE_HOOK_INPUT is empty.' } }
|
|
76
|
+
try { $payload = $RawPayload | ConvertFrom-Json -ErrorAction Stop } catch { return @{ Ok = $false; Message = "prd-feature hook: failed to parse CLAUDE_HOOK_INPUT as JSON: $($_.Exception.Message)" } }
|
|
77
|
+
$output = if ($null -ne $payload.PSObject.Properties['output']) { $payload.output } else { $null }
|
|
78
|
+
if ([string]::IsNullOrWhiteSpace($output)) { return @{ Ok = $false; Message = 'prd-feature hook: agent output is empty.' } }
|
|
79
|
+
$specPath = Get-ArtifactPathFromOutput -Output $output -Label 'spec-path'
|
|
80
|
+
if ([string]::IsNullOrWhiteSpace($specPath) -or -not (Test-Path -LiteralPath $specPath -PathType Leaf)) { return @{ Ok = $false; Message = 'prd-feature hook: spec-path is required and must exist.' } }
|
|
81
|
+
$specContent = Get-Content -LiteralPath $specPath -Raw -ErrorAction Stop
|
|
82
|
+
if (-not (Test-SpecNumericCriterion -SpecContent $specContent)) { return @{ Ok = $true; Message = $null } }
|
|
83
|
+
$researchPath = Get-ArtifactPathFromOutput -Output $output -Label 'research-path'
|
|
84
|
+
if ([string]::IsNullOrWhiteSpace($researchPath) -or -not (Test-Path -LiteralPath $researchPath -PathType Leaf)) { return @{ Ok = $false; Message = 'prd-feature hook: numeric acceptance criterion requires an existing research-path.' } }
|
|
85
|
+
return Test-NumericDerivationEvidence -Content (Get-Content -LiteralPath $researchPath -Raw -ErrorAction Stop)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if ($MyInvocation.InvocationName -eq '.') { return }
|
|
89
|
+
$result = Invoke-PrdFeatureOutputValidation -RawPayload $env:CLAUDE_HOOK_INPUT
|
|
90
|
+
if (-not $result.Ok) { Write-Error $result.Message; exit 1 }
|
|
91
|
+
exit 0
|