phasegate 0.161.0 → 0.163.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/contracts/requirement-test-matrix.schema.json +1 -1
  3. package/package.json +6 -3
  4. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +91 -23
  5. package/scripts/harness/agent-integration/domain/value-objects/protected-file-list.ts +4 -0
  6. package/scripts/harness/agent-integration/infrastructure/adapters/ci-governance-baseline-grandfather-adapter.ts +25 -2
  7. package/scripts/harness/agent-integration/infrastructure/adapters/phase-gate-query-adapter.ts +17 -2
  8. package/scripts/harness/biome-ast-engine/domain/value-objects/import-graph.ts +28 -1
  9. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +1 -0
  10. package/scripts/harness/config-foundation/domain/harness-config.ts +1 -0
  11. package/scripts/harness/config-foundation/domain/value-objects/l3-config.ts +4 -0
  12. package/scripts/harness/config-foundation/domain/value-objects/layers-config.ts +1 -1
  13. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +2 -1
  14. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +2 -1
  15. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +2 -1
  16. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +14 -9
  17. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +14 -9
  18. package/scripts/harness/harness-api/infrastructure/adapters/phase-dependency-model-query-adapter.ts +18 -4
  19. package/scripts/harness/harness-api/infrastructure/adapters/validator-system-execution-adapter.ts +27 -4
  20. package/scripts/harness/installation/application/ports/model-delegation-port.ts +10 -0
  21. package/scripts/harness/installation/application/usecases/run-install.ts +56 -7
  22. package/scripts/harness/installation/application/usecases/run-reconcile.ts +12 -7
  23. package/scripts/harness/installation/composition-root.ts +4 -2
  24. package/scripts/harness/installation/infrastructure/adapters/skill-deployer-model-delegation-adapter.ts +16 -0
  25. package/scripts/harness/installation/presentation/cli/install-handler.ts +2 -1
  26. package/scripts/harness/main.ts +68 -12
  27. package/scripts/harness/nyquist-validation/application/dto/generate-matrix-output.ts +9 -7
  28. package/scripts/harness/nyquist-validation/domain/services/matrix-validation-service.ts +27 -2
  29. package/scripts/harness/nyquist-validation/domain/services/requirement-intent-coverage-service.ts +4 -4
  30. package/scripts/harness/nyquist-validation/domain/value-objects/intent-coverage.ts +33 -0
  31. package/scripts/harness/nyquist-validation/infrastructure/adapters/markdown-requirement-source-adapter.ts +3 -1
  32. package/scripts/harness/nyquist-validation/infrastructure/adapters/type-script-test-reference-source-adapter.ts +4 -1
  33. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +8 -3
  34. package/scripts/harness/quick-mode/composition-root.ts +3 -0
  35. package/scripts/harness/quick-mode/infrastructure/adapters/validator-system-quick-mode-execution-adapter.ts +18 -0
  36. package/scripts/harness/quick-mode/presentation/handlers/ci-check-quick-mode-handler.ts +51 -5
  37. package/scripts/harness/skill-quality/infrastructure/adapters/l1-biome-validator-adapter.ts +12 -2
  38. package/scripts/harness/skill-quality/infrastructure/adapters/l2-validator-system-adapter.ts +12 -2
  39. package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts +6 -1
  40. package/scripts/harness/traceability-model/infrastructure/parsers/story-catalog-parser.ts +28 -9
  41. package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +65 -27
  42. package/scripts/harness/validator-system/application/use-cases/run-l4-validators-usecase.ts +6 -7
  43. package/scripts/harness/validator-system/composition-root.ts +8 -1
  44. package/scripts/harness/validator-system/infrastructure/adapters/file-system-security-pattern-scanner-adapter.ts +16 -6
  45. package/scripts/harness/validator-system/infrastructure/adapters/file-system-work-item-reflection-adapter.ts +10 -1
  46. package/scripts/harness/validator-system/infrastructure/adapters/import-graph-source-analysis-adapter.ts +35 -3
  47. package/scripts/harness/validator-system/infrastructure/adapters/nyquist-ac-coverage-policy-adapter.ts +86 -14
  48. package/scripts/harness/validator-system/infrastructure/adapters/phase-dependency-phase-gate-policy-adapter.ts +29 -3
  49. package/templates/.claude/scripts/deny-check.sh +73 -12
@@ -19,7 +19,6 @@
19
19
 
20
20
  import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink, readdir, rm } from "node:fs/promises";
21
21
  import { dirname, join } from "node:path";
22
- import { readModelDelegationPolicy, renderSkillForModelDelegation } from "../../../setup/skill-deployer.js";
23
22
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
24
23
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
25
24
  import type { ManagedBlockInput } from "../../domain/managed-block.js";
@@ -27,6 +26,7 @@ import type { RepairMode } from "../../domain/repair-mode.js";
27
26
  import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
28
27
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
29
28
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
29
+ import type { ModelDelegationPort } from "../ports/model-delegation-port.js";
30
30
 
31
31
  type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
32
32
  type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "text-managed" | "copy" | "copy-dir" | "symlink";
@@ -40,6 +40,10 @@ export interface InstallPlanItem {
40
40
  readonly summary: string;
41
41
  readonly diff: string;
42
42
  readonly skillHint: string | null;
43
+ // Non-null when a target could not be applied cleanly and the user must act
44
+ // (e.g. a pre-existing git hook that phasegate will not overwrite). Surfaces
45
+ // situations that would otherwise be silently skipped.
46
+ readonly warning: string | null;
43
47
  }
44
48
 
45
49
  export interface RunInstallInput {
@@ -135,13 +139,14 @@ async function copyDirectory(src: string, dest: string): Promise<void> {
135
139
  }
136
140
 
137
141
  async function copySelectedSkillDirectories(
142
+ modelDelegation: ModelDelegationPort,
138
143
  harnessRoot: string,
139
144
  projectRoot: string,
140
145
  targetRoot: string,
141
146
  skills: readonly string[],
142
147
  ): Promise<void> {
143
148
  await mkdir(targetRoot, { recursive: true });
144
- const modelDelegationPolicy = await readModelDelegationPolicy(projectRoot);
149
+ const modelDelegationPolicy = await modelDelegation.readPolicy(projectRoot);
145
150
  for (const skill of skills) {
146
151
  const source = join(harnessRoot, "skills", skill);
147
152
  const target = join(targetRoot, skill);
@@ -150,7 +155,7 @@ async function copySelectedSkillDirectories(
150
155
  if (modelDelegationPolicy === "none") {
151
156
  const skillPath = join(target, "SKILL.md");
152
157
  const content = await readFile(skillPath, "utf8");
153
- await writeFile(skillPath, renderSkillForModelDelegation(content, modelDelegationPolicy), "utf8");
158
+ await writeFile(skillPath, modelDelegation.renderSkill(content, modelDelegationPolicy), "utf8");
154
159
  }
155
160
  }
156
161
  }
@@ -165,8 +170,28 @@ async function listSelectedBundledSkills(harnessRoot: string, skillSet: SkillSet
165
170
  .sort();
166
171
  }
167
172
 
173
+ // Canonical, key-order-independent serialization used as a dedup key when
174
+ // merging hook arrays. Plain `JSON.stringify` is sensitive to object key order,
175
+ // so two structurally identical hook entries authored with different key orders
176
+ // would be treated as distinct and appended on every merge, breaking merge
177
+ // idempotency. Sorting keys recursively makes the key stable.
178
+ function canonicalize(value: unknown): unknown {
179
+ if (Array.isArray(value)) {
180
+ return value.map(canonicalize);
181
+ }
182
+ if (value !== null && typeof value === "object") {
183
+ const record = value as Record<string, unknown>;
184
+ const sorted: Record<string, unknown> = {};
185
+ for (const key of Object.keys(record).sort()) {
186
+ sorted[key] = canonicalize(record[key]);
187
+ }
188
+ return sorted;
189
+ }
190
+ return value;
191
+ }
192
+
168
193
  function normalizeJsonEntry(value: unknown): string {
169
- return JSON.stringify(value);
194
+ return JSON.stringify(canonicalize(value));
170
195
  }
171
196
 
172
197
  function mergeHookArrays(existing: unknown, incoming: unknown): unknown[] {
@@ -344,6 +369,7 @@ export class RunInstallUseCase {
344
369
  constructor(
345
370
  private readonly manifestRepository: ManifestRepositoryPort,
346
371
  private readonly hashCalculator: HashCalculatorPort,
372
+ private readonly modelDelegation: ModelDelegationPort,
347
373
  ) {}
348
374
 
349
375
  async execute(input: RunInstallInput): Promise<RunInstallResult> {
@@ -384,6 +410,18 @@ export class RunInstallUseCase {
384
410
  const next = unmanagedPersonalRuntimeFile ? before : this.merge(target, before, template, input.phasegateVersion);
385
411
  const didChange = before !== next;
386
412
  const action = this.actionFor(before, didChange, input.force);
413
+ // A `copy` target with a pre-existing, non-matching file is left untouched
414
+ // (merge returns `before`). For executable git hooks this means the
415
+ // phasegate hook is NOT wired in — warn instead of skipping silently.
416
+ const preservedExistingHook =
417
+ target.strategy === "copy"
418
+ && target.executable === true
419
+ && before !== null
420
+ && before.trim() !== template.trim()
421
+ && !input.force;
422
+ const warning = preservedExistingHook
423
+ ? `${target.path}: an existing hook was found and left unchanged; phasegate's checks are NOT wired in. Merge the phasegate hook manually (or rerun with --force to back up and overwrite).`
424
+ : null;
387
425
  const item: InstallPlanItem = {
388
426
  path: target.path,
389
427
  action,
@@ -392,9 +430,12 @@ export class RunInstallUseCase {
392
430
  changed: didChange,
393
431
  summary: unmanagedPersonalRuntimeFile
394
432
  ? `${target.path}: existing non-phasegate runtime path requires manual review`
395
- : didChange ? `${target.path}: ${action}` : `${target.path}: already up to date`,
433
+ : warning !== null
434
+ ? warning
435
+ : didChange ? `${target.path}: ${action}` : `${target.path}: already up to date`,
396
436
  diff: unmanagedPersonalRuntimeFile ? "manual review required" : this.diffSummary(before, next),
397
437
  skillHint: repairMode === "ai-assisted" ? SKILL_HINT : null,
438
+ warning,
398
439
  };
399
440
  plan.push(item);
400
441
 
@@ -458,7 +499,7 @@ export class RunInstallUseCase {
458
499
  plan.push(item);
459
500
  if (input.apply && item.changed && item.repairMode === "mechanical") {
460
501
  try {
461
- await copySelectedSkillDirectories(input.harnessRoot, input.projectRoot, join(input.projectRoot, skillPath), selectedPersonalSkills);
502
+ await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, join(input.projectRoot, skillPath), selectedPersonalSkills);
462
503
  await writeFile(
463
504
  join(input.projectRoot, skillPath, ".harness-version"),
464
505
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -548,6 +589,7 @@ export class RunInstallUseCase {
548
589
  summary: "~/.codex/config.toml: personal mode does not write user-level Codex feature flags; enable hooks manually when needed",
549
590
  diff: "manual Codex hooks feature enablement may be required",
550
591
  skillHint: null,
592
+ warning: null,
551
593
  });
552
594
  }
553
595
 
@@ -803,6 +845,7 @@ export class RunInstallUseCase {
803
845
  summary: `${relativePath}: existing non-directory skills path requires manual review`,
804
846
  diff: "manual review required",
805
847
  skillHint: null,
848
+ warning: null,
806
849
  };
807
850
  }
808
851
  } catch {}
@@ -830,6 +873,7 @@ export class RunInstallUseCase {
830
873
  summary: changed ? `${relativePath}: deploy bundled skills` : `${relativePath}: already up to date`,
831
874
  diff: changed ? `+ ${skills.length} bundled skills (${skillSet})` : "no changes",
832
875
  skillHint: null,
876
+ warning: null,
833
877
  };
834
878
  }
835
879
 
@@ -870,12 +914,13 @@ export class RunInstallUseCase {
870
914
  summary: changed ? "skills: deploy shared bundled skills" : "skills: already up to date",
871
915
  diff: changed ? `+ ${skills.length} bundled skills (${skillSet})` : "no changes",
872
916
  skillHint: null,
917
+ warning: null,
873
918
  };
874
919
  }
875
920
 
876
921
  private async deploySharedSkills(input: RunInstallInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
877
922
  const targetRoot = join(input.projectRoot, "skills");
878
- await copySelectedSkillDirectories(input.harnessRoot, input.projectRoot, targetRoot, skills);
923
+ await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
879
924
  await writeFile(
880
925
  join(targetRoot, ".harness-version"),
881
926
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -927,6 +972,7 @@ export class RunInstallUseCase {
927
972
  summary: `${relativePath}: already linked`,
928
973
  diff: "no changes",
929
974
  skillHint: null,
975
+ warning: null,
930
976
  };
931
977
  }
932
978
  return {
@@ -938,6 +984,7 @@ export class RunInstallUseCase {
938
984
  summary: `${relativePath}: existing non-phasegate path requires manual review`,
939
985
  diff: "manual review required",
940
986
  skillHint: null,
987
+ warning: null,
941
988
  };
942
989
  } catch {
943
990
  try {
@@ -952,6 +999,7 @@ export class RunInstallUseCase {
952
999
  summary: `${relativePath}: parent path exists and requires manual review`,
953
1000
  diff: "manual review required",
954
1001
  skillHint: null,
1002
+ warning: null,
955
1003
  };
956
1004
  }
957
1005
  } catch {}
@@ -964,6 +1012,7 @@ export class RunInstallUseCase {
964
1012
  summary: `${relativePath}: create symlink`,
965
1013
  diff: `+ symlink ${target}`,
966
1014
  skillHint: null,
1015
+ warning: null,
967
1016
  };
968
1017
  }
969
1018
  }
@@ -9,7 +9,6 @@
9
9
 
10
10
  import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
11
11
  import { dirname, join, relative, resolve } from "node:path";
12
- import { readModelDelegationPolicy, renderSkillForModelDelegation } from "../../../setup/skill-deployer.js";
13
12
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
14
13
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
15
14
  import type { ManagedBlockInput } from "../../domain/managed-block.js";
@@ -17,6 +16,7 @@ import type { RepairMode } from "../../domain/repair-mode.js";
17
16
  import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
18
17
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
19
18
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
19
+ import type { ModelDelegationPort } from "../ports/model-delegation-port.js";
20
20
 
21
21
  type ReconcileAction = "missing-manifest" | "update" | "add" | "link" | "skip" | "refuse";
22
22
  type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "copy-dir" | "symlink" | "unknown";
@@ -99,13 +99,14 @@ async function copyDirectory(src: string, dest: string): Promise<void> {
99
99
  }
100
100
 
101
101
  async function copySelectedSkillDirectories(
102
+ modelDelegation: ModelDelegationPort,
102
103
  harnessRoot: string,
103
104
  projectRoot: string,
104
105
  targetRoot: string,
105
106
  skills: readonly string[],
106
107
  ): Promise<void> {
107
108
  await mkdir(targetRoot, { recursive: true });
108
- const modelDelegationPolicy = await readModelDelegationPolicy(projectRoot);
109
+ const modelDelegationPolicy = await modelDelegation.readPolicy(projectRoot);
109
110
  for (const skill of skills) {
110
111
  const source = join(harnessRoot, "skills", skill);
111
112
  const target = join(targetRoot, skill);
@@ -114,7 +115,7 @@ async function copySelectedSkillDirectories(
114
115
  if (modelDelegationPolicy === "none") {
115
116
  const skillPath = join(target, "SKILL.md");
116
117
  const content = await readFile(skillPath, "utf8");
117
- await writeFile(skillPath, renderSkillForModelDelegation(content, modelDelegationPolicy), "utf8");
118
+ await writeFile(skillPath, modelDelegation.renderSkill(content, modelDelegationPolicy), "utf8");
118
119
  }
119
120
  }
120
121
  }
@@ -129,18 +130,19 @@ async function listSelectedBundledSkills(harnessRoot: string, skillSet: SkillSet
129
130
  }
130
131
 
131
132
  async function hasRenderedSkillDrift(
133
+ modelDelegation: ModelDelegationPort,
132
134
  harnessRoot: string,
133
135
  projectRoot: string,
134
136
  targetRoot: string,
135
137
  skills: readonly string[],
136
138
  ): Promise<boolean> {
137
- const modelDelegationPolicy = await readModelDelegationPolicy(projectRoot);
139
+ const modelDelegationPolicy = await modelDelegation.readPolicy(projectRoot);
138
140
  for (const skill of skills) {
139
141
  const current = await readTextOrNull(join(targetRoot, skill, "SKILL.md"));
140
142
  if (current === null) return true;
141
143
  const source = await readTextOrNull(join(harnessRoot, "skills", skill, "SKILL.md"));
142
144
  if (source === null) continue;
143
- const expected = renderSkillForModelDelegation(source, modelDelegationPolicy);
145
+ const expected = modelDelegation.renderSkill(source, modelDelegationPolicy);
144
146
  if (current !== expected) return true;
145
147
  }
146
148
  return false;
@@ -265,6 +267,7 @@ export class RunReconcileUseCase {
265
267
  constructor(
266
268
  private readonly manifestRepository: ManifestRepositoryPort,
267
269
  private readonly hashCalculator: HashCalculatorPort,
270
+ private readonly modelDelegation: ModelDelegationPort,
268
271
  ) {}
269
272
 
270
273
  async execute(input: RunReconcileInput): Promise<RunReconcileResult> {
@@ -523,6 +526,7 @@ export class RunReconcileUseCase {
523
526
  const missingManifest = manifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null
524
527
  || skills.some((skill) => manifest.findEntry(`skills/${skill}`) === null);
525
528
  const renderedSkillDrift = await hasRenderedSkillDrift(
529
+ this.modelDelegation,
526
530
  input.harnessRoot,
527
531
  input.projectRoot,
528
532
  this.resolveProjectPath(input.projectRoot, "skills"),
@@ -551,7 +555,7 @@ export class RunReconcileUseCase {
551
555
 
552
556
  private async deploySharedSkills(input: RunReconcileInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
553
557
  const targetRoot = this.resolveProjectPath(input.projectRoot, "skills");
554
- await copySelectedSkillDirectories(input.harnessRoot, input.projectRoot, targetRoot, skills);
558
+ await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
555
559
  await writeFile(
556
560
  join(targetRoot, ".harness-version"),
557
561
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -579,6 +583,7 @@ export class RunReconcileUseCase {
579
583
  const missingManifest = manifest.findEntry(`${relativePath}/.harness-version`) === null
580
584
  || skills.some((skill) => manifest.findEntry(`${relativePath}/${skill}`) === null);
581
585
  const renderedSkillDrift = await hasRenderedSkillDrift(
586
+ this.modelDelegation,
582
587
  input.harnessRoot,
583
588
  input.projectRoot,
584
589
  this.resolveProjectPath(input.projectRoot, relativePath),
@@ -600,7 +605,7 @@ export class RunReconcileUseCase {
600
605
  apply: async () => {
601
606
  if (!changed) return null;
602
607
  const targetRoot = this.resolveProjectPath(input.projectRoot, relativePath);
603
- await copySelectedSkillDirectories(input.harnessRoot, input.projectRoot, targetRoot, skills);
608
+ await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
604
609
  await writeFile(
605
610
  join(targetRoot, ".harness-version"),
606
611
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -26,6 +26,7 @@ import type { UninstallReverseStrategy } from "./domain/ports/uninstall-reverse-
26
26
  import { FileSystemManifestRepositoryAdapter } from "./infrastructure/adapters/file-system-manifest-repository-adapter.js";
27
27
  import { NodeCryptoHashAdapter } from "./infrastructure/adapters/node-crypto-hash-adapter.js";
28
28
  import { NodeFsFileInspectorAdapter } from "./infrastructure/adapters/node-fs-file-inspector-adapter.js";
29
+ import { SkillDeployerModelDelegationAdapter } from "./infrastructure/adapters/skill-deployer-model-delegation-adapter.js";
29
30
  import { DoctorHandler } from "./presentation/cli/doctor-handler.js";
30
31
  import { InstallHandler } from "./presentation/cli/install-handler.js";
31
32
  import { ReconcileHandler } from "./presentation/cli/reconcile-handler.js";
@@ -43,6 +44,7 @@ export function createInstallationModule() {
43
44
  const inspector = new NodeFsFileInspectorAdapter();
44
45
  const manifestRepository = new FileSystemManifestRepositoryAdapter();
45
46
  const hashCalculator = new NodeCryptoHashAdapter();
47
+ const modelDelegation = new SkillDeployerModelDelegationAdapter();
46
48
  const checks = [
47
49
  new ClaudeHookMissingCheck(),
48
50
  new ClaudeContextMissingCheck(),
@@ -58,8 +60,8 @@ export function createInstallationModule() {
58
60
  new WiWorkflowDriftCheck(),
59
61
  ];
60
62
  const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(checks, inspector, manifestRepository);
61
- const runInstallUseCase = new RunInstallUseCase(manifestRepository, hashCalculator);
62
- const runReconcileUseCase = new RunReconcileUseCase(manifestRepository, hashCalculator);
63
+ const runInstallUseCase = new RunInstallUseCase(manifestRepository, hashCalculator, modelDelegation);
64
+ const runReconcileUseCase = new RunReconcileUseCase(manifestRepository, hashCalculator, modelDelegation);
63
65
  const runUninstallUseCase = new RunUninstallUseCase(manifestRepository, hashCalculator);
64
66
  return {
65
67
  manifestRepository,
@@ -0,0 +1,16 @@
1
+ // @unit installation
2
+ // @layer infrastructure
3
+ // @work-item-id WI-219
4
+
5
+ import { readModelDelegationPolicy, renderSkillForModelDelegation } from "../../../setup/skill-deployer.js";
6
+ import type { ModelDelegationPolicy, ModelDelegationPort } from "../../application/ports/model-delegation-port.js";
7
+
8
+ export class SkillDeployerModelDelegationAdapter implements ModelDelegationPort {
9
+ readPolicy(projectRoot: string): Promise<ModelDelegationPolicy> {
10
+ return readModelDelegationPolicy(projectRoot);
11
+ }
12
+
13
+ renderSkill(content: string, policy: ModelDelegationPolicy): string {
14
+ return renderSkillForModelDelegation(content, policy);
15
+ }
16
+ }
@@ -44,7 +44,8 @@ export class InstallHandler {
44
44
  input.apply ? `phasegate install${input.personal ? " --personal" : ""} apply` : `phasegate install${input.personal ? " --personal" : ""} dry-run`,
45
45
  ...result.plan.map((item) => {
46
46
  const hint = item.skillHint ? `; hint: ${item.skillHint}` : "";
47
- return `- ${item.path}: ${item.action} (${item.repairMode}, ${item.strategy}); diff: ${item.diff}${hint}`;
47
+ const warn = item.warning ? `\n WARNING: ${item.warning}` : "";
48
+ return `- ${item.path}: ${item.action} (${item.repairMode}, ${item.strategy}); diff: ${item.diff}${hint}${warn}`;
48
49
  }),
49
50
  ];
50
51
  if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
@@ -38,6 +38,7 @@ import {
38
38
  writeFile as fsWriteFile,
39
39
  } from "node:fs/promises";
40
40
  import { dirname, join, resolve } from "node:path";
41
+ import { fileURLToPath } from "node:url";
41
42
  import { createAdrFoundationModule } from "./adr-foundation/composition-root.js";
42
43
  import { createBiomeAstEngineModule } from "./biome-ast-engine/composition-root.js";
43
44
  import { buildCiGovernance } from "./ci-governance/composition-root.js";
@@ -335,21 +336,42 @@ async function listFilesRecursive(root: string): Promise<string[]> {
335
336
  }
336
337
  }
337
338
 
338
- async function nextWorkItemId(rootDir: string, inceptionRoot = "docs/inception"): Promise<string> {
339
- const files = await listFilesRecursive(join(rootDir, inceptionRoot));
339
+ // Normalize path separators so scanning works on Windows (`\`) as well as POSIX.
340
+ export function toPosixPath(filePath: string): string {
341
+ return filePath.replace(/\\/g, "/");
342
+ }
343
+
344
+ // Derive the next WI id from an already-collected list of file paths. Exposed
345
+ // (with the file list injected) so the numbering rules — Windows separator
346
+ // handling and dynamic zero-pad width past WI-999 — can be unit tested without
347
+ // touching the filesystem.
348
+ export function computeNextWorkItemId(files: readonly string[]): string {
340
349
  let max = 0;
350
+ let maxWidth = 3;
341
351
  for (const file of files) {
342
- const match = file.match(/\/WI-(\d{3})\/description\.md$/);
343
- if (match) max = Math.max(max, Number(match[1]));
352
+ const match = toPosixPath(file).match(/\/WI-(\d+)\/description\.md$/);
353
+ if (match) {
354
+ max = Math.max(max, Number(match[1]));
355
+ maxWidth = Math.max(maxWidth, match[1].length);
356
+ }
344
357
  }
345
- return `WI-${String(max + 1).padStart(3, "0")}`;
358
+ const next = max + 1;
359
+ return `WI-${String(next).padStart(maxWidth, "0")}`;
360
+ }
361
+
362
+ async function nextWorkItemId(rootDir: string, inceptionRoot = "docs/inception"): Promise<string> {
363
+ const files = await listFilesRecursive(join(rootDir, inceptionRoot));
364
+ return computeNextWorkItemId(files);
346
365
  }
347
366
 
348
367
  async function countLegacyPlansWithoutWorkItems(rootDir: string): Promise<number> {
349
368
  const files = await listFilesRecursive(join(rootDir, "docs", "inception"));
350
- const hasWorkItem = files.some((file) => /\/WI-\d{3}\/description\.md$/.test(file));
369
+ const hasWorkItem = files.some((file) => /\/WI-\d+\/description\.md$/.test(toPosixPath(file)));
351
370
  if (hasWorkItem) return 0;
352
- return files.filter((file) => file.includes("/codding_plan/") || file.endsWith("_plan.md")).length;
371
+ return files.filter((file) => {
372
+ const posix = toPosixPath(file);
373
+ return posix.includes("/codding_plan/") || posix.endsWith("_plan.md");
374
+ }).length;
353
375
  }
354
376
 
355
377
  async function scaffoldInceptionRoots(rootDir: string, unit: string | null = null, inceptionRoot = "docs/inception"): Promise<void> {
@@ -1378,11 +1400,11 @@ function buildConfigPatchPreview(intent: ConfigChangeIntent, before: unknown | n
1378
1400
  const configIntents: Record<ConfigChangeIntent, readonly { readonly pointer: string; readonly path: readonly string[]; readonly value: unknown }[]> = {
1379
1401
  "l4-strict": [
1380
1402
  { pointer: "/layers/L4/enabled", path: ["layers", "L4", "enabled"], value: true },
1381
- { pointer: "/layers/L4/failOnWarning", path: ["layers", "L4", "failOnWarning"], value: true },
1403
+ { pointer: "/validate/failOnWarning", path: ["validate", "failOnWarning"], value: true },
1382
1404
  ],
1383
1405
  "ci-fail-on-warning": [
1384
1406
  { pointer: "/ci/enabled", path: ["ci", "enabled"], value: true },
1385
- { pointer: "/layers/L4/failOnWarning", path: ["layers", "L4", "failOnWarning"], value: true },
1407
+ { pointer: "/validate/failOnWarning", path: ["validate", "failOnWarning"], value: true },
1386
1408
  ],
1387
1409
  "quick-mode-strict": [
1388
1410
  { pointer: "/quickMode/allowedCategories", path: ["quickMode", "allowedCategories"], value: ["bugfix"] },
@@ -1483,7 +1505,7 @@ async function buildConfigChangePlan(rootDir: string, intent: ConfigChangeIntent
1483
1505
  readonly risks: readonly string[];
1484
1506
  }> = {
1485
1507
  "l4-strict": {
1486
- targets: ["phasegate.config.json: layers.L4.enabled", "phasegate.config.json: layers.L4.failOnWarning"],
1508
+ targets: ["phasegate.config.json: layers.L4.enabled", "phasegate.config.json: validate.failOnWarning"],
1487
1509
  managedTargets: ["phasegate.config.json"],
1488
1510
  externalActions: [],
1489
1511
  commands: ["phasegate config:plan --intent l4-strict --apply --json", "phasegate validate --layer L4 --fail-on-warning --format human"],
@@ -2766,9 +2788,28 @@ async function main(): Promise<void> {
2766
2788
  }
2767
2789
 
2768
2790
  case "phasegate:generate-matrix": {
2791
+ // REAL registry: 有効 storyId 一覧を traceability-model の StoryCatalog から取得する。
2792
+ // 空スタブだと生成マトリクスの storyId 整合性検査が事実上 no-op になるため配線する。
2793
+ // config・catalog が読めない環境(config 不在など)では空配列にフォールバックし、
2794
+ // マトリクス生成自体は継続する。
2795
+ const loadCatalogStoryIds = async (): Promise<readonly string[]> => {
2796
+ try {
2797
+ const { createConfigFoundationModule } = await import("./config-foundation/composition-root.js");
2798
+ const configModule = createConfigFoundationModule();
2799
+ const resolvedConfig = await configModule.usecases.loadResolvedConfigUseCase.execute();
2800
+ const { createTraceabilityModelModule } = await import("./traceability-model/composition-root.js");
2801
+ const traceModule = createTraceabilityModelModule(process.cwd(), {
2802
+ pathRoots: { designDocsRoot: resolvedConfig.config.paths.designDocs },
2803
+ });
2804
+ const catalogStoryIds = await traceModule.storyCatalog.getAllStoryIds();
2805
+ return catalogStoryIds.map((s) => s.value);
2806
+ } catch {
2807
+ return [];
2808
+ }
2809
+ };
2769
2810
  const { createNyquistValidationModule } = await import("./nyquist-validation/composition-root.js");
2770
2811
  const mod = createNyquistValidationModule({
2771
- getStoryIds: async () => [],
2812
+ getStoryIds: loadCatalogStoryIds,
2772
2813
  });
2773
2814
  const flags: Record<string, boolean | string> = {};
2774
2815
  if (json) flags.json = true;
@@ -3250,4 +3291,19 @@ Examples:
3250
3291
  }
3251
3292
  }
3252
3293
 
3253
- main();
3294
+ // Only run the CLI when this module is the process entry point. Guarding this
3295
+ // keeps `main()` from executing (and calling process.exit) when the module is
3296
+ // imported for unit testing of exported helpers such as computeNextWorkItemId.
3297
+ function isMainModule(): boolean {
3298
+ const entry = process.argv[1];
3299
+ if (!entry) return false;
3300
+ try {
3301
+ return resolve(entry) === fileURLToPath(import.meta.url);
3302
+ } catch {
3303
+ return false;
3304
+ }
3305
+ }
3306
+
3307
+ if (isMainModule()) {
3308
+ main();
3309
+ }
@@ -3,6 +3,13 @@
3
3
  // @work-item-id WI-125
4
4
  // @work-item-id WI-131
5
5
 
6
+ // Intent coverage 系の型は domain 層(value-objects/intent-coverage)を正準とし、
7
+ // application 層はそれを参照・再エクスポートする(domain ← application の正しい依存方向)。
8
+ import type {
9
+ IntentCoverageStatus as DomainIntentCoverageStatus,
10
+ IntentCoverageItem as DomainIntentCoverageItem,
11
+ } from '../../domain/value-objects/intent-coverage.js';
12
+
6
13
  export interface RequirementSourceDto {
7
14
  readonly storyId: string;
8
15
  readonly acIds: readonly string[];
@@ -48,14 +55,9 @@ export interface OrphanTestDto {
48
55
  readonly testName?: string;
49
56
  }
50
57
 
51
- export type IntentCoverageStatus = 'observed' | 'weakly-observed' | 'unobserved';
58
+ export type IntentCoverageStatus = DomainIntentCoverageStatus;
52
59
 
53
- export interface IntentCoverageItemDto {
54
- readonly storyId: string;
55
- readonly acId: string;
56
- readonly status: IntentCoverageStatus;
57
- readonly warnings: readonly string[];
58
- }
60
+ export type IntentCoverageItemDto = DomainIntentCoverageItem;
59
61
 
60
62
  export interface MatrixGenerationReportDto {
61
63
  readonly missingTests: readonly MissingTestDto[];
@@ -23,11 +23,25 @@ interface RawStoryMappingEntry {
23
23
  storyId: string;
24
24
  }
25
25
 
26
+ /**
27
+ * requirement-test-matrix.json から storyId を持つエントリを抽出する。
28
+ *
29
+ * スキーマ準拠の実データはトップレベルキー `stories`(各要素が `{ storyId, storyMappings }`)
30
+ * を用いる。旧来のテスト・入力ではトップレベル `storyMappings` を用いる場合があるため
31
+ * 両形式に対応する。以前は `storyMappings` のみを参照していたため、スキーマ準拠の実データ
32
+ * (`stories` 形式)に対しては常に空配列を返し、storyId 整合性検査が事実上 no-op だった。
33
+ */
26
34
  function extractStoryMappings(rawData: unknown): RawStoryMappingEntry[] {
27
35
  if (rawData === null || typeof rawData !== 'object') return [];
28
36
  const obj = rawData as Record<string, unknown>;
29
- if (!Array.isArray(obj['storyMappings'])) return [];
30
- return (obj['storyMappings'] as unknown[]).map((item) => {
37
+
38
+ const source = Array.isArray(obj['stories'])
39
+ ? (obj['stories'] as unknown[])
40
+ : Array.isArray(obj['storyMappings'])
41
+ ? (obj['storyMappings'] as unknown[])
42
+ : [];
43
+
44
+ return source.map((item) => {
31
45
  const entry = item as Record<string, unknown>;
32
46
  return { storyId: String(entry['storyId'] ?? '') };
33
47
  });
@@ -53,7 +67,9 @@ export class MatrixValidationService {
53
67
  const storyMappings = extractStoryMappings(rawData);
54
68
 
55
69
  const errors: NyquistHarnessError[] = [];
70
+ const seenStoryIds = new Set<string>();
56
71
  for (const sm of storyMappings) {
72
+ // storyId 整合性: 有効 storyId 一覧に含まれるか照合する
57
73
  if (!validStoryIds.includes(sm.storyId)) {
58
74
  errors.push({
59
75
  code: 'L3-004',
@@ -61,6 +77,15 @@ export class MatrixValidationService {
61
77
  message: `未登録のstoryIdです: ${sm.storyId}`,
62
78
  });
63
79
  }
80
+ // INV-1: 同一 storyId の StoryMapping は1つのみ存在する(重複を検出)
81
+ if (seenStoryIds.has(sm.storyId)) {
82
+ errors.push({
83
+ code: 'L3-004',
84
+ severity: 'error',
85
+ message: `storyIdが重複しています: ${sm.storyId}`,
86
+ });
87
+ }
88
+ seenStoryIds.add(sm.storyId);
64
89
  }
65
90
 
66
91
  if (errors.length === 0) {
@@ -3,12 +3,12 @@
3
3
  // @work-item-id WI-131
4
4
 
5
5
  import type {
6
- IntentCoverageItemDto,
7
- MatrixStoryDto,
8
- } from '../../application/dto/generate-matrix-output.js';
6
+ IntentCoverageItem,
7
+ IntentCoverageStory,
8
+ } from '../value-objects/intent-coverage.js';
9
9
 
10
10
  export class RequirementIntentCoverageService {
11
- evaluate(stories: readonly MatrixStoryDto[]): readonly IntentCoverageItemDto[] {
11
+ evaluate(stories: readonly IntentCoverageStory[]): readonly IntentCoverageItem[] {
12
12
  return stories.flatMap((story) => story.storyMappings.map((mapping) => {
13
13
  if (mapping.testReferences.length === 0) {
14
14
  return {
@@ -0,0 +1,33 @@
1
+ // @layer domain
2
+ // @unit nyquist-validation
3
+ // @work-item-id WI-131
4
+ //
5
+ // RequirementIntentCoverageService が入出力に用いるドメイン層の型定義。
6
+ // 依存方向是正: これまで domain 層のサービスが application/dto から型を import しており
7
+ // domain → application の逆流(レイヤー依存違反)が発生していた。型の正準を domain 層へ
8
+ // 移し、application 層の DTO はここを参照する(domain ← application の正しい向き)。
9
+
10
+ export interface IntentCoverageTestReference {
11
+ readonly filePath: string;
12
+ readonly testType: 'unit' | 'it' | 'scenario';
13
+ readonly testName?: string;
14
+ }
15
+
16
+ export interface IntentCoverageAcMapping {
17
+ readonly acId: string;
18
+ readonly testReferences: readonly IntentCoverageTestReference[];
19
+ }
20
+
21
+ export interface IntentCoverageStory {
22
+ readonly storyId: string;
23
+ readonly storyMappings: readonly IntentCoverageAcMapping[];
24
+ }
25
+
26
+ export type IntentCoverageStatus = 'observed' | 'weakly-observed' | 'unobserved';
27
+
28
+ export interface IntentCoverageItem {
29
+ readonly storyId: string;
30
+ readonly acId: string;
31
+ readonly status: IntentCoverageStatus;
32
+ readonly warnings: readonly string[];
33
+ }
@@ -6,7 +6,9 @@ import { readFile } from 'node:fs/promises';
6
6
  import type { RequirementSourcePort } from '../../application/usecases/generate-requirement-test-matrix-usecase.js';
7
7
  import type { RequirementSourceDto } from '../../application/dto/generate-matrix-output.js';
8
8
 
9
- const STORY_HEADING = /^###\s+(H\d{2}-\d{2}):/;
9
+ // StoryId HXX-XX 形式に加え Phase 2 拡張 Epic の HF\d+-XX 形式も許容する
10
+ // (traceability-model の StoryId 正規表現と整合)。旧 /H\d{2}-\d{2}/ は HF2-01 を取りこぼしていた。
11
+ const STORY_HEADING = /^###\s+(H(?:F\d+|\d{2})-\d{2}):/;
10
12
  const AC_ITEM = /^\s*-\s+\[[ xX]\]\s+(AC-[1-9][0-9]*):/;
11
13
 
12
14
  export class MarkdownRequirementSourceAdapter implements RequirementSourcePort {
@@ -7,7 +7,10 @@ import path from 'node:path';
7
7
  import type { TestReferenceSourcePort } from '../../application/usecases/generate-requirement-test-matrix-usecase.js';
8
8
  import type { TestReferenceSourceDto } from '../../application/dto/generate-matrix-output.js';
9
9
 
10
- const STORY_TAG = /@story(?:-id)?\s+(H\d{2}-\d{2})/;
10
+ // StoryId HXX-XX 形式に加え Phase 2 拡張 Epic の HF\d+-XX 形式も許容する
11
+ // (markdown-requirement-source-adapter の STORY_HEADING / traceability-model の StoryId 正規表現と整合)。
12
+ // 旧 /H\d{2}-\d{2}/ は HF2-01 等を取りこぼし、正しく注釈されたテストが「テストなし」と誤判定されていた。
13
+ const STORY_TAG = /@story(?:-id)?\s+(H(?:F\d+|\d{2})-\d{2})/;
11
14
  const TEST_NAME = /\b(?:it|test)(?:\.each\([^)]*\))?\s*\(\s*['"`]([^'"`]+)['"`]/g;
12
15
 
13
16
  async function collectTestFiles(root: string): Promise<readonly string[]> {