phasegate 0.44.0 → 0.63.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 (52) hide show
  1. package/README.ja.md +32 -0
  2. package/README.md +33 -0
  3. package/docs/guide/codex-integration.md +162 -0
  4. package/docs/guide/quick-vs-full-mode.md +141 -0
  5. package/package.json +1 -1
  6. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +60 -0
  7. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +299 -0
  8. package/scripts/harness/agent-integration/presentation/session-start-hook.ts +54 -0
  9. package/scripts/harness/agent-integration/presentation/user-prompt-submit-hook.ts +70 -0
  10. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +15 -0
  11. package/scripts/harness/integrations/pre-commit.ts +128 -28
  12. package/scripts/harness/main.ts +87 -9
  13. package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-input.ts +9 -0
  14. package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-output.ts +17 -0
  15. package/scripts/harness/phase2-extensions/application/usecases/check-initial-creation-expiration-usecase.ts +103 -0
  16. package/scripts/harness/phase2-extensions/composition-root.ts +22 -0
  17. package/scripts/harness/phase2-extensions/domain/aggregates/initial-creation-expiration-rule.ts +103 -0
  18. package/scripts/harness/phase2-extensions/domain/ports/frontmatter-reader-port.ts +17 -0
  19. package/scripts/harness/phase2-extensions/domain/ports/initial-creation-age-port.ts +9 -0
  20. package/scripts/harness/phase2-extensions/domain/ports/initial-creation-expiration-config-port.ts +9 -0
  21. package/scripts/harness/phase2-extensions/domain/services/initial-creation-expiration-check-service.ts +54 -0
  22. package/scripts/harness/phase2-extensions/domain/value-objects/initial-creation-age.ts +54 -0
  23. package/scripts/harness/phase2-extensions/infrastructure/adapters/git-log-initial-creation-age-adapter.ts +77 -0
  24. package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-initial-creation-expiration-adapter.ts +57 -0
  25. package/scripts/harness/phase2-extensions/infrastructure/adapters/markdown-frontmatter-reader-adapter.ts +56 -0
  26. package/scripts/harness/phase2-extensions/presentation/formatters/initial-creation-expiration-result-formatter.ts +23 -0
  27. package/scripts/harness/phase2-extensions/presentation/handlers/check-initial-creation-expiration-handler.ts +38 -0
  28. package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +20 -0
  29. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +83 -0
  30. package/scripts/harness/quick-mode/composition-root.ts +12 -0
  31. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +38 -32
  32. package/scripts/harness/quick-mode/domain/value-objects/quick-mode-config.ts +36 -4
  33. package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +6 -0
  34. package/scripts/harness/quick-mode/presentation/formatters/change-category-formatter.ts +42 -0
  35. package/scripts/harness/quick-mode/presentation/handlers/check-change-category-handler.ts +50 -0
  36. package/scripts/harness/setup/skill-deployer.ts +30 -0
  37. package/scripts/harness/traceability-model/composition-root.ts +14 -0
  38. package/scripts/harness/traceability-model/domain/value-objects/project-relative-path.ts +3 -0
  39. package/scripts/harness/traceability-model/infrastructure/parsers/markdown-story-annotation-parser.ts +39 -4
  40. package/scripts/harness/traceability-model/presentation/cli/validate-metadata-command-handler.ts +103 -9
  41. package/skills/domain-designer/SKILL.md +34 -0
  42. package/skills/it-test-logic-designer/SKILL.md +16 -0
  43. package/skills/logical-designer/SKILL.md +34 -0
  44. package/skills/quick-implementor/SKILL.md +10 -1
  45. package/skills/scenario-test-logic-designer/SKILL.md +16 -0
  46. package/skills/story-implementor/SKILL.md +58 -0
  47. package/skills/unit-designer/SKILL.md +41 -0
  48. package/skills/unit-test-logic-designer/SKILL.md +18 -0
  49. package/templates/.codex/hooks.json +63 -0
  50. package/templates/logical_design.template.md +79 -0
  51. package/templates/source.template.ts +18 -0
  52. package/templates/test.template.ts +37 -0
@@ -29,7 +29,7 @@ import { buildCiGovernance } from './ci-governance/composition-root.js';
29
29
  import { createSkillQualityHandlers } from './skill-quality/composition-root.js';
30
30
  import { buildRegressionSuite } from './regression-suite/composition-root.js';
31
31
  import { buildPhase2Extensions } from './phase2-extensions/composition-root.js';
32
- import { deploySkills, deployHookScripts, getDeployedVersion, getHarnessVersion, initHarnessConfig, deployDesignDocs, deployHuskyHook, SKILL_CATEGORIES, getCategoryForSkill } from './setup/skill-deployer.js';
32
+ import { deploySkills, deployHookScripts, getDeployedVersion, getHarnessVersion, initHarnessConfig, deployDesignDocs, deployHuskyHook, deployCodexHooks, SKILL_CATEGORIES, getCategoryForSkill } from './setup/skill-deployer.js';
33
33
  import type { SkillSet } from './setup/skill-deployer.js';
34
34
  import type { HarnessConfigV2 } from './config-foundation/domain/harness-config.js';
35
35
  import { ConfigValidationError } from './config-foundation/domain/errors/config-validation-error.js';
@@ -53,7 +53,7 @@ Usage: phasegate <command> [options]
53
53
 
54
54
  Setup:
55
55
  init Initialize project: deploy skills + design docs + phasegate.config.json
56
- (--name <project-name>, --preset <full|standard|minimal|custom>, --with-husky)
56
+ (--name <project-name>, --preset <full|standard|minimal|custom>, --agent <claude|codex|both>, --with-husky)
57
57
  update-skills Re-deploy skills from current harness version
58
58
 
59
59
  Commands:
@@ -106,7 +106,8 @@ Commands:
106
106
  p2:check-freshness Check doc freshness (--pattern <glob>, --dry-run, --format text|json)
107
107
  p2:validate-pointers Validate doc pointers (--include-urls, --format text|json)
108
108
  p2:generate-e2e-template Generate E2E test template (--phase <phase>, --output <path>)
109
- hook <pre-tool-use|post-tool-use|stop> Run Claude Code hook (reads JSON from stdin)
109
+ p2:check-initial-creation Detect long-lived initial_creation:true docs (--pattern <glob>, --format text|json)
110
+ hook <pre-tool-use|post-tool-use|stop|session-start|user-prompt-submit> Run agent hook (reads JSON from stdin; writes JSON to stdout for session-start/user-prompt-submit)
110
111
  pre-commit Run L2 pre-commit validators on staged files
111
112
  delegate-sonnet [...args] Delegate task to Sonnet 4.6 (forwards args to scripts/delegate-sonnet.sh)
112
113
 
@@ -400,9 +401,22 @@ async function main(): Promise<void> {
400
401
  process.exit(2);
401
402
  }
402
403
  const skillSet: SkillSet = skillSetRaw;
404
+ const agentRaw = parseFlag(args, '--agent') ?? 'claude';
405
+ if (agentRaw !== 'claude' && agentRaw !== 'codex' && agentRaw !== 'both') {
406
+ console.error(`Invalid --agent value: "${agentRaw}". Use "claude", "codex", or "both".`);
407
+ process.exit(2);
408
+ }
409
+ const agent = agentRaw;
410
+ const deployClaude = agent === 'claude' || agent === 'both';
411
+ const deployCodex = agent === 'codex' || agent === 'both';
403
412
  const result = await deploySkills(harnessRoot, rootDir, skillSet);
404
413
  const configResult = await initHarnessConfig(rootDir, projectName, phasePreset);
405
- const hooksResult = await deployHookScripts(harnessRoot, rootDir);
414
+ const hooksResult = deployClaude
415
+ ? await deployHookScripts(harnessRoot, rootDir)
416
+ : { scriptsDeployed: 0, settingsCreated: false };
417
+ const codexResult = deployCodex
418
+ ? await deployCodexHooks(harnessRoot, rootDir)
419
+ : null;
406
420
  const designDocsResult = await deployDesignDocs(harnessRoot, rootDir);
407
421
  const withHusky = hasFlag(args, '--with-husky');
408
422
  const huskyResult = withHusky
@@ -422,6 +436,13 @@ async function main(): Promise<void> {
422
436
  } else if (hooksResult.scriptsDeployed > 0) {
423
437
  console.log(` .claude/settings.json already exists, skipped`);
424
438
  }
439
+ if (codexResult !== null) {
440
+ if (codexResult.created) {
441
+ console.log(`✓ .codex/hooks.json deployed`);
442
+ } else {
443
+ console.log(` .codex/hooks.json already exists, skipped`);
444
+ }
445
+ }
425
446
  if (designDocsResult.copiedFiles.length > 0) {
426
447
  console.log(`✓ Design docs deployed (${designDocsResult.copiedFiles.length} files)`);
427
448
  }
@@ -435,7 +456,7 @@ async function main(): Promise<void> {
435
456
  console.log(` .husky/pre-commit already exists, skipped`);
436
457
  }
437
458
  }
438
- console.log(`✓ Harness v${result.version} initialized`);
459
+ console.log(`✓ Harness v${result.version} initialized (agent: ${agent})`);
439
460
  console.log('');
440
461
  console.log('Next steps:');
441
462
  if (skillSet === 'core') {
@@ -444,7 +465,14 @@ async function main(): Promise<void> {
444
465
  console.log(' 1. Run the product-architect skill to start AIDLC');
445
466
  }
446
467
  console.log(' 2. Customize phasegate.config.json if needed');
447
- console.log(' 3. Edit .claude/scripts/hook-config.json to set target directories');
468
+ if (deployClaude) {
469
+ console.log(' 3. Edit .claude/scripts/hook-config.json to set target directories');
470
+ }
471
+ if (deployCodex) {
472
+ console.log(` ${deployClaude ? '4' : '3'}. Enable Codex hooks: codex features enable codex_hooks`);
473
+ console.log(` ${deployClaude ? '5' : '4'}. (Recommended) Install pre-commit backstop: rerun with --with-husky or set up husky manually`);
474
+ console.log(` See docs/guide/codex-integration.md for the native apply_patch limitation.`);
475
+ }
448
476
  process.exit(0);
449
477
  break;
450
478
  }
@@ -688,6 +716,41 @@ async function main(): Promise<void> {
688
716
  break;
689
717
  }
690
718
 
719
+ // ── quick-mode / check-change-category (H10-05) ──
720
+ case 'check-change-category': {
721
+ if (hasFlag(args, '--help')) {
722
+ process.stdout.write([
723
+ 'Usage: phasegate check-change-category --paths <csv> [options]',
724
+ '',
725
+ 'Classify changed file paths into quick-mode categories and report',
726
+ 'whether Full Mode is required.',
727
+ '',
728
+ 'Options:',
729
+ ' --paths <csv> Comma-separated file paths to classify.',
730
+ ' --format <human|json> Output format. Default: human.',
731
+ ' --fail-on-full-required Exit with code 1 when Full Mode is required.',
732
+ ' --help Show this help.',
733
+ '',
734
+ 'Examples:',
735
+ ' phasegate check-change-category --paths src/foo.ts,src/bar.ts',
736
+ ' phasegate check-change-category --paths src/foo.ts --format json',
737
+ '',
738
+ ].join('\n'));
739
+ return;
740
+ }
741
+ const mod = createQuickModeCompositionRoot();
742
+ const paths = parseFlag(args, '--paths');
743
+ const format = parseFlag(args, '--format') as 'human' | 'json' | undefined;
744
+ const failOnFullRequired = hasFlag(args, '--fail-on-full-required');
745
+ const result = await mod.checkChangeCategoryHandler.handle({
746
+ paths,
747
+ format,
748
+ failOnFullRequired,
749
+ });
750
+ process.exit(result.exitCode);
751
+ break;
752
+ }
753
+
691
754
  // ── harness-api ──
692
755
  case 'phasegate:check-ready': {
693
756
  const mod = createHarnessApiModule();
@@ -996,22 +1059,34 @@ Examples:
996
1059
  break;
997
1060
  }
998
1061
 
1062
+ case 'p2:check-initial-creation': {
1063
+ const mod = buildPhase2Extensions(rootDir, resolvedConfig ?? undefined);
1064
+ const p2args = args.slice(1);
1065
+ const result = await mod.checkInitialCreationExpirationHandler.handle(p2args);
1066
+ console.log(result.stdout);
1067
+ process.exit(result.exitCode);
1068
+ break;
1069
+ }
1070
+
999
1071
  // ── agent integration / hooks ──
1000
1072
  case 'hook': {
1001
1073
  const subCommand = args[1];
1074
+ const usage = 'Usage: phasegate hook <pre-tool-use|post-tool-use|stop|session-start|user-prompt-submit>';
1002
1075
  if (!subCommand) {
1003
- console.error('Usage: phasegate hook <pre-tool-use|post-tool-use|stop>');
1076
+ console.error(usage);
1004
1077
  process.exit(2);
1005
1078
  }
1006
1079
  const hookFileName: Record<string, string> = {
1007
1080
  'pre-tool-use': 'pre-tool-use-hook.js',
1008
1081
  'post-tool-use': 'post-tool-use-hook.js',
1009
1082
  'stop': 'stop-hook.js',
1083
+ 'session-start': 'session-start-hook.js',
1084
+ 'user-prompt-submit': 'user-prompt-submit-hook.js',
1010
1085
  };
1011
1086
  const fileName = hookFileName[subCommand];
1012
1087
  if (!fileName) {
1013
1088
  console.error(`Unknown hook subcommand: ${subCommand}`);
1014
- console.error('Usage: phasegate hook <pre-tool-use|post-tool-use|stop>');
1089
+ console.error(usage);
1015
1090
  process.exit(2);
1016
1091
  }
1017
1092
  const hookPath = join(harnessRoot, 'scripts/harness/agent-integration/presentation', fileName);
@@ -1021,7 +1096,10 @@ Examples:
1021
1096
 
1022
1097
  case 'pre-commit': {
1023
1098
  const preCommitPath = join(harnessRoot, 'scripts/harness/integrations/pre-commit.js');
1024
- await import(preCommitPath);
1099
+ const preCommitMod = (await import(preCommitPath)) as {
1100
+ runPreCommitCli: () => Promise<void>;
1101
+ };
1102
+ await preCommitMod.runPreCommitCli();
1025
1103
  break;
1026
1104
  }
1027
1105
 
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @layer application
3
+ * @unit phase2-extensions
4
+ */
5
+ export interface CheckInitialCreationExpirationInput {
6
+ targetPattern?: string;
7
+ format?: 'text' | 'json';
8
+ dryRun?: boolean;
9
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @layer application
3
+ * @unit phase2-extensions
4
+ */
5
+ import type { HarnessErrorContract } from '../../../harness-error/application/dto/harness-error-contract.js';
6
+ import type { InitialCreationExpirationResult } from '../../domain/services/initial-creation-expiration-check-service.js';
7
+
8
+ export interface CheckInitialCreationExpirationOutput {
9
+ results: InitialCreationExpirationResult[];
10
+ summary: {
11
+ total: number;
12
+ ok: number;
13
+ warn: number;
14
+ };
15
+ warnings: HarnessErrorContract[];
16
+ errors: HarnessErrorContract[];
17
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @layer application
3
+ * @unit phase2-extensions
4
+ */
5
+ import type { CheckInitialCreationExpirationInput } from '../dto/check-initial-creation-expiration-input.js';
6
+ import type { CheckInitialCreationExpirationOutput } from '../dto/check-initial-creation-expiration-output.js';
7
+ import type { InitialCreationExpirationConfigPort } from '../../domain/ports/initial-creation-expiration-config-port.js';
8
+ import type { DocumentScannerPort } from '../../domain/ports/document-scanner-port.js';
9
+ import type { FrontmatterReaderPort } from '../../domain/ports/frontmatter-reader-port.js';
10
+ import type { InitialCreationAgePort } from '../../domain/ports/initial-creation-age-port.js';
11
+ import type { InitialCreationExpirationCheckService } from '../../domain/services/initial-creation-expiration-check-service.js';
12
+ import type { HarnessErrorContract } from '../../../harness-error/application/dto/harness-error-contract.js';
13
+ import type { InitialCreationExpirationResult } from '../../domain/services/initial-creation-expiration-check-service.js';
14
+
15
+ export class CheckInitialCreationExpirationUseCase {
16
+ constructor(
17
+ private readonly configPort: InitialCreationExpirationConfigPort,
18
+ private readonly scannerPort: DocumentScannerPort,
19
+ private readonly frontmatterReaderPort: FrontmatterReaderPort,
20
+ private readonly agePort: InitialCreationAgePort,
21
+ private readonly checkService: InitialCreationExpirationCheckService,
22
+ ) {}
23
+
24
+ async execute(input: CheckInitialCreationExpirationInput): Promise<CheckInitialCreationExpirationOutput> {
25
+ try {
26
+ const allRules = await this.configPort.loadRules();
27
+ const filteredRules = input.targetPattern
28
+ ? allRules.filter((rule) => rule.documentPattern === input.targetPattern)
29
+ : allRules;
30
+
31
+ const results: InitialCreationExpirationResult[] = [];
32
+ const warnings: HarnessErrorContract[] = [];
33
+
34
+ for (const rule of filteredRules) {
35
+ if (!rule.isEnabled()) {
36
+ continue;
37
+ }
38
+
39
+ const documentPaths = await this.scannerPort.scan(rule.documentPattern);
40
+
41
+ for (const documentPath of documentPaths) {
42
+ const fmResult = await this.frontmatterReaderPort.read(documentPath);
43
+
44
+ if (fmResult.parseError !== null) {
45
+ warnings.push({
46
+ code: 'L4-232',
47
+ severity: 'warning',
48
+ message: `frontmatter parse failed for ${documentPath}: ${fmResult.parseError}`,
49
+ suggestion: 'YAML 構文を確認してください',
50
+ });
51
+ continue;
52
+ }
53
+
54
+ if (fmResult.flags === null || fmResult.flags.initialCreation !== true) {
55
+ continue;
56
+ }
57
+
58
+ const age = await this.agePort.getAge(documentPath);
59
+ const checkResult = this.checkService.check(rule, age, documentPath);
60
+ results.push(checkResult);
61
+
62
+ if (checkResult.level === 'warn') {
63
+ warnings.push({
64
+ code: 'L4-231',
65
+ severity: 'warning',
66
+ message: checkResult.message,
67
+ suggestion: 'frontmatter を削除し @story-id 注釈を付与してください',
68
+ });
69
+ }
70
+ }
71
+ }
72
+
73
+ const summary = {
74
+ total: results.length,
75
+ ok: results.filter((result) => result.level === 'ok').length,
76
+ warn: results.filter((result) => result.level === 'warn').length,
77
+ };
78
+
79
+ return {
80
+ results,
81
+ summary,
82
+ warnings,
83
+ errors: [],
84
+ };
85
+ } catch (error) {
86
+ const message = error instanceof Error ? error.message : 'unknown error';
87
+
88
+ return {
89
+ results: [],
90
+ summary: { total: 0, ok: 0, warn: 0 },
91
+ warnings: [],
92
+ errors: [
93
+ {
94
+ code: 'L4-299',
95
+ severity: 'error',
96
+ message,
97
+ suggestion: 'phasegate.config.json を確認してください',
98
+ },
99
+ ],
100
+ };
101
+ }
102
+ }
103
+ }
@@ -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
+ }