phasegate 0.160.1 → 0.160.3

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.160.3] - 2026-05-14
11
+
12
+ ### Fixed
13
+
14
+ - **WI-187 — doctor no-op repair semantics** — stops `phasegate doctor` from advertising `migrate work-items --apply` as a mechanical repair for `_shared` ad-hoc plan drift, marks the finding manual with `repairHint: null`, and adds a regression flow proving the migration command would apply zero candidates and leave the doctor finding unresolved.
15
+
16
+ ## [0.160.2] - 2026-05-14
17
+
18
+ ### Fixed
19
+
20
+ - **WI-185 / WI-186 / WI-188 — validator and gate trust** — fixes downstream P2 freshness/pointer scans so package-bin execution resolves caller project docs, makes `phasegate:status` JSON fail when enabled live layer validation fails while preserving its informational exit-code contract, and prevents `skill:check-coverage` from launching Vitest or network-prone auto-install paths before story/test prerequisites are validated.
21
+
10
22
  ## [0.160.1] - 2026-05-14
11
23
 
12
24
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.160.1",
3
+ "version": "0.160.3",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  // @layer domain
2
2
  // @unit harness-api
3
- // @work-item-id WI-108 / WI-114
3
+ // @work-item-id WI-108 / WI-114, WI-186
4
4
  // command-dispatch-service.ts — CommandDispatchService Domain Service
5
5
 
6
6
  import { CommandRegistry } from './command-registry.js';
@@ -68,6 +68,16 @@ function summarizeLayerResults(items: readonly { validatorId: string; passed: bo
68
68
  return result;
69
69
  }
70
70
 
71
+ function hasEnabledLiveFailure(
72
+ enabledLayers: readonly LayerId[],
73
+ liveValidationByLayer: Partial<Record<LayerId, LiveValidationState>>,
74
+ ): boolean {
75
+ return enabledLayers.some((layerId) => {
76
+ const liveState = liveValidationByLayer[layerId];
77
+ return liveState === 'fail' || liveState === 'error';
78
+ });
79
+ }
80
+
71
81
  function buildOperationalWarnings(
72
82
  hookHealth: HookHealth | undefined,
73
83
  baselineHealth: BaselineHealth | undefined,
@@ -257,6 +267,13 @@ export class CommandDispatchService {
257
267
  baselineHealth,
258
268
  operationalWarnings,
259
269
  });
270
+ if (hasEnabledLiveFailure(presetInfo.enabledLayers, liveValidationByLayer)) {
271
+ const errors = statusSummary.layers
272
+ .filter((layer) => layer.enabled && (layer.liveValidationState === 'fail' || layer.liveValidationState === 'error'))
273
+ .map((layer) => makeError(`Layer ${layer.layerId} live validation ${layer.liveValidationState}`));
274
+ const r = HarnessApiResponse.fail(errors, { ...summary, failed: 1 }, statusSummary);
275
+ return { status: 'fail', errors: r.errors, summary: r.summary, data: statusSummary as unknown as T, exitCode: 0 };
276
+ }
260
277
  const r = HarnessApiResponse.pass({ ...summary, passed: 1 }, statusSummary);
261
278
  return { status: 'pass', errors: [], summary: r.summary, data: statusSummary as unknown as T, exitCode: 0 };
262
279
  }
@@ -1,6 +1,7 @@
1
1
  // @unit installation
2
2
  // @layer application
3
3
  // @work-item-id WI-143
4
+ // @work-item-id WI-187
4
5
 
5
6
  import { join, relative, sep } from "node:path";
6
7
  import { DiagnosticFinding } from "../../domain/diagnostic-finding.js";
@@ -37,8 +38,8 @@ export class WiWorkflowDriftCheck implements HeuristicCheck {
37
38
  severity: "red",
38
39
  target: "docs/inception",
39
40
  message,
40
- repairMode: "mechanical",
41
- repairHint: "phasegate migrate work-items --apply",
41
+ repairMode: "manual",
42
+ repairHint: null,
42
43
  suggestedSkill: null,
43
44
  });
44
45
  }
@@ -193,6 +193,12 @@ Commands:
193
193
  phasegate:impact-analysis Impact analysis for story (<storyId>, --json)
194
194
  phasegate:generate-matrix Generate requirement-test matrix (--requirements, --tests, --out, --json)
195
195
 
196
+ Gate semantics:
197
+ phasegate:complete-check Gate: lint + all validators; exits 1 on failure
198
+ phasegate:check-ready Gate: story phase readiness; exits 1 on pending/failed stories
199
+ validate --layer <Lx> Gate: requested validator layer; exits 1 on layer failure
200
+ phasegate:status Informational health; JSON status may be fail, exit remains 0 unless command error
201
+
196
202
  ci:generate-template Generate CI template (--preset <id>, default: standard; --type <aidlc-gate|consistency-check|pre-commit|agent-context-refresh>, --render, --json)
197
203
  ci:migrate-agents-md Migrate AGENTS.md (--dry-run, --validate-only, --json)
198
204
  ci:auto-refresh-agent-context Refresh AGENTS.md / CLAUDE.md (--dry-run, --apply, --json)
@@ -219,7 +225,7 @@ Commands:
219
225
  regression:migrate-v0-tests Execute V0 test migration (--confirm)
220
226
 
221
227
  p2:check-freshness Check doc freshness (--pattern <glob>, --dry-run, --format text|json)
222
- p2:validate-pointers Validate doc pointers (--include-urls, --format text|json)
228
+ p2:validate-pointers Validate doc pointers (--pattern <glob>, --include-urls, --format text|json)
223
229
  p2:generate-e2e-template Generate E2E test template (--phase <phase>, --output <path>)
224
230
  p2:check-initial-creation Public compatibility detector for long-lived initial_creation:true docs (--pattern <glob>, --format text|json)
225
231
  hook <pre-tool-use|post-tool-use|stop|session-start|user-prompt-submit> Run agent hook (reads JSON from stdin; writes JSON to stdout for session-start/user-prompt-submit)
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @layer application
3
3
  * @unit phase2-extensions
4
+ * @work-item-id WI-185
4
5
  */
5
6
  import type { CheckDocFreshnessInput } from '../dto/check-doc-freshness-input.js';
6
7
  import type { CheckDocFreshnessOutput } from '../dto/check-doc-freshness-output.js';
@@ -29,18 +30,16 @@ export class CheckDocFreshnessUseCase {
29
30
  async execute(input: CheckDocFreshnessInput): Promise<CheckDocFreshnessOutput> {
30
31
  try {
31
32
  const rules = await this.freshnessConfigPort.loadRules();
32
- const filteredRules = input.targetPattern
33
- ? rules.filter((rule) => rule.documentPattern === input.targetPattern)
34
- : rules;
35
33
 
36
34
  const results = [];
37
35
 
38
- for (const rule of filteredRules) {
36
+ for (const rule of rules) {
39
37
  if (!rule.isEnabled()) {
40
38
  continue;
41
39
  }
42
40
 
43
- const documentPaths = await this.documentScannerPort.scan(rule.documentPattern);
41
+ const scanPattern = input.targetPattern ?? rule.documentPattern;
42
+ const documentPaths = await this.documentScannerPort.scan(scanPattern);
44
43
  for (const documentPath of documentPaths) {
45
44
  const age = await this.documentAgePort.getAge(documentPath);
46
45
  results.push(this.freshnessCheckService.check(rule, age, documentPath));
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @layer application
3
3
  * @unit phase2-extensions
4
- * @work-item-id WI-122
4
+ * @work-item-id WI-122, WI-185
5
5
  */
6
6
  import type { FreshnessConfigPort } from '../../domain/ports/freshness-config-port.js';
7
7
  import type { DocumentScannerPort } from '../../domain/ports/document-scanner-port.js';
@@ -30,16 +30,14 @@ export class ValidateDocPointersUseCase {
30
30
  async execute(input: ValidateDocPointersInput): Promise<ValidateDocPointersOutput> {
31
31
  try {
32
32
  const rules = await this.freshnessConfigPort.loadPointerRules();
33
- const filteredRules = input.targetPattern
34
- ? rules.filter((rule) => rule.documentPattern === input.targetPattern)
35
- : rules;
36
33
 
37
34
  const results = [];
38
35
  let totalDocuments = 0;
39
36
  let skippedUrlPointers = 0;
40
37
 
41
- for (const rule of filteredRules) {
42
- const documentPaths = await this.documentScannerPort.scan(rule.documentPattern);
38
+ for (const rule of rules) {
39
+ const scanPattern = input.targetPattern ?? rule.documentPattern;
40
+ const documentPaths = await this.documentScannerPort.scan(scanPattern);
43
41
  totalDocuments += documentPaths.length;
44
42
 
45
43
  for (const documentPath of documentPaths) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @layer infrastructure
3
3
  * @unit phase2-extensions
4
- * @work-item-id WI-122
4
+ * @work-item-id WI-122, WI-185
5
5
  */
6
6
  import type { HarnessConfigV2 } from '../../../config-foundation/domain/harness-config.js';
7
7
  import { DocFreshnessRule } from '../../domain/aggregates/doc-freshness-rule.js';
@@ -32,16 +32,20 @@ type Phase2RuleConfig = {
32
32
  };
33
33
  };
34
34
 
35
+ function getPhase2RuleConfig(config: HarnessConfigV2 | Phase2RuleConfig | undefined): Phase2RuleConfig['phase2Extensions'] {
36
+ return (config as Phase2RuleConfig | undefined)?.phase2Extensions;
37
+ }
38
+
35
39
  function normalizePathPatternRoot(value: string | undefined): string {
36
40
  const normalized = value?.replace(/\\/g, '/').replace(/\/+$/g, '');
37
- return normalized && normalized.length > 0 ? normalized : 'docs/product/construction';
41
+ return normalized && normalized.length > 0 ? normalized : 'docs';
38
42
  }
39
43
 
40
44
  export class HarnessConfigFreshnessAdapter implements FreshnessConfigPort {
41
45
  constructor(private readonly config?: HarnessConfigV2 | Phase2RuleConfig) {}
42
46
 
43
47
  async loadRules(): Promise<DocFreshnessRule[]> {
44
- const configRules = this.config && 'phase2Extensions' in this.config ? this.config.phase2Extensions?.freshnessRules : undefined;
48
+ const configRules = getPhase2RuleConfig(this.config)?.freshnessRules;
45
49
  if (!configRules || configRules.length === 0) {
46
50
  const designDocsRoot = normalizePathPatternRoot(this.config?.paths?.designDocs);
47
51
  return [
@@ -68,7 +72,7 @@ export class HarnessConfigFreshnessAdapter implements FreshnessConfigPort {
68
72
  }
69
73
 
70
74
  async loadPointerRules(): Promise<PointerRule[]> {
71
- const configRules = this.config && 'phase2Extensions' in this.config ? this.config.phase2Extensions?.pointerRules : undefined;
75
+ const configRules = getPhase2RuleConfig(this.config)?.pointerRules;
72
76
  if (!configRules || configRules.length === 0) {
73
77
  const designDocsRoot = normalizePathPatternRoot(this.config?.paths?.designDocs);
74
78
  return [
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @layer presentation
3
3
  * @unit phase2-extensions
4
+ * @work-item-id WI-185
4
5
  */
5
6
  import type { ValidateDocPointersUseCase } from '../../application/usecases/validate-doc-pointers-usecase.js';
6
7
  import { PointerResultFormatter } from '../formatters/pointer-result-formatter.js';
@@ -13,18 +14,22 @@ export class ValidatePointersHandler {
13
14
  async handle(args: string[]): Promise<{ exitCode: number; stdout: string }> {
14
15
  let includeUrlPointers = false;
15
16
  let format: 'text' | 'json' = 'text';
17
+ let targetPattern: string | undefined;
16
18
 
17
19
  for (let index = 0; index < args.length; index += 1) {
18
20
  const arg = args[index];
19
21
  if (arg === '--include-urls') {
20
22
  includeUrlPointers = true;
23
+ } else if (arg === '--pattern') {
24
+ targetPattern = args[index + 1];
25
+ index += 1;
21
26
  } else if (arg === '--format') {
22
27
  format = (args[index + 1] as 'text' | 'json') ?? 'text';
23
28
  index += 1;
24
29
  }
25
30
  }
26
31
 
27
- const result = await this.useCase.execute({ includeUrlPointers, format });
32
+ const result = await this.useCase.execute({ targetPattern, includeUrlPointers, format });
28
33
  return {
29
34
  exitCode: result.passed ? 0 : 1,
30
35
  stdout: format === 'json' ? this.formatter.formatJson(result) : this.formatter.formatText(result),
@@ -9,4 +9,6 @@ export interface CheckCoverageOutput {
9
9
  readonly meetsThreshold: boolean;
10
10
  readonly requirementThreshold: number;
11
11
  readonly codeThreshold: number;
12
+ readonly skipped?: boolean;
13
+ readonly skipReason?: 'no-tests';
12
14
  }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * @layer application
3
3
  * @unit skill-quality
4
+ * @work-item-id WI-188
4
5
  */
5
6
  import { CoverageReport } from '../../domain/value-objects/coverage-report.js';
6
7
  import { RequirementCoverageResult } from '../../domain/value-objects/requirement-coverage-result.js';
8
+ import { CodeCoverageResult } from '../../domain/value-objects/code-coverage-result.js';
7
9
  import type { RequirementTestMatrixPort } from '../../domain/ports/requirement-test-matrix-port.js';
8
10
  import type { CoverageRunnerPort } from '../../domain/ports/coverage-runner-port.js';
9
11
  import type { ConfigQueryPort } from '../../domain/ports/config-query-port.js';
@@ -25,6 +27,20 @@ export class CheckCoverageUseCase {
25
27
  matrix.covered,
26
28
  matrix.uncoveredIds,
27
29
  );
30
+ if (matrix.total === 0) {
31
+ const coverageReport = CoverageReport.create(
32
+ requirementCoverage,
33
+ CodeCoverageResult.create(100, 100, 100),
34
+ );
35
+ return {
36
+ coverageReport,
37
+ meetsThreshold: true,
38
+ requirementThreshold: threshold.requirement,
39
+ codeThreshold: threshold.code,
40
+ skipped: true,
41
+ skipReason: 'no-tests',
42
+ };
43
+ }
28
44
  const codeCoverage = await this.coverageRunnerPort.run(input.storyId);
29
45
  const coverageReport = CoverageReport.create(requirementCoverage, codeCoverage);
30
46
  const meetsThreshold = coverageReport.meetsThreshold(threshold.requirement, threshold.code);
@@ -28,6 +28,7 @@ export type SkillQualityErrorCode =
28
28
  | "GIT_COMMIT_FAILED"
29
29
  | "COVERAGE_RUN_FAILED"
30
30
  | "MATRIX_FILE_NOT_FOUND"
31
+ | "STORY_NOT_FOUND"
31
32
  | "LESSON_ARTIFACT_SCHEMA_VIOLATION";
32
33
 
33
34
  export class SkillQualityError extends Error {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @layer infrastructure
3
3
  * @unit skill-quality
4
+ * @work-item-id WI-188
4
5
  */
5
6
  import { readFile } from 'node:fs/promises';
6
7
  import type { RequirementTestMatrixPort, RequirementTestMatrix } from '../../domain/ports/requirement-test-matrix-port.js';
@@ -17,7 +18,12 @@ export class FileSystemRequirementTestMatrixAdapter implements RequirementTestMa
17
18
  throw new SkillQualityError('MATRIX_FILE_NOT_FOUND', `requirement-test-matrix.json not found at ${this.matrixFilePath}`);
18
19
  }
19
20
  const data = JSON.parse(raw) as Record<string, unknown>;
20
- const entry = (data[storyId] ?? data) as { total: number; covered: number; uncoveredIds: string[] };
21
+ const rootEntry = data as { total?: unknown; covered?: unknown; uncoveredIds?: unknown };
22
+ const explicitEntry = data[storyId];
23
+ if (explicitEntry === undefined && rootEntry.total === undefined) {
24
+ throw new SkillQualityError('STORY_NOT_FOUND', `Story ${storyId} not found in ${this.matrixFilePath}`);
25
+ }
26
+ const entry = (explicitEntry ?? data) as { total: number; covered: number; uncoveredIds: string[] };
21
27
  return {
22
28
  storyId,
23
29
  total: entry.total ?? 0,
@@ -1,17 +1,15 @@
1
1
  /**
2
2
  * @layer infrastructure
3
3
  * @unit skill-quality
4
+ * @work-item-id WI-188
4
5
  */
5
- import { execSync } from 'node:child_process';
6
+ import { execFileSync } from 'node:child_process';
6
7
  import { readFileSync, existsSync } from 'node:fs';
7
8
  import * as path from 'node:path';
8
9
  import type { CoverageRunnerPort } from '../../domain/ports/coverage-runner-port.js';
9
10
  import { CodeCoverageResult } from '../../domain/value-objects/code-coverage-result.js';
10
11
  import { SkillQualityError } from '../../domain/errors/skill-quality-error.js';
11
12
 
12
- const COVERAGE_SUMMARY_PATH = path.join(process.cwd(), '.harness', 'coverage-summary.json');
13
- const COVERAGE_REPORTS_DIR = path.join(process.cwd(), '.harness');
14
-
15
13
  interface CoverageSummary {
16
14
  total: {
17
15
  lines: { pct: number };
@@ -20,15 +18,15 @@ interface CoverageSummary {
20
18
  };
21
19
  }
22
20
 
23
- function parseCoverageSummary(): CodeCoverageResult {
24
- if (!existsSync(COVERAGE_SUMMARY_PATH)) {
21
+ function parseCoverageSummary(coverageSummaryPath: string): CodeCoverageResult {
22
+ if (!existsSync(coverageSummaryPath)) {
25
23
  throw new SkillQualityError(
26
24
  'INVALID_COVERAGE_REPORT',
27
- `Coverage summary not found at ${COVERAGE_SUMMARY_PATH}. Run: pnpm test --coverage first.`,
25
+ `Coverage summary not found at ${coverageSummaryPath}. Run: pnpm test --coverage first.`,
28
26
  );
29
27
  }
30
28
  try {
31
- const raw = readFileSync(COVERAGE_SUMMARY_PATH, 'utf-8');
29
+ const raw = readFileSync(coverageSummaryPath, 'utf-8');
32
30
  const data = JSON.parse(raw) as CoverageSummary;
33
31
  const line = Math.round(data.total.lines.pct);
34
32
  const branch = Math.round(data.total.branches.pct);
@@ -43,19 +41,36 @@ function parseCoverageSummary(): CodeCoverageResult {
43
41
  }
44
42
 
45
43
  export class VitestCoverageRunnerAdapter implements CoverageRunnerPort {
44
+ constructor(
45
+ private readonly projectRoot: string = process.cwd(),
46
+ private readonly execFile: typeof execFileSync = execFileSync,
47
+ ) {}
48
+
46
49
  async run(_storyId: string): Promise<CodeCoverageResult> {
50
+ const coverageSummaryPath = path.join(this.projectRoot, '.harness', 'coverage-summary.json');
51
+ const coverageReportsDir = path.join(this.projectRoot, '.harness');
52
+ const vitestBin = path.join(this.projectRoot, 'node_modules', '.bin', 'vitest');
53
+
47
54
  // Use pre-existing summary if available
48
- if (existsSync(COVERAGE_SUMMARY_PATH)) {
49
- return parseCoverageSummary();
55
+ if (existsSync(coverageSummaryPath)) {
56
+ return parseCoverageSummary(coverageSummaryPath);
57
+ }
58
+
59
+ if (!existsSync(vitestBin) && !existsSync(path.join(this.projectRoot, 'node_modules', 'vitest', 'package.json'))) {
60
+ throw new SkillQualityError(
61
+ 'COVERAGE_RUN_FAILED',
62
+ 'Install vitest locally before running coverage: npm install -D vitest @vitest/coverage-v8',
63
+ );
50
64
  }
51
65
 
52
- // Try to run vitest coverage (requires @vitest/coverage-v8)
66
+ // Run the local Vitest binary only; never use npx auto-install.
53
67
  try {
54
- execSync(
55
- `npx vitest run --coverage --coverage.reporter=json-summary "--coverage.reportsDirectory=${COVERAGE_REPORTS_DIR}"`,
56
- { stdio: 'pipe', cwd: process.cwd() },
68
+ this.execFile(
69
+ vitestBin,
70
+ ['run', '--coverage', '--coverage.reporter=json-summary', `--coverage.reportsDirectory=${coverageReportsDir}`],
71
+ { stdio: 'pipe', cwd: this.projectRoot },
57
72
  );
58
- return parseCoverageSummary();
73
+ return parseCoverageSummary(coverageSummaryPath);
59
74
  } catch (err) {
60
75
  const msg = err instanceof Error ? err.message : String(err);
61
76
  if (msg.includes('coverage-v8') || msg.includes('MISSING DEPENDENCY')) {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @layer presentation
3
3
  * @unit skill-quality
4
+ * @work-item-id WI-188
4
5
  */
5
6
  import type { CheckCoverageUseCase } from '../../application/usecases/check-coverage-usecase.js';
6
7
 
@@ -25,6 +26,10 @@ export class CheckCoverageHandler {
25
26
  const codeRate = output.coverageReport.codeCoverage.lineCoverage.toFixed(1);
26
27
  const msg = `Requirement coverage: ${reqRate}% (threshold: ${output.requirementThreshold}%)\nCode coverage: ${codeRate}% (threshold: ${output.codeThreshold}%)`;
27
28
 
29
+ if (output.skipped === true && output.skipReason === 'no-tests') {
30
+ return { exitCode: 0, message: `Coverage SKIPPED (no tests)\n${msg}` };
31
+ }
32
+
28
33
  if (output.meetsThreshold) {
29
34
  return { exitCode: 0, message: `Coverage OK\n${msg}` };
30
35
  }