@event4u/agent-config 9.21.0 → 9.23.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +69 -221
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/optimize/deep.md +13 -0
  5. package/dist/agent-src/commands/worktree/cleanup.md +17 -0
  6. package/dist/agent-src/commands/worktree/status.md +6 -0
  7. package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
  8. package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
  9. package/dist/agent-src/rules/context-hygiene.md +17 -0
  10. package/dist/agent-src/rules/downstream-changes.md +26 -0
  11. package/dist/agent-src/rules/think-before-action.md +25 -0
  12. package/dist/agent-src/rules/token-budget-discipline.md +46 -3
  13. package/dist/agent-src/rules/token-efficiency.md +13 -0
  14. package/dist/agent-src/rules/ui-audit-gate.md +33 -2
  15. package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
  16. package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
  17. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
  18. package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
  19. package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
  20. package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
  21. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
  22. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  23. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +22 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli/registry.js +1 -0
  26. package/dist/cli/registry.js.map +1 -1
  27. package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
  28. package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
  29. package/dist/cli-delegate/cmd_export.js +2 -1
  30. package/dist/cli-delegate/cmd_prune.js +2 -1
  31. package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
  32. package/dist/cli-delegate/cmd_settings_set.js +3370 -0
  33. package/dist/cli-delegate/cmd_sync.js +2 -1
  34. package/dist/cli-delegate/cmd_uninstall.js +2 -1
  35. package/dist/cli-delegate/cmd_validate.js +2 -1
  36. package/dist/discovery/deprecation-report.md +1 -1
  37. package/dist/discovery/discovery-manifest.json +20 -20
  38. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  39. package/dist/discovery/discovery-manifest.summary.md +1 -1
  40. package/dist/discovery/orphan-report.md +1 -1
  41. package/dist/discovery/packs.json +2 -2
  42. package/dist/discovery/trust-report.md +1 -1
  43. package/dist/discovery/workspaces.json +2 -2
  44. package/dist/hooks/dispatch.js +79 -1
  45. package/dist/mcp/registry-manifest.json +2 -2
  46. package/dist/mcp/server.json +2 -2
  47. package/dist/scripts/condense.js +47 -2
  48. package/dist/scripts/condense.js.map +1 -1
  49. package/dist/server/routes/settings.js +82 -0
  50. package/dist/server/routes/settings.js.map +1 -1
  51. package/dist/shared/settingsClasses.js +191 -0
  52. package/dist/shared/settingsClasses.js.map +1 -0
  53. package/docs/contracts/settings-classes.md +342 -0
  54. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
  55. package/docs/guidelines/agent-infra/false-green.md +187 -0
  56. package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
  57. package/package.json +3 -1
  58. package/src/config/ci-local-parity.yml +12 -0
  59. package/src/config/evaluator-budgets.json +4 -4
  60. package/src/config/gate-coverage.yml +54 -12
  61. package/src/config/gate-violation-baselines.json +6 -0
  62. package/src/config/pack-size-budget.json +4 -3
  63. package/src/scripts/_cli/cmd_settings_set.ts +509 -0
  64. package/src/scripts/_dispatch.bash +16 -0
  65. package/src/scripts/_lib/bench_ab_activation.ts +368 -0
  66. package/src/scripts/_lib/gate_ledger.ts +295 -0
  67. package/src/scripts/_lib/gate_result.ts +101 -0
  68. package/src/scripts/_lib/gate_self_test.ts +129 -0
  69. package/src/scripts/_lib/measured_render.ts +97 -0
  70. package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
  71. package/src/scripts/bench_ab_task_runner.ts +18 -0
  72. package/src/scripts/bench_ab_v2_run.ts +147 -6
  73. package/src/scripts/bench_ab_v2_stats.ts +74 -2
  74. package/src/scripts/check_gate_completeness.ts +244 -0
  75. package/src/scripts/check_gate_coverage.ts +35 -6
  76. package/src/scripts/check_references.ts +125 -15
  77. package/src/scripts/check_review_dispositions.ts +14 -1
  78. package/src/scripts/check_suppression_hygiene.ts +503 -0
  79. package/src/scripts/condense.ts +47 -2
  80. package/src/scripts/hook_manifest.yaml +23 -0
  81. package/src/scripts/hooks/dispatch_hook.ts +65 -1
  82. package/src/scripts/hooks/host_semantics.ts +140 -0
  83. package/src/scripts/lint_framework_leakage.ts +147 -15
  84. package/src/scripts/lint_mandated_lines.ts +339 -0
  85. package/src/scripts/lint_roadmap_complexity.ts +102 -11
  86. package/src/scripts/lint_roadmap_family_cap.ts +191 -0
  87. package/src/scripts/lint_settings_classes.ts +397 -0
  88. package/src/scripts/lint_token_budget_discipline.ts +109 -1
  89. package/src/scripts/report_imperative_density.ts +229 -0
  90. package/src/scripts/verify_physical_move.ts +10 -1
  91. package/src/scripts/worktree_cleanup_check.ts +319 -4
  92. package/src/server/io/yamlIO.ts +173 -0
  93. package/src/shared/settingsClasses.ts +235 -0
@@ -16,11 +16,18 @@
16
16
  * scope-overlap Scan every worktree's .worktree-scope.md and
17
17
  * report pairwise `owns:` overlaps (the hazard
18
18
  * /worktree status + verify must surface).
19
+ * inventory Classify EVERY worktree safe / review / live and
20
+ * report the counts, the review reasons, and a
21
+ * prepared removal plan for the safe set. Reporting
22
+ * only — it never removes anything, because bulk
23
+ * worktree + branch deletion is a Hard-Floor action
24
+ * (`non-destructive-by-default`) that needs the
25
+ * user's explicit this-turn approval.
19
26
  *
20
27
  * Reachability counts ALL other refs — local branches, remotes, AND tags
21
28
  * (a branch whose only other ref is a tag is still reachable elsewhere).
22
- * Exit codes: 0 = allowed / no overlap, 1 = refused / overlap found,
23
- * 2 = usage error, 3 = internal error.
29
+ * Exit codes: 0 = allowed / no overlap / inventory reported, 1 = refused /
30
+ * overlap found, 2 = usage error, 3 = internal error.
24
31
  */
25
32
 
26
33
  import { execFileSync } from 'node:child_process';
@@ -53,6 +60,17 @@ function tryGit(cwd: string, args: string[]): string | null {
53
60
  }
54
61
  }
55
62
 
63
+ /**
64
+ * `git status` with the index left alone. Plain `git status` refreshes the
65
+ * on-disk index, which bumps its mtime — and the inventory mode reads that
66
+ * mtime as the "another session is working here" signal. Without
67
+ * `--no-optional-locks` the check corrupts the very signal it is measured
68
+ * against: two consecutive inventory runs moved 10 worktrees from safe to live.
69
+ */
70
+ function statusPorcelain(cwd: string): string {
71
+ return git(cwd, ['--no-optional-locks', 'status', '--porcelain']);
72
+ }
73
+
56
74
  /** Refs (heads + remotes + tags) EXCLUDING the given branch's own head ref. */
57
75
  function otherRefs(cwd: string, branch: string): string[] {
58
76
  const out = git(cwd, ['for-each-ref', '--format=%(refname)']);
@@ -77,7 +95,7 @@ export function checkWorktree(worktreePath: string): CheckResult {
77
95
  }
78
96
  const branch = headRef.trim().replace(/^refs\/heads\//, '');
79
97
 
80
- const status = git(worktreePath, ['status', '--porcelain']);
98
+ const status = statusPorcelain(worktreePath);
81
99
  if (status.trim().length > 0) {
82
100
  const files = status
83
101
  .trimEnd()
@@ -194,9 +212,260 @@ export function findScopeOverlaps(repoPath: string): ScopeOverlap[] {
194
212
  return overlaps;
195
213
  }
196
214
 
215
+ /**
216
+ * A worktree is "live" when another session may be working in it. Git touches
217
+ * the per-worktree index on almost every command, so its mtime is the cheapest
218
+ * available activity signal.
219
+ */
220
+ export const LIVE_WINDOW_HOURS = 48;
221
+
222
+ /**
223
+ * The two conventional worktree roots, relative to the repo. Anything else is
224
+ * a non-standard location: it is not wrong, but it is excluded from the safe
225
+ * set on purpose — worktrees that sit beside the repo can be mistaken for
226
+ * sibling packages, so their removal is a judgement the maintainer makes.
227
+ */
228
+ export const STANDARD_WORKTREE_DIRS = ['.claude/worktrees', '.worktrees'] as const;
229
+
230
+ /**
231
+ * Symlink-resolved absolute path, falling back to a plain resolve when the
232
+ * path is gone. Git reports worktree paths as realpaths, so a repo reached
233
+ * through a symlinked parent (`/tmp` → `/private/tmp` on macOS, or any
234
+ * symlinked checkout root) would otherwise fail every path comparison and
235
+ * mis-report conventional worktrees as non-standard.
236
+ */
237
+ function canonical(p: string): string {
238
+ const abs = path.resolve(p);
239
+ const tail: string[] = [];
240
+ let head = abs;
241
+ for (;;) {
242
+ try {
243
+ // Resolve the longest existing ancestor, then re-append the rest —
244
+ // a registration pointing at an already-deleted directory still has
245
+ // to classify its location correctly.
246
+ const real = fs.realpathSync(head);
247
+ return tail.length === 0 ? real : path.join(real, ...tail);
248
+ } catch {
249
+ const parent = path.dirname(head);
250
+ if (parent === head) return abs;
251
+ tail.unshift(path.basename(head));
252
+ head = parent;
253
+ }
254
+ }
255
+ }
256
+
257
+ export type Classification = 'safe' | 'review' | 'live';
258
+
259
+ export interface InventoryRow {
260
+ path: string;
261
+ branch: string | null;
262
+ classification: Classification;
263
+ /** Why it is not safe. Empty exactly when `classification === 'safe'`. */
264
+ reasons: string[];
265
+ /** The branch is an ancestor of the trunk, so `git branch -d` is safe. */
266
+ mergedIntoTrunk: boolean;
267
+ standardLocation: boolean;
268
+ /** Main worktree — never removable, and never counted as residue. */
269
+ isMain: boolean;
270
+ }
271
+
272
+ export interface Inventory {
273
+ trunk: string;
274
+ rows: InventoryRow[];
275
+ counts: {
276
+ total: number;
277
+ safe: number;
278
+ review: number;
279
+ live: number;
280
+ /** Review rows grouped by reason, highest first. */
281
+ reviewReasons: Record<string, number>;
282
+ };
283
+ }
284
+
285
+ /** First existing trunk ref, preferring the remote (the merge authority). */
286
+ export function resolveTrunk(repoPath: string): string {
287
+ for (const ref of [
288
+ 'refs/remotes/origin/main',
289
+ 'refs/remotes/origin/master',
290
+ 'refs/heads/main',
291
+ 'refs/heads/master',
292
+ ]) {
293
+ if (tryGit(repoPath, ['rev-parse', '--verify', '--quiet', ref]) !== null) return ref;
294
+ }
295
+ return 'HEAD';
296
+ }
297
+
298
+ function isAncestor(repoPath: string, branch: string, trunk: string): boolean {
299
+ try {
300
+ git(repoPath, ['merge-base', '--is-ancestor', branch, trunk]);
301
+ return true;
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
306
+
307
+ export function isStandardLocation(repoPath: string, worktreePath: string): boolean {
308
+ const rel = path.relative(canonical(repoPath), canonical(worktreePath));
309
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
310
+ return STANDARD_WORKTREE_DIRS.some(
311
+ (dir) => rel === dir || rel.startsWith(`${dir}${path.sep}`),
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Per-worktree git-dir mtime, or null when it cannot be read. A linked
317
+ * worktree's `.git` is a file holding `gitdir: <path>`; the main worktree's is
318
+ * a directory.
319
+ */
320
+ function lastGitActivity(worktreePath: string): Date | null {
321
+ const dotGit = path.join(worktreePath, '.git');
322
+ let gitDir: string;
323
+ try {
324
+ const st = fs.statSync(dotGit);
325
+ if (st.isDirectory()) {
326
+ gitDir = dotGit;
327
+ } else {
328
+ const m = fs.readFileSync(dotGit, 'utf-8').match(/^gitdir:\s*(.+)$/m);
329
+ if (!m) return null;
330
+ gitDir = path.resolve(worktreePath, m[1]!.trim());
331
+ }
332
+ } catch {
333
+ return null;
334
+ }
335
+ let newest: number | null = null;
336
+ for (const name of ['index', 'HEAD']) {
337
+ try {
338
+ const t = fs.statSync(path.join(gitDir, name)).mtimeMs;
339
+ if (newest === null || t > newest) newest = t;
340
+ } catch {
341
+ /* absent is not an error — the other candidate may still exist */
342
+ }
343
+ }
344
+ return newest === null ? null : new Date(newest);
345
+ }
346
+
347
+ /**
348
+ * Classify every worktree. `now` is injectable so the live-window boundary is
349
+ * testable rather than wall-clock dependent.
350
+ */
351
+ export function buildInventory(repoPath: string, now: Date = new Date()): Inventory {
352
+ const trunk = resolveTrunk(repoPath);
353
+ const entries = listWorktrees(repoPath);
354
+ const mainPath = canonical(entries.length > 0 ? entries[0]!.path : repoPath);
355
+ const liveCutoff = now.getTime() - LIVE_WINDOW_HOURS * 3600 * 1000;
356
+
357
+ const rows: InventoryRow[] = entries.map((e) => {
358
+ const resolved = canonical(e.path);
359
+ const isMain = resolved === mainPath;
360
+ const standardLocation = isMain ? true : isStandardLocation(repoPath, resolved);
361
+ const reasons: string[] = [];
362
+
363
+ if (!fs.existsSync(resolved)) {
364
+ return {
365
+ path: e.path,
366
+ branch: e.branch,
367
+ classification: 'review',
368
+ reasons: ['registration points at a missing directory — `git worktree prune` clears it'],
369
+ mergedIntoTrunk: false,
370
+ standardLocation,
371
+ isMain,
372
+ };
373
+ }
374
+
375
+ const activity = lastGitActivity(resolved);
376
+ if (activity !== null && activity.getTime() >= liveCutoff) {
377
+ return {
378
+ path: e.path,
379
+ branch: e.branch,
380
+ classification: 'live',
381
+ reasons: [`git activity within the last ${LIVE_WINDOW_HOURS}h — another session may hold it`],
382
+ mergedIntoTrunk: e.branch !== null && isAncestor(resolved, e.branch, trunk),
383
+ standardLocation,
384
+ isMain,
385
+ };
386
+ }
387
+
388
+ if (isMain) reasons.push('main worktree — cannot be removed');
389
+ if (e.branch === null) {
390
+ reasons.push('detached HEAD — no branch to judge reachability for');
391
+ }
392
+ if (!standardLocation) {
393
+ reasons.push('non-standard location — outside the conventional worktree roots');
394
+ }
395
+
396
+ const mergedIntoTrunk = e.branch !== null && isAncestor(resolved, e.branch, trunk);
397
+
398
+ // A branch that is an ancestor of the trunk has every commit reachable
399
+ // from the trunk ref, so the expensive rev-list gate cannot find a
400
+ // unique commit. Only run it for the branches that are NOT merged —
401
+ // that is where it can actually refuse.
402
+ if (e.branch !== null && !mergedIntoTrunk) {
403
+ reasons.push(`branch is not an ancestor of ${trunk} — unmerged work`);
404
+ const gate = checkWorktree(resolved);
405
+ if (!gate.allowed) reasons.push(...gate.reasons);
406
+ } else if (e.branch !== null) {
407
+ const status = statusPorcelain(resolved);
408
+ if (status.trim().length > 0) {
409
+ const n = status.trimEnd().split('\n').length;
410
+ reasons.push(`unsaved work — ${n} dirty or untracked path(s)`);
411
+ }
412
+ }
413
+
414
+ return {
415
+ path: e.path,
416
+ branch: e.branch,
417
+ classification: reasons.length === 0 ? 'safe' : 'review',
418
+ reasons,
419
+ mergedIntoTrunk,
420
+ standardLocation,
421
+ isMain,
422
+ };
423
+ });
424
+
425
+ const reviewReasons: Record<string, number> = {};
426
+ for (const r of rows) {
427
+ if (r.classification !== 'review') continue;
428
+ // Group by the first reason: it is the primary disqualifier, and the
429
+ // per-row reasons stay available in --json for the full picture.
430
+ const key = (r.reasons[0] ?? 'unknown').split('\n')[0]!;
431
+ reviewReasons[key] = (reviewReasons[key] ?? 0) + 1;
432
+ }
433
+
434
+ return {
435
+ trunk,
436
+ rows,
437
+ counts: {
438
+ total: rows.length,
439
+ safe: rows.filter((r) => r.classification === 'safe').length,
440
+ review: rows.filter((r) => r.classification === 'review').length,
441
+ live: rows.filter((r) => r.classification === 'live').length,
442
+ reviewReasons: Object.fromEntries(
443
+ Object.entries(reviewReasons).sort((a, b) => b[1] - a[1]),
444
+ ),
445
+ },
446
+ };
447
+ }
448
+
449
+ /**
450
+ * The removal commands for the safe set — printed for a human to review and
451
+ * run, never executed here. `git branch -d` (never `-D`) so git itself
452
+ * re-checks the merge before the branch goes.
453
+ */
454
+ export function removalPlan(inv: Inventory): string[] {
455
+ return inv.rows
456
+ .filter((r) => r.classification === 'safe')
457
+ .map((r) => {
458
+ const remove = `git worktree remove ${JSON.stringify(r.path)}`;
459
+ return r.branch !== null && r.mergedIntoTrunk
460
+ ? `${remove} && git branch -d ${JSON.stringify(r.branch)}`
461
+ : remove;
462
+ });
463
+ }
464
+
197
465
  function usage(): never {
198
466
  process.stderr.write(
199
- 'usage: worktree_cleanup_check check <worktree-path> | scope-overlap [repo-path]\n',
467
+ 'usage: worktree_cleanup_check check <worktree-path> | scope-overlap [repo-path] | ' +
468
+ 'inventory [repo-path] [--json|--plan]\n',
200
469
  );
201
470
  process.exit(2);
202
471
  }
@@ -236,6 +505,52 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
236
505
  }
237
506
  return 1;
238
507
  }
508
+ if (mode === 'inventory') {
509
+ const rest = argv.slice(1);
510
+ const flags = rest.filter((a) => a.startsWith('--'));
511
+ const positional = rest.filter((a) => !a.startsWith('--'));
512
+ if (positional.length > 1 || flags.some((f) => f !== '--json' && f !== '--plan')) usage();
513
+ const repo = path.resolve(positional[0] ?? '.');
514
+ const inv = buildInventory(repo);
515
+
516
+ if (flags.includes('--json')) {
517
+ process.stdout.write(`${JSON.stringify(inv, null, 2)}\n`);
518
+ return 0;
519
+ }
520
+ if (flags.includes('--plan')) {
521
+ const plan = removalPlan(inv);
522
+ if (plan.length === 0) {
523
+ process.stdout.write('# no safe worktrees — nothing to propose.\n');
524
+ return 0;
525
+ }
526
+ process.stdout.write(
527
+ `# Prepared removal plan for ${plan.length} safe worktree(s).\n` +
528
+ '# NOT executed: bulk worktree + branch deletion is a Hard-Floor action that\n' +
529
+ '# needs the maintainer\'s explicit approval. Review, then run deliberately.\n',
530
+ );
531
+ process.stdout.write(`${plan.join('\n')}\n`);
532
+ return 0;
533
+ }
534
+
535
+ const c = inv.counts;
536
+ process.stdout.write(
537
+ `worktree inventory · ${c.total} registered · trunk ${inv.trunk}\n` +
538
+ ` safe ${c.safe}\n` +
539
+ ` review ${c.review}\n` +
540
+ ` live ${c.live}\n`,
541
+ );
542
+ if (c.review > 0) {
543
+ process.stdout.write('\nreview reasons (primary disqualifier):\n');
544
+ for (const [reason, n] of Object.entries(c.reviewReasons)) {
545
+ process.stdout.write(` ${String(n).padStart(4)} ${reason}\n`);
546
+ }
547
+ }
548
+ process.stdout.write(
549
+ '\nThis mode reports only. `--plan` prints the removal commands for the safe set;\n' +
550
+ 'running them is a Hard-Floor action needing the maintainer\'s explicit approval.\n',
551
+ );
552
+ return 0;
553
+ }
239
554
  usage();
240
555
  }
241
556
 
@@ -0,0 +1,173 @@
1
+ /**
2
+ * YAML round-trip helpers for `.agent-settings.yml`.
3
+ *
4
+ * Important contract: the template ships with comments that explain
5
+ * every key — we MUST NOT discard them when the wizard writes back.
6
+ * `js-yaml.dump` cannot preserve comments, so we use a key-level
7
+ * replace strategy that mirrors `scripts/install.py::_replace_template_value`:
8
+ * scan the rendered template line-by-line, replace only the scalar
9
+ * value at each dotted path, leave comments and indentation alone.
10
+ *
11
+ * For arrays and nested objects we fall back to re-serializing the
12
+ * subtree (`js-yaml.dump`) because line-level replace can't safely
13
+ * splice multi-line blocks. The form renderer keeps depth ≤ 2, so
14
+ * the subtree footprint is small.
15
+ */
16
+
17
+ import { dump as yamlDump, load as yamlLoad } from 'js-yaml';
18
+
19
+ export interface ReadResult {
20
+ values: Record<string, unknown>;
21
+ raw: string;
22
+ }
23
+
24
+ export function parseYaml(raw: string): Record<string, unknown> {
25
+ const parsed = yamlLoad(raw);
26
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
27
+ throw new Error('settings file did not parse to an object');
28
+ }
29
+ return parsed as Record<string, unknown>;
30
+ }
31
+
32
+ function formatScalar(value: unknown): string {
33
+ if (typeof value === 'string') {
34
+ // js-yaml safely quotes strings that need it; trim the trailing newline.
35
+ return yamlDump(value, { lineWidth: -1 }).replace(/\n$/, '').trim();
36
+ }
37
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
38
+ if (value === null || value === undefined) return '""';
39
+ // Arrays / nested objects: dump inline. Form keeps depth ≤ 2 so this is rare.
40
+ return yamlDump(value, { lineWidth: -1, flowLevel: 0 }).replace(/\n$/, '').trim();
41
+ }
42
+
43
+ interface FlatEntry {
44
+ path: string[];
45
+ value: unknown;
46
+ }
47
+
48
+ function flatten(value: unknown, prefix: string[] = []): FlatEntry[] {
49
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
50
+ return [{ path: prefix, value }];
51
+ }
52
+ const entries: FlatEntry[] = [];
53
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
54
+ entries.push(...flatten(v, [...prefix, k]));
55
+ }
56
+ return entries;
57
+ }
58
+
59
+ /**
60
+ * Replace the scalar at `dottedPath` in `template` with `value`.
61
+ * Comments and unrelated keys are preserved verbatim. Returns the
62
+ * template unchanged when the path cannot be located (matches the
63
+ * Python installer's tolerant behaviour).
64
+ */
65
+ export function replaceScalar(template: string, dottedPath: string[], value: unknown): string {
66
+ if (dottedPath.length === 0) return template;
67
+ const sections = dottedPath.slice(0, -1);
68
+ const key = dottedPath[dottedPath.length - 1];
69
+ const targetIndent = ' '.repeat(sections.length);
70
+
71
+ const lines = template.split('\n');
72
+ const currentPath: (string | null)[] = new Array<string | null>(sections.length).fill(null);
73
+ const formatted = formatScalar(value);
74
+
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const line = lines[i];
77
+ if (line === undefined) continue;
78
+ const stripped = line.trim();
79
+ if (stripped === '' || stripped.startsWith('#')) continue;
80
+ const indentLen = line.length - line.trimStart().length;
81
+ if (indentLen % 2 !== 0) continue;
82
+ const level = indentLen / 2;
83
+ if (level > sections.length) continue;
84
+
85
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*:/.exec(stripped);
86
+ if (!m) continue;
87
+ const lineKey = m[1];
88
+ if (lineKey === undefined) continue;
89
+ if (level < sections.length) {
90
+ if (lineKey === sections[level]) {
91
+ currentPath[level] = lineKey;
92
+ for (let j = level + 1; j < currentPath.length; j++) currentPath[j] = null;
93
+ } else if (currentPath[level] !== null && lineKey !== currentPath[level]) {
94
+ currentPath[level] = null;
95
+ }
96
+ continue;
97
+ }
98
+ // level === sections.length → leaf candidate
99
+ const parentsMatch = sections.every((s, idx) => currentPath[idx] === s);
100
+ if (!parentsMatch) continue;
101
+ if (lineKey !== key) continue;
102
+ lines[i] = `${targetIndent}${key}: ${formatted}`;
103
+ return lines.join('\n');
104
+ }
105
+ return template;
106
+ }
107
+
108
+ /**
109
+ * Apply every leaf change from `newValues` to `templateBody`. Paths that
110
+ * are not present in the template are appended at the end. Comments are
111
+ * preserved for every key that already exists in the template.
112
+ */
113
+ export function mergeIntoTemplate(templateBody: string, newValues: Record<string, unknown>): string {
114
+ let body = templateBody;
115
+ const appended: string[] = [];
116
+ for (const entry of flatten(newValues)) {
117
+ const before = body;
118
+ body = replaceScalar(body, entry.path, entry.value);
119
+ if (body === before) {
120
+ // Path is not in the template — append a flat key=value at EOF.
121
+ // Form coverage is asserted by parity test, so this branch only
122
+ // fires on hand-edited templates.
123
+ appended.push(`${entry.path.join('.')}: ${formatScalar(entry.value)}`);
124
+ }
125
+ }
126
+ if (appended.length > 0) {
127
+ if (!body.endsWith('\n')) body += '\n';
128
+ body += `\n# Wizard-added keys (no template entry)\n${appended.join('\n')}\n`;
129
+ }
130
+ return body;
131
+ }
132
+
133
+ export function diffValues(
134
+ before: Record<string, unknown>,
135
+ after: Record<string, unknown>,
136
+ ): Array<{ path: string; from: unknown; to: unknown }> {
137
+ const out: Array<{ path: string; from: unknown; to: unknown }> = [];
138
+ const bMap = new Map(flatten(before).map((e) => [e.path.join('.'), e.value]));
139
+ const aMap = new Map(flatten(after).map((e) => [e.path.join('.'), e.value]));
140
+ const paths = new Set<string>([...bMap.keys(), ...aMap.keys()]);
141
+ for (const path of paths) {
142
+ const fromV = bMap.get(path);
143
+ const toV = aMap.get(path);
144
+ if (JSON.stringify(fromV) !== JSON.stringify(toV)) out.push({ path, from: fromV, to: toV });
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
150
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
151
+ }
152
+
153
+ /**
154
+ * Recursive dict merge — overlay wins, nested dicts are merged, lists
155
+ * are replaced (not concatenated). Mirrors `scripts/install.py::deep_merge`
156
+ * so the Python installer and the Fastify server produce the same
157
+ * three-layer settings tree (`defaults < global < project`).
158
+ */
159
+ export function deepMerge(
160
+ base: Record<string, unknown>,
161
+ overlay: Record<string, unknown>,
162
+ ): Record<string, unknown> {
163
+ const result: Record<string, unknown> = { ...base };
164
+ for (const [key, value] of Object.entries(overlay)) {
165
+ const existing = result[key];
166
+ if (isPlainObject(existing) && isPlainObject(value)) {
167
+ result[key] = deepMerge(existing, value);
168
+ } else {
169
+ result[key] = value;
170
+ }
171
+ }
172
+ return result;
173
+ }