phasegate 0.160.19 → 0.160.21

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 (25) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -4
  3. package/docs/guide/installation.md +5 -5
  4. package/docs/guide/setup-artifacts.md +3 -2
  5. package/docs/templates/personal/hooks/pre-commit +16 -0
  6. package/package.json +1 -1
  7. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +19 -2
  8. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +1 -3
  9. package/scripts/harness/installation/application/bundled-skill-selection.ts +46 -0
  10. package/scripts/harness/installation/application/checks/check-utils.ts +11 -0
  11. package/scripts/harness/installation/application/checks/claude-skills-symlink-check.ts +5 -3
  12. package/scripts/harness/installation/application/checks/codex-skills-symlink-check.ts +5 -3
  13. package/scripts/harness/installation/application/usecases/run-install.ts +65 -26
  14. package/scripts/harness/installation/application/usecases/run-reconcile.ts +97 -10
  15. package/scripts/harness/installation/application/usecases/run-uninstall.ts +36 -5
  16. package/scripts/harness/main.ts +40 -18
  17. package/scripts/harness/validator-system/application/dto/validation-result-contract.ts +1 -0
  18. package/scripts/harness/validator-system/application/mappers/validation-result-contract-mapper.ts +1 -0
  19. package/scripts/harness/validator-system/application/use-cases/run-l4-validators-usecase.ts +32 -3
  20. package/scripts/harness/validator-system/composition-root.ts +15 -1
  21. package/scripts/harness/validator-system/domain/services/l4/consistency-check-service.ts +78 -1
  22. package/scripts/harness/validator-system/domain/value-objects/validation-result.ts +7 -0
  23. package/scripts/harness/validator-system/infrastructure/adapters/file-system-work-item-reflection-adapter.ts +102 -0
  24. package/scripts/harness/validator-system/presentation/formatters/agent-validation-result-formatter.ts +3 -0
  25. package/scripts/harness/validator-system/presentation/formatters/human-validation-result-formatter.ts +3 -0
@@ -4,14 +4,15 @@
4
4
  // @work-item-id WI-174
5
5
  // @work-item-id WI-198
6
6
  // @work-item-id WI-210
7
+ // @work-item-id WI-216
7
8
 
8
9
  import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
9
10
  import { dirname, join, relative, resolve } from "node:path";
10
- import { getSkillsForSet, type SkillSet } from "../../../setup/skill-deployer.js";
11
11
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
12
12
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
13
13
  import type { ManagedBlockInput } from "../../domain/managed-block.js";
14
14
  import type { RepairMode } from "../../domain/repair-mode.js";
15
+ import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
15
16
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
16
17
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
17
18
 
@@ -95,8 +96,18 @@ async function copyDirectory(src: string, dest: string): Promise<void> {
95
96
  }
96
97
  }
97
98
 
99
+ async function copySelectedSkillDirectories(harnessRoot: string, targetRoot: string, skills: readonly string[]): Promise<void> {
100
+ await mkdir(targetRoot, { recursive: true });
101
+ for (const skill of skills) {
102
+ const source = join(harnessRoot, "skills", skill);
103
+ const target = join(targetRoot, skill);
104
+ await rm(target, { recursive: true, force: true });
105
+ await copyDirectory(source, target);
106
+ }
107
+ }
108
+
98
109
  async function listSelectedBundledSkills(harnessRoot: string, skillSet: SkillSet): Promise<string[]> {
99
- const allowed = new Set(getSkillsForSet(skillSet));
110
+ const allowed = new Set(getBundledSkillsForSet(skillSet));
100
111
  const entries = await readdir(join(harnessRoot, "skills"), { withFileTypes: true });
101
112
  return entries
102
113
  .filter((entry) => entry.isDirectory() && allowed.has(entry.name))
@@ -285,12 +296,21 @@ export class RunReconcileUseCase {
285
296
  }
286
297
  }
287
298
 
288
- if (this.manifestIntendsSharedSkills(manifest)) {
299
+ const personalInstall = this.isPersonalManifest(manifest);
300
+ if (!personalInstall && this.manifestIntendsSharedSkills(manifest)) {
289
301
  const sharedSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
290
302
  const outcome = await this.planSharedSkills(input, manifest, sharedSkills, "all");
291
303
  outcomes.push(outcome);
292
304
  plan.push(outcome.item);
293
305
  }
306
+ if (personalInstall) {
307
+ const personalSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
308
+ for (const skillPath of this.personalSkillPaths(manifest)) {
309
+ const outcome = await this.planPersonalSkills(input, manifest, skillPath, personalSkills, "all");
310
+ outcomes.push(outcome);
311
+ plan.push(outcome.item);
312
+ }
313
+ }
294
314
 
295
315
  if (!input.apply || refused.length > 0) {
296
316
  return { plan, refused, changed, backupDir: null };
@@ -311,6 +331,12 @@ export class RunReconcileUseCase {
311
331
  for (const skill of sharedSkills) {
312
332
  nextManifest = nextManifest.addEntry(this.createdEntry(`skills/${skill}`, this.sharedSkillHashInput(skill, input.phasegateVersion, "all")));
313
333
  }
334
+ } else if (outcome.item.strategy === "copy-dir" && (outcome.item.path === ".claude/skills" || outcome.item.path === ".codex/skills")) {
335
+ const personalSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
336
+ nextManifest = nextManifest.addEntry(this.createdEntry(`${outcome.item.path}/.harness-version`, this.personalSkillsVersionHashInput(outcome.item.path, input.phasegateVersion, "all", personalSkills)));
337
+ for (const skill of personalSkills) {
338
+ nextManifest = nextManifest.addEntry(this.createdEntry(`${outcome.item.path}/${skill}`, this.personalSkillHashInput(outcome.item.path, skill, input.phasegateVersion, "all")));
339
+ }
314
340
  } else {
315
341
  const mode = outcome.item.strategy === "symlink" ? "symlink" : outcome.item.action === "add" ? "created" : (manifest.findEntry(outcome.item.path)?.mode ?? "merged");
316
342
  nextManifest = nextManifest.addEntry(
@@ -435,6 +461,19 @@ export class RunReconcileUseCase {
435
461
  || manifest.entries.some((entry) => entry.path.startsWith("skills/"));
436
462
  }
437
463
 
464
+ private isPersonalManifest(manifest: DeploymentManifest): boolean {
465
+ return manifest.findEntry(".phasegate-local/phasegate.config.json") !== null
466
+ || manifest.entries.some((entry) => (entry.path === ".claude/skills" || entry.path === ".codex/skills") && entry.mode === "created")
467
+ || manifest.entries.some((entry) => entry.path.startsWith(".claude/skills/") || entry.path.startsWith(".codex/skills/"));
468
+ }
469
+
470
+ private personalSkillPaths(manifest: DeploymentManifest): Array<".claude/skills" | ".codex/skills"> {
471
+ const paths = new Set<".claude/skills" | ".codex/skills">();
472
+ if (manifest.findEntry(".claude/skills") !== null || manifest.entries.some((entry) => entry.path.startsWith(".claude/skills/"))) paths.add(".claude/skills");
473
+ if (manifest.findEntry(".codex/skills") !== null || manifest.entries.some((entry) => entry.path.startsWith(".codex/skills/"))) paths.add(".codex/skills");
474
+ return [...paths].sort();
475
+ }
476
+
438
477
  private async planSharedSkills(
439
478
  input: RunReconcileInput,
440
479
  manifest: DeploymentManifest,
@@ -475,13 +514,7 @@ export class RunReconcileUseCase {
475
514
 
476
515
  private async deploySharedSkills(input: RunReconcileInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
477
516
  const targetRoot = this.resolveProjectPath(input.projectRoot, "skills");
478
- await mkdir(targetRoot, { recursive: true });
479
- for (const skill of skills) {
480
- const source = join(input.harnessRoot, "skills", skill);
481
- const target = join(targetRoot, skill);
482
- await rm(target, { recursive: true, force: true });
483
- await copyDirectory(source, target);
484
- }
517
+ await copySelectedSkillDirectories(input.harnessRoot, targetRoot, skills);
485
518
  await writeFile(
486
519
  join(targetRoot, ".harness-version"),
487
520
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -489,6 +522,52 @@ export class RunReconcileUseCase {
489
522
  );
490
523
  }
491
524
 
525
+ private async planPersonalSkills(
526
+ input: RunReconcileInput,
527
+ manifest: DeploymentManifest,
528
+ relativePath: ".claude/skills" | ".codex/skills",
529
+ skills: readonly string[],
530
+ skillSet: SkillSet,
531
+ ) {
532
+ const versionPath = this.resolveProjectPath(input.projectRoot, `${relativePath}/.harness-version`);
533
+ const versionContent = await readTextOrNull(versionPath);
534
+ const expectedVersion = `"version": "${input.phasegateVersion}"`;
535
+ let missingSkill = false;
536
+ for (const skill of skills) {
537
+ if (!(await exists(this.resolveProjectPath(input.projectRoot, `${relativePath}/${skill}/SKILL.md`)))) {
538
+ missingSkill = true;
539
+ break;
540
+ }
541
+ }
542
+ const missingManifest = manifest.findEntry(`${relativePath}/.harness-version`) === null
543
+ || skills.some((skill) => manifest.findEntry(`${relativePath}/${skill}`) === null);
544
+ const changed = versionContent === null || !versionContent.includes(expectedVersion) || missingSkill || missingManifest;
545
+ return {
546
+ item: this.item(
547
+ relativePath,
548
+ changed ? "add" : "skip",
549
+ "mechanical",
550
+ "copy-dir",
551
+ changed,
552
+ changed ? `${relativePath}: deploy bundled skills` : `${relativePath}: already up to date`,
553
+ changed ? `+ ${skills.length} bundled skills (${skillSet})` : "no changes",
554
+ null,
555
+ ),
556
+ needsBackup: false,
557
+ apply: async () => {
558
+ if (!changed) return null;
559
+ const targetRoot = this.resolveProjectPath(input.projectRoot, relativePath);
560
+ await copySelectedSkillDirectories(input.harnessRoot, targetRoot, skills);
561
+ await writeFile(
562
+ join(targetRoot, ".harness-version"),
563
+ `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
564
+ "utf8",
565
+ );
566
+ return this.personalSkillsVersionHashInput(relativePath, input.phasegateVersion, skillSet, skills);
567
+ },
568
+ };
569
+ }
570
+
492
571
  private createdEntry(path: string, hashInput: string): DeploymentEntry {
493
572
  return DeploymentEntry.create({
494
573
  path,
@@ -507,6 +586,14 @@ export class RunReconcileUseCase {
507
586
  return `shared-skill:${skill}:${version}:${skillSet}`;
508
587
  }
509
588
 
589
+ private personalSkillsVersionHashInput(path: string, version: string, skillSet: SkillSet, skills: readonly string[]): string {
590
+ return `personal-skills-version:${path}:${version}:${skillSet}:${skills.join(",")}`;
591
+ }
592
+
593
+ private personalSkillHashInput(path: string, skill: string, version: string, skillSet: SkillSet): string {
594
+ return `personal-skill:${path}:${skill}:${version}:${skillSet}`;
595
+ }
596
+
510
597
  private reconcileContent(target: ReconcileTarget, before: string | null, template: string, version: string): string {
511
598
  if (target.strategy === "yaml-add") return template;
512
599
  if (target.strategy === "shell") return reconcileShell(before, template);
@@ -6,8 +6,9 @@
6
6
  // @work-item-id WI-207
7
7
  // @work-item-id WI-208
8
8
  // @work-item-id WI-209
9
+ // @work-item-id WI-216
9
10
 
10
- import { access, copyFile, lstat, mkdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
11
+ import { access, copyFile, lstat, mkdir, readFile, readdir, readlink, rm, rmdir, writeFile } from "node:fs/promises";
11
12
  import { dirname, join, relative, resolve } from "node:path";
12
13
  import type { DeploymentEntry } from "../../domain/deployment-entry.js";
13
14
  import type { RepairMode } from "../../domain/repair-mode.js";
@@ -222,7 +223,7 @@ export class RunUninstallUseCase {
222
223
  const strategy = this.strategyFor(entry.path, entry.mode);
223
224
  if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
224
225
  if (entry.mode === "created" && await this.isDirectory(absolutePath)) {
225
- return this.planCreatedDirectory(input.projectRoot, entry);
226
+ return this.planCreatedDirectory(input, entry);
226
227
  }
227
228
  const currentContent = await readTextOrNull(absolutePath);
228
229
  if (currentContent === null && strategy !== "symlink") {
@@ -271,8 +272,9 @@ export class RunUninstallUseCase {
271
272
  }
272
273
  }
273
274
 
274
- private async planCreatedDirectory(projectRoot: string, entry: DeploymentEntry) {
275
- const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
275
+ private async planCreatedDirectory(input: RunUninstallInput, entry: DeploymentEntry) {
276
+ if (entry.path === ".claude/skills" || entry.path === ".codex/skills") return this.planLegacyPersonalSkillsDirectory(input, entry);
277
+ const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
276
278
  return {
277
279
  item: this.item(
278
280
  entry.path,
@@ -291,10 +293,39 @@ export class RunUninstallUseCase {
291
293
  };
292
294
  }
293
295
 
296
+ private async planLegacyPersonalSkillsDirectory(input: RunUninstallInput, entry: DeploymentEntry) {
297
+ const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
298
+ const bundledSkills = await this.listBundledSkills(input.harnessRoot);
299
+ return {
300
+ item: this.item(
301
+ entry.path,
302
+ "delete",
303
+ "mechanical",
304
+ "created",
305
+ true,
306
+ `${entry.path}: remove managed bundled skills from legacy personal catalog`,
307
+ `- ${bundledSkills.length} bundled skills and .harness-version`,
308
+ null,
309
+ ),
310
+ needsBackup: false,
311
+ apply: async () => {
312
+ for (const skill of bundledSkills) {
313
+ await rm(join(absolutePath, skill), { recursive: true, force: true });
314
+ }
315
+ await rm(join(absolutePath, ".harness-version"), { force: true });
316
+ },
317
+ };
318
+ }
319
+
320
+ private async listBundledSkills(harnessRoot: string): Promise<string[]> {
321
+ const entries = await readdir(join(harnessRoot, "skills"), { withFileTypes: true });
322
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
323
+ }
324
+
294
325
  private async planCreated(projectRoot: string, entry: DeploymentEntry, currentContent: string) {
295
326
  const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
296
327
  const currentHash = this.hashCalculator.compute(currentContent);
297
- const matchesManifest = currentHash.equals(entry.hash);
328
+ const matchesManifest = currentHash.equals(entry.hash) || entry.path.endsWith("/.harness-version") || entry.path === "skills/.harness-version";
298
329
  const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
299
330
  return {
300
331
  item: this.item(
@@ -18,6 +18,7 @@
18
18
  * @work-item-id WI-205
19
19
  * @work-item-id WI-206
20
20
  * @work-item-id WI-213
21
+ * @work-item-id WI-217
21
22
  *
22
23
  * Phasegate CLI エントリポイント。
23
24
  * 各Unitの Composition Root からハンドラーを取得し、コマンドに応じてディスパッチする。
@@ -169,7 +170,7 @@ Setup:
169
170
  --with-husky, --with-ci, --yes)
170
171
  update-skills Alias for reconcile (kept for compatibility)
171
172
  doctor Diagnose silent installation failures (--json, --strict, --personal, --agent <claude|codex|both>, --report-out <path>)
172
- scaffold-wi <unit|_cross> <story|issue|chore>
173
+ scaffold-wi <unit|_cross> <story|issue|fix|refactor|chore>
173
174
  Create docs/inception/{unit}/WI-XXX/description.md
174
175
  emit-agent-rules Print AGENTS.md / CLAUDE.md WI workflow rules block
175
176
  install Install phasegate managed files (--dry-run|--apply, --force, --personal, --agent <claude|codex|both>)
@@ -280,14 +281,14 @@ function hasFlag(args: readonly string[], flag: string): boolean {
280
281
  }
281
282
 
282
283
  type WorkflowMode = "standard" | "strict";
283
- type ScaffoldWorkItemType = "story" | "issue" | "chore";
284
+ type ScaffoldWorkItemType = "story" | "issue" | "fix" | "refactor" | "chore";
284
285
 
285
286
  function parseWorkflowMode(value: string | undefined): WorkflowMode {
286
287
  return value === "strict" ? "strict" : "standard";
287
288
  }
288
289
 
289
290
  function parseScaffoldWorkItemType(value: string | undefined): ScaffoldWorkItemType | null {
290
- if (value === "story" || value === "issue" || value === "chore") return value;
291
+ if (value === "story" || value === "issue" || value === "fix" || value === "refactor" || value === "chore") return value;
291
292
  return null;
292
293
  }
293
294
 
@@ -319,8 +320,8 @@ async function listFilesRecursive(root: string): Promise<string[]> {
319
320
  }
320
321
  }
321
322
 
322
- async function nextWorkItemId(rootDir: string): Promise<string> {
323
- const files = await listFilesRecursive(join(rootDir, "docs", "inception"));
323
+ async function nextWorkItemId(rootDir: string, inceptionRoot = "docs/inception"): Promise<string> {
324
+ const files = await listFilesRecursive(join(rootDir, inceptionRoot));
324
325
  let max = 0;
325
326
  for (const file of files) {
326
327
  const match = file.match(/\/WI-(\d{3})\/description\.md$/);
@@ -336,19 +337,25 @@ async function countLegacyPlansWithoutWorkItems(rootDir: string): Promise<number
336
337
  return files.filter((file) => file.includes("/codding_plan/") || file.endsWith("_plan.md")).length;
337
338
  }
338
339
 
339
- async function scaffoldInceptionRoots(rootDir: string, unit: string | null = null): Promise<void> {
340
- await fsMkdir(join(rootDir, "docs", "inception", "_shared"), { recursive: true });
341
- await fsMkdir(join(rootDir, "docs", "inception", "_cross"), { recursive: true });
340
+ async function scaffoldInceptionRoots(rootDir: string, unit: string | null = null, inceptionRoot = "docs/inception"): Promise<void> {
341
+ await fsMkdir(join(rootDir, inceptionRoot, "_shared"), { recursive: true });
342
+ await fsMkdir(join(rootDir, inceptionRoot, "_cross"), { recursive: true });
342
343
  if (unit && unit !== "_cross" && unit !== "_shared") {
343
- await fsMkdir(join(rootDir, "docs", "inception", unit), { recursive: true });
344
- await fsWriteFile(join(rootDir, "docs", "inception", unit, ".gitkeep"), "", "utf8").catch(() => undefined);
344
+ await fsMkdir(join(rootDir, inceptionRoot, unit), { recursive: true });
345
+ await fsWriteFile(join(rootDir, inceptionRoot, unit, ".gitkeep"), "", "utf8").catch(() => undefined);
345
346
  }
346
347
  }
347
348
 
348
- async function scaffoldWorkItem(rootDir: string, unit: string, type: ScaffoldWorkItemType): Promise<string> {
349
- const id = await nextWorkItemId(rootDir);
350
- await scaffoldInceptionRoots(rootDir, unit);
351
- const targetBase = unit === "_cross" ? join(rootDir, "docs", "inception", "_cross") : join(rootDir, "docs", "inception", unit);
349
+ async function scaffoldWorkItem(
350
+ rootDir: string,
351
+ unit: string,
352
+ type: ScaffoldWorkItemType,
353
+ options: { readonly id?: string; readonly inceptionRoot?: string } = {},
354
+ ): Promise<string> {
355
+ const inceptionRoot = options.inceptionRoot ?? "docs/inception";
356
+ const id = options.id ?? await nextWorkItemId(rootDir, inceptionRoot);
357
+ await scaffoldInceptionRoots(rootDir, unit, inceptionRoot);
358
+ const targetBase = unit === "_cross" ? join(rootDir, inceptionRoot, "_cross") : join(rootDir, inceptionRoot, unit);
352
359
  const targetDir = join(targetBase, id);
353
360
  await fsMkdir(targetDir, { recursive: true });
354
361
  const descriptionPath = join(targetDir, "description.md");
@@ -754,15 +761,18 @@ Options:
754
761
  Examples:
755
762
  phasegate check-change-category --paths src/foo.ts,src/bar.ts
756
763
  phasegate check-change-category --paths src/foo.ts --format json`,
757
- "scaffold-wi": `Usage: phasegate scaffold-wi <unit|_cross> <story|issue|chore>
764
+ "scaffold-wi": `Usage: phasegate scaffold-wi <unit|_cross> <story|issue|fix|refactor|chore> [options]
758
765
 
759
766
  Create docs/inception/{unit}/WI-XXX/description.md.
760
767
 
761
768
  Arguments:
762
769
  <unit|_cross> Unit id or _cross for cross-cutting work items.
763
- <story|issue|chore> Work item type.
770
+ <story|issue|fix|refactor|chore>
771
+ Work item type.
764
772
 
765
773
  Options:
774
+ --id <work-item-id> Use the supplied id instead of allocating WI-XXX.
775
+ --root <path> Inception root. Defaults to docs/inception, or personal paths.inceptionDocs.
766
776
  --help, -h Show this help`,
767
777
  "scaffold-design": `Usage: phasegate scaffold-design --unit <id> --phase <phase> [options]
768
778
 
@@ -2035,13 +2045,25 @@ async function main(): Promise<void> {
2035
2045
  }
2036
2046
 
2037
2047
  case "scaffold-wi": {
2048
+ const KNOWN_SCAFFOLD_WI_FLAGS = ["--id", "--root"];
2049
+ const flagError = validateKnownFlags(args, KNOWN_SCAFFOLD_WI_FLAGS);
2050
+ if (flagError) {
2051
+ console.error(flagError);
2052
+ process.exit(2);
2053
+ }
2038
2054
  const unit = args[1];
2039
2055
  const type = parseScaffoldWorkItemType(args[2]);
2040
2056
  if (!unit || !type) {
2041
- console.error("Usage: phasegate scaffold-wi <unit|_cross> <story|issue|chore>");
2057
+ console.error("Usage: phasegate scaffold-wi <unit|_cross> <story|issue|fix|refactor|chore> [--id <work-item-id>] [--root <path>]");
2042
2058
  process.exit(2);
2043
2059
  }
2044
- const descriptionPath = await scaffoldWorkItem(rootDir, unit, type);
2060
+ const configuredPersonalRoot = resolvedConfig?.paths.inceptionDocs?.startsWith(".phasegate-local/")
2061
+ ? resolvedConfig.paths.inceptionDocs
2062
+ : undefined;
2063
+ const descriptionPath = await scaffoldWorkItem(rootDir, unit, type, {
2064
+ id: parseFlag(args, "--id"),
2065
+ inceptionRoot: parseFlag(args, "--root") ?? configuredPersonalRoot,
2066
+ });
2045
2067
  console.log(`Created ${descriptionPath}`);
2046
2068
  process.exit(0);
2047
2069
  break;
@@ -18,4 +18,5 @@ export interface ValidationResultContract {
18
18
  }[];
19
19
  readonly durationMs: number;
20
20
  readonly skipped?: boolean;
21
+ readonly skipReason?: string;
21
22
  }
@@ -24,6 +24,7 @@ export class ValidationResultContractMapper {
24
24
  }),
25
25
  durationMs: result.durationMs,
26
26
  skipped: result.skipped,
27
+ ...(result.skipReason ? { skipReason: result.skipReason } : {}),
27
28
  };
28
29
  }
29
30
 
@@ -2,6 +2,7 @@
2
2
  * @layer application
3
3
  * @unit validator-system
4
4
  * @work-item-id WI-107 / WI-156
5
+ * @work-item-id WI-217
5
6
  *
6
7
  * RunL4ValidatorsUseCase — H08-03: L4バリデータ実行
7
8
  */
@@ -70,6 +71,10 @@ export interface RunL4ValidatorsUseCaseDeps {
70
71
  deadCodeDetectionService?: DeadCodeDetectionService;
71
72
  architectureSemanticAnalysisService?: ArchitectureSemanticAnalysisService;
72
73
  skillCatalogDriftPort?: SkillCatalogDriftPort;
74
+ pathRoots?: {
75
+ readonly inceptionRoot: string;
76
+ readonly designRoot: string;
77
+ };
73
78
  checkDocFreshnessUseCase?: CheckDocFreshnessUseCasePort;
74
79
  validateDocPointersUseCase?: ValidateDocPointersUseCasePort;
75
80
  }
@@ -85,6 +90,7 @@ export class RunL4ValidatorsUseCase {
85
90
  private readonly architectureSemanticAnalysisService?: ArchitectureSemanticAnalysisService;
86
91
  private readonly skillCatalogDriftPort?: SkillCatalogDriftPort;
87
92
  private readonly skillCatalogDriftService = new SkillCatalogDriftService();
93
+ private readonly pathRoots: { readonly inceptionRoot: string; readonly designRoot: string };
88
94
  private readonly checkDocFreshnessUseCase?: CheckDocFreshnessUseCasePort;
89
95
  private readonly validateDocPointersUseCase?: ValidateDocPointersUseCasePort;
90
96
 
@@ -98,6 +104,10 @@ export class RunL4ValidatorsUseCase {
98
104
  this.deadCodeDetectionService = deps.deadCodeDetectionService;
99
105
  this.architectureSemanticAnalysisService = deps.architectureSemanticAnalysisService;
100
106
  this.skillCatalogDriftPort = deps.skillCatalogDriftPort;
107
+ this.pathRoots = deps.pathRoots ?? {
108
+ inceptionRoot: 'docs/inception',
109
+ designRoot: 'docs/product/construction',
110
+ };
101
111
  this.checkDocFreshnessUseCase = deps.checkDocFreshnessUseCase;
102
112
  this.validateDocPointersUseCase = deps.validateDocPointersUseCase;
103
113
  }
@@ -158,13 +168,25 @@ export class RunL4ValidatorsUseCase {
158
168
  const l4002Result = overrideMap.get('L4-002');
159
169
  if (l4002Result && !l4002Result.skipped) {
160
170
  const report = await this.consistencyCheckService.check(input.targetUnits ? [...input.targetUnits] : undefined);
171
+ const reflectionResult = this.usesConfiguredDocumentRoots()
172
+ ? await this.consistencyCheckService.checkWorkItemReflection(this.pathRoots)
173
+ : undefined;
161
174
  const architectureSemanticErrors = this.architectureSemanticAnalysisService
162
175
  ? await this.architectureSemanticAnalysisService.analyze()
163
176
  : [];
164
- if (report.hasMismatches() || architectureSemanticErrors.length > 0) {
177
+ if (report.hasMismatches() || (reflectionResult?.report.hasMismatches() ?? false) || architectureSemanticErrors.length > 0) {
165
178
  overrideMap.set(
166
179
  'L4-002',
167
- ValidationResult.fail(ValidatorId.create('L4-002'), [...report.toHarnessErrors(), ...architectureSemanticErrors], 0),
180
+ ValidationResult.fail(
181
+ ValidatorId.create('L4-002'),
182
+ [...report.toHarnessErrors(), ...(reflectionResult?.report.toHarnessErrors() ?? []), ...architectureSemanticErrors],
183
+ 0,
184
+ ),
185
+ );
186
+ } else if (reflectionResult?.skipReason && !report.hasMismatches() && architectureSemanticErrors.length === 0) {
187
+ overrideMap.set(
188
+ 'L4-002',
189
+ ValidationResult.skipWithReason(ValidatorId.create('L4-002'), reflectionResult.skipReason),
168
190
  );
169
191
  }
170
192
  }
@@ -187,7 +209,10 @@ export class RunL4ValidatorsUseCase {
187
209
  if (this.checkDocFreshnessUseCase) {
188
210
  const l4004Result = overrideMap.get('L4-004');
189
211
  if (l4004Result && !l4004Result.skipped) {
190
- const freshnessOutput = await this.checkDocFreshnessUseCase.execute({ format: 'json' });
212
+ const freshnessOutput = await this.checkDocFreshnessUseCase.execute({
213
+ format: 'json',
214
+ targetPattern: `${this.pathRoots.designRoot.replace(/\/+$/g, '')}/**/*.md`,
215
+ });
191
216
  const errors = this.toDocFreshnessHarnessErrors(freshnessOutput);
192
217
  overrideMap.set(
193
218
  'L4-004',
@@ -251,6 +276,10 @@ export class RunL4ValidatorsUseCase {
251
276
  return [...executionErrors, ...freshnessFindings];
252
277
  }
253
278
 
279
+ private usesConfiguredDocumentRoots(): boolean {
280
+ return this.pathRoots.inceptionRoot !== 'docs/inception' || this.pathRoots.designRoot !== 'docs/product/construction';
281
+ }
282
+
254
283
  private toPointerValidationHarnessErrors(output: ValidateDocPointersOutputContract): readonly ValidationResult['errors'][number][] {
255
284
  const executionErrors = output.errors.map((error) => this.toHarnessErrorLike(
256
285
  'L4-005',
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * DI 組み立て — validator-system の全依存関係を構築する
6
6
  * @work-item-id WI-110 / WI-111 / WI-132 / WI-133 / WI-136 / WI-137 / WI-138 / WI-156
7
+ * @work-item-id WI-217
7
8
  */
8
9
  import { ValidatorId } from './domain/value-objects/validator-id.js';
9
10
  import { ValidatorDefinition } from './domain/value-objects/validator-definition.js';
@@ -37,6 +38,7 @@ import { AdrFoundationReferenceAdapter } from './infrastructure/adapters/adr-fou
37
38
  import { ImportGraphSourceAnalysisAdapter } from './infrastructure/adapters/import-graph-source-analysis-adapter.js';
38
39
  import { FileSystemArchitectureSemanticSourceAdapter } from './infrastructure/adapters/file-system-architecture-semantic-source-adapter.js';
39
40
  import { FileSystemSkillCatalogDriftAdapter } from './infrastructure/adapters/file-system-skill-catalog-drift-adapter.js';
41
+ import { FileSystemWorkItemReflectionAdapter } from './infrastructure/adapters/file-system-work-item-reflection-adapter.js';
40
42
  import { DriftDetectionService } from './domain/services/l4/drift-detection-service.js';
41
43
  import { ConsistencyCheckService } from './domain/services/l4/consistency-check-service.js';
42
44
  import { DeadCodeDetectionService } from './domain/services/l4/dead-code-detection-service.js';
@@ -55,6 +57,10 @@ const DEFAULT_CONFIG = {
55
57
  L3: { enabled: true, validators: ['L3-001', 'L3-002', 'L3-003', 'L3-004'], coverageThreshold: 90, bundleSizeLimit: 512000 },
56
58
  L4: { enabled: true, validators: ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005', 'L4-006'] },
57
59
  },
60
+ paths: {
61
+ designDocs: 'docs/product/construction',
62
+ inceptionDocs: 'docs/inception',
63
+ },
58
64
  validate: { failOnWarning: false },
59
65
  architecture: {
60
66
  capabilityPolicies: {
@@ -149,8 +155,10 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
149
155
  const workItemStatusPolicyPort = new TraceabilityWorkItemStatusPolicyAdapter(process.cwd());
150
156
  const contractTraceabilityPolicyPort = new FileSystemContractTraceabilityPolicyAdapter();
151
157
 
152
- const docsRoot = join(process.cwd(), 'docs/product/construction');
153
158
  const cwd = process.cwd();
159
+ const designDocsRoot = configData.paths?.designDocs ?? 'docs/product/construction';
160
+ const inceptionDocsRoot = configData.paths?.inceptionDocs ?? 'docs/inception';
161
+ const docsRoot = join(cwd, designDocsRoot);
154
162
  const e2eTestFileRegistryPort = new E2eTestFileRegistryAdapter({ e2eTestRoot: join(cwd, 'scripts/harness/__tests__/e2e') });
155
163
  const cliCommandRegistryPort = new CliCommandRegistryAdapter({
156
164
  commands: [
@@ -191,6 +199,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
191
199
  const sourceAnalysisPort = new ImportGraphSourceAnalysisAdapter();
192
200
  const architectureSemanticSourcePort = new FileSystemArchitectureSemanticSourceAdapter();
193
201
  const skillCatalogDriftPort = new FileSystemSkillCatalogDriftAdapter(cwd);
202
+ const workItemReflectionPort = new FileSystemWorkItemReflectionAdapter(cwd);
194
203
 
195
204
  const driftDetectionService = new DriftDetectionService({
196
205
  designDocumentPort: markdownDesignDocumentPort,
@@ -199,6 +208,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
199
208
  const consistencyCheckService = new ConsistencyCheckService({
200
209
  designDocumentPort: markdownDesignDocumentPort,
201
210
  adrReferencePort,
211
+ workItemReflectionPort,
202
212
  });
203
213
  const deadCodeDetectionService = new DeadCodeDetectionService({
204
214
  sourceAnalysisPort,
@@ -219,6 +229,10 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
219
229
  deadCodeDetectionService,
220
230
  architectureSemanticAnalysisService,
221
231
  skillCatalogDriftPort,
232
+ pathRoots: {
233
+ inceptionRoot: inceptionDocsRoot,
234
+ designRoot: designDocsRoot,
235
+ },
222
236
  checkDocFreshnessUseCase: phase2Extensions.checkDocFreshnessUseCase,
223
237
  validateDocPointersUseCase: phase2Extensions.validateDocPointersUseCase,
224
238
  });
@@ -7,6 +7,7 @@
7
7
  * 設計文書間のレイヤー整合性検証(L4-002)
8
8
  */
9
9
  import { ConsistencyReport } from '../../value-objects/consistency-report.js';
10
+ import type { MismatchPair } from '../../value-objects/consistency-report.js';
10
11
 
11
12
  export interface ConsistencyDesignDocumentPort {
12
13
  getLayerAnnotations(targetDocs?: readonly string[]): Promise<Record<string, string>>;
@@ -16,24 +17,47 @@ export interface ConsistencyAdrReferencePort {
16
17
  exists(adrRef: string): Promise<boolean>;
17
18
  }
18
19
 
20
+ export interface WorkItemReflectionSnapshot {
21
+ readonly workItems: readonly {
22
+ readonly id: string;
23
+ readonly path: string;
24
+ readonly type?: string;
25
+ }[];
26
+ readonly productRefs: readonly {
27
+ readonly id: string;
28
+ readonly path: string;
29
+ }[];
30
+ readonly skipReason?: string;
31
+ }
32
+
33
+ export interface WorkItemReflectionPort {
34
+ collect(input: {
35
+ readonly inceptionRoot: string;
36
+ readonly designRoot: string;
37
+ }): Promise<WorkItemReflectionSnapshot>;
38
+ }
39
+
19
40
  export interface ConsistencyCheckServiceDeps {
20
41
  designDocumentPort: ConsistencyDesignDocumentPort;
21
42
  adrReferencePort: ConsistencyAdrReferencePort;
43
+ workItemReflectionPort?: WorkItemReflectionPort;
22
44
  }
23
45
 
24
46
  export class ConsistencyCheckService {
25
47
  private readonly designDocumentPort: ConsistencyDesignDocumentPort;
26
48
  private readonly adrReferencePort: ConsistencyAdrReferencePort;
49
+ private readonly workItemReflectionPort?: WorkItemReflectionPort;
27
50
 
28
51
  constructor(deps: ConsistencyCheckServiceDeps) {
29
52
  this.designDocumentPort = deps.designDocumentPort;
30
53
  this.adrReferencePort = deps.adrReferencePort;
54
+ this.workItemReflectionPort = deps.workItemReflectionPort;
31
55
  }
32
56
 
33
57
  async check(targetDocs?: readonly string[]): Promise<ConsistencyReport> {
34
58
  const layerAnnotations = await this.designDocumentPort.getLayerAnnotations(targetDocs);
35
59
 
36
- const mismatchPairs: { expected: string; actual: string; location: string }[] = [];
60
+ const mismatchPairs: MismatchPair[] = [];
37
61
  const checkTargets = Object.keys(layerAnnotations);
38
62
 
39
63
  for (const [location, annotation] of Object.entries(layerAnnotations)) {
@@ -75,4 +99,57 @@ export class ConsistencyCheckService {
75
99
  checkedAt: new Date().toISOString(),
76
100
  });
77
101
  }
102
+
103
+ async checkWorkItemReflection(input: {
104
+ readonly inceptionRoot: string;
105
+ readonly designRoot: string;
106
+ }): Promise<{ readonly report: ConsistencyReport; readonly skipReason?: string }> {
107
+ if (!this.workItemReflectionPort) {
108
+ return {
109
+ report: ConsistencyReport.create({ mismatchPairs: [], checkTargets: [], checkedAt: new Date().toISOString() }),
110
+ skipReason: 'work item reflection scanner is not configured',
111
+ };
112
+ }
113
+
114
+ const snapshot = await this.workItemReflectionPort.collect(input);
115
+ const checkTargets = [
116
+ ...snapshot.workItems.map((item) => `${item.path}#work-item:${item.id}`),
117
+ ...snapshot.productRefs.map((ref) => `${ref.path}#work-item:${ref.id}`),
118
+ ];
119
+ const productRefIds = new Set(snapshot.productRefs.map((ref) => ref.id));
120
+ const workItemIds = new Set(snapshot.workItems.map((item) => item.id));
121
+ const reflectionRequiredItems = snapshot.workItems.filter((item) => item.type !== 'chore');
122
+ const mismatchPairs: MismatchPair[] = [];
123
+
124
+ for (const item of reflectionRequiredItems) {
125
+ if (!productRefIds.has(item.id)) {
126
+ mismatchPairs.push({
127
+ expected: `product docs contain @work-item-id ${item.id}`,
128
+ actual: 'missing product reflection',
129
+ location: item.path,
130
+ nextAction: `Add @work-item-id ${item.id} to the matching product construction document under ${input.designRoot}.`,
131
+ });
132
+ }
133
+ }
134
+
135
+ for (const ref of snapshot.productRefs) {
136
+ if (!workItemIds.has(ref.id)) {
137
+ mismatchPairs.push({
138
+ expected: `inception description exists for ${ref.id}`,
139
+ actual: 'orphan product reflection',
140
+ location: ref.path,
141
+ nextAction: `Create or restore an inception description for ${ref.id} under ${input.inceptionRoot}, or remove the stale product annotation.`,
142
+ });
143
+ }
144
+ }
145
+
146
+ return {
147
+ report: ConsistencyReport.create({
148
+ mismatchPairs,
149
+ checkTargets,
150
+ checkedAt: new Date().toISOString(),
151
+ }),
152
+ skipReason: snapshot.skipReason,
153
+ };
154
+ }
78
155
  }