phasegate 0.340.0 → 0.341.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 (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/folder_management_rules.md +10 -1
  3. package/docs/guide/cli-reference.md +23 -3
  4. package/docs/guide/configuration.md +28 -2
  5. package/docs/guide/hooks-integration.md +7 -1
  6. package/docs/guide/skills-overview.md +3 -0
  7. package/package.json +2 -1
  8. package/scripts/harness/agent-integration/application/usecases/handle-post-tool-use-usecase.ts +4 -0
  9. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +36 -1
  10. package/scripts/harness/agent-integration/application/usecases/handle-stop-usecase.ts +3 -9
  11. package/scripts/harness/agent-integration/domain/ports/story-reflection-query-port.ts +2 -0
  12. package/scripts/harness/agent-integration/domain/services/hook-to-cli-translator.ts +8 -5
  13. package/scripts/harness/agent-integration/domain/value-objects/hook-translation-result.ts +1 -1
  14. package/scripts/harness/agent-integration/domain/value-objects/story-reflection-query-result.ts +10 -2
  15. package/scripts/harness/agent-integration/domain/value-objects/write-target-scope.ts +12 -0
  16. package/scripts/harness/agent-integration/infrastructure/adapters/child-process-cli-executor-adapter.ts +64 -13
  17. package/scripts/harness/agent-integration/infrastructure/adapters/file-system-story-reflection-query-adapter.ts +108 -2
  18. package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +8 -6
  19. package/scripts/harness/agent-integration/presentation/hook-skip-event-recorder.ts +3 -0
  20. package/scripts/harness/agent-integration/presentation/post-tool-use-feedback.ts +39 -0
  21. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +20 -27
  22. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +3 -0
  23. package/scripts/harness/agent-integration/presentation/stop-hook.ts +1 -3
  24. package/scripts/harness/biome-ast-engine/infrastructure/adapters/biome-cli-executor-adapter.ts +21 -1
  25. package/scripts/harness/biome-ast-engine/infrastructure/adapters/typescript-source-module-analyzer-adapter.ts +5 -0
  26. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +15 -0
  27. package/scripts/harness/config-foundation/infrastructure/validators/ajv-config-schema-validator.ts +6 -3
  28. package/scripts/harness/harness-api/domain/ports/biome-lint-port.ts +2 -1
  29. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +12 -1
  30. package/scripts/harness/harness-api/infrastructure/adapters/biome-ast-engine-lint-adapter.ts +10 -2
  31. package/scripts/harness/installation/application/bundled-skill-selection.ts +28 -2
  32. package/scripts/harness/installation/application/checks/check-utils.ts +2 -2
  33. package/scripts/harness/installation/application/usecases/run-install.ts +5 -5
  34. package/scripts/harness/installation/application/usecases/run-reconcile.ts +62 -21
  35. package/scripts/harness/installation/presentation/cli/install-handler.ts +2 -1
  36. package/scripts/harness/main.ts +128 -90
  37. package/scripts/harness/phase-dependency-model/domain/services/story-reflection-checker.ts +48 -5
  38. package/scripts/harness/phase-dependency-model/domain/services/work-item-reflection-scope-resolver.ts +64 -0
  39. package/scripts/harness/phase-dependency-model/domain/values/story-reflection-mapping.ts +8 -2
  40. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-story-reflection-adapter.ts +6 -1
  41. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-work-item-dependency-catalog.ts +73 -0
  42. package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +4 -0
  43. package/scripts/harness/quick-mode/application/ports/change-risk-advisory-port.ts +16 -0
  44. package/scripts/harness/quick-mode/composition-root.ts +5 -1
  45. package/scripts/harness/quick-mode/infrastructure/adapters/snapshot-risk-advisory-adapter.ts +118 -0
  46. package/scripts/harness/quick-mode/presentation/formatters/change-category-formatter.ts +7 -0
  47. package/scripts/harness/quick-mode/presentation/handlers/check-change-category-handler.ts +12 -1
  48. package/scripts/harness/setup/skill-deployer.ts +3 -45
  49. package/scripts/harness/skill-quality/application/dto/run-plan-checker-loop-output.ts +1 -0
  50. package/scripts/harness/skill-quality/application/usecases/apply-cascade-update-usecase.ts +9 -4
  51. package/scripts/harness/skill-quality/application/usecases/execute-tdd-cycle-usecase.ts +2 -1
  52. package/scripts/harness/skill-quality/application/usecases/run-plan-checker-loop-usecase.ts +8 -0
  53. package/scripts/harness/skill-quality/composition-root.ts +14 -7
  54. package/scripts/harness/skill-quality/domain/ports/plan-check-executor-port.ts +2 -0
  55. package/scripts/harness/skill-quality/domain/value-objects/cascade-update-target.ts +15 -1
  56. package/scripts/harness/skill-quality/infrastructure/adapters/l1-biome-validator-adapter.ts +9 -1
  57. package/scripts/harness/skill-quality/infrastructure/adapters/l2-validator-system-adapter.ts +18 -2
  58. package/scripts/harness/skill-quality/presentation/handlers/apply-cascade-update-handler.ts +8 -2
  59. package/scripts/harness/skill-quality/presentation/handlers/execute-tdd-cycle-handler.ts +4 -3
  60. package/scripts/harness/skill-quality/presentation/handlers/run-plan-checker-loop-handler.ts +11 -4
  61. package/scripts/harness/traceability-model/infrastructure/parsers/work-item-frontmatter-parser.ts +41 -0
  62. package/skills/README.md +7 -0
  63. package/skills/cascade-updater/SKILL.md +37 -109
  64. package/skills/cascade-updater/references//345/261/244/345/210/245/345/210/244/345/256/232/343/202/254/343/202/244/343/203/211.md +5 -3
  65. package/skills/release-publisher/SKILL.md +3 -1
  66. package/skills/skill-creator/SKILL.md +8 -9
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * @layer domain
3
3
  * @unit phase-dependency-model
4
+ * @work-item-id WI-220
4
5
  */
5
6
 
6
7
  import type { StoryReflectionFileSystemPort } from "../ports/story-reflection-file-system-port.js";
7
8
  import type { StoryReflectionConfig } from "../values/story-reflection-config.js";
8
9
  import type { StoryReflectionMapping } from "../values/story-reflection-mapping.js";
9
10
  import { StoryReflectionResult, type StoryReflectionViolation } from "../values/story-reflection-result.js";
11
+ import type { WorkItemReflectionScope } from "./work-item-reflection-scope-resolver.js";
12
+ import type { PathRoots } from "../values/artifact.js";
10
13
 
11
14
  const CROSS_WORK_ITEM_PATTERN = /^WI-\d+$/;
12
15
 
@@ -18,13 +21,50 @@ interface ResolvedInceptionPath {
18
21
  export class StoryReflectionChecker {
19
22
  constructor(private readonly fsPort: StoryReflectionFileSystemPort) {}
20
23
 
21
- async check(unitId: string, config: StoryReflectionConfig): Promise<StoryReflectionResult> {
24
+ async check(unitId: string, config: StoryReflectionConfig, roots?: PathRoots): Promise<StoryReflectionResult> {
22
25
  if (!config.enabled) {
23
26
  return StoryReflectionResult.pass();
24
27
  }
25
28
 
26
29
  const storyIds = await this.fsPort.listStoryDirectories(unitId);
27
30
 
31
+ return this.checkItems(unitId, storyIds, config, false, roots);
32
+ }
33
+
34
+ /** Accept only a complete, caller-resolved scope; this method does not authenticate its origin. */
35
+ async checkResolvedScope(
36
+ scope: Extract<WorkItemReflectionScope, { status: 'complete' }>,
37
+ config: StoryReflectionConfig,
38
+ roots?: PathRoots,
39
+ ): Promise<StoryReflectionResult> {
40
+ if (!config.enabled) return StoryReflectionResult.pass();
41
+ if (scope.workItems.length === 0) throw new Error('Resolved reflection scope must not be empty');
42
+ const byUnit = new Map<string, Set<string>>();
43
+ for (const item of scope.workItems) {
44
+ for (const unit of item.unitIds) {
45
+ const ids = byUnit.get(unit) ?? new Set<string>();
46
+ ids.add(item.id);
47
+ byUnit.set(unit, ids);
48
+ }
49
+ }
50
+ const violations: StoryReflectionViolation[] = [];
51
+ const warnings: StoryReflectionViolation[] = [];
52
+ for (const [unit, ids] of byUnit) {
53
+ const result = await this.checkItems(unit, [...ids].sort(), config, true, roots);
54
+ violations.push(...result.violations);
55
+ warnings.push(...result.warnings);
56
+ }
57
+ return StoryReflectionResult.create({ violations, warnings });
58
+ }
59
+
60
+ private async checkItems(
61
+ unitId: string,
62
+ storyIds: readonly string[],
63
+ config: StoryReflectionConfig,
64
+ explicitScope: boolean,
65
+ roots?: PathRoots,
66
+ ): Promise<StoryReflectionResult> {
67
+
28
68
  if (storyIds.length === 0) {
29
69
  return StoryReflectionResult.pass();
30
70
  }
@@ -34,22 +74,24 @@ export class StoryReflectionChecker {
34
74
 
35
75
  for (const storyId of storyIds) {
36
76
  for (const mapping of config.mappings) {
37
- const { product: productPath } = mapping.resolve({ unitId, storyId });
77
+ const { product: productPath } = mapping.resolve({ unitId, storyId }, roots);
38
78
  const resolvedInception = await this.resolveInceptionPath({
39
79
  mapping,
40
80
  unitId,
41
81
  storyId,
82
+ roots,
42
83
  });
43
84
 
44
85
  if (resolvedInception === null) {
45
86
  continue;
46
87
  }
47
88
 
48
- if (resolvedInception.isCrossWorkItem && !(await this.fsPort.storyAffectsUnit(storyId, unitId))) {
89
+ if (!explicitScope && resolvedInception.isCrossWorkItem && !(await this.fsPort.storyAffectsUnit(storyId, unitId))) {
49
90
  continue;
50
91
  }
51
92
 
52
93
  if (
94
+ !explicitScope &&
53
95
  resolvedInception.isCrossWorkItem &&
54
96
  productPath.endsWith("domain_model.md") &&
55
97
  !(await this.fsPort.storyTouchesUnitLayer(storyId, unitId, "domain"))
@@ -83,11 +125,12 @@ export class StoryReflectionChecker {
83
125
  readonly mapping: StoryReflectionMapping;
84
126
  readonly unitId: string;
85
127
  readonly storyId: string;
128
+ readonly roots?: PathRoots;
86
129
  }): Promise<ResolvedInceptionPath | null> {
87
130
  const normalPath = input.mapping.resolve({
88
131
  unitId: input.unitId,
89
132
  storyId: input.storyId,
90
- }).inception;
133
+ }, input.roots).inception;
91
134
 
92
135
  if (await this.fsPort.fileExists(normalPath)) {
93
136
  return { path: normalPath, isCrossWorkItem: false };
@@ -100,7 +143,7 @@ export class StoryReflectionChecker {
100
143
  const crossPath = input.mapping.resolve({
101
144
  unitId: "_cross",
102
145
  storyId: input.storyId,
103
- }).inception;
146
+ }, input.roots).inception;
104
147
 
105
148
  if (await this.fsPort.fileExists(crossPath)) {
106
149
  return { path: crossPath, isCrossWorkItem: true };
@@ -0,0 +1,64 @@
1
+ // @unit phase-dependency-model
2
+ // @layer domain
3
+ // @work-item-id WI-220
4
+
5
+ export interface WorkItemDependencyRecord {
6
+ readonly id: string;
7
+ readonly unitIds: readonly string[];
8
+ /** Missing is unknown; an explicit empty list declares no dependencies. */
9
+ readonly dependsOn?: readonly string[];
10
+ }
11
+
12
+ export type WorkItemScopeUnknownCode =
13
+ | 'NO_TARGET' | 'INVALID_ID' | 'MISSING_WORK_ITEM' | 'DUPLICATE_ID'
14
+ | 'UNIT_MISMATCH' | 'UNKNOWN_UNITS' | 'UNDECLARED_DEPENDENCIES';
15
+
16
+ export type WorkItemReflectionScope =
17
+ | { readonly status: 'complete'; readonly workItems: readonly WorkItemDependencyRecord[] }
18
+ | { readonly status: 'unknown'; readonly code: WorkItemScopeUnknownCode; readonly workItemId?: string };
19
+
20
+ /** Resolves declared dependencies only; it does not authenticate a session or approve design meaning. */
21
+ export class WorkItemReflectionScopeResolver {
22
+ resolve(
23
+ unitId: string,
24
+ targetIds: readonly string[],
25
+ catalog: readonly WorkItemDependencyRecord[],
26
+ ): WorkItemReflectionScope {
27
+ const unknown = (code: WorkItemScopeUnknownCode, workItemId?: string): WorkItemReflectionScope =>
28
+ ({ status: 'unknown', code, workItemId });
29
+ if (targetIds.length === 0) return unknown('NO_TARGET');
30
+
31
+ const byId = new Map<string, WorkItemDependencyRecord[]>();
32
+ for (const item of catalog) {
33
+ const matches = byId.get(item.id);
34
+ if (matches) matches.push(item);
35
+ else byId.set(item.id, [item]);
36
+ }
37
+
38
+ const roots = new Set(targetIds);
39
+ const pending = [...roots].sort();
40
+ const visited = new Map<string, WorkItemDependencyRecord>();
41
+ // Iteration, rather than recursive traversal, also bounds work for cycles.
42
+ for (let index = 0; index < pending.length; index++) {
43
+ const id = pending[index];
44
+ if (visited.has(id)) continue;
45
+ if (!/^WI-\d+$/.test(id)) return unknown('INVALID_ID', id);
46
+ const matches = byId.get(id);
47
+ if (!matches) return unknown('MISSING_WORK_ITEM', id);
48
+ if (matches.length !== 1) return unknown('DUPLICATE_ID', id);
49
+ const item = matches[0];
50
+ if (item.unitIds.length === 0 || item.unitIds.some((unit) => !unit.trim() || unit.startsWith('_'))) {
51
+ return unknown('UNKNOWN_UNITS', id);
52
+ }
53
+ if (roots.has(id) && !item.unitIds.includes(unitId)) return unknown('UNIT_MISMATCH', id);
54
+ if (item.dependsOn === undefined) return unknown('UNDECLARED_DEPENDENCIES', id);
55
+ visited.set(id, item);
56
+ for (const dependency of new Set(item.dependsOn)) pending.push(dependency);
57
+ }
58
+
59
+ return {
60
+ status: 'complete',
61
+ workItems: [...visited.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
62
+ };
63
+ }
64
+ }
@@ -3,6 +3,8 @@
3
3
  * @unit phase-dependency-model
4
4
  */
5
5
 
6
+ import type { PathRoots } from './artifact.js';
7
+
6
8
  export interface StoryReflectionMappingCreateArgs {
7
9
  readonly inception: string;
8
10
  readonly product: string;
@@ -69,7 +71,7 @@ export class StoryReflectionMapping {
69
71
  });
70
72
  }
71
73
 
72
- resolve(scope: { unitId: string; storyId: string }): { inception: string; product: string } {
74
+ resolve(scope: { unitId: string; storyId: string }, roots?: PathRoots): { inception: string; product: string } {
73
75
  const inception = this.inception
74
76
  .replaceAll('{unit}', scope.unitId)
75
77
  .replaceAll('{storyId}', scope.storyId);
@@ -78,7 +80,11 @@ export class StoryReflectionMapping {
78
80
  .replaceAll('{unit}', scope.unitId)
79
81
  .replaceAll('{storyId}', scope.storyId);
80
82
 
81
- return { inception, product };
83
+ if (!roots) return { inception, product };
84
+ return {
85
+ inception: inception.replace(/^docs\/inception\//, () => `${roots.inceptionDocsRoot}/`),
86
+ product: product.replace(/^docs\/product\/construction\//, () => `${roots.designDocsRoot}/`),
87
+ };
82
88
  }
83
89
 
84
90
  equals(other: StoryReflectionMapping): boolean {
@@ -27,6 +27,7 @@ export interface FileSystemStoryReflectionAdapterDeps {
27
27
  readonly rootDir: string;
28
28
  /** inception ディレクトリのルート(デフォルト: docs/inception) */
29
29
  readonly inceptionRoot?: string;
30
+ readonly designDocsRoot?: string;
30
31
  }
31
32
 
32
33
  /**
@@ -43,11 +44,13 @@ export interface FileSystemStoryReflectionAdapterDeps {
43
44
  export class FileSystemStoryReflectionAdapter implements StoryReflectionFileSystemPort {
44
45
  private readonly rootDir: string;
45
46
  private readonly inceptionRoot: string;
47
+ private readonly designDocsRoot: string;
46
48
  private readonly changedPathsByStoryId = new Map<string, Promise<ReadonlySet<string>>>();
47
49
 
48
50
  constructor(deps: FileSystemStoryReflectionAdapterDeps) {
49
51
  this.rootDir = deps.rootDir;
50
52
  this.inceptionRoot = deps.inceptionRoot ?? "docs/inception";
53
+ this.designDocsRoot = deps.designDocsRoot ?? "docs/product/construction";
51
54
  }
52
55
 
53
56
  async listStoryDirectories(unitId: string): Promise<readonly string[]> {
@@ -325,7 +328,9 @@ export class FileSystemStoryReflectionAdapter implements StoryReflectionFileSyst
325
328
 
326
329
  private extractProductUnitId(productPath: string): string | null {
327
330
  const normalized = productPath.split(path.sep).join("/");
328
- const match = /^docs\/product\/construction\/([^/]+)\//.exec(normalized);
331
+ const prefix = `${this.designDocsRoot}/`;
332
+ if (!normalized.startsWith(prefix)) return null;
333
+ const match = /^([^/]+)\//.exec(normalized.slice(prefix.length));
329
334
  return match?.[1] ?? null;
330
335
  }
331
336
 
@@ -0,0 +1,73 @@
1
+ // @unit phase-dependency-model
2
+ // @layer infrastructure
3
+ // @work-item-id WI-220
4
+ import { readdir, readFile } from 'node:fs/promises';
5
+ import { join, relative, resolve, sep } from 'node:path';
6
+ import type { WorkItemDependencyRecord } from '../../domain/services/work-item-reflection-scope-resolver.js';
7
+ import type { WorkItemFrontmatter } from '../../../traceability-model/domain/value-objects/work-item-frontmatter.js';
8
+ import { parseWorkItemDependencies, parseWorkItemFrontmatter } from '../../../traceability-model/infrastructure/parsers/work-item-frontmatter-parser.js';
9
+
10
+ export interface WorkItemDependencyCatalogEntry extends WorkItemDependencyRecord {
11
+ readonly descriptionPath: string;
12
+ readonly metadata?: WorkItemFrontmatter;
13
+ readonly diagnostic?: string;
14
+ }
15
+
16
+ interface IndexedDescription {
17
+ readonly owner: string;
18
+ readonly path: string;
19
+ }
20
+
21
+ /** Directory index is global, document reads are limited to reachable WI IDs. */
22
+ export class FileSystemWorkItemDependencyCatalog {
23
+ constructor(private readonly options: { rootDir: string; inceptionRoot?: string }) {}
24
+
25
+ async load(targetIds: readonly string[]): Promise<readonly WorkItemDependencyCatalogEntry[]> {
26
+ const root = resolve(this.options.rootDir, this.options.inceptionRoot ?? 'docs/inception');
27
+ const index = new Map<string, IndexedDescription[]>();
28
+ const owners = await readdir(root, { withFileTypes: true });
29
+ for (const owner of owners.sort((a, b) => a.name.localeCompare(b.name))) {
30
+ if (!owner.isDirectory() || owner.name.startsWith('.') || (owner.name.startsWith('_') && owner.name !== '_cross')) continue;
31
+ const entries = await readdir(join(root, owner.name), { withFileTypes: true });
32
+ for (const entry of entries) {
33
+ if (!entry.isDirectory() || !/^WI-\d+$/.test(entry.name)) continue;
34
+ const descriptions = index.get(entry.name) ?? [];
35
+ descriptions.push({ owner: owner.name, path: join(root, owner.name, entry.name, 'description.md') });
36
+ index.set(entry.name, descriptions);
37
+ }
38
+ }
39
+
40
+ const pending = [...new Set(targetIds)].sort();
41
+ const visited = new Set<string>();
42
+ const result: WorkItemDependencyCatalogEntry[] = [];
43
+ for (let cursor = 0; cursor < pending.length; cursor++) {
44
+ const id = pending[cursor];
45
+ if (visited.has(id)) continue;
46
+ visited.add(id);
47
+ // Only indexed paths are read; an input ID is never used to construct a path.
48
+ for (const description of index.get(id) ?? []) {
49
+ const entry = await this.readEntry(id, description);
50
+ result.push(entry);
51
+ for (const dependency of entry.dependsOn ?? []) pending.push(dependency);
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ private async readEntry(id: string, description: IndexedDescription): Promise<WorkItemDependencyCatalogEntry> {
58
+ const descriptionPath = relative(this.options.rootDir, description.path).split(sep).join('/');
59
+ try {
60
+ const content = await readFile(description.path, 'utf8');
61
+ const metadata = parseWorkItemFrontmatter(content);
62
+ if (metadata === null || metadata.id !== id) throw new Error('WI frontmatter id must match its directory');
63
+ const dependsOn = parseWorkItemDependencies(content);
64
+ const unitIds = metadata.affects ?? (description.owner === '_cross' ? [] : [description.owner]);
65
+ if (unitIds.length === 0 || unitIds.some((unit) => !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(unit))) {
66
+ throw new Error('WI owner/affects must identify actual Units');
67
+ }
68
+ return { id, unitIds, dependsOn, descriptionPath, metadata };
69
+ } catch (error) {
70
+ return { id, unitIds: [], descriptionPath, diagnostic: error instanceof Error ? error.message : String(error) };
71
+ }
72
+ }
73
+ }
@@ -1,6 +1,9 @@
1
1
  // @unit quick-mode
2
2
  // @layer application
3
3
  // @story H10-05
4
+ // @work-item-id WI-220
5
+
6
+ import type { ChangeRiskAdvice } from '../ports/change-risk-advisory-port.js';
4
7
 
5
8
  export interface ChangeCategoryPerFile {
6
9
  readonly path: string;
@@ -8,6 +11,7 @@ export interface ChangeCategoryPerFile {
8
11
  }
9
12
 
10
13
  export interface ChangeCategoryClassificationContract {
14
+ readonly riskAdvice?: readonly ChangeRiskAdvice[];
11
15
  readonly dominantCategory: string | null;
12
16
  readonly perFile: readonly ChangeCategoryPerFile[];
13
17
  readonly fullModeRequired: boolean;
@@ -0,0 +1,16 @@
1
+ // @unit quick-mode
2
+ // @layer application
3
+ // @work-item-id WI-220
4
+ export interface ChangeRiskAdvice {
5
+ readonly path: string;
6
+ readonly kind: 'module-surface-change' | 'behavior-review' | 'unknown' | 'no-content-change';
7
+ readonly source: 'caller-snapshot';
8
+ readonly enforcement: 'none';
9
+ readonly beforeHash: string | null;
10
+ readonly afterHash: string | null;
11
+ readonly reason: string;
12
+ }
13
+
14
+ export interface ChangeRiskAdvisoryPort {
15
+ assess(paths: readonly string[], snapshotFile: string): Promise<readonly ChangeRiskAdvice[]>;
16
+ }
@@ -13,6 +13,7 @@ import { JudgeQuickModeEligibilityUseCase } from "./application/usecases/judge-q
13
13
  import { QuickModeJudgmentEngine } from "./domain/services/quick-mode-judgment-engine.js";
14
14
  import { ValidatorRelaxationService } from "./domain/services/validator-relaxation-service.js";
15
15
  import { FsFileExistenceAdapter } from "./infrastructure/adapters/fs-file-existence-adapter.js";
16
+ import { SnapshotRiskAdvisoryAdapter } from "./infrastructure/adapters/snapshot-risk-advisory-adapter.js";
16
17
  import { GitDiffChangedFilesAdapter } from "./infrastructure/adapters/git-diff-changed-files-adapter.js";
17
18
  import { HarnessConfigQuickModeConfigAdapter } from "./infrastructure/adapters/harness-config-quick-mode-config-adapter.js";
18
19
  import { ValidatorSystemQuickModeExecutionAdapter } from "./infrastructure/adapters/validator-system-quick-mode-execution-adapter.js";
@@ -86,7 +87,10 @@ export function createQuickModeCompositionRoot(
86
87
 
87
88
  // Presentation
88
89
  const handler = new CiCheckQuickModeHandler({ useCase: executeUseCase });
89
- const checkChangeCategoryHandler = new CheckChangeCategoryHandler({ useCase: classifyUseCase });
90
+ const checkChangeCategoryHandler = new CheckChangeCategoryHandler({
91
+ useCase: classifyUseCase,
92
+ riskAdvisoryPort: new SnapshotRiskAdvisoryAdapter(options.rootDir),
93
+ });
90
94
 
91
95
  return {
92
96
  handler,
@@ -0,0 +1,118 @@
1
+ // @unit quick-mode
2
+ // @layer infrastructure
3
+ // @work-item-id WI-220
4
+ import { createHash } from 'node:crypto';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { resolve } from 'node:path';
7
+ import type * as TypeScript from 'typescript';
8
+ import type { ChangeRiskAdvice, ChangeRiskAdvisoryPort } from '../../application/ports/change-risk-advisory-port.js';
9
+
10
+ interface Snapshot {
11
+ filePath: string;
12
+ beforeContent: string | null;
13
+ afterContent: string | null;
14
+ }
15
+
16
+ function isSnapshot(value: unknown): value is Snapshot {
17
+ if (typeof value !== 'object' || value === null) return false;
18
+ const item = value as Record<string, unknown>;
19
+ return typeof item.filePath === 'string' && item.filePath.length > 0
20
+ && (typeof item.beforeContent === 'string' || item.beforeContent === null)
21
+ && (typeof item.afterContent === 'string' || item.afterContent === null);
22
+ }
23
+
24
+ function hash(content: string | null): string | null {
25
+ return content === null ? null : createHash('sha256').update(content).digest('hex');
26
+ }
27
+
28
+ function classSurface(ts: typeof TypeScript, declaration: TypeScript.ClassDeclaration): TypeScript.ClassDeclaration | null {
29
+ const decorated = (node: TypeScript.Node) => ts.canHaveDecorators(node) && (ts.getDecorators(node)?.length ?? 0) > 0;
30
+ if (decorated(declaration)) return null;
31
+ const members: TypeScript.ClassElement[] = [];
32
+ for (const member of declaration.members) {
33
+ if (decorated(member)) return null;
34
+ if (ts.isMethodDeclaration(member) || ts.isConstructorDeclaration(member)) {
35
+ if (member.parameters.some(parameter => !parameter.type || parameter.initializer || decorated(parameter))) return null;
36
+ if (ts.isMethodDeclaration(member)) {
37
+ if (!member.type || member.asteriskToken) return null;
38
+ members.push(ts.factory.updateMethodDeclaration(member, member.modifiers, member.asteriskToken,
39
+ member.name, member.questionToken, member.typeParameters, member.parameters, member.type, undefined));
40
+ } else members.push(ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined));
41
+ } else if (ts.isPropertyDeclaration(member)) {
42
+ if (!member.type) return null;
43
+ members.push(ts.factory.updatePropertyDeclaration(member, member.modifiers, member.name,
44
+ member.questionToken ?? member.exclamationToken, member.type, undefined));
45
+ } else return null;
46
+ }
47
+ return ts.factory.updateClassDeclaration(declaration, declaration.modifiers, declaration.name,
48
+ declaration.typeParameters, declaration.heritageClauses, members);
49
+ }
50
+
51
+ // A deliberately bounded module-surface observation, not an API or behavior proof.
52
+ function surface(ts: typeof TypeScript, path: string, content: string): string | null {
53
+ const diagnostics = ts.transpileModule(content, { fileName: path, reportDiagnostics: true }).diagnostics ?? [];
54
+ if (diagnostics.some((item) => item.category === ts.DiagnosticCategory.Error)) return null;
55
+ const file = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true);
56
+ const printer = ts.createPrinter({ removeComments: true });
57
+ const declarations: string[] = [];
58
+ for (const statement of file.statements) {
59
+ if (ts.isExportDeclaration(statement) || ts.isExportAssignment(statement)) return null;
60
+ const exported = ts.canHaveModifiers(statement)
61
+ && ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
62
+ if (!exported) continue;
63
+ let declaration: TypeScript.Node = statement;
64
+ if (ts.isFunctionDeclaration(statement)) {
65
+ if (!statement.type || statement.parameters.some((parameter) => !parameter.type || parameter.initializer)
66
+ || statement.asteriskToken) return null;
67
+ declaration = ts.factory.updateFunctionDeclaration(statement, statement.modifiers, statement.asteriskToken,
68
+ statement.name, statement.typeParameters, statement.parameters, statement.type, undefined);
69
+ } else if (ts.isClassDeclaration(statement)) {
70
+ const observed = classSurface(ts, statement);
71
+ if (observed === null) return null;
72
+ declaration = observed;
73
+ } else if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement)) {
74
+ return null;
75
+ }
76
+ declarations.push(printer.printNode(ts.EmitHint.Unspecified, declaration, file));
77
+ }
78
+ return declarations.join('\n');
79
+ }
80
+
81
+ export class SnapshotRiskAdvisoryAdapter implements ChangeRiskAdvisoryPort {
82
+ constructor(private readonly rootDir: string = process.cwd()) {}
83
+
84
+ async assess(paths: readonly string[], snapshotFile: string): Promise<readonly ChangeRiskAdvice[]> {
85
+ const raw: unknown = JSON.parse(await readFile(resolve(this.rootDir, snapshotFile), 'utf8'));
86
+ if (!Array.isArray(raw) || !raw.every(isSnapshot)) {
87
+ throw new Error('Risk snapshots must be an array of { filePath, beforeContent: string|null, afterContent: string|null }.');
88
+ }
89
+ // No TypeScript runtime loading or source parsing occurs on the default classification path.
90
+ const ts = await import('typescript');
91
+ return paths.map((path) => {
92
+ const matches = raw.filter((snapshot) => snapshot.filePath === path);
93
+ const snapshot = matches.length === 1 ? matches[0] : undefined;
94
+ const before = snapshot?.beforeContent ?? null;
95
+ const after = snapshot?.afterContent ?? null;
96
+ const base = { path, source: 'caller-snapshot' as const, enforcement: 'none' as const,
97
+ beforeHash: hash(before), afterHash: hash(after) };
98
+ if (!snapshot || before === null || after === null) {
99
+ return { ...base, kind: 'unknown', reason: 'Missing, duplicate, creation or deletion snapshot: inspect the actual revision and affected contracts.' };
100
+ }
101
+ if (before === after) {
102
+ return { ...base, kind: 'no-content-change', reason: 'Only the supplied snapshots are identical; this is not approval or verification of the current revision.' };
103
+ }
104
+ if (!/\.[cm]?tsx?$/.test(path) || /\.d\.[cm]?ts$/.test(path)) {
105
+ return { ...base, kind: 'unknown', reason: 'Unsupported source language: inspect the contract and behavior changes.' };
106
+ }
107
+ const beforeSurface = surface(ts, path, before);
108
+ const afterSurface = surface(ts, path, after);
109
+ if (beforeSurface === null || afterSurface === null) {
110
+ return { ...base, kind: 'unknown', reason: 'Invalid or unsupported declaration: inspect exported contracts, authorization, invariants and persisted formats.' };
111
+ }
112
+ if (beforeSurface !== afterSurface) {
113
+ return { ...base, kind: 'module-surface-change', reason: 'Declared module surface changed. Confirm public consumers and escalate changes to agreed contracts before dependent work.' };
114
+ }
115
+ return { ...base, kind: 'behavior-review', reason: 'Declared module surface is unchanged, but behavior equivalence is unproven. Review authorization, business invariants and persisted formats; internal extraction is not automatically approved.' };
116
+ });
117
+ }
118
+ }
@@ -37,6 +37,13 @@ export class ChangeCategoryFormatter {
37
37
  }
38
38
  }
39
39
 
40
+ if (contract.riskAdvice !== undefined) {
41
+ lines.push('riskAdvice (caller snapshots; advisory only, existing gate unchanged):');
42
+ for (const advice of contract.riskAdvice) {
43
+ lines.push(` ${advice.path} -> ${advice.kind}: ${advice.reason}`);
44
+ lines.push(` before=${advice.beforeHash ?? 'unknown'} after=${advice.afterHash ?? 'unknown'}`);
45
+ }
46
+ }
40
47
  return lines.join('\n') + '\n';
41
48
  }
42
49
  }
@@ -7,17 +7,20 @@
7
7
  */
8
8
 
9
9
  import type { ClassifyChangeCategoryUseCase } from '../../application/usecases/classify-change-category-usecase.js';
10
+ import type { ChangeRiskAdvisoryPort } from '../../application/ports/change-risk-advisory-port.js';
10
11
  import { ChangeCategoryFormatter, type ChangeCategoryOutputFormat } from '../formatters/change-category-formatter.js';
11
12
 
12
13
  export interface CheckChangeCategoryHandlerDeps {
13
14
  useCase: Pick<ClassifyChangeCategoryUseCase, 'execute'>;
14
15
  writer?: (s: string) => void;
16
+ riskAdvisoryPort?: ChangeRiskAdvisoryPort;
15
17
  }
16
18
 
17
19
  export interface CheckChangeCategoryHandlerOptions {
18
20
  paths?: string;
19
21
  format?: ChangeCategoryOutputFormat;
20
22
  failOnFullRequired?: boolean;
23
+ riskSnapshots?: string;
21
24
  }
22
25
 
23
26
  export interface CheckChangeCategoryHandlerResult {
@@ -28,9 +31,11 @@ export class CheckChangeCategoryHandler {
28
31
  private readonly useCase: Pick<ClassifyChangeCategoryUseCase, 'execute'>;
29
32
  private readonly writer: (s: string) => void;
30
33
  private readonly formatter = new ChangeCategoryFormatter();
34
+ private readonly riskAdvisoryPort?: ChangeRiskAdvisoryPort;
31
35
 
32
36
  constructor(deps: CheckChangeCategoryHandlerDeps) {
33
37
  this.useCase = deps.useCase;
38
+ this.riskAdvisoryPort = deps.riskAdvisoryPort;
34
39
  this.writer = deps.writer ?? ((s: string) => process.stdout.write(s));
35
40
  }
36
41
 
@@ -41,7 +46,13 @@ export class CheckChangeCategoryHandler {
41
46
  ? pathsRaw.split(',').map((p) => p.trim()).filter((p) => p.length > 0)
42
47
  : [];
43
48
 
44
- const contract = await this.useCase.execute({ paths });
49
+ const classification = await this.useCase.execute({ paths });
50
+ if (options.riskSnapshots !== undefined && this.riskAdvisoryPort === undefined) {
51
+ throw new Error('Risk advisory is not available in this handler configuration.');
52
+ }
53
+ const contract = options.riskSnapshots !== undefined
54
+ ? { ...classification, riskAdvice: await this.riskAdvisoryPort!.assess(paths, options.riskSnapshots) }
55
+ : classification;
45
56
  this.writer(this.formatter.format(contract, format));
46
57
 
47
58
  const exitCode = failOnFullRequired && contract.fullModeRequired ? 1 : 0;
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { promises as fs } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
+ import { getBundledSkillsForSet, SKILL_CATEGORIES, type SkillCategory, type SkillSet } from "../installation/application/bundled-skill-selection.js";
14
+ export { SKILL_CATEGORIES, type SkillCategory, type SkillSet } from "../installation/application/bundled-skill-selection.js";
13
15
 
14
16
  const HARNESS_VERSION_FILE = ".harness-version";
15
17
  const SKILLS_SOURCE_DIR = "skills";
@@ -25,52 +27,8 @@ const DEFAULT_FOLDER_RULES_DOC = join("docs", "folder_management_rules.md");
25
27
 
26
28
  // ── Skill Category Map ──
27
29
 
28
- export type SkillCategory = "core" | "aidlc" | "utility" | "guidance";
29
- export type SkillSet = "core" | "all";
30
-
31
- export const SKILL_CATEGORIES: Record<SkillCategory, readonly string[]> = {
32
- core: [
33
- "cascade-updater",
34
- "codebase-mapper",
35
- "consistency-checker",
36
- "doc-health-checker",
37
- "engineering-perspective",
38
- "implementation-readiness-checker",
39
- "test-coverage-checker",
40
- ],
41
- aidlc: [
42
- "domain-designer",
43
- "environment-designer",
44
- "it-test-designer",
45
- "it-test-logic-designer",
46
- "logical-designer",
47
- "mock-designer",
48
- "product-architect",
49
- "quick-implementor",
50
- "scenario-test-designer",
51
- "scenario-test-logic-designer",
52
- "story-implementor",
53
- "story-mapper",
54
- "story-writer",
55
- "uiux-designer",
56
- "unit-designer",
57
- "unit-test-designer",
58
- "unit-test-logic-designer",
59
- ],
60
- utility: ["codex-delegator", "skill-creator"],
61
- guidance: ["phasegate-toolkit-guide", "phasegate-config-doctor", "release-publisher"],
62
- } as const;
63
-
64
30
  export function getSkillsForSet(skillSet: SkillSet): string[] {
65
- if (skillSet === "core") {
66
- return [...SKILL_CATEGORIES.core];
67
- }
68
- return [
69
- ...SKILL_CATEGORIES.core,
70
- ...SKILL_CATEGORIES.aidlc,
71
- ...SKILL_CATEGORIES.utility,
72
- ...SKILL_CATEGORIES.guidance,
73
- ];
31
+ return getBundledSkillsForSet(skillSet);
74
32
  }
75
33
 
76
34
  export function getCategoryForSkill(skillName: string): SkillCategory | null {
@@ -9,4 +9,5 @@ export interface RunPlanCheckerLoopOutput {
9
9
  readonly status: LoopStatus;
10
10
  readonly loopHistory: readonly LoopAttempt[];
11
11
  readonly escalationRequired: boolean;
12
+ readonly stopReason?: 'UNCHANGED_INPUT';
12
13
  }
@@ -2,6 +2,7 @@
2
2
  * @layer application
3
3
  * @unit skill-quality
4
4
  * @work-item-id WI-192
5
+ * @work-item-id WI-220
5
6
  */
6
7
  import { CascadeUpdateResult } from '../../domain/value-objects/cascade-update-result.js';
7
8
  import type { CascadeUpdateService } from '../../domain/services/cascade-update-service.js';
@@ -21,6 +22,7 @@ export class ApplyCascadeUpdateUseCase {
21
22
  let updatedCount = 0;
22
23
  const appliedStoryIds: string[] = [];
23
24
  const errors: string[] = [];
25
+ const visitedFiles = new Set<string>();
24
26
 
25
27
  for (const target of targets) {
26
28
  try {
@@ -30,13 +32,16 @@ export class ApplyCascadeUpdateUseCase {
30
32
  : [target.filePath];
31
33
 
32
34
  for (const filePath of filePaths) {
35
+ if (visitedFiles.has(filePath)) continue;
36
+ visitedFiles.add(filePath);
33
37
  try {
34
38
  const content = await this.fileSystemPort.read(filePath);
35
- // Append story-id tag if not already present
36
- const updatedContent = content.includes(target.storyIdTag)
39
+ // This is exact tag presence, not proof of semantic reflection.
40
+ const updatedContent = target.hasAnnotationIn(content)
37
41
  ? content
38
- : `${content}\n${target.storyIdTag}`;
39
- if (!input.dryRun && updatedContent !== content) {
42
+ : `${content}\n${target.renderAnnotation(filePath)}`;
43
+ if (updatedContent === content) continue;
44
+ if (!input.dryRun) {
40
45
  await this.fileSystemPort.write(filePath, updatedContent);
41
46
  }
42
47
  updatedCount++;