phasegate 0.39.0 → 0.62.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 (56) hide show
  1. package/README.ja.md +32 -0
  2. package/README.md +33 -0
  3. package/docs/guide/cli-reference.md +15 -0
  4. package/docs/guide/codex-integration.md +162 -0
  5. package/docs/guide/quick-vs-full-mode.md +141 -0
  6. package/package.json +1 -1
  7. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +60 -0
  8. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +299 -0
  9. package/scripts/harness/agent-integration/presentation/session-start-hook.ts +54 -0
  10. package/scripts/harness/agent-integration/presentation/user-prompt-submit-hook.ts +70 -0
  11. package/scripts/harness/ci-governance/application/usecases/generate-ci-template-usecase.ts +6 -2
  12. package/scripts/harness/harness-api/infrastructure/adapters/validator-system-execution-adapter.ts +11 -2
  13. package/scripts/harness/integrations/pre-commit.ts +161 -127
  14. package/scripts/harness/main.ts +94 -11
  15. package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-input.ts +9 -0
  16. package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-output.ts +17 -0
  17. package/scripts/harness/phase2-extensions/application/usecases/check-initial-creation-expiration-usecase.ts +103 -0
  18. package/scripts/harness/phase2-extensions/composition-root.ts +22 -0
  19. package/scripts/harness/phase2-extensions/domain/aggregates/initial-creation-expiration-rule.ts +103 -0
  20. package/scripts/harness/phase2-extensions/domain/ports/frontmatter-reader-port.ts +17 -0
  21. package/scripts/harness/phase2-extensions/domain/ports/initial-creation-age-port.ts +9 -0
  22. package/scripts/harness/phase2-extensions/domain/ports/initial-creation-expiration-config-port.ts +9 -0
  23. package/scripts/harness/phase2-extensions/domain/services/initial-creation-expiration-check-service.ts +54 -0
  24. package/scripts/harness/phase2-extensions/domain/value-objects/initial-creation-age.ts +54 -0
  25. package/scripts/harness/phase2-extensions/infrastructure/adapters/git-log-document-age-adapter.ts +7 -1
  26. package/scripts/harness/phase2-extensions/infrastructure/adapters/git-log-initial-creation-age-adapter.ts +77 -0
  27. package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-initial-creation-expiration-adapter.ts +57 -0
  28. package/scripts/harness/phase2-extensions/infrastructure/adapters/markdown-frontmatter-reader-adapter.ts +56 -0
  29. package/scripts/harness/phase2-extensions/presentation/formatters/initial-creation-expiration-result-formatter.ts +23 -0
  30. package/scripts/harness/phase2-extensions/presentation/handlers/check-initial-creation-expiration-handler.ts +38 -0
  31. package/scripts/harness/quick-mode/infrastructure/adapters/git-diff-changed-files-adapter.ts +20 -1
  32. package/scripts/harness/regression-suite/composition-root.ts +4 -1
  33. package/scripts/harness/setup/skill-deployer.ts +30 -0
  34. package/scripts/harness/traceability-model/composition-root.ts +14 -0
  35. package/scripts/harness/traceability-model/domain/value-objects/project-relative-path.ts +3 -0
  36. package/scripts/harness/traceability-model/infrastructure/parsers/markdown-story-annotation-parser.ts +39 -4
  37. package/scripts/harness/traceability-model/presentation/cli/validate-metadata-command-handler.ts +103 -9
  38. package/scripts/harness/validator-system/application/dto/run-full-validation-input.ts +5 -0
  39. package/scripts/harness/validator-system/application/use-cases/run-full-validation-usecase.ts +40 -12
  40. package/scripts/harness/validator-system/composition-root.ts +2 -0
  41. package/scripts/harness/validator-system/domain/services/l4/drift-detection-service.ts +29 -2
  42. package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-source-code-analyzer-adapter.ts +17 -0
  43. package/scripts/harness/validator-system/infrastructure/adapters/markdown-design-document-adapter.ts +65 -5
  44. package/scripts/harness/validator-system/presentation/handlers/run-validators-handler.ts +9 -0
  45. package/skills/domain-designer/SKILL.md +34 -0
  46. package/skills/it-test-logic-designer/SKILL.md +16 -0
  47. package/skills/logical-designer/SKILL.md +34 -0
  48. package/skills/quick-implementor/SKILL.md +10 -1
  49. package/skills/scenario-test-logic-designer/SKILL.md +16 -0
  50. package/skills/story-implementor/SKILL.md +58 -0
  51. package/skills/unit-designer/SKILL.md +41 -0
  52. package/skills/unit-test-logic-designer/SKILL.md +18 -0
  53. package/templates/.codex/hooks.json +63 -0
  54. package/templates/logical_design.template.md +79 -0
  55. package/templates/source.template.ts +18 -0
  56. package/templates/test.template.ts +37 -0
@@ -4,16 +4,22 @@
4
4
  */
5
5
  import type { HarnessConfigV2 } from '../config-foundation/domain/harness-config.js';
6
6
  import { CheckDocFreshnessUseCase } from './application/usecases/check-doc-freshness-usecase.js';
7
+ import { CheckInitialCreationExpirationUseCase } from './application/usecases/check-initial-creation-expiration-usecase.js';
7
8
  import { GenerateE2ETemplateUseCase } from './application/usecases/generate-e2e-template-usecase.js';
8
9
  import { ValidateDocPointersUseCase } from './application/usecases/validate-doc-pointers-usecase.js';
9
10
  import { FreshnessCheckService } from './domain/services/freshness-check-service.js';
11
+ import { InitialCreationExpirationCheckService } from './domain/services/initial-creation-expiration-check-service.js';
10
12
  import { PointerResolutionService } from './domain/services/pointer-resolution-service.js';
11
13
  import { FileSystemDocumentScannerAdapter } from './infrastructure/adapters/file-system-document-scanner-adapter.js';
12
14
  import { FileSystemPointerResolverAdapter } from './infrastructure/adapters/file-system-pointer-resolver-adapter.js';
13
15
  import { GitLogDocumentAgeAdapter } from './infrastructure/adapters/git-log-document-age-adapter.js';
16
+ import { GitLogInitialCreationAgeAdapter } from './infrastructure/adapters/git-log-initial-creation-age-adapter.js';
14
17
  import { HarnessConfigFreshnessAdapter } from './infrastructure/adapters/harness-config-freshness-adapter.js';
18
+ import { HarnessConfigInitialCreationExpirationAdapter } from './infrastructure/adapters/harness-config-initial-creation-expiration-adapter.js';
19
+ import { MarkdownFrontmatterReaderAdapter } from './infrastructure/adapters/markdown-frontmatter-reader-adapter.js';
15
20
  import { RegexPointerExtractorAdapter } from './infrastructure/adapters/regex-pointer-extractor-adapter.js';
16
21
  import { CheckFreshnessHandler } from './presentation/handlers/check-freshness-handler.js';
22
+ import { CheckInitialCreationExpirationHandler } from './presentation/handlers/check-initial-creation-expiration-handler.js';
17
23
  import { GenerateE2ETemplateHandler } from './presentation/handlers/generate-e2e-template-handler.js';
18
24
  import { ValidatePointersHandler } from './presentation/handlers/validate-pointers-handler.js';
19
25
 
@@ -24,8 +30,13 @@ export function buildPhase2Extensions(projectRoot: string, config?: HarnessConfi
24
30
  const pointerExtractor = new RegexPointerExtractorAdapter(projectRoot);
25
31
  const pointerResolver = new FileSystemPointerResolverAdapter(projectRoot);
26
32
 
33
+ const initialCreationConfigAdapter = new HarnessConfigInitialCreationExpirationAdapter(config);
34
+ const frontmatterReader = new MarkdownFrontmatterReaderAdapter(projectRoot);
35
+ const initialCreationAge = new GitLogInitialCreationAgeAdapter(projectRoot);
36
+
27
37
  const freshnessCheckService = new FreshnessCheckService();
28
38
  const pointerResolutionService = new PointerResolutionService(pointerResolver);
39
+ const initialCreationExpirationCheckService = new InitialCreationExpirationCheckService();
29
40
 
30
41
  const checkDocFreshnessUseCase = new CheckDocFreshnessUseCase(
31
42
  configAdapter,
@@ -40,13 +51,24 @@ export function buildPhase2Extensions(projectRoot: string, config?: HarnessConfi
40
51
  pointerResolutionService,
41
52
  );
42
53
  const generateE2ETemplateUseCase = new GenerateE2ETemplateUseCase();
54
+ const checkInitialCreationExpirationUseCase = new CheckInitialCreationExpirationUseCase(
55
+ initialCreationConfigAdapter,
56
+ documentScanner,
57
+ frontmatterReader,
58
+ initialCreationAge,
59
+ initialCreationExpirationCheckService,
60
+ );
43
61
 
44
62
  return {
45
63
  checkDocFreshnessUseCase,
46
64
  validateDocPointersUseCase,
47
65
  generateE2ETemplateUseCase,
66
+ checkInitialCreationExpirationUseCase,
48
67
  checkFreshnessHandler: new CheckFreshnessHandler(checkDocFreshnessUseCase),
49
68
  validatePointersHandler: new ValidatePointersHandler(validateDocPointersUseCase),
50
69
  generateE2ETemplateHandler: new GenerateE2ETemplateHandler(generateE2ETemplateUseCase),
70
+ checkInitialCreationExpirationHandler: new CheckInitialCreationExpirationHandler(
71
+ checkInitialCreationExpirationUseCase,
72
+ ),
51
73
  };
52
74
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit phase2-extensions
4
+ */
5
+ import { Phase2ExtensionsDomainError } from '../errors/phase2-extensions-domain-error.js';
6
+
7
+ export type InitialCreationEvaluationMode = 'or' | 'and';
8
+
9
+ export interface InitialCreationExpirationRuleProps {
10
+ ruleId: string;
11
+ documentPattern: string;
12
+ daysThreshold: number;
13
+ commitCountThreshold: number;
14
+ evaluationMode: InitialCreationEvaluationMode;
15
+ enabled: boolean;
16
+ }
17
+
18
+ function escapeRegex(value: string): string {
19
+ return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
20
+ }
21
+
22
+ function toPatternRegex(pattern: string): RegExp {
23
+ let regex = '^';
24
+
25
+ for (let index = 0; index < pattern.length; index += 1) {
26
+ const current = pattern[index];
27
+ const next = pattern[index + 1];
28
+ const afterNext = pattern[index + 2];
29
+
30
+ if (current === '*' && next === '*') {
31
+ if (afterNext === '/') {
32
+ regex += '(?:.*/)?';
33
+ index += 2;
34
+ } else {
35
+ regex += '.*';
36
+ index += 1;
37
+ }
38
+ continue;
39
+ }
40
+
41
+ if (current === '*') {
42
+ regex += '[^/]*';
43
+ continue;
44
+ }
45
+
46
+ regex += escapeRegex(current);
47
+ }
48
+
49
+ return new RegExp(`${regex}$`);
50
+ }
51
+
52
+ export class InitialCreationExpirationRule {
53
+ readonly ruleId: string;
54
+ readonly documentPattern: string;
55
+ readonly daysThreshold: number;
56
+ readonly commitCountThreshold: number;
57
+ readonly evaluationMode: InitialCreationEvaluationMode;
58
+ readonly enabled: boolean;
59
+ private readonly patternRegex: RegExp;
60
+
61
+ private constructor(props: InitialCreationExpirationRuleProps) {
62
+ this.ruleId = props.ruleId;
63
+ this.documentPattern = props.documentPattern;
64
+ this.daysThreshold = props.daysThreshold;
65
+ this.commitCountThreshold = props.commitCountThreshold;
66
+ this.evaluationMode = props.evaluationMode;
67
+ this.enabled = props.enabled;
68
+ this.patternRegex = toPatternRegex(props.documentPattern);
69
+ Object.freeze(this);
70
+ }
71
+
72
+ static create(props: InitialCreationExpirationRuleProps): InitialCreationExpirationRule {
73
+ if (props.ruleId.trim().length === 0) {
74
+ throw new Phase2ExtensionsDomainError('L4-247', 'ruleId は空文字不可です');
75
+ }
76
+
77
+ if (props.documentPattern.trim().length === 0) {
78
+ throw new Phase2ExtensionsDomainError('L4-248', 'documentPattern は空文字不可です');
79
+ }
80
+
81
+ if (!Number.isInteger(props.daysThreshold) || props.daysThreshold <= 0) {
82
+ throw new Phase2ExtensionsDomainError('L4-244', 'daysThreshold は正の整数 (0 超) である必要があります');
83
+ }
84
+
85
+ if (!Number.isInteger(props.commitCountThreshold) || props.commitCountThreshold <= 0) {
86
+ throw new Phase2ExtensionsDomainError('L4-245', 'commitCountThreshold は正の整数 (0 超) である必要があります');
87
+ }
88
+
89
+ if (props.evaluationMode !== 'or' && props.evaluationMode !== 'and') {
90
+ throw new Phase2ExtensionsDomainError('L4-246', 'evaluationMode が不正です');
91
+ }
92
+
93
+ return new InitialCreationExpirationRule(props);
94
+ }
95
+
96
+ isEnabled(): boolean {
97
+ return this.enabled;
98
+ }
99
+
100
+ matchesDocument(documentPath: string): boolean {
101
+ return this.patternRegex.test(documentPath);
102
+ }
103
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit phase2-extensions
4
+ */
5
+ export interface FrontmatterFlags {
6
+ initialCreation: boolean;
7
+ }
8
+
9
+ export interface FrontmatterReadResult {
10
+ filePath: string;
11
+ flags: FrontmatterFlags | null;
12
+ parseError: string | null;
13
+ }
14
+
15
+ export interface FrontmatterReaderPort {
16
+ read(filePath: string): Promise<FrontmatterReadResult>;
17
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit phase2-extensions
4
+ */
5
+ import type { InitialCreationAge } from '../value-objects/initial-creation-age.js';
6
+
7
+ export interface InitialCreationAgePort {
8
+ getAge(filePath: string): Promise<InitialCreationAge>;
9
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit phase2-extensions
4
+ */
5
+ import type { InitialCreationExpirationRule } from '../aggregates/initial-creation-expiration-rule.js';
6
+
7
+ export interface InitialCreationExpirationConfigPort {
8
+ loadRules(): Promise<InitialCreationExpirationRule[]>;
9
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit phase2-extensions
4
+ */
5
+ import type { InitialCreationExpirationRule } from '../aggregates/initial-creation-expiration-rule.js';
6
+ import type { InitialCreationAge, InitialCreationAgeSource } from '../value-objects/initial-creation-age.js';
7
+
8
+ export type InitialCreationExpirationLevel = 'ok' | 'warn';
9
+
10
+ export interface InitialCreationExpirationResult {
11
+ ruleId: string;
12
+ documentPath: string;
13
+ ageInDays: number;
14
+ commitCount: number;
15
+ ageSource: InitialCreationAgeSource;
16
+ level: InitialCreationExpirationLevel;
17
+ message: string;
18
+ }
19
+
20
+ export class InitialCreationExpirationCheckService {
21
+ check(
22
+ rule: InitialCreationExpirationRule,
23
+ age: InitialCreationAge,
24
+ documentPath: string,
25
+ ): InitialCreationExpirationResult {
26
+ if (!rule.isEnabled()) {
27
+ return {
28
+ ruleId: rule.ruleId,
29
+ documentPath,
30
+ ageInDays: age.ageInDays,
31
+ commitCount: age.commitCount,
32
+ ageSource: age.source,
33
+ level: 'ok',
34
+ message: 'disabled rule skipped',
35
+ };
36
+ }
37
+
38
+ const daysHit = age.ageInDays >= rule.daysThreshold;
39
+ const commitHit = age.commitCount >= rule.commitCountThreshold;
40
+ const expired = rule.evaluationMode === 'or' ? daysHit || commitHit : daysHit && commitHit;
41
+
42
+ return {
43
+ ruleId: rule.ruleId,
44
+ documentPath,
45
+ ageInDays: age.ageInDays,
46
+ commitCount: age.commitCount,
47
+ ageSource: age.source,
48
+ level: expired ? 'warn' : 'ok',
49
+ message: expired
50
+ ? `${documentPath} has been initial_creation:true for ${age.ageInDays} days (commits=${age.commitCount})`
51
+ : `${documentPath} within threshold`,
52
+ };
53
+ }
54
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit phase2-extensions
4
+ */
5
+ import { Phase2ExtensionsDomainError } from '../errors/phase2-extensions-domain-error.js';
6
+
7
+ export type InitialCreationAgeSource = 'git-log' | 'file-mtime';
8
+
9
+ export interface InitialCreationAgeProps {
10
+ ageInDays: number;
11
+ commitCount: number;
12
+ source: InitialCreationAgeSource;
13
+ }
14
+
15
+ export class InitialCreationAge {
16
+ readonly ageInDays: number;
17
+ readonly commitCount: number;
18
+ readonly source: InitialCreationAgeSource;
19
+
20
+ private constructor(props: InitialCreationAgeProps) {
21
+ this.ageInDays = props.ageInDays;
22
+ this.commitCount = props.commitCount;
23
+ this.source = props.source;
24
+ Object.freeze(this);
25
+ }
26
+
27
+ static create(props: InitialCreationAgeProps): InitialCreationAge {
28
+ if (!Number.isFinite(props.ageInDays) || props.ageInDays < 0) {
29
+ throw new Phase2ExtensionsDomainError('L4-241', 'ageInDays は 0 以上である必要があります');
30
+ }
31
+
32
+ if (!Number.isFinite(props.commitCount) || props.commitCount < 1) {
33
+ throw new Phase2ExtensionsDomainError('L4-242', 'commitCount は 1 以上である必要があります');
34
+ }
35
+
36
+ if (props.source !== 'git-log' && props.source !== 'file-mtime') {
37
+ throw new Phase2ExtensionsDomainError('L4-243', 'source が不正です');
38
+ }
39
+
40
+ return new InitialCreationAge({
41
+ ageInDays: Math.floor(props.ageInDays),
42
+ commitCount: Math.floor(props.commitCount),
43
+ source: props.source,
44
+ });
45
+ }
46
+
47
+ equals(other: InitialCreationAge): boolean {
48
+ return (
49
+ this.ageInDays === other.ageInDays &&
50
+ this.commitCount === other.commitCount &&
51
+ this.source === other.source
52
+ );
53
+ }
54
+ }
@@ -17,13 +17,19 @@ export class GitLogDocumentAgeAdapter implements DocumentAgePort {
17
17
  constructor(
18
18
  private readonly projectRoot: string,
19
19
  private readonly nowProvider: () => Date = () => new Date(),
20
- private readonly gitLogExecutor: (command: string, options: { cwd: string }) => Buffer = execSync,
20
+ private readonly gitLogExecutor: (
21
+ command: string,
22
+ options: { cwd: string; stdio?: readonly ['pipe', 'pipe', 'pipe'] },
23
+ ) => Buffer = execSync,
21
24
  ) {}
22
25
 
23
26
  async getAge(documentPath: string): Promise<DocumentAge> {
24
27
  try {
25
28
  const output = this.gitLogExecutor(`git log --format=%ai -1 -- "${documentPath}"`, {
26
29
  cwd: this.projectRoot,
30
+ // ISSUE-005 P1-3: fresh repo では "fatal: your current branch ... does not have
31
+ // any commits yet" が 34回 stderr に漏れる。pipe に束ねて静音化する。
32
+ stdio: ['pipe', 'pipe', 'pipe'] as const,
27
33
  })
28
34
  .toString()
29
35
  .trim();
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @layer infrastructure
3
+ * @unit phase2-extensions
4
+ */
5
+ import { execSync } from 'node:child_process';
6
+ import * as fs from 'node:fs/promises';
7
+ import * as path from 'node:path';
8
+ import type { InitialCreationAgePort } from '../../domain/ports/initial-creation-age-port.js';
9
+ import { InitialCreationAge } from '../../domain/value-objects/initial-creation-age.js';
10
+
11
+ type GitExecutor = (
12
+ command: string,
13
+ options: { cwd: string; stdio?: readonly ['pipe', 'pipe', 'pipe'] },
14
+ ) => Buffer;
15
+
16
+ function diffInDays(now: Date, past: Date): number {
17
+ const millisecondsPerDay = 1000 * 60 * 60 * 24;
18
+ return Math.max(0, Math.floor((now.getTime() - past.getTime()) / millisecondsPerDay));
19
+ }
20
+
21
+ export class GitLogInitialCreationAgeAdapter implements InitialCreationAgePort {
22
+ constructor(
23
+ private readonly projectRoot: string,
24
+ private readonly nowProvider: () => Date = () => new Date(),
25
+ private readonly gitExecutor: GitExecutor = execSync,
26
+ ) {}
27
+
28
+ async getAge(filePath: string): Promise<InitialCreationAge> {
29
+ const dateOutput = this.runGit(`git log --diff-filter=A --format=%ai -- "${filePath}"`);
30
+ const countOutput = this.runGit(`git rev-list --count HEAD -- "${filePath}"`);
31
+
32
+ if (dateOutput !== null && dateOutput.length > 0) {
33
+ const commitCount = this.parseCount(countOutput);
34
+
35
+ return InitialCreationAge.create({
36
+ ageInDays: diffInDays(this.nowProvider(), new Date(dateOutput.split('\n')[0].trim())),
37
+ commitCount: commitCount > 0 ? commitCount : 1,
38
+ source: 'git-log',
39
+ });
40
+ }
41
+
42
+ return this.fileMtimeFallback(filePath);
43
+ }
44
+
45
+ private runGit(command: string): string | null {
46
+ try {
47
+ return this.gitExecutor(command, {
48
+ cwd: this.projectRoot,
49
+ // ISSUE-005 P1-3 と同様に、fresh repo の fatal stderr を静音化する。
50
+ stdio: ['pipe', 'pipe', 'pipe'] as const,
51
+ })
52
+ .toString()
53
+ .trim();
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ private parseCount(output: string | null): number {
60
+ if (output === null || output.length === 0) {
61
+ return 1;
62
+ }
63
+
64
+ const parsed = Number.parseInt(output.trim(), 10);
65
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : 1;
66
+ }
67
+
68
+ private async fileMtimeFallback(filePath: string): Promise<InitialCreationAge> {
69
+ const stat = await fs.stat(path.resolve(this.projectRoot, filePath));
70
+
71
+ return InitialCreationAge.create({
72
+ ageInDays: diffInDays(this.nowProvider(), stat.mtime),
73
+ commitCount: 1,
74
+ source: 'file-mtime',
75
+ });
76
+ }
77
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @layer infrastructure
3
+ * @unit phase2-extensions
4
+ */
5
+ import type { HarnessConfigV2 } from '../../../config-foundation/domain/harness-config.js';
6
+ import { InitialCreationExpirationRule } from '../../domain/aggregates/initial-creation-expiration-rule.js';
7
+ import type { InitialCreationExpirationConfigPort } from '../../domain/ports/initial-creation-expiration-config-port.js';
8
+
9
+ type Phase2InitialCreationConfig = {
10
+ phase2Extensions?: {
11
+ initialCreationExpirationRules?: Array<{
12
+ ruleId: string;
13
+ documentPattern: string;
14
+ daysThreshold: number;
15
+ commitCountThreshold: number;
16
+ evaluationMode: 'or' | 'and';
17
+ enabled?: boolean;
18
+ }>;
19
+ };
20
+ };
21
+
22
+ const DEFAULT_RULE_CONFIG = {
23
+ ruleId: 'default-initial-creation-expiration',
24
+ documentPattern: 'docs/**/*.md',
25
+ daysThreshold: 90,
26
+ commitCountThreshold: 5,
27
+ evaluationMode: 'or' as const,
28
+ enabled: true,
29
+ };
30
+
31
+ export class HarnessConfigInitialCreationExpirationAdapter
32
+ implements InitialCreationExpirationConfigPort
33
+ {
34
+ constructor(private readonly config?: HarnessConfigV2 | Phase2InitialCreationConfig) {}
35
+
36
+ async loadRules(): Promise<InitialCreationExpirationRule[]> {
37
+ const configRules =
38
+ this.config && 'phase2Extensions' in this.config
39
+ ? this.config.phase2Extensions?.initialCreationExpirationRules
40
+ : undefined;
41
+
42
+ if (!configRules || configRules.length === 0) {
43
+ return [InitialCreationExpirationRule.create(DEFAULT_RULE_CONFIG)];
44
+ }
45
+
46
+ return configRules.map((rule) =>
47
+ InitialCreationExpirationRule.create({
48
+ ruleId: rule.ruleId,
49
+ documentPattern: rule.documentPattern,
50
+ daysThreshold: rule.daysThreshold,
51
+ commitCountThreshold: rule.commitCountThreshold,
52
+ evaluationMode: rule.evaluationMode,
53
+ enabled: rule.enabled ?? true,
54
+ }),
55
+ );
56
+ }
57
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @layer infrastructure
3
+ * @unit phase2-extensions
4
+ */
5
+ import * as fs from 'node:fs/promises';
6
+ import * as path from 'node:path';
7
+ import { parseFrontmatterFlags } from '../../../traceability-model/infrastructure/parsers/frontmatter-flag-parser.js';
8
+ import type {
9
+ FrontmatterReadResult,
10
+ FrontmatterReaderPort,
11
+ } from '../../domain/ports/frontmatter-reader-port.js';
12
+
13
+ const FRONTMATTER_INITIAL_CREATION_PATTERN =
14
+ /^---\r?\n[\s\S]*?^\s*initial_creation\s*:\s*(.+)\s*$/m;
15
+
16
+ export class MarkdownFrontmatterReaderAdapter implements FrontmatterReaderPort {
17
+ constructor(private readonly projectRoot: string) {}
18
+
19
+ async read(filePath: string): Promise<FrontmatterReadResult> {
20
+ try {
21
+ const absolutePath = path.resolve(this.projectRoot, filePath);
22
+ const content = await fs.readFile(absolutePath, 'utf8');
23
+
24
+ try {
25
+ const initialCreationMatch = FRONTMATTER_INITIAL_CREATION_PATTERN.exec(content);
26
+ if (initialCreationMatch) {
27
+ const rawValue = initialCreationMatch[1].trim();
28
+ if (rawValue !== 'true' && rawValue !== 'false') {
29
+ throw new Error(
30
+ `traceability.initial_creation の値が不正です(true/false のみ許容): ${rawValue}`,
31
+ );
32
+ }
33
+ }
34
+
35
+ const parsed = parseFrontmatterFlags(content);
36
+ return {
37
+ filePath,
38
+ flags: { initialCreation: parsed.initialCreation },
39
+ parseError: null,
40
+ };
41
+ } catch (error) {
42
+ return {
43
+ filePath,
44
+ flags: null,
45
+ parseError: error instanceof Error ? error.message : 'unknown parse error',
46
+ };
47
+ }
48
+ } catch (error) {
49
+ return {
50
+ filePath,
51
+ flags: null,
52
+ parseError: error instanceof Error ? `read failed: ${error.message}` : 'read failed',
53
+ };
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @layer presentation
3
+ * @unit phase2-extensions
4
+ */
5
+ import type { CheckInitialCreationExpirationOutput } from '../../application/dto/check-initial-creation-expiration-output.js';
6
+
7
+ export class InitialCreationExpirationResultFormatter {
8
+ formatJson(result: CheckInitialCreationExpirationOutput): string {
9
+ return JSON.stringify(result, null, 2);
10
+ }
11
+
12
+ formatText(result: CheckInitialCreationExpirationOutput): string {
13
+ const header = `total=${result.summary.total} ok=${result.summary.ok} warn=${result.summary.warn}`;
14
+ const entries = result.results.map(
15
+ (entry) =>
16
+ `${entry.level}: ${entry.documentPath} (ageInDays=${entry.ageInDays}, commitCount=${entry.commitCount}, source=${entry.ageSource})`,
17
+ );
18
+ const warnings = result.warnings.map((warning) => `warning ${warning.code}: ${warning.message}`);
19
+ const errors = result.errors.map((error) => `error ${error.code}: ${error.message}`);
20
+
21
+ return [header, ...entries, ...warnings, ...errors].join('\n');
22
+ }
23
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @layer presentation
3
+ * @unit phase2-extensions
4
+ */
5
+ import type { CheckInitialCreationExpirationUseCase } from '../../application/usecases/check-initial-creation-expiration-usecase.js';
6
+ import { InitialCreationExpirationResultFormatter } from '../formatters/initial-creation-expiration-result-formatter.js';
7
+
8
+ export class CheckInitialCreationExpirationHandler {
9
+ private readonly formatter = new InitialCreationExpirationResultFormatter();
10
+
11
+ constructor(private readonly useCase: CheckInitialCreationExpirationUseCase) {}
12
+
13
+ async handle(args: string[]): Promise<{ exitCode: number; stdout: string }> {
14
+ let targetPattern: string | undefined;
15
+ let format: 'text' | 'json' = 'text';
16
+ let dryRun = false;
17
+
18
+ for (let index = 0; index < args.length; index += 1) {
19
+ const arg = args[index];
20
+ if (arg === '--pattern') {
21
+ targetPattern = args[index + 1];
22
+ index += 1;
23
+ } else if (arg === '--format') {
24
+ format = (args[index + 1] as 'text' | 'json') ?? 'text';
25
+ index += 1;
26
+ } else if (arg === '--dry-run') {
27
+ dryRun = true;
28
+ }
29
+ }
30
+
31
+ const result = await this.useCase.execute({ targetPattern, format, dryRun });
32
+
33
+ return {
34
+ exitCode: result.errors.length > 0 ? 1 : 0,
35
+ stdout: format === 'json' ? this.formatter.formatJson(result) : this.formatter.formatText(result),
36
+ };
37
+ }
38
+ }
@@ -42,9 +42,17 @@ export class GitDiffChangedFilesAdapter {
42
42
  getChangedFiles(): readonly ChangedFile[] {
43
43
  let output: string;
44
44
 
45
+ // ISSUE-005 P1-3: fresh repo (HEAD 未作成) では `git diff ... HEAD` が fatal
46
+ // になるため、HEAD の存在を事前確認して命令を切り替える
47
+ const hasHead = this.hasHead();
48
+ const command = hasHead
49
+ ? 'git diff --name-status --cached HEAD'
50
+ : 'git diff --name-status --cached';
51
+
45
52
  try {
46
- output = childProcess.execSync('git diff --name-status --cached HEAD', {
53
+ output = childProcess.execSync(command, {
47
54
  encoding: 'utf8',
55
+ stdio: ['pipe', 'pipe', 'pipe'],
48
56
  }) as string;
49
57
  } catch (err) {
50
58
  const message = err instanceof Error ? err.message : String(err);
@@ -89,4 +97,15 @@ export class GitDiffChangedFilesAdapter {
89
97
 
90
98
  return Object.freeze(files);
91
99
  }
100
+
101
+ private hasHead(): boolean {
102
+ try {
103
+ childProcess.execSync('git rev-parse --verify HEAD', {
104
+ stdio: ['pipe', 'pipe', 'pipe'],
105
+ });
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
92
111
  }
@@ -40,7 +40,10 @@ export function buildRegressionSuite(baseDir: string): RegressionSuiteCompositio
40
40
  const suiteRegistryPort = new StaticSuiteRegistryAdapter();
41
41
  const testRunnerPort = new VitestTestRunnerAdapter();
42
42
  const configQueryPort = new HarnessConfigQueryAdapter();
43
- const ciGateResultWriterPort = new JsonCiGateResultWriterAdapter(baseDir);
43
+ // ISSUE-005 P2-7: リポジトリ直下ではなく reports/regression/ 配下に出力する
44
+ const ciGateResultWriterPort = new JsonCiGateResultWriterAdapter(
45
+ path.join(baseDir, 'reports', 'regression'),
46
+ );
44
47
  const importAnalyzerPort = new BiomeAstImportAnalyzerAdapter();
45
48
  const v0SpecReaderPort = new FileSystemV0SpecReaderAdapter(baseDir);
46
49
  const migrationMappingRepositoryPort = new MarkdownMigrationMappingRepositoryAdapter(
@@ -391,3 +391,33 @@ export async function deployHuskyHook(
391
391
 
392
392
  return { created: true, path: targetPath };
393
393
  }
394
+
395
+ export interface DeployCodexHooksResult {
396
+ created: boolean;
397
+ path: string;
398
+ }
399
+
400
+ /**
401
+ * Codex CLI 向け hooks.json を対象プロジェクトの .codex/ にデプロイする。
402
+ * 既存があればスキップする (ユーザー定義 hooks を上書きしない)。
403
+ * ISSUE-013 Wave 2: --agent codex フラグ経由で init から呼び出される。
404
+ */
405
+ export async function deployCodexHooks(
406
+ harnessRoot: string,
407
+ projectRoot: string,
408
+ ): Promise<DeployCodexHooksResult> {
409
+ const targetPath = join(projectRoot, '.codex', 'hooks.json');
410
+
411
+ try {
412
+ await fs.access(targetPath);
413
+ return { created: false, path: targetPath };
414
+ } catch {
415
+ // 配置先が存在しない場合は新規作成
416
+ }
417
+
418
+ const sourcePath = join(harnessRoot, 'templates', '.codex', 'hooks.json');
419
+ await fs.mkdir(join(projectRoot, '.codex'), { recursive: true });
420
+ await fs.copyFile(sourcePath, targetPath);
421
+
422
+ return { created: true, path: targetPath };
423
+ }