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.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -4
- package/docs/guide/installation.md +5 -5
- package/docs/guide/setup-artifacts.md +3 -2
- package/docs/templates/personal/hooks/pre-commit +16 -0
- package/package.json +1 -1
- package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +19 -2
- package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +1 -3
- package/scripts/harness/installation/application/bundled-skill-selection.ts +46 -0
- package/scripts/harness/installation/application/checks/check-utils.ts +11 -0
- package/scripts/harness/installation/application/checks/claude-skills-symlink-check.ts +5 -3
- package/scripts/harness/installation/application/checks/codex-skills-symlink-check.ts +5 -3
- package/scripts/harness/installation/application/usecases/run-install.ts +65 -26
- package/scripts/harness/installation/application/usecases/run-reconcile.ts +97 -10
- package/scripts/harness/installation/application/usecases/run-uninstall.ts +36 -5
- package/scripts/harness/main.ts +40 -18
- package/scripts/harness/validator-system/application/dto/validation-result-contract.ts +1 -0
- package/scripts/harness/validator-system/application/mappers/validation-result-contract-mapper.ts +1 -0
- package/scripts/harness/validator-system/application/use-cases/run-l4-validators-usecase.ts +32 -3
- package/scripts/harness/validator-system/composition-root.ts +15 -1
- package/scripts/harness/validator-system/domain/services/l4/consistency-check-service.ts +78 -1
- package/scripts/harness/validator-system/domain/value-objects/validation-result.ts +7 -0
- package/scripts/harness/validator-system/infrastructure/adapters/file-system-work-item-reflection-adapter.ts +102 -0
- package/scripts/harness/validator-system/presentation/formatters/agent-validation-result-formatter.ts +3 -0
- 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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
275
|
-
|
|
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(
|
package/scripts/harness/main.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
341
|
-
await fsMkdir(join(rootDir,
|
|
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,
|
|
344
|
-
await fsWriteFile(join(rootDir,
|
|
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(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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>
|
|
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
|
|
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;
|
|
@@ -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(
|
|
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({
|
|
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:
|
|
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
|
}
|