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
@@ -7,8 +7,22 @@
7
7
  // @work-item-id WI-216
8
8
  // @work-item-id WI-219
9
9
  // @work-item-id WI-264
10
-
11
- import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
10
+ // @work-item-id WI-315
11
+ // @work-item-id WI-326
12
+
13
+ import {
14
+ access,
15
+ chmod,
16
+ copyFile,
17
+ lstat,
18
+ mkdir,
19
+ readdir,
20
+ readFile,
21
+ readlink,
22
+ rm,
23
+ symlink,
24
+ writeFile,
25
+ } from "node:fs/promises";
12
26
  import { dirname, join, relative, resolve } from "node:path";
13
27
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
14
28
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
@@ -20,7 +34,15 @@ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.j
20
34
  import type { ModelDelegationPort } from "../ports/model-delegation-port.js";
21
35
 
22
36
  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";
37
+ type StrategyType =
38
+ | "json"
39
+ | "shell"
40
+ | "yaml-add"
41
+ | "package-json"
42
+ | "markdown-managed"
43
+ | "copy-dir"
44
+ | "symlink"
45
+ | "unknown";
24
46
 
25
47
  export interface ReconcilePlanItem {
26
48
  readonly path: string;
@@ -40,6 +62,12 @@ export interface RunReconcileInput {
40
62
  readonly dryRun: boolean;
41
63
  readonly apply: boolean;
42
64
  readonly force: boolean;
65
+ // Explicit per-run overrides. When omitted, the flags recorded in the
66
+ // manifest at install time (installationFlags) are used; manifests written
67
+ // before WI-326 carry no flags and fall back to the legacy behavior of
68
+ // reconciling every bundled target.
69
+ readonly includeHusky?: boolean;
70
+ readonly includeCi?: boolean;
43
71
  }
44
72
 
45
73
  export interface RunReconcileResult {
@@ -62,6 +90,8 @@ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
62
90
  const SHELL_END = "# === phasegate managed (END) ===";
63
91
  const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
64
92
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
93
+ const USER_SECTION_BEGIN = "<!-- phasegate:user-section:start -->";
94
+ const USER_SECTION_END = "<!-- phasegate:user-section:end -->";
65
95
  const SHARED_SKILLS_VERSION_PATH = "skills/.harness-version";
66
96
  const HARNESS_VERSION_BASENAME = ".harness-version";
67
97
  const SKILL_ROOT_PREFIXES = ["skills", ".claude/skills", ".codex/skills"] as const;
@@ -177,7 +207,10 @@ function replaceHookArrays(existing: unknown, incoming: unknown): unknown[] {
177
207
  return result;
178
208
  }
179
209
 
180
- function reconcileJsonObject(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
210
+ function reconcileJsonObject(
211
+ existing: Record<string, unknown>,
212
+ incoming: Record<string, unknown>,
213
+ ): Record<string, unknown> {
181
214
  const result: Record<string, unknown> = { ...existing };
182
215
  const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
183
216
  const incomingHooks = isRecord(incoming.hooks) ? incoming.hooks : {};
@@ -220,11 +253,39 @@ function managedMarkdownBlock(content: string): string {
220
253
  return content.slice(start, end + MARKDOWN_END.length).trim();
221
254
  }
222
255
 
256
+ // Extracts the user-authored body between the user-section markers. Returns
257
+ // null when the markers are absent or the body is blank, so callers fall back
258
+ // to the template placeholder.
259
+ function extractUserSectionBody(content: string | null): string | null {
260
+ if (content === null) return null;
261
+ const start = content.indexOf(USER_SECTION_BEGIN);
262
+ const end = content.indexOf(USER_SECTION_END);
263
+ if (start === -1 || end === -1 || end < start) return null;
264
+ const body = content.slice(start + USER_SECTION_BEGIN.length, end).trim();
265
+ return body.length === 0 ? null : body;
266
+ }
267
+
268
+ // The CLAUDE.md template nests the user-section inside the managed section, so
269
+ // replacing the managed block wholesale would wipe user-authored instructions
270
+ // with the template placeholder. Re-inject the existing user-section body into
271
+ // the incoming block. Templates whose managed block carries no user-section
272
+ // markers (e.g. AGENTS.md) are returned unchanged.
273
+ function restoreUserSection(block: string, existing: string | null): string {
274
+ const start = block.indexOf(USER_SECTION_BEGIN);
275
+ const end = block.indexOf(USER_SECTION_END);
276
+ if (start === -1 || end === -1 || end < start) return block;
277
+ const preserved = extractUserSectionBody(existing);
278
+ if (preserved === null) return block;
279
+ return `${block.slice(0, start + USER_SECTION_BEGIN.length)}\n${preserved}\n${block.slice(end)}`;
280
+ }
281
+
223
282
  function reconcileManagedMarkdown(existing: string | null, incoming: string): string {
224
- const block = managedMarkdownBlock(incoming);
225
283
  if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
284
+ const block = restoreUserSection(managedMarkdownBlock(incoming), existing);
226
285
  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");
286
+ // Replacer function keeps user-authored text (now part of the block) from
287
+ // being interpreted as `$`-substitution patterns by String.replace.
288
+ if (pattern.test(existing)) return existing.replace(pattern, () => block).replace(/\s*$/, "\n");
228
289
  return `${block}\n\n${existing.replace(/\s*$/, "\n")}`;
229
290
  }
230
291
 
@@ -235,7 +296,9 @@ function renderAgentContextTemplate(template: string): string {
235
296
  "phasegate validate --layer L2 --format human",
236
297
  "phasegate setup:agent --dry-run",
237
298
  "phasegate config:plan --intent l4-strict --dry-run",
238
- ].map((command) => `- \`${command}\``).join("\n");
299
+ ]
300
+ .map((command) => `- \`${command}\``)
301
+ .join("\n");
239
302
  return template
240
303
  .replaceAll("{{PHASEGATE_AGENT}}", "both")
241
304
  .replaceAll("{{PHASEGATE_SKILLS_MODE}}", "all")
@@ -289,12 +352,15 @@ export class RunReconcileUseCase {
289
352
  return { plan: [item], refused: [], changed: [], backupDir: null };
290
353
  }
291
354
 
292
- const targets = this.createTargets();
355
+ const includeHusky = input.includeHusky ?? manifest.installationFlags?.includeHusky ?? true;
356
+ const includeCi = input.includeCi ?? manifest.installationFlags?.includeCi ?? true;
357
+ const targets = this.createTargets({ includeHusky, includeCi });
293
358
  const targetsByPath = new Map(targets.map((target) => [target.path, target]));
294
359
  let nextManifest = DeploymentManifest.reconstitute({
295
360
  version: input.phasegateVersion,
296
361
  installedAt: manifest.installedAt,
297
362
  entries: manifest.entries,
363
+ installationFlags: manifest.installationFlags,
298
364
  });
299
365
  const plan: ReconcilePlanItem[] = [];
300
366
  const refused: ReconcilePlanItem[] = [];
@@ -312,14 +378,28 @@ export class RunReconcileUseCase {
312
378
  for (const entry of manifest.entries) {
313
379
  const target = targetsByPath.get(entry.path);
314
380
  if (target === undefined) {
315
- const item = this.item(entry.path, "skip", "manual", "unknown", false, `${entry.path}: no bundled template`, "manual review required", SKILL_HINT);
381
+ const item = this.item(
382
+ entry.path,
383
+ "skip",
384
+ "manual",
385
+ "unknown",
386
+ false,
387
+ `${entry.path}: no bundled template`,
388
+ "manual review required",
389
+ SKILL_HINT,
390
+ );
316
391
  plan.push(item);
317
392
  continue;
318
393
  }
319
394
  const outcome = await this.planManagedEntry(input, entry, target);
320
395
  outcomes.push(outcome);
321
396
  plan.push(outcome.item);
322
- if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
397
+ if (
398
+ input.apply &&
399
+ outcome.item.changed &&
400
+ (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") &&
401
+ !input.force
402
+ ) {
323
403
  refused.push({ ...outcome.item, action: "refuse" });
324
404
  }
325
405
  }
@@ -329,7 +409,12 @@ export class RunReconcileUseCase {
329
409
  const outcome = await this.planMissingTarget(input, target);
330
410
  outcomes.push(outcome);
331
411
  plan.push(outcome.item);
332
- if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
412
+ if (
413
+ input.apply &&
414
+ outcome.item.changed &&
415
+ (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") &&
416
+ !input.force
417
+ ) {
333
418
  refused.push({ ...outcome.item, action: "refuse" });
334
419
  }
335
420
  }
@@ -376,18 +461,43 @@ export class RunReconcileUseCase {
376
461
  if (hashContent !== null) {
377
462
  if (outcome.item.strategy === "copy-dir" && outcome.item.path === "skills") {
378
463
  const sharedSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
379
- nextManifest = nextManifest.addEntry(this.createdEntry(SHARED_SKILLS_VERSION_PATH, this.sharedSkillsVersionHashInput(input.phasegateVersion, "all", sharedSkills)));
464
+ nextManifest = nextManifest.addEntry(
465
+ this.createdEntry(
466
+ SHARED_SKILLS_VERSION_PATH,
467
+ this.sharedSkillsVersionHashInput(input.phasegateVersion, "all", sharedSkills),
468
+ ),
469
+ );
380
470
  for (const skill of sharedSkills) {
381
- nextManifest = nextManifest.addEntry(this.createdEntry(`skills/${skill}`, this.sharedSkillHashInput(skill, input.phasegateVersion, "all")));
471
+ nextManifest = nextManifest.addEntry(
472
+ this.createdEntry(`skills/${skill}`, this.sharedSkillHashInput(skill, input.phasegateVersion, "all")),
473
+ );
382
474
  }
383
- } else if (outcome.item.strategy === "copy-dir" && (outcome.item.path === ".claude/skills" || outcome.item.path === ".codex/skills")) {
475
+ } else if (
476
+ outcome.item.strategy === "copy-dir" &&
477
+ (outcome.item.path === ".claude/skills" || outcome.item.path === ".codex/skills")
478
+ ) {
384
479
  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)));
480
+ nextManifest = nextManifest.addEntry(
481
+ this.createdEntry(
482
+ `${outcome.item.path}/.harness-version`,
483
+ this.personalSkillsVersionHashInput(outcome.item.path, input.phasegateVersion, "all", personalSkills),
484
+ ),
485
+ );
386
486
  for (const skill of personalSkills) {
387
- nextManifest = nextManifest.addEntry(this.createdEntry(`${outcome.item.path}/${skill}`, this.personalSkillHashInput(outcome.item.path, skill, input.phasegateVersion, "all")));
487
+ nextManifest = nextManifest.addEntry(
488
+ this.createdEntry(
489
+ `${outcome.item.path}/${skill}`,
490
+ this.personalSkillHashInput(outcome.item.path, skill, input.phasegateVersion, "all"),
491
+ ),
492
+ );
388
493
  }
389
494
  } else {
390
- const mode = outcome.item.strategy === "symlink" ? "symlink" : outcome.item.action === "add" ? "created" : (manifest.findEntry(outcome.item.path)?.mode ?? "merged");
495
+ const mode =
496
+ outcome.item.strategy === "symlink"
497
+ ? "symlink"
498
+ : outcome.item.action === "add"
499
+ ? "created"
500
+ : (manifest.findEntry(outcome.item.path)?.mode ?? "merged");
391
501
  nextManifest = nextManifest.addEntry(
392
502
  DeploymentEntry.create({
393
503
  path: outcome.item.path,
@@ -416,9 +526,10 @@ export class RunReconcileUseCase {
416
526
  const matchesManifest = currentHash.equals(entry.hash);
417
527
  const rawTemplate = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
418
528
  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);
529
+ const next =
530
+ entry.mode === "created" && target.strategy !== "package-json" && target.strategy !== "markdown-managed"
531
+ ? template
532
+ : this.reconcileContent(target, before, template, input.phasegateVersion);
422
533
  const changed = before !== next;
423
534
  const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
424
535
  return {
@@ -448,11 +559,13 @@ export class RunReconcileUseCase {
448
559
  const before = await readTextOrNull(absolutePath);
449
560
  const rawTemplate = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
450
561
  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);
562
+ const next =
563
+ before === null && target.strategy !== "package-json"
564
+ ? template
565
+ : this.reconcileContent(target, before, template, input.phasegateVersion);
454
566
  const changed = before !== next;
455
- const repairMode: RepairMode = target.strategy === "shell" && before !== null && !before.includes(SHELL_BEGIN) ? "ai-assisted" : "mechanical";
567
+ const repairMode: RepairMode =
568
+ target.strategy === "shell" && before !== null && !before.includes(SHELL_BEGIN) ? "ai-assisted" : "mechanical";
456
569
  return {
457
570
  item: this.item(
458
571
  target.path,
@@ -480,19 +593,46 @@ export class RunReconcileUseCase {
480
593
  const stat = await lstat(absolutePath);
481
594
  if (stat.isSymbolicLink() && (await readlink(absolutePath)) === "../skills") {
482
595
  return {
483
- item: this.item(relativePath, "skip", "mechanical", "symlink", false, `${relativePath}: already linked`, "no changes", null),
596
+ item: this.item(
597
+ relativePath,
598
+ "skip",
599
+ "mechanical",
600
+ "symlink",
601
+ false,
602
+ `${relativePath}: already linked`,
603
+ "no changes",
604
+ null,
605
+ ),
484
606
  needsBackup: false,
485
607
  apply: async () => "../skills",
486
608
  };
487
609
  }
488
610
  return {
489
- item: this.item(relativePath, "skip", "manual", "symlink", false, `${relativePath}: existing non-phasegate path requires manual review`, "manual review required", SKILL_HINT),
611
+ item: this.item(
612
+ relativePath,
613
+ "skip",
614
+ "manual",
615
+ "symlink",
616
+ false,
617
+ `${relativePath}: existing non-phasegate path requires manual review`,
618
+ "manual review required",
619
+ SKILL_HINT,
620
+ ),
490
621
  needsBackup: false,
491
622
  apply: async () => null,
492
623
  };
493
624
  } catch {
494
625
  return {
495
- item: this.item(relativePath, "link", "mechanical", "symlink", true, `${relativePath}: create symlink`, "+ symlink ../skills", null),
626
+ item: this.item(
627
+ relativePath,
628
+ "link",
629
+ "mechanical",
630
+ "symlink",
631
+ true,
632
+ `${relativePath}: create symlink`,
633
+ "+ symlink ../skills",
634
+ null,
635
+ ),
496
636
  needsBackup: false,
497
637
  apply: async () => {
498
638
  await mkdir(join(projectRoot, "skills"), { recursive: true });
@@ -505,21 +645,40 @@ export class RunReconcileUseCase {
505
645
  }
506
646
 
507
647
  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/"));
648
+ return (
649
+ manifest.findEntry(".claude/skills") !== null ||
650
+ manifest.findEntry(".codex/skills") !== null ||
651
+ manifest.entries.some((entry) => entry.path.startsWith("skills/"))
652
+ );
511
653
  }
512
654
 
513
655
  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/"));
656
+ // Manifests written since WI-326 record the install mode explicitly;
657
+ // older manifests fall back to the legacy entry-shape heuristic.
658
+ if (manifest.installationFlags !== undefined) return manifest.installationFlags.personal;
659
+ return (
660
+ manifest.findEntry(".phasegate-local/phasegate.config.json") !== null ||
661
+ manifest.entries.some(
662
+ (entry) => (entry.path === ".claude/skills" || entry.path === ".codex/skills") && entry.mode === "created",
663
+ ) ||
664
+ manifest.entries.some(
665
+ (entry) => entry.path.startsWith(".claude/skills/") || entry.path.startsWith(".codex/skills/"),
666
+ )
667
+ );
517
668
  }
518
669
 
519
670
  private personalSkillPaths(manifest: DeploymentManifest): Array<".claude/skills" | ".codex/skills"> {
520
671
  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");
672
+ if (
673
+ manifest.findEntry(".claude/skills") !== null ||
674
+ manifest.entries.some((entry) => entry.path.startsWith(".claude/skills/"))
675
+ )
676
+ paths.add(".claude/skills");
677
+ if (
678
+ manifest.findEntry(".codex/skills") !== null ||
679
+ manifest.entries.some((entry) => entry.path.startsWith(".codex/skills/"))
680
+ )
681
+ paths.add(".codex/skills");
523
682
  return [...paths].sort();
524
683
  }
525
684
 
@@ -538,8 +697,9 @@ export class RunReconcileUseCase {
538
697
  break;
539
698
  }
540
699
  }
541
- const missingManifest = manifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null
542
- || skills.some((skill) => manifest.findEntry(`skills/${skill}`) === null);
700
+ const missingManifest =
701
+ manifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null ||
702
+ skills.some((skill) => manifest.findEntry(`skills/${skill}`) === null);
543
703
  const renderedSkillDrift = await hasRenderedSkillDrift(
544
704
  this.modelDelegation,
545
705
  input.harnessRoot,
@@ -547,7 +707,12 @@ export class RunReconcileUseCase {
547
707
  this.resolveProjectPath(input.projectRoot, "skills"),
548
708
  skills,
549
709
  );
550
- const changed = versionContent === null || !versionContent.includes(expectedVersion) || missingSkill || missingManifest || renderedSkillDrift;
710
+ const changed =
711
+ versionContent === null ||
712
+ !versionContent.includes(expectedVersion) ||
713
+ missingSkill ||
714
+ missingManifest ||
715
+ renderedSkillDrift;
551
716
  return {
552
717
  item: this.item(
553
718
  "skills",
@@ -568,7 +733,11 @@ export class RunReconcileUseCase {
568
733
  };
569
734
  }
570
735
 
571
- private async deploySharedSkills(input: RunReconcileInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
736
+ private async deploySharedSkills(
737
+ input: RunReconcileInput,
738
+ skills: readonly string[],
739
+ skillSet: SkillSet,
740
+ ): Promise<void> {
572
741
  const targetRoot = this.resolveProjectPath(input.projectRoot, "skills");
573
742
  await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
574
743
  await writeFile(
@@ -595,8 +764,9 @@ export class RunReconcileUseCase {
595
764
  break;
596
765
  }
597
766
  }
598
- const missingManifest = manifest.findEntry(`${relativePath}/.harness-version`) === null
599
- || skills.some((skill) => manifest.findEntry(`${relativePath}/${skill}`) === null);
767
+ const missingManifest =
768
+ manifest.findEntry(`${relativePath}/.harness-version`) === null ||
769
+ skills.some((skill) => manifest.findEntry(`${relativePath}/${skill}`) === null);
600
770
  const renderedSkillDrift = await hasRenderedSkillDrift(
601
771
  this.modelDelegation,
602
772
  input.harnessRoot,
@@ -604,7 +774,12 @@ export class RunReconcileUseCase {
604
774
  this.resolveProjectPath(input.projectRoot, relativePath),
605
775
  skills,
606
776
  );
607
- const changed = versionContent === null || !versionContent.includes(expectedVersion) || missingSkill || missingManifest || renderedSkillDrift;
777
+ const changed =
778
+ versionContent === null ||
779
+ !versionContent.includes(expectedVersion) ||
780
+ missingSkill ||
781
+ missingManifest ||
782
+ renderedSkillDrift;
608
783
  return {
609
784
  item: this.item(
610
785
  relativePath,
@@ -620,7 +795,13 @@ export class RunReconcileUseCase {
620
795
  apply: async () => {
621
796
  if (!changed) return null;
622
797
  const targetRoot = this.resolveProjectPath(input.projectRoot, relativePath);
623
- await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
798
+ await copySelectedSkillDirectories(
799
+ this.modelDelegation,
800
+ input.harnessRoot,
801
+ input.projectRoot,
802
+ targetRoot,
803
+ skills,
804
+ );
624
805
  await writeFile(
625
806
  join(targetRoot, ".harness-version"),
626
807
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -631,7 +812,10 @@ export class RunReconcileUseCase {
631
812
  };
632
813
  }
633
814
 
634
- private planOrphanSkills(input: RunReconcileInput, manifest: DeploymentManifest): Array<{
815
+ private planOrphanSkills(
816
+ input: RunReconcileInput,
817
+ manifest: DeploymentManifest,
818
+ ): Array<{
635
819
  readonly item: ReconcilePlanItem;
636
820
  readonly needsBackup: boolean;
637
821
  readonly prune: boolean;
@@ -702,7 +886,12 @@ export class RunReconcileUseCase {
702
886
  return `shared-skill:${skill}:${version}:${skillSet}`;
703
887
  }
704
888
 
705
- private personalSkillsVersionHashInput(path: string, version: string, skillSet: SkillSet, skills: readonly string[]): string {
889
+ private personalSkillsVersionHashInput(
890
+ path: string,
891
+ version: string,
892
+ skillSet: SkillSet,
893
+ skills: readonly string[],
894
+ ): string {
706
895
  return `personal-skills-version:${path}:${version}:${skillSet}:${skills.join(",")}`;
707
896
  }
708
897
 
@@ -723,7 +912,10 @@ export class RunReconcileUseCase {
723
912
  return `${JSON.stringify(reconcileJsonObject(isRecord(existing) ? existing : {}, isRecord(incoming) ? incoming : {}), null, 2)}\n`;
724
913
  }
725
914
 
726
- private createTargets(): readonly ReconcileTarget[] {
915
+ private createTargets(options: {
916
+ readonly includeHusky: boolean;
917
+ readonly includeCi: boolean;
918
+ }): readonly ReconcileTarget[] {
727
919
  return [
728
920
  { path: ".claude/settings.json", strategy: "json", templatePath: "templates/.claude/settings.json" },
729
921
  {
@@ -739,28 +931,40 @@ export class RunReconcileUseCase {
739
931
  templatePath: "docs/templates/agent-context/AGENTS.md.template.md",
740
932
  block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate AGENTS.md managed section" },
741
933
  },
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" },
934
+ ...(options.includeHusky
935
+ ? ([
936
+ {
937
+ path: ".husky/pre-commit",
938
+ strategy: "shell",
939
+ templatePath: "docs/templates/hooks/pre-commit",
940
+ executable: true,
941
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-commit managed block" },
942
+ },
943
+ {
944
+ path: ".husky/commit-msg",
945
+ strategy: "shell",
946
+ templatePath: "docs/templates/hooks/commit-msg",
947
+ executable: true,
948
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate commit-msg managed block" },
949
+ },
950
+ {
951
+ path: ".husky/pre-push",
952
+ strategy: "shell",
953
+ templatePath: "docs/templates/hooks/pre-push",
954
+ executable: true,
955
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-push managed block" },
956
+ },
957
+ ] as const satisfies readonly ReconcileTarget[])
958
+ : []),
959
+ ...(options.includeCi
960
+ ? ([
961
+ {
962
+ path: ".github/workflows/phasegate-aidlc-gate.yml",
963
+ strategy: "yaml-add",
964
+ templatePath: "docs/templates/ci/aidlc-gate.yml",
965
+ },
966
+ ] as const satisfies readonly ReconcileTarget[])
967
+ : []),
764
968
  { path: "package.json", strategy: "package-json", templatePath: "package.json" },
765
969
  { path: ".claude/skills", strategy: "symlink" },
766
970
  { path: ".codex/skills", strategy: "symlink" },
@@ -786,7 +990,11 @@ export class RunReconcileUseCase {
786
990
  private resolveProjectPath(projectRoot: string, relativePath: string): string {
787
991
  const absolutePath = resolve(projectRoot, relativePath);
788
992
  const root = resolve(projectRoot);
789
- if (absolutePath !== root && !absolutePath.startsWith(`${root}/`) && relative(root, absolutePath).startsWith("..")) {
993
+ if (
994
+ absolutePath !== root &&
995
+ !absolutePath.startsWith(`${root}/`) &&
996
+ relative(root, absolutePath).startsWith("..")
997
+ ) {
790
998
  throw new Error(`Manifest entry escapes project root: ${relativePath}`);
791
999
  }
792
1000
  return absolutePath;
@@ -1,19 +1,32 @@
1
1
  // @unit installation
2
2
  // @layer domain
3
3
  // @work-item-id WI-145
4
+ // @work-item-id WI-326
4
5
 
5
6
  import { DeploymentEntry, type DeploymentEntryJson } from "./deployment-entry.js";
6
7
 
8
+ // Records which install-time options produced this manifest so a later
9
+ // reconcile can honor the original opt-in state instead of assuming defaults.
10
+ // Optional for backward compatibility: manifests written before WI-326 carry
11
+ // no flags, and consumers must fall back to legacy behavior in that case.
12
+ export interface InstallationFlags {
13
+ readonly includeHusky: boolean;
14
+ readonly includeCi: boolean;
15
+ readonly personal: boolean;
16
+ }
17
+
7
18
  export interface DeploymentManifestInput {
8
19
  readonly version: string;
9
20
  readonly installedAt: string;
10
21
  readonly entries: readonly DeploymentEntry[];
22
+ readonly installationFlags?: InstallationFlags;
11
23
  }
12
24
 
13
25
  export interface DeploymentManifestJson {
14
26
  readonly version: string;
15
27
  readonly installedAt: string;
16
28
  readonly entries: readonly DeploymentEntryJson[];
29
+ readonly installationFlags?: InstallationFlags;
17
30
  }
18
31
 
19
32
  export class DeploymentManifest {
@@ -22,6 +35,7 @@ export class DeploymentManifest {
22
35
  readonly version: string;
23
36
  readonly installedAt: string;
24
37
  readonly entries: readonly DeploymentEntry[];
38
+ readonly installationFlags?: InstallationFlags;
25
39
 
26
40
  private constructor(input: DeploymentManifestInput) {
27
41
  if (!DeploymentManifest.SEMVER_PATTERN.test(input.version)) {
@@ -30,6 +44,12 @@ export class DeploymentManifest {
30
44
  if (!Number.isFinite(Date.parse(input.installedAt))) {
31
45
  throw new Error("DeploymentManifest installedAt must be ISO8601-compatible");
32
46
  }
47
+ if (input.installationFlags !== undefined) {
48
+ const { includeHusky, includeCi, personal } = input.installationFlags;
49
+ if (typeof includeHusky !== "boolean" || typeof includeCi !== "boolean" || typeof personal !== "boolean") {
50
+ throw new Error("DeploymentManifest installationFlags must contain boolean includeHusky/includeCi/personal");
51
+ }
52
+ }
33
53
  const paths = new Set<string>();
34
54
  for (const entry of input.entries) {
35
55
  if (paths.has(entry.path)) {
@@ -40,6 +60,14 @@ export class DeploymentManifest {
40
60
  this.version = input.version;
41
61
  this.installedAt = input.installedAt;
42
62
  this.entries = Object.freeze([...input.entries]);
63
+ this.installationFlags =
64
+ input.installationFlags === undefined
65
+ ? undefined
66
+ : Object.freeze({
67
+ includeHusky: input.installationFlags.includeHusky,
68
+ includeCi: input.installationFlags.includeCi,
69
+ personal: input.installationFlags.personal,
70
+ });
43
71
  Object.freeze(this);
44
72
  }
45
73
 
@@ -56,6 +84,7 @@ export class DeploymentManifest {
56
84
  version: input.version,
57
85
  installedAt: input.installedAt,
58
86
  entries: input.entries.map((entry) => DeploymentEntry.fromJSON(entry)),
87
+ installationFlags: input.installationFlags,
59
88
  });
60
89
  }
61
90
 
@@ -65,6 +94,7 @@ export class DeploymentManifest {
65
94
  version: this.version,
66
95
  installedAt: this.installedAt,
67
96
  entries: [...entries, entry],
97
+ installationFlags: this.installationFlags,
68
98
  });
69
99
  }
70
100
 
@@ -73,6 +103,16 @@ export class DeploymentManifest {
73
103
  version: this.version,
74
104
  installedAt: this.installedAt,
75
105
  entries: this.entries.filter((entry) => entry.path !== path),
106
+ installationFlags: this.installationFlags,
107
+ });
108
+ }
109
+
110
+ withInstallationFlags(installationFlags: InstallationFlags): DeploymentManifest {
111
+ return DeploymentManifest.reconstitute({
112
+ version: this.version,
113
+ installedAt: this.installedAt,
114
+ entries: this.entries,
115
+ installationFlags,
76
116
  });
77
117
  }
78
118
 
@@ -85,6 +125,9 @@ export class DeploymentManifest {
85
125
  version: this.version,
86
126
  installedAt: this.installedAt,
87
127
  entries: this.entries.map((entry) => entry.toJSON()),
128
+ // Omit the key entirely for legacy manifests so serialized output stays
129
+ // byte-compatible with pre-WI-326 manifest.json files.
130
+ ...(this.installationFlags === undefined ? {} : { installationFlags: this.installationFlags }),
88
131
  };
89
132
  }
90
133
  }