phasegate 0.264.0 → 0.283.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 (29) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/guide/installation.md +1 -1
  3. package/docs/templates/ci/aidlc-gate.yml +22 -4
  4. package/package.json +2 -2
  5. package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +30 -27
  6. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +29 -16
  7. package/scripts/harness/agent-integration/presentation/stop-hook.ts +35 -26
  8. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +5 -1
  9. package/scripts/harness/config-foundation/domain/harness-config.ts +10 -7
  10. package/scripts/harness/config-foundation/domain/services/preset-resolution-service.ts +4 -1
  11. package/scripts/harness/config-foundation/domain/value-objects/project-config.ts +34 -18
  12. package/scripts/harness/config-foundation/infrastructure/repositories/file-system-config-repository.ts +27 -18
  13. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +1 -8
  14. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +1 -8
  15. package/scripts/harness/harness-api/domain/ports/config-query-port.ts +11 -1
  16. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +125 -86
  17. package/scripts/harness/harness-api/domain/services/status-derivation-service.ts +31 -21
  18. package/scripts/harness/harness-api/domain/value-objects/harness-status-summary.ts +23 -8
  19. package/scripts/harness/harness-api/infrastructure/adapters/biome-ast-engine-lint-adapter.ts +4 -4
  20. package/scripts/harness/harness-api/infrastructure/adapters/harness-config-query-adapter.ts +79 -34
  21. package/scripts/harness/installation/application/usecases/run-install.ts +199 -51
  22. package/scripts/harness/installation/application/usecases/run-reconcile.ts +277 -69
  23. package/scripts/harness/installation/domain/deployment-manifest.ts +43 -0
  24. package/scripts/harness/main.ts +26 -6
  25. package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +24 -7
  26. package/scripts/harness/validator-system/composition-root.ts +4 -1
  27. package/scripts/harness/validator-system/domain/ports/ac-coverage-policy-port.ts +10 -1
  28. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +89 -3
  29. package/scripts/harness/validator-system/infrastructure/adapters/nyquist-ac-coverage-policy-adapter.ts +49 -20
@@ -16,20 +16,43 @@
16
16
  // @work-item-id WI-215
17
17
  // @work-item-id WI-216
18
18
  // @work-item-id WI-219
19
-
20
- import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink, readdir, rm } from "node:fs/promises";
19
+ // @work-item-id WI-315
20
+ // @work-item-id WI-326
21
+
22
+ import {
23
+ access,
24
+ chmod,
25
+ copyFile,
26
+ lstat,
27
+ mkdir,
28
+ readdir,
29
+ readFile,
30
+ readlink,
31
+ rm,
32
+ symlink,
33
+ writeFile,
34
+ } from "node:fs/promises";
21
35
  import { dirname, join } from "node:path";
22
36
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
23
- import { DeploymentManifest } from "../../domain/deployment-manifest.js";
37
+ import { DeploymentManifest, type InstallationFlags } from "../../domain/deployment-manifest.js";
24
38
  import type { ManagedBlockInput } from "../../domain/managed-block.js";
25
39
  import type { RepairMode } from "../../domain/repair-mode.js";
26
40
  import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
27
- import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
28
41
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
42
+ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
29
43
  import type { ModelDelegationPort } from "../ports/model-delegation-port.js";
30
44
 
31
45
  type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
32
- type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "text-managed" | "copy" | "copy-dir" | "symlink";
46
+ type StrategyType =
47
+ | "json"
48
+ | "shell"
49
+ | "yaml-add"
50
+ | "package-json"
51
+ | "markdown-managed"
52
+ | "text-managed"
53
+ | "copy"
54
+ | "copy-dir"
55
+ | "symlink";
33
56
 
34
57
  export interface InstallPlanItem {
35
58
  readonly path: string;
@@ -96,9 +119,16 @@ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
96
119
  const SHELL_END = "# === phasegate managed (END) ===";
97
120
  const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
98
121
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
122
+ const USER_SECTION_BEGIN = "<!-- phasegate:user-section:start -->";
123
+ const USER_SECTION_END = "<!-- phasegate:user-section:end -->";
99
124
  const TEXT_BEGIN = "# phasegate personal install exclude (BEGIN)";
100
125
  const TEXT_END = "# phasegate personal install exclude (END)";
101
- const PERSONAL_AGENT_RUNTIME_FILES = new Set([".claude/CLAUDE.md", ".claude/settings.json", "AGENTS.md", ".codex/hooks.json"]);
126
+ const PERSONAL_AGENT_RUNTIME_FILES = new Set([
127
+ ".claude/CLAUDE.md",
128
+ ".claude/settings.json",
129
+ "AGENTS.md",
130
+ ".codex/hooks.json",
131
+ ]);
102
132
  const SHARED_SKILLS_VERSION_PATH = "skills/.harness-version";
103
133
  const PERSONAL_PRINCIPLES_DOCS = ".phasegate-local/docs/principles";
104
134
  const PERSONAL_FOLDER_RULES_DOC = ".phasegate-local/docs/folder_management_rules.md";
@@ -207,7 +237,10 @@ function mergeHookArrays(existing: unknown, incoming: unknown): unknown[] {
207
237
  return result;
208
238
  }
209
239
 
210
- function mergeJsonObject(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
240
+ function mergeJsonObject(
241
+ existing: Record<string, unknown>,
242
+ incoming: Record<string, unknown>,
243
+ ): Record<string, unknown> {
211
244
  const result: Record<string, unknown> = { ...existing };
212
245
  const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
213
246
  const incomingHooks = isRecord(incoming.hooks) ? incoming.hooks : {};
@@ -256,11 +289,39 @@ function managedMarkdownBlock(content: string): string {
256
289
  return content.slice(start, end + MARKDOWN_END.length).trim();
257
290
  }
258
291
 
292
+ // Extracts the user-authored body between the user-section markers. Returns
293
+ // null when the markers are absent or the body is blank, so callers fall back
294
+ // to the template placeholder.
295
+ function extractUserSectionBody(content: string | null): string | null {
296
+ if (content === null) return null;
297
+ const start = content.indexOf(USER_SECTION_BEGIN);
298
+ const end = content.indexOf(USER_SECTION_END);
299
+ if (start === -1 || end === -1 || end < start) return null;
300
+ const body = content.slice(start + USER_SECTION_BEGIN.length, end).trim();
301
+ return body.length === 0 ? null : body;
302
+ }
303
+
304
+ // The CLAUDE.md template nests the user-section inside the managed section, so
305
+ // replacing the managed block wholesale would wipe user-authored instructions
306
+ // with the template placeholder. Re-inject the existing user-section body into
307
+ // the incoming block. Templates whose managed block carries no user-section
308
+ // markers (e.g. AGENTS.md) are returned unchanged.
309
+ function restoreUserSection(block: string, existing: string | null): string {
310
+ const start = block.indexOf(USER_SECTION_BEGIN);
311
+ const end = block.indexOf(USER_SECTION_END);
312
+ if (start === -1 || end === -1 || end < start) return block;
313
+ const preserved = extractUserSectionBody(existing);
314
+ if (preserved === null) return block;
315
+ return `${block.slice(0, start + USER_SECTION_BEGIN.length)}\n${preserved}\n${block.slice(end)}`;
316
+ }
317
+
259
318
  function mergeManagedMarkdown(existing: string | null, incoming: string): string {
260
- const block = managedMarkdownBlock(incoming);
261
319
  if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
320
+ const block = restoreUserSection(managedMarkdownBlock(incoming), existing);
262
321
  const pattern = new RegExp(`${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}`);
263
- if (pattern.test(existing)) return existing.replace(pattern, block).replace(/\s*$/, "\n");
322
+ // Replacer function keeps user-authored text (now part of the block) from
323
+ // being interpreted as `$`-substitution patterns by String.replace.
324
+ if (pattern.test(existing)) return existing.replace(pattern, () => block).replace(/\s*$/, "\n");
264
325
  return `${block}\n\n${existing.replace(/\s*$/, "\n")}`;
265
326
  }
266
327
 
@@ -321,7 +382,8 @@ function hasCustomJson(content: string | null): boolean {
321
382
  const parsed = JSON.parse(content) as unknown;
322
383
  if (!isRecord(parsed)) return true;
323
384
  const withoutEmptyHooks = { ...parsed };
324
- if (isRecord(withoutEmptyHooks.hooks) && Object.keys(withoutEmptyHooks.hooks).length === 0) delete withoutEmptyHooks.hooks;
385
+ if (isRecord(withoutEmptyHooks.hooks) && Object.keys(withoutEmptyHooks.hooks).length === 0)
386
+ delete withoutEmptyHooks.hooks;
325
387
  return Object.keys(withoutEmptyHooks).length > 0;
326
388
  } catch {
327
389
  return true;
@@ -343,10 +405,13 @@ function likelyCauseFor(code: string): string {
343
405
  }
344
406
 
345
407
  function recoveryFor(code: string, target: string): string {
346
- if (code === "EPERM") return `Review sandbox or filesystem permissions for ${target}, ask the user for write access when needed, then rerun phasegate setup:agent --apply or phasegate install --apply.`;
408
+ if (code === "EPERM")
409
+ return `Review sandbox or filesystem permissions for ${target}, ask the user for write access when needed, then rerun phasegate setup:agent --apply or phasegate install --apply.`;
347
410
  if (code === "EACCES") return `Fix ownership or permissions for ${target}, then rerun phasegate install --apply.`;
348
- if (code === "EROFS") return `Move the project to a writable filesystem or rerun in a writable workspace before applying ${target}.`;
349
- if (code === "EEXIST" || code === "ENOTDIR") return `Inspect the parent path for ${target}; if it is user-owned, rename or move it before rerunning phasegate install --dry-run --json and then --apply.`;
411
+ if (code === "EROFS")
412
+ return `Move the project to a writable filesystem or rerun in a writable workspace before applying ${target}.`;
413
+ if (code === "EEXIST" || code === "ENOTDIR")
414
+ return `Inspect the parent path for ${target}; if it is user-owned, rename or move it before rerunning phasegate install --dry-run --json and then --apply.`;
350
415
  return `Inspect ${target}, run phasegate install --dry-run --json, then rerun with --apply after resolving the filesystem issue.`;
351
416
  }
352
417
 
@@ -380,12 +445,22 @@ export class RunInstallUseCase {
380
445
  const skillSet = input.skillSet ?? "all";
381
446
  const workflow = input.workflow ?? "standard";
382
447
  const agent = input.agent ?? (includeClaude && includeCodex ? "both" : includeCodex ? "codex" : "claude");
383
- const targets = input.personal
448
+ const personal = input.personal ?? false;
449
+ const targets = personal
384
450
  ? this.createPersonalTargets({ includeClaude, includeCodex })
385
451
  : this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
452
+ // Persist the effective opt-in state so a later reconcile can honor the
453
+ // original install options. Personal installs never deploy Husky/CI
454
+ // targets, so their effective state is recorded as false regardless of
455
+ // input.
456
+ const installationFlags: InstallationFlags = {
457
+ includeHusky: personal ? false : includeHusky,
458
+ includeCi: personal ? false : includeCi,
459
+ personal,
460
+ };
386
461
  const existingManifest = await this.manifestRepository.load(input.projectRoot);
387
462
  const baseManifest = existingManifest ?? DeploymentManifest.create(input.phasegateVersion);
388
- let manifest = baseManifest;
463
+ let manifest = baseManifest.withInstallationFlags(installationFlags);
389
464
  const plan: InstallPlanItem[] = [];
390
465
  const refused: InstallPlanItem[] = [];
391
466
  const changed: InstallPlanItem[] = [];
@@ -396,16 +471,18 @@ export class RunInstallUseCase {
396
471
  const absolutePath = join(input.projectRoot, target.path);
397
472
  const before = await readTextOrNull(absolutePath);
398
473
  const rawTemplate = await readFile(join(input.harnessRoot, target.templatePath), "utf8");
399
- const template = target.strategy === "markdown-managed"
400
- ? renderAgentContextTemplate(rawTemplate, { agent, skillSet, workflow, includeHusky, includeCi })
401
- : rawTemplate;
474
+ const template =
475
+ target.strategy === "markdown-managed"
476
+ ? renderAgentContextTemplate(rawTemplate, { agent, skillSet, workflow, includeHusky, includeCi })
477
+ : rawTemplate;
402
478
  const existingEntry = baseManifest.findEntry(target.path);
403
479
  const beforeHash = before === null ? null : this.hashCalculator.compute(before);
404
- const unmanagedPersonalRuntimeFile = input.personal
405
- && (PERSONAL_AGENT_RUNTIME_FILES.has(target.path) || target.personalManualIfUnmanaged === true)
406
- && before !== null
407
- && !before.includes(MARKDOWN_BEGIN)
408
- && (existingEntry === null || beforeHash === null || !beforeHash.equals(existingEntry.hash));
480
+ const unmanagedPersonalRuntimeFile =
481
+ input.personal &&
482
+ (PERSONAL_AGENT_RUNTIME_FILES.has(target.path) || target.personalManualIfUnmanaged === true) &&
483
+ before !== null &&
484
+ !before.includes(MARKDOWN_BEGIN) &&
485
+ (existingEntry === null || beforeHash === null || !beforeHash.equals(existingEntry.hash));
409
486
  const repairMode = unmanagedPersonalRuntimeFile ? "manual" : this.repairMode(target, before);
410
487
  const next = unmanagedPersonalRuntimeFile ? before : this.merge(target, before, template, input.phasegateVersion);
411
488
  const didChange = before !== next;
@@ -414,11 +491,11 @@ export class RunInstallUseCase {
414
491
  // (merge returns `before`). For executable git hooks this means the
415
492
  // phasegate hook is NOT wired in — warn instead of skipping silently.
416
493
  const preservedExistingHook =
417
- target.strategy === "copy"
418
- && target.executable === true
419
- && before !== null
420
- && before.trim() !== template.trim()
421
- && !input.force;
494
+ target.strategy === "copy" &&
495
+ target.executable === true &&
496
+ before !== null &&
497
+ before.trim() !== template.trim() &&
498
+ !input.force;
422
499
  const warning = preservedExistingHook
423
500
  ? `${target.path}: an existing hook was found and left unchanged; phasegate's checks are NOT wired in. Merge the phasegate hook manually (or rerun with --force to back up and overwrite).`
424
501
  : null;
@@ -432,7 +509,9 @@ export class RunInstallUseCase {
432
509
  ? `${target.path}: existing non-phasegate runtime path requires manual review`
433
510
  : warning !== null
434
511
  ? warning
435
- : didChange ? `${target.path}: ${action}` : `${target.path}: already up to date`,
512
+ : didChange
513
+ ? `${target.path}: ${action}`
514
+ : `${target.path}: already up to date`,
436
515
  diff: unmanagedPersonalRuntimeFile ? "manual review required" : this.diffSummary(before, next),
437
516
  skillHint: repairMode === "ai-assisted" ? SKILL_HINT : null,
438
517
  warning,
@@ -488,10 +567,7 @@ export class RunInstallUseCase {
488
567
  }
489
568
 
490
569
  const personalSkillTargets = input.personal
491
- ? [
492
- ...(includeClaude ? [".claude/skills"] : []),
493
- ...(includeCodex ? [".codex/skills"] : []),
494
- ]
570
+ ? [...(includeClaude ? [".claude/skills"] : []), ...(includeCodex ? [".codex/skills"] : [])]
495
571
  : [];
496
572
  const selectedPersonalSkills = input.personal ? await listSelectedBundledSkills(input.harnessRoot, skillSet) : [];
497
573
  for (const skillPath of personalSkillTargets) {
@@ -499,7 +575,13 @@ export class RunInstallUseCase {
499
575
  plan.push(item);
500
576
  if (input.apply && item.changed && item.repairMode === "mechanical") {
501
577
  try {
502
- await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, join(input.projectRoot, skillPath), selectedPersonalSkills);
578
+ await copySelectedSkillDirectories(
579
+ this.modelDelegation,
580
+ input.harnessRoot,
581
+ input.projectRoot,
582
+ join(input.projectRoot, skillPath),
583
+ selectedPersonalSkills,
584
+ );
503
585
  await writeFile(
504
586
  join(input.projectRoot, skillPath, ".harness-version"),
505
587
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -512,7 +594,12 @@ export class RunInstallUseCase {
512
594
  manifest = this.addManifestEntry(baseManifest, manifest, {
513
595
  path: `${item.path}/.harness-version`,
514
596
  mode: "created",
515
- contentForHash: this.personalSkillsVersionHashInput(item.path, input.phasegateVersion, skillSet, selectedPersonalSkills),
597
+ contentForHash: this.personalSkillsVersionHashInput(
598
+ item.path,
599
+ input.phasegateVersion,
600
+ skillSet,
601
+ selectedPersonalSkills,
602
+ ),
516
603
  });
517
604
  for (const skill of selectedPersonalSkills) {
518
605
  manifest = this.addManifestEntry(baseManifest, manifest, {
@@ -567,7 +654,11 @@ export class RunInstallUseCase {
567
654
  return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "mkdir", error);
568
655
  }
569
656
  try {
570
- await symlink(linkSpec.target, join(input.projectRoot, linkSpec.path), process.platform === "win32" ? "junction" : "dir");
657
+ await symlink(
658
+ linkSpec.target,
659
+ join(input.projectRoot, linkSpec.path),
660
+ process.platform === "win32" ? "junction" : "dir",
661
+ );
571
662
  } catch (error) {
572
663
  return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "symlink", error);
573
664
  }
@@ -586,24 +677,45 @@ export class RunInstallUseCase {
586
677
  repairMode: "manual",
587
678
  strategy: "json",
588
679
  changed: false,
589
- summary: "~/.codex/config.toml: personal mode does not write user-level Codex feature flags; enable hooks manually when needed",
680
+ summary:
681
+ "~/.codex/config.toml: personal mode does not write user-level Codex feature flags; enable hooks manually when needed",
590
682
  diff: "manual Codex hooks feature enablement may be required",
591
683
  skillHint: null,
592
684
  warning: null,
593
685
  });
594
686
  }
595
687
 
596
- if (input.apply && changed.length > 0) {
688
+ // Re-save on apply when the recorded install flags drift from a previous
689
+ // manifest (e.g. re-install with different --with-husky/--with-ci), even
690
+ // if no managed file content changed. A missing manifest with zero changes
691
+ // keeps the legacy behavior of not creating one.
692
+ const flagsDrifted =
693
+ existingManifest !== null && !this.installationFlagsEqual(existingManifest.installationFlags, installationFlags);
694
+ if (input.apply && (changed.length > 0 || flagsDrifted)) {
597
695
  try {
598
696
  await this.manifestRepository.save(input.projectRoot, manifest);
599
697
  } catch (error) {
600
- return this.withApplyError({ plan, refused, changed, backupDir }, ".phasegate/manifest.json", "manifest-save", error);
698
+ return this.withApplyError(
699
+ { plan, refused, changed, backupDir },
700
+ ".phasegate/manifest.json",
701
+ "manifest-save",
702
+ error,
703
+ );
601
704
  }
602
705
  }
603
706
 
604
707
  return { plan, refused, changed, backupDir };
605
708
  }
606
709
 
710
+ private installationFlagsEqual(existing: InstallationFlags | undefined, next: InstallationFlags): boolean {
711
+ return (
712
+ existing !== undefined &&
713
+ existing.includeHusky === next.includeHusky &&
714
+ existing.includeCi === next.includeCi &&
715
+ existing.personal === next.personal
716
+ );
717
+ }
718
+
607
719
  private withApplyError(
608
720
  result: RunInstallResult,
609
721
  target: string,
@@ -638,7 +750,11 @@ export class RunInstallUseCase {
638
750
  },
639
751
  ...(options.includeClaude
640
752
  ? [
641
- { path: ".claude/settings.json", strategy: "json" as const, templatePath: "templates/.claude/settings.json" },
753
+ {
754
+ path: ".claude/settings.json",
755
+ strategy: "json" as const,
756
+ templatePath: "templates/.claude/settings.json",
757
+ },
642
758
  {
643
759
  path: "CLAUDE.md",
644
760
  strategy: "markdown-managed" as const,
@@ -696,7 +812,10 @@ export class RunInstallUseCase {
696
812
  ];
697
813
  }
698
814
 
699
- private createPersonalTargets(options: { readonly includeClaude: boolean; readonly includeCodex: boolean }): readonly InstallTarget[] {
815
+ private createPersonalTargets(options: {
816
+ readonly includeClaude: boolean;
817
+ readonly includeCodex: boolean;
818
+ }): readonly InstallTarget[] {
700
819
  return [
701
820
  {
702
821
  path: ".phasegate-local/phasegate.config.json",
@@ -709,7 +828,11 @@ export class RunInstallUseCase {
709
828
  path: ".claude/CLAUDE.md",
710
829
  strategy: "markdown-managed" as const,
711
830
  templatePath: "docs/templates/agent-context/CLAUDE.md.template.md",
712
- block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate personal .claude/CLAUDE.md managed section" },
831
+ block: {
832
+ start: MARKDOWN_BEGIN,
833
+ end: MARKDOWN_END,
834
+ content: "phasegate personal .claude/CLAUDE.md managed section",
835
+ },
713
836
  personalManualIfUnmanaged: true,
714
837
  },
715
838
  {
@@ -725,7 +848,11 @@ export class RunInstallUseCase {
725
848
  path: "AGENTS.md",
726
849
  strategy: "markdown-managed" as const,
727
850
  templatePath: "docs/templates/agent-context/AGENTS.md.template.md",
728
- block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate personal AGENTS.md managed section" },
851
+ block: {
852
+ start: MARKDOWN_BEGIN,
853
+ end: MARKDOWN_END,
854
+ content: "phasegate personal AGENTS.md managed section",
855
+ },
729
856
  personalManualIfUnmanaged: true,
730
857
  },
731
858
  {
@@ -861,9 +988,15 @@ export class RunInstallUseCase {
861
988
  break;
862
989
  }
863
990
  }
864
- const missingManifest = baseManifest.findEntry(`${relativePath}/.harness-version`) === null
865
- || skills.some((skill) => baseManifest.findEntry(`${relativePath}/${skill}`) === null);
866
- const changed = current === null || !current.includes(expectedVersion) || !current.includes(expectedSkillSet) || missingSkill || missingManifest;
991
+ const missingManifest =
992
+ baseManifest.findEntry(`${relativePath}/.harness-version`) === null ||
993
+ skills.some((skill) => baseManifest.findEntry(`${relativePath}/${skill}`) === null);
994
+ const changed =
995
+ current === null ||
996
+ !current.includes(expectedVersion) ||
997
+ !current.includes(expectedSkillSet) ||
998
+ missingSkill ||
999
+ missingManifest;
867
1000
  return {
868
1001
  path: relativePath,
869
1002
  action: changed ? "missing" : "will-skip",
@@ -877,7 +1010,12 @@ export class RunInstallUseCase {
877
1010
  };
878
1011
  }
879
1012
 
880
- private personalSkillsVersionHashInput(path: string, version: string, skillSet: "core" | "all", skills: readonly string[]): string {
1013
+ private personalSkillsVersionHashInput(
1014
+ path: string,
1015
+ version: string,
1016
+ skillSet: "core" | "all",
1017
+ skills: readonly string[],
1018
+ ): string {
881
1019
  return `personal-skills-version:${path}:${version}:${skillSet}:${skills.join(",")}`;
882
1020
  }
883
1021
 
@@ -902,9 +1040,15 @@ export class RunInstallUseCase {
902
1040
  break;
903
1041
  }
904
1042
  }
905
- const missingManifest = baseManifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null
906
- || skills.some((skill) => baseManifest.findEntry(`skills/${skill}`) === null);
907
- const changed = current === null || !current.includes(expectedVersion) || !current.includes(expectedSkillSet) || missingSkill || missingManifest;
1043
+ const missingManifest =
1044
+ baseManifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null ||
1045
+ skills.some((skill) => baseManifest.findEntry(`skills/${skill}`) === null);
1046
+ const changed =
1047
+ current === null ||
1048
+ !current.includes(expectedVersion) ||
1049
+ !current.includes(expectedSkillSet) ||
1050
+ missingSkill ||
1051
+ missingManifest;
908
1052
  return {
909
1053
  path: "skills",
910
1054
  action: changed ? "missing" : "will-skip",
@@ -918,7 +1062,11 @@ export class RunInstallUseCase {
918
1062
  };
919
1063
  }
920
1064
 
921
- private async deploySharedSkills(input: RunInstallInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
1065
+ private async deploySharedSkills(
1066
+ input: RunInstallInput,
1067
+ skills: readonly string[],
1068
+ skillSet: SkillSet,
1069
+ ): Promise<void> {
922
1070
  const targetRoot = join(input.projectRoot, "skills");
923
1071
  await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
924
1072
  await writeFile(