phasegate 0.264.0 → 0.315.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 (92) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/docs/ADR/017-warning-severity-aggregation.md +17 -0
  3. package/docs/ADR/038-config-state-operation-permission-policy.md +78 -0
  4. package/docs/guide/installation.md +1 -1
  5. package/docs/guide/layer-model.md +2 -0
  6. package/docs/guide/quick-vs-full-mode.md +28 -3
  7. package/docs/guide/troubleshooting.md +37 -0
  8. package/docs/templates/agent-context/CLAUDE.md.template.md +6 -6
  9. package/docs/templates/ci/aidlc-gate.yml +22 -4
  10. package/package.json +2 -2
  11. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +98 -18
  12. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +25 -3
  13. package/scripts/harness/agent-integration/infrastructure/adapters/file-system-full-mode-session-query-adapter.ts +75 -23
  14. package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +45 -27
  15. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +29 -16
  16. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +51 -6
  17. package/scripts/harness/agent-integration/presentation/stop-hook.ts +35 -26
  18. package/scripts/harness/ci-governance/composition-root.ts +2 -2
  19. package/scripts/harness/ci-governance/domain/services/claude-md-composer.ts +20 -11
  20. package/scripts/harness/ci-governance/presentation/handlers/check-repetition-handler.ts +10 -2
  21. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +5 -1
  22. package/scripts/harness/config-foundation/domain/harness-config.ts +10 -7
  23. package/scripts/harness/config-foundation/domain/services/preset-resolution-service.ts +4 -1
  24. package/scripts/harness/config-foundation/domain/value-objects/project-config.ts +34 -18
  25. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +1 -1
  26. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +1 -1
  27. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +1 -1
  28. package/scripts/harness/config-foundation/infrastructure/repositories/file-system-config-repository.ts +27 -18
  29. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +1 -8
  30. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +1 -8
  31. package/scripts/harness/harness-api/domain/ports/config-query-port.ts +11 -1
  32. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +135 -87
  33. package/scripts/harness/harness-api/domain/services/status-derivation-service.ts +31 -21
  34. package/scripts/harness/harness-api/domain/value-objects/ci-check-result.ts +2 -22
  35. package/scripts/harness/harness-api/domain/value-objects/harness-status-summary.ts +23 -8
  36. package/scripts/harness/harness-api/infrastructure/adapters/biome-ast-engine-lint-adapter.ts +4 -4
  37. package/scripts/harness/harness-api/infrastructure/adapters/harness-config-query-adapter.ts +79 -34
  38. package/scripts/harness/harness-error/application/dto/create-harness-error-input.ts +3 -1
  39. package/scripts/harness/harness-error/application/dto/harness-error-contract.ts +3 -1
  40. package/scripts/harness/harness-error/application/mappers/harness-error-contract-mapper.ts +9 -17
  41. package/scripts/harness/harness-error/application/usecases/create-harness-error-use-case.ts +7 -7
  42. package/scripts/harness/harness-error/domain/services/harness-error-factory.ts +30 -33
  43. package/scripts/harness/harness-error/domain/value-objects/error-definition.ts +28 -17
  44. package/scripts/harness/harness-error/domain/value-objects/harness-error.ts +30 -9
  45. package/scripts/harness/harness-error/domain/value-objects/remediation-type.ts +30 -0
  46. package/scripts/harness/harness-error/infrastructure/registry/l2-error-definitions.ts +44 -29
  47. package/scripts/harness/harness-error/infrastructure/registry/l3-error-definitions.ts +44 -27
  48. package/scripts/harness/harness-error/infrastructure/registry/l4-error-definitions.ts +47 -32
  49. package/scripts/harness/installation/application/checks/claude-context-missing-check.ts +13 -7
  50. package/scripts/harness/installation/application/checks/config-status-check.ts +52 -0
  51. package/scripts/harness/installation/application/checks/husky-pre-commit-missing-check.ts +6 -0
  52. package/scripts/harness/installation/application/ports/config-status-probe-port.ts +9 -0
  53. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +30 -8
  54. package/scripts/harness/installation/application/usecases/run-install.ts +234 -53
  55. package/scripts/harness/installation/application/usecases/run-reconcile.ts +311 -70
  56. package/scripts/harness/installation/composition-root.ts +13 -3
  57. package/scripts/harness/installation/domain/check-id.ts +2 -0
  58. package/scripts/harness/installation/domain/config-status.ts +17 -0
  59. package/scripts/harness/installation/domain/deployment-manifest.ts +43 -0
  60. package/scripts/harness/installation/domain/ports/heuristic-check.ts +10 -1
  61. package/scripts/harness/installation/infrastructure/adapters/config-status-probe-adapter.ts +79 -0
  62. package/scripts/harness/installation/presentation/cli/doctor-handler.ts +6 -1
  63. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +19 -5
  64. package/scripts/harness/integrations/pre-commit.ts +17 -3
  65. package/scripts/harness/main.ts +83 -15
  66. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/markdown-plan-document-reader.ts +60 -32
  67. package/scripts/harness/phase2-extensions/presentation/handlers/check-freshness-handler.ts +17 -9
  68. package/scripts/harness/quick-mode/application/ports/file-existence-port.ts +15 -0
  69. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +60 -22
  70. package/scripts/harness/quick-mode/composition-root.ts +25 -15
  71. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +72 -3
  72. package/scripts/harness/quick-mode/infrastructure/adapters/fs-file-existence-adapter.ts +38 -0
  73. package/scripts/harness/skill-quality/infrastructure/adapters/file-system-requirement-test-matrix-adapter.ts +51 -8
  74. package/scripts/harness/skill-quality/presentation/handlers/check-coverage-handler.ts +13 -6
  75. package/scripts/harness/traceability-model/domain/value-objects/work-item-frontmatter.ts +5 -1
  76. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-identity-gateway.ts +6 -1
  77. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +15 -2
  78. package/scripts/harness/validator-system/application/use-cases/aggregate-validation-results-usecase.ts +11 -14
  79. package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +32 -7
  80. package/scripts/harness/validator-system/composition-root.ts +4 -1
  81. package/scripts/harness/validator-system/domain/ports/ac-coverage-policy-port.ts +10 -1
  82. package/scripts/harness/validator-system/domain/services/effective-severity-policy.ts +39 -0
  83. package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +6 -4
  84. package/scripts/harness/validator-system/domain/value-objects/drift-report.ts +12 -7
  85. package/scripts/harness/validator-system/domain/value-objects/validation-result.ts +11 -3
  86. package/scripts/harness/validator-system/infrastructure/adapters/file-system-security-pattern-scanner-adapter.ts +20 -17
  87. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +89 -3
  88. package/scripts/harness/validator-system/infrastructure/adapters/nyquist-ac-coverage-policy-adapter.ts +49 -20
  89. package/scripts/harness/validator-system/infrastructure/adapters/phase-dependency-phase-gate-policy-adapter.ts +35 -14
  90. package/scripts/harness/validator-system/infrastructure/adapters/traceability-metadata-policy-adapter.ts +13 -8
  91. package/scripts/harness/validator-system/presentation/formatters/agent-validation-result-formatter.ts +19 -10
  92. package/skills/quick-implementor/SKILL.md +19 -0
@@ -7,8 +7,23 @@
7
7
  // @work-item-id WI-216
8
8
  // @work-item-id WI-219
9
9
  // @work-item-id WI-264
10
+ // @work-item-id WI-315
11
+ // @work-item-id WI-326
12
+ // @work-item-id WI-331
10
13
 
11
- import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
14
+ import {
15
+ access,
16
+ chmod,
17
+ copyFile,
18
+ lstat,
19
+ mkdir,
20
+ readdir,
21
+ readFile,
22
+ readlink,
23
+ rm,
24
+ symlink,
25
+ writeFile,
26
+ } from "node:fs/promises";
12
27
  import { dirname, join, relative, resolve } from "node:path";
13
28
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
14
29
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
@@ -20,7 +35,15 @@ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.j
20
35
  import type { ModelDelegationPort } from "../ports/model-delegation-port.js";
21
36
 
22
37
  type ReconcileAction = "missing-manifest" | "update" | "add" | "link" | "skip" | "refuse" | "prune";
23
- type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "copy-dir" | "symlink" | "unknown";
38
+ type StrategyType =
39
+ | "json"
40
+ | "shell"
41
+ | "yaml-add"
42
+ | "package-json"
43
+ | "markdown-managed"
44
+ | "copy-dir"
45
+ | "symlink"
46
+ | "unknown";
24
47
 
25
48
  export interface ReconcilePlanItem {
26
49
  readonly path: string;
@@ -40,6 +63,12 @@ export interface RunReconcileInput {
40
63
  readonly dryRun: boolean;
41
64
  readonly apply: boolean;
42
65
  readonly force: boolean;
66
+ // Explicit per-run overrides. When omitted, the flags recorded in the
67
+ // manifest at install time (installationFlags) are used; manifests written
68
+ // before WI-326 carry no flags and fall back to the legacy behavior of
69
+ // reconciling every bundled target.
70
+ readonly includeHusky?: boolean;
71
+ readonly includeCi?: boolean;
43
72
  }
44
73
 
45
74
  export interface RunReconcileResult {
@@ -62,6 +91,9 @@ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
62
91
  const SHELL_END = "# === phasegate managed (END) ===";
63
92
  const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
64
93
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
94
+ const USER_SECTION_BEGIN = "<!-- phasegate:user-section:start -->";
95
+ const USER_SECTION_END = "<!-- phasegate:user-section:end -->";
96
+ const USER_SECTION_PLACEHOLDER = "Project-specific agent instructions go here.";
65
97
  const SHARED_SKILLS_VERSION_PATH = "skills/.harness-version";
66
98
  const HARNESS_VERSION_BASENAME = ".harness-version";
67
99
  const SKILL_ROOT_PREFIXES = ["skills", ".claude/skills", ".codex/skills"] as const;
@@ -177,7 +209,10 @@ function replaceHookArrays(existing: unknown, incoming: unknown): unknown[] {
177
209
  return result;
178
210
  }
179
211
 
180
- function reconcileJsonObject(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
212
+ function reconcileJsonObject(
213
+ existing: Record<string, unknown>,
214
+ incoming: Record<string, unknown>,
215
+ ): Record<string, unknown> {
181
216
  const result: Record<string, unknown> = { ...existing };
182
217
  const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
183
218
  const incomingHooks = isRecord(incoming.hooks) ? incoming.hooks : {};
@@ -220,12 +255,71 @@ function managedMarkdownBlock(content: string): string {
220
255
  return content.slice(start, end + MARKDOWN_END.length).trim();
221
256
  }
222
257
 
258
+ // Extracts the user-authored body between the user-section markers. Returns
259
+ // null when the markers are absent or the body is blank, so callers fall back
260
+ // to the template placeholder.
261
+ function extractUserSectionBody(content: string | null): string | null {
262
+ if (content === null) return null;
263
+ const start = content.indexOf(USER_SECTION_BEGIN);
264
+ const end = content.indexOf(USER_SECTION_END);
265
+ if (start === -1 || end === -1 || end < start) return null;
266
+ const body = content.slice(start + USER_SECTION_BEGIN.length, end).trim();
267
+ return body.length === 0 ? null : body;
268
+ }
269
+
270
+ function hasUserSectionMarkers(content: string): boolean {
271
+ const start = content.indexOf(USER_SECTION_BEGIN);
272
+ const end = content.indexOf(USER_SECTION_END);
273
+ return start !== -1 && end !== -1 && start < end;
274
+ }
275
+
276
+ // Legacy (pre-WI-331) templates nested the user-section inside the managed
277
+ // section, so replacing the managed block wholesale would wipe user-authored
278
+ // instructions with the template placeholder. Re-inject the existing
279
+ // user-section body into the incoming block. Blocks without user-section
280
+ // markers (all current templates) are returned unchanged; the outside-of-block
281
+ // migration for those is handled in reconcileManagedMarkdown.
282
+ function restoreUserSection(block: string, existing: string | null): string {
283
+ const start = block.indexOf(USER_SECTION_BEGIN);
284
+ const end = block.indexOf(USER_SECTION_END);
285
+ if (start === -1 || end === -1 || end < start) return block;
286
+ const preserved = extractUserSectionBody(existing);
287
+ if (preserved === null) return block;
288
+ return `${block.slice(0, start + USER_SECTION_BEGIN.length)}\n${preserved}\n${block.slice(end)}`;
289
+ }
290
+
223
291
  function reconcileManagedMarkdown(existing: string | null, incoming: string): string {
224
- const block = managedMarkdownBlock(incoming);
225
292
  if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
293
+ const incomingBlock = managedMarkdownBlock(incoming);
226
294
  const pattern = new RegExp(`${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}`);
227
- if (pattern.test(existing)) return existing.replace(pattern, block).replace(/\s*$/, "\n");
228
- return `${block}\n\n${existing.replace(/\s*$/, "\n")}`;
295
+ if (!pattern.test(existing)) return `${incomingBlock}\n\n${existing.replace(/\s*$/, "\n")}`;
296
+
297
+ const blockStart = existing.indexOf(MARKDOWN_BEGIN);
298
+ const blockEnd = existing.indexOf(MARKDOWN_END) + MARKDOWN_END.length;
299
+ const existingBlock = existing.slice(blockStart, blockEnd);
300
+ const outsideBlock = existing.slice(0, blockStart) + existing.slice(blockEnd);
301
+ const block = restoreUserSection(incomingBlock, existing);
302
+ // Replacer function keeps user-authored text (possibly part of the block)
303
+ // from being interpreted as `$`-substitution patterns by String.replace.
304
+ let next = existing.replace(pattern, () => block);
305
+
306
+ // Structure migration (WI-331): the existing file nests the user-section
307
+ // inside the managed block (old template shape) while the incoming block
308
+ // keeps it outside. Replacing the block would drop the user-authored body,
309
+ // so relocate it to just after the managed block — the position the current
310
+ // template uses. When a user-section already exists outside the block, keep
311
+ // that one and do not duplicate.
312
+ if (
313
+ hasUserSectionMarkers(existingBlock) &&
314
+ !hasUserSectionMarkers(incomingBlock) &&
315
+ !hasUserSectionMarkers(outsideBlock)
316
+ ) {
317
+ const body = extractUserSectionBody(existingBlock) ?? USER_SECTION_PLACEHOLDER;
318
+ const insertAt = next.indexOf(MARKDOWN_END) + MARKDOWN_END.length;
319
+ const relocated = `\n\n## User Section\n\n${USER_SECTION_BEGIN}\n${body}\n${USER_SECTION_END}`;
320
+ next = `${next.slice(0, insertAt)}${relocated}${next.slice(insertAt)}`;
321
+ }
322
+ return next.replace(/\s*$/, "\n");
229
323
  }
230
324
 
231
325
  function renderAgentContextTemplate(template: string): string {
@@ -235,7 +329,9 @@ function renderAgentContextTemplate(template: string): string {
235
329
  "phasegate validate --layer L2 --format human",
236
330
  "phasegate setup:agent --dry-run",
237
331
  "phasegate config:plan --intent l4-strict --dry-run",
238
- ].map((command) => `- \`${command}\``).join("\n");
332
+ ]
333
+ .map((command) => `- \`${command}\``)
334
+ .join("\n");
239
335
  return template
240
336
  .replaceAll("{{PHASEGATE_AGENT}}", "both")
241
337
  .replaceAll("{{PHASEGATE_SKILLS_MODE}}", "all")
@@ -245,7 +341,7 @@ function renderAgentContextTemplate(template: string): string {
245
341
  .replaceAll("{{PHASEGATE_COMMANDS}}", commands)
246
342
  .replaceAll("{{PHASEGATE_SKILLS}}", "- `all bundled skills`")
247
343
  .replaceAll("{{PHASEGATE_PRESETS}}", "- `minimal`\n- `standard`\n- `full`\n- `custom`")
248
- .replaceAll("{{PHASEGATE_USER_SECTION}}", "Project-specific agent instructions go here.");
344
+ .replaceAll("{{PHASEGATE_USER_SECTION}}", USER_SECTION_PLACEHOLDER);
249
345
  }
250
346
 
251
347
  function reconcilePackageJson(existing: Record<string, unknown>, version: string): Record<string, unknown> {
@@ -289,12 +385,15 @@ export class RunReconcileUseCase {
289
385
  return { plan: [item], refused: [], changed: [], backupDir: null };
290
386
  }
291
387
 
292
- const targets = this.createTargets();
388
+ const includeHusky = input.includeHusky ?? manifest.installationFlags?.includeHusky ?? true;
389
+ const includeCi = input.includeCi ?? manifest.installationFlags?.includeCi ?? true;
390
+ const targets = this.createTargets({ includeHusky, includeCi });
293
391
  const targetsByPath = new Map(targets.map((target) => [target.path, target]));
294
392
  let nextManifest = DeploymentManifest.reconstitute({
295
393
  version: input.phasegateVersion,
296
394
  installedAt: manifest.installedAt,
297
395
  entries: manifest.entries,
396
+ installationFlags: manifest.installationFlags,
298
397
  });
299
398
  const plan: ReconcilePlanItem[] = [];
300
399
  const refused: ReconcilePlanItem[] = [];
@@ -312,14 +411,28 @@ export class RunReconcileUseCase {
312
411
  for (const entry of manifest.entries) {
313
412
  const target = targetsByPath.get(entry.path);
314
413
  if (target === undefined) {
315
- const item = this.item(entry.path, "skip", "manual", "unknown", false, `${entry.path}: no bundled template`, "manual review required", SKILL_HINT);
414
+ const item = this.item(
415
+ entry.path,
416
+ "skip",
417
+ "manual",
418
+ "unknown",
419
+ false,
420
+ `${entry.path}: no bundled template`,
421
+ "manual review required",
422
+ SKILL_HINT,
423
+ );
316
424
  plan.push(item);
317
425
  continue;
318
426
  }
319
427
  const outcome = await this.planManagedEntry(input, entry, target);
320
428
  outcomes.push(outcome);
321
429
  plan.push(outcome.item);
322
- if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
430
+ if (
431
+ input.apply &&
432
+ outcome.item.changed &&
433
+ (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") &&
434
+ !input.force
435
+ ) {
323
436
  refused.push({ ...outcome.item, action: "refuse" });
324
437
  }
325
438
  }
@@ -329,7 +442,12 @@ export class RunReconcileUseCase {
329
442
  const outcome = await this.planMissingTarget(input, target);
330
443
  outcomes.push(outcome);
331
444
  plan.push(outcome.item);
332
- if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
445
+ if (
446
+ input.apply &&
447
+ outcome.item.changed &&
448
+ (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") &&
449
+ !input.force
450
+ ) {
333
451
  refused.push({ ...outcome.item, action: "refuse" });
334
452
  }
335
453
  }
@@ -376,18 +494,43 @@ export class RunReconcileUseCase {
376
494
  if (hashContent !== null) {
377
495
  if (outcome.item.strategy === "copy-dir" && outcome.item.path === "skills") {
378
496
  const sharedSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
379
- nextManifest = nextManifest.addEntry(this.createdEntry(SHARED_SKILLS_VERSION_PATH, this.sharedSkillsVersionHashInput(input.phasegateVersion, "all", sharedSkills)));
497
+ nextManifest = nextManifest.addEntry(
498
+ this.createdEntry(
499
+ SHARED_SKILLS_VERSION_PATH,
500
+ this.sharedSkillsVersionHashInput(input.phasegateVersion, "all", sharedSkills),
501
+ ),
502
+ );
380
503
  for (const skill of sharedSkills) {
381
- nextManifest = nextManifest.addEntry(this.createdEntry(`skills/${skill}`, this.sharedSkillHashInput(skill, input.phasegateVersion, "all")));
504
+ nextManifest = nextManifest.addEntry(
505
+ this.createdEntry(`skills/${skill}`, this.sharedSkillHashInput(skill, input.phasegateVersion, "all")),
506
+ );
382
507
  }
383
- } else if (outcome.item.strategy === "copy-dir" && (outcome.item.path === ".claude/skills" || outcome.item.path === ".codex/skills")) {
508
+ } else if (
509
+ outcome.item.strategy === "copy-dir" &&
510
+ (outcome.item.path === ".claude/skills" || outcome.item.path === ".codex/skills")
511
+ ) {
384
512
  const personalSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
385
- nextManifest = nextManifest.addEntry(this.createdEntry(`${outcome.item.path}/.harness-version`, this.personalSkillsVersionHashInput(outcome.item.path, input.phasegateVersion, "all", personalSkills)));
513
+ nextManifest = nextManifest.addEntry(
514
+ this.createdEntry(
515
+ `${outcome.item.path}/.harness-version`,
516
+ this.personalSkillsVersionHashInput(outcome.item.path, input.phasegateVersion, "all", personalSkills),
517
+ ),
518
+ );
386
519
  for (const skill of personalSkills) {
387
- nextManifest = nextManifest.addEntry(this.createdEntry(`${outcome.item.path}/${skill}`, this.personalSkillHashInput(outcome.item.path, skill, input.phasegateVersion, "all")));
520
+ nextManifest = nextManifest.addEntry(
521
+ this.createdEntry(
522
+ `${outcome.item.path}/${skill}`,
523
+ this.personalSkillHashInput(outcome.item.path, skill, input.phasegateVersion, "all"),
524
+ ),
525
+ );
388
526
  }
389
527
  } else {
390
- const mode = outcome.item.strategy === "symlink" ? "symlink" : outcome.item.action === "add" ? "created" : (manifest.findEntry(outcome.item.path)?.mode ?? "merged");
528
+ const mode =
529
+ outcome.item.strategy === "symlink"
530
+ ? "symlink"
531
+ : outcome.item.action === "add"
532
+ ? "created"
533
+ : (manifest.findEntry(outcome.item.path)?.mode ?? "merged");
391
534
  nextManifest = nextManifest.addEntry(
392
535
  DeploymentEntry.create({
393
536
  path: outcome.item.path,
@@ -416,9 +559,10 @@ export class RunReconcileUseCase {
416
559
  const matchesManifest = currentHash.equals(entry.hash);
417
560
  const rawTemplate = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
418
561
  const template = target.strategy === "markdown-managed" ? renderAgentContextTemplate(rawTemplate) : rawTemplate;
419
- const next = entry.mode === "created" && target.strategy !== "package-json" && target.strategy !== "markdown-managed"
420
- ? template
421
- : this.reconcileContent(target, before, template, input.phasegateVersion);
562
+ const next =
563
+ entry.mode === "created" && target.strategy !== "package-json" && target.strategy !== "markdown-managed"
564
+ ? template
565
+ : this.reconcileContent(target, before, template, input.phasegateVersion);
422
566
  const changed = before !== next;
423
567
  const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
424
568
  return {
@@ -448,11 +592,13 @@ export class RunReconcileUseCase {
448
592
  const before = await readTextOrNull(absolutePath);
449
593
  const rawTemplate = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
450
594
  const template = target.strategy === "markdown-managed" ? renderAgentContextTemplate(rawTemplate) : rawTemplate;
451
- const next = before === null && target.strategy !== "package-json"
452
- ? template
453
- : this.reconcileContent(target, before, template, input.phasegateVersion);
595
+ const next =
596
+ before === null && target.strategy !== "package-json"
597
+ ? template
598
+ : this.reconcileContent(target, before, template, input.phasegateVersion);
454
599
  const changed = before !== next;
455
- const repairMode: RepairMode = target.strategy === "shell" && before !== null && !before.includes(SHELL_BEGIN) ? "ai-assisted" : "mechanical";
600
+ const repairMode: RepairMode =
601
+ target.strategy === "shell" && before !== null && !before.includes(SHELL_BEGIN) ? "ai-assisted" : "mechanical";
456
602
  return {
457
603
  item: this.item(
458
604
  target.path,
@@ -480,19 +626,46 @@ export class RunReconcileUseCase {
480
626
  const stat = await lstat(absolutePath);
481
627
  if (stat.isSymbolicLink() && (await readlink(absolutePath)) === "../skills") {
482
628
  return {
483
- item: this.item(relativePath, "skip", "mechanical", "symlink", false, `${relativePath}: already linked`, "no changes", null),
629
+ item: this.item(
630
+ relativePath,
631
+ "skip",
632
+ "mechanical",
633
+ "symlink",
634
+ false,
635
+ `${relativePath}: already linked`,
636
+ "no changes",
637
+ null,
638
+ ),
484
639
  needsBackup: false,
485
640
  apply: async () => "../skills",
486
641
  };
487
642
  }
488
643
  return {
489
- item: this.item(relativePath, "skip", "manual", "symlink", false, `${relativePath}: existing non-phasegate path requires manual review`, "manual review required", SKILL_HINT),
644
+ item: this.item(
645
+ relativePath,
646
+ "skip",
647
+ "manual",
648
+ "symlink",
649
+ false,
650
+ `${relativePath}: existing non-phasegate path requires manual review`,
651
+ "manual review required",
652
+ SKILL_HINT,
653
+ ),
490
654
  needsBackup: false,
491
655
  apply: async () => null,
492
656
  };
493
657
  } catch {
494
658
  return {
495
- item: this.item(relativePath, "link", "mechanical", "symlink", true, `${relativePath}: create symlink`, "+ symlink ../skills", null),
659
+ item: this.item(
660
+ relativePath,
661
+ "link",
662
+ "mechanical",
663
+ "symlink",
664
+ true,
665
+ `${relativePath}: create symlink`,
666
+ "+ symlink ../skills",
667
+ null,
668
+ ),
496
669
  needsBackup: false,
497
670
  apply: async () => {
498
671
  await mkdir(join(projectRoot, "skills"), { recursive: true });
@@ -505,21 +678,40 @@ export class RunReconcileUseCase {
505
678
  }
506
679
 
507
680
  private manifestIntendsSharedSkills(manifest: DeploymentManifest): boolean {
508
- return manifest.findEntry(".claude/skills") !== null
509
- || manifest.findEntry(".codex/skills") !== null
510
- || manifest.entries.some((entry) => entry.path.startsWith("skills/"));
681
+ return (
682
+ manifest.findEntry(".claude/skills") !== null ||
683
+ manifest.findEntry(".codex/skills") !== null ||
684
+ manifest.entries.some((entry) => entry.path.startsWith("skills/"))
685
+ );
511
686
  }
512
687
 
513
688
  private isPersonalManifest(manifest: DeploymentManifest): boolean {
514
- return manifest.findEntry(".phasegate-local/phasegate.config.json") !== null
515
- || manifest.entries.some((entry) => (entry.path === ".claude/skills" || entry.path === ".codex/skills") && entry.mode === "created")
516
- || manifest.entries.some((entry) => entry.path.startsWith(".claude/skills/") || entry.path.startsWith(".codex/skills/"));
689
+ // Manifests written since WI-326 record the install mode explicitly;
690
+ // older manifests fall back to the legacy entry-shape heuristic.
691
+ if (manifest.installationFlags !== undefined) return manifest.installationFlags.personal;
692
+ return (
693
+ manifest.findEntry(".phasegate-local/phasegate.config.json") !== null ||
694
+ manifest.entries.some(
695
+ (entry) => (entry.path === ".claude/skills" || entry.path === ".codex/skills") && entry.mode === "created",
696
+ ) ||
697
+ manifest.entries.some(
698
+ (entry) => entry.path.startsWith(".claude/skills/") || entry.path.startsWith(".codex/skills/"),
699
+ )
700
+ );
517
701
  }
518
702
 
519
703
  private personalSkillPaths(manifest: DeploymentManifest): Array<".claude/skills" | ".codex/skills"> {
520
704
  const paths = new Set<".claude/skills" | ".codex/skills">();
521
- if (manifest.findEntry(".claude/skills") !== null || manifest.entries.some((entry) => entry.path.startsWith(".claude/skills/"))) paths.add(".claude/skills");
522
- if (manifest.findEntry(".codex/skills") !== null || manifest.entries.some((entry) => entry.path.startsWith(".codex/skills/"))) paths.add(".codex/skills");
705
+ if (
706
+ manifest.findEntry(".claude/skills") !== null ||
707
+ manifest.entries.some((entry) => entry.path.startsWith(".claude/skills/"))
708
+ )
709
+ paths.add(".claude/skills");
710
+ if (
711
+ manifest.findEntry(".codex/skills") !== null ||
712
+ manifest.entries.some((entry) => entry.path.startsWith(".codex/skills/"))
713
+ )
714
+ paths.add(".codex/skills");
523
715
  return [...paths].sort();
524
716
  }
525
717
 
@@ -538,8 +730,9 @@ export class RunReconcileUseCase {
538
730
  break;
539
731
  }
540
732
  }
541
- const missingManifest = manifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null
542
- || skills.some((skill) => manifest.findEntry(`skills/${skill}`) === null);
733
+ const missingManifest =
734
+ manifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null ||
735
+ skills.some((skill) => manifest.findEntry(`skills/${skill}`) === null);
543
736
  const renderedSkillDrift = await hasRenderedSkillDrift(
544
737
  this.modelDelegation,
545
738
  input.harnessRoot,
@@ -547,7 +740,12 @@ export class RunReconcileUseCase {
547
740
  this.resolveProjectPath(input.projectRoot, "skills"),
548
741
  skills,
549
742
  );
550
- const changed = versionContent === null || !versionContent.includes(expectedVersion) || missingSkill || missingManifest || renderedSkillDrift;
743
+ const changed =
744
+ versionContent === null ||
745
+ !versionContent.includes(expectedVersion) ||
746
+ missingSkill ||
747
+ missingManifest ||
748
+ renderedSkillDrift;
551
749
  return {
552
750
  item: this.item(
553
751
  "skills",
@@ -568,7 +766,11 @@ export class RunReconcileUseCase {
568
766
  };
569
767
  }
570
768
 
571
- private async deploySharedSkills(input: RunReconcileInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
769
+ private async deploySharedSkills(
770
+ input: RunReconcileInput,
771
+ skills: readonly string[],
772
+ skillSet: SkillSet,
773
+ ): Promise<void> {
572
774
  const targetRoot = this.resolveProjectPath(input.projectRoot, "skills");
573
775
  await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
574
776
  await writeFile(
@@ -595,8 +797,9 @@ export class RunReconcileUseCase {
595
797
  break;
596
798
  }
597
799
  }
598
- const missingManifest = manifest.findEntry(`${relativePath}/.harness-version`) === null
599
- || skills.some((skill) => manifest.findEntry(`${relativePath}/${skill}`) === null);
800
+ const missingManifest =
801
+ manifest.findEntry(`${relativePath}/.harness-version`) === null ||
802
+ skills.some((skill) => manifest.findEntry(`${relativePath}/${skill}`) === null);
600
803
  const renderedSkillDrift = await hasRenderedSkillDrift(
601
804
  this.modelDelegation,
602
805
  input.harnessRoot,
@@ -604,7 +807,12 @@ export class RunReconcileUseCase {
604
807
  this.resolveProjectPath(input.projectRoot, relativePath),
605
808
  skills,
606
809
  );
607
- const changed = versionContent === null || !versionContent.includes(expectedVersion) || missingSkill || missingManifest || renderedSkillDrift;
810
+ const changed =
811
+ versionContent === null ||
812
+ !versionContent.includes(expectedVersion) ||
813
+ missingSkill ||
814
+ missingManifest ||
815
+ renderedSkillDrift;
608
816
  return {
609
817
  item: this.item(
610
818
  relativePath,
@@ -620,7 +828,13 @@ export class RunReconcileUseCase {
620
828
  apply: async () => {
621
829
  if (!changed) return null;
622
830
  const targetRoot = this.resolveProjectPath(input.projectRoot, relativePath);
623
- await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
831
+ await copySelectedSkillDirectories(
832
+ this.modelDelegation,
833
+ input.harnessRoot,
834
+ input.projectRoot,
835
+ targetRoot,
836
+ skills,
837
+ );
624
838
  await writeFile(
625
839
  join(targetRoot, ".harness-version"),
626
840
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -631,7 +845,10 @@ export class RunReconcileUseCase {
631
845
  };
632
846
  }
633
847
 
634
- private planOrphanSkills(input: RunReconcileInput, manifest: DeploymentManifest): Array<{
848
+ private planOrphanSkills(
849
+ input: RunReconcileInput,
850
+ manifest: DeploymentManifest,
851
+ ): Array<{
635
852
  readonly item: ReconcilePlanItem;
636
853
  readonly needsBackup: boolean;
637
854
  readonly prune: boolean;
@@ -702,7 +919,12 @@ export class RunReconcileUseCase {
702
919
  return `shared-skill:${skill}:${version}:${skillSet}`;
703
920
  }
704
921
 
705
- private personalSkillsVersionHashInput(path: string, version: string, skillSet: SkillSet, skills: readonly string[]): string {
922
+ private personalSkillsVersionHashInput(
923
+ path: string,
924
+ version: string,
925
+ skillSet: SkillSet,
926
+ skills: readonly string[],
927
+ ): string {
706
928
  return `personal-skills-version:${path}:${version}:${skillSet}:${skills.join(",")}`;
707
929
  }
708
930
 
@@ -723,7 +945,10 @@ export class RunReconcileUseCase {
723
945
  return `${JSON.stringify(reconcileJsonObject(isRecord(existing) ? existing : {}, isRecord(incoming) ? incoming : {}), null, 2)}\n`;
724
946
  }
725
947
 
726
- private createTargets(): readonly ReconcileTarget[] {
948
+ private createTargets(options: {
949
+ readonly includeHusky: boolean;
950
+ readonly includeCi: boolean;
951
+ }): readonly ReconcileTarget[] {
727
952
  return [
728
953
  { path: ".claude/settings.json", strategy: "json", templatePath: "templates/.claude/settings.json" },
729
954
  {
@@ -739,28 +964,40 @@ export class RunReconcileUseCase {
739
964
  templatePath: "docs/templates/agent-context/AGENTS.md.template.md",
740
965
  block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate AGENTS.md managed section" },
741
966
  },
742
- {
743
- path: ".husky/pre-commit",
744
- strategy: "shell",
745
- templatePath: "docs/templates/hooks/pre-commit",
746
- executable: true,
747
- block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-commit managed block" },
748
- },
749
- {
750
- path: ".husky/commit-msg",
751
- strategy: "shell",
752
- templatePath: "docs/templates/hooks/commit-msg",
753
- executable: true,
754
- block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate commit-msg managed block" },
755
- },
756
- {
757
- path: ".husky/pre-push",
758
- strategy: "shell",
759
- templatePath: "docs/templates/hooks/pre-push",
760
- executable: true,
761
- block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-push managed block" },
762
- },
763
- { path: ".github/workflows/phasegate-aidlc-gate.yml", strategy: "yaml-add", templatePath: "docs/templates/ci/aidlc-gate.yml" },
967
+ ...(options.includeHusky
968
+ ? ([
969
+ {
970
+ path: ".husky/pre-commit",
971
+ strategy: "shell",
972
+ templatePath: "docs/templates/hooks/pre-commit",
973
+ executable: true,
974
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-commit managed block" },
975
+ },
976
+ {
977
+ path: ".husky/commit-msg",
978
+ strategy: "shell",
979
+ templatePath: "docs/templates/hooks/commit-msg",
980
+ executable: true,
981
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate commit-msg managed block" },
982
+ },
983
+ {
984
+ path: ".husky/pre-push",
985
+ strategy: "shell",
986
+ templatePath: "docs/templates/hooks/pre-push",
987
+ executable: true,
988
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-push managed block" },
989
+ },
990
+ ] as const satisfies readonly ReconcileTarget[])
991
+ : []),
992
+ ...(options.includeCi
993
+ ? ([
994
+ {
995
+ path: ".github/workflows/phasegate-aidlc-gate.yml",
996
+ strategy: "yaml-add",
997
+ templatePath: "docs/templates/ci/aidlc-gate.yml",
998
+ },
999
+ ] as const satisfies readonly ReconcileTarget[])
1000
+ : []),
764
1001
  { path: "package.json", strategy: "package-json", templatePath: "package.json" },
765
1002
  { path: ".claude/skills", strategy: "symlink" },
766
1003
  { path: ".codex/skills", strategy: "symlink" },
@@ -786,7 +1023,11 @@ export class RunReconcileUseCase {
786
1023
  private resolveProjectPath(projectRoot: string, relativePath: string): string {
787
1024
  const absolutePath = resolve(projectRoot, relativePath);
788
1025
  const root = resolve(projectRoot);
789
- if (absolutePath !== root && !absolutePath.startsWith(`${root}/`) && relative(root, absolutePath).startsWith("..")) {
1026
+ if (
1027
+ absolutePath !== root &&
1028
+ !absolutePath.startsWith(`${root}/`) &&
1029
+ relative(root, absolutePath).startsWith("..")
1030
+ ) {
790
1031
  throw new Error(`Manifest entry escapes project root: ${relativePath}`);
791
1032
  }
792
1033
  return absolutePath;
@@ -3,26 +3,29 @@
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-148
5
5
  // @work-item-id WI-215
6
+ // @work-item-id WI-330
6
7
 
8
+ import { CiWorkflowMissingCheck } from "./application/checks/ci-workflow-missing-check.js";
7
9
  import { ClaudeContextMissingCheck } from "./application/checks/claude-context-missing-check.js";
8
10
  import { ClaudeHookMissingCheck } from "./application/checks/claude-hook-missing-check.js";
9
11
  import { ClaudeSkillsSymlinkCheck } from "./application/checks/claude-skills-symlink-check.js";
10
12
  import { CodexContextMissingCheck } from "./application/checks/codex-context-missing-check.js";
11
13
  import { CodexHookMissingCheck } from "./application/checks/codex-hook-missing-check.js";
12
14
  import { CodexSkillsSymlinkCheck } from "./application/checks/codex-skills-symlink-check.js";
13
- import { CiWorkflowMissingCheck } from "./application/checks/ci-workflow-missing-check.js";
15
+ import { ConfigStatusCheck } from "./application/checks/config-status-check.js";
14
16
  import { HuskyCommitMsgMissingCheck } from "./application/checks/husky-commit-msg-missing-check.js";
15
17
  import { HuskyPreCommitMissingCheck } from "./application/checks/husky-pre-commit-missing-check.js";
16
18
  import { HuskyPrePushMissingCheck } from "./application/checks/husky-pre-push-missing-check.js";
17
19
  import { PackageJsonDevdepMissingCheck } from "./application/checks/package-json-devdep-missing-check.js";
18
20
  import { WiWorkflowDriftCheck } from "./application/checks/wi-workflow-drift-check.js";
21
+ import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
19
22
  import { RunInstallUseCase } from "./application/usecases/run-install.js";
20
23
  import { RunReconcileUseCase } from "./application/usecases/run-reconcile.js";
21
24
  import { RunUninstallUseCase } from "./application/usecases/run-uninstall.js";
22
- import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
23
25
  import type { MergeStrategy } from "./domain/ports/merge-strategy.js";
24
26
  import type { ReconcileStrategy } from "./domain/ports/reconcile-strategy.js";
25
27
  import type { UninstallReverseStrategy } from "./domain/ports/uninstall-reverse-strategy.js";
28
+ import { ConfigStatusProbeAdapter } from "./infrastructure/adapters/config-status-probe-adapter.js";
26
29
  import { FileSystemManifestRepositoryAdapter } from "./infrastructure/adapters/file-system-manifest-repository-adapter.js";
27
30
  import { NodeCryptoHashAdapter } from "./infrastructure/adapters/node-crypto-hash-adapter.js";
28
31
  import { NodeFsFileInspectorAdapter } from "./infrastructure/adapters/node-fs-file-inspector-adapter.js";
@@ -45,6 +48,7 @@ export function createInstallationModule() {
45
48
  const manifestRepository = new FileSystemManifestRepositoryAdapter();
46
49
  const hashCalculator = new NodeCryptoHashAdapter();
47
50
  const modelDelegation = new SkillDeployerModelDelegationAdapter();
51
+ const configStatusProbe = new ConfigStatusProbeAdapter();
48
52
  const checks = [
49
53
  new ClaudeHookMissingCheck(),
50
54
  new ClaudeContextMissingCheck(),
@@ -58,8 +62,14 @@ export function createInstallationModule() {
58
62
  new ClaudeSkillsSymlinkCheck(),
59
63
  new CodexSkillsSymlinkCheck(),
60
64
  new WiWorkflowDriftCheck(),
65
+ new ConfigStatusCheck(configStatusProbe),
61
66
  ];
62
- const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(checks, inspector, manifestRepository);
67
+ const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(
68
+ checks,
69
+ inspector,
70
+ manifestRepository,
71
+ configStatusProbe,
72
+ );
63
73
  const runInstallUseCase = new RunInstallUseCase(manifestRepository, hashCalculator, modelDelegation);
64
74
  const runReconcileUseCase = new RunReconcileUseCase(manifestRepository, hashCalculator, modelDelegation);
65
75
  const runUninstallUseCase = new RunUninstallUseCase(manifestRepository, hashCalculator);