@dzhechkov/harness-core 0.8.46 → 0.8.48

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 (153) hide show
  1. package/.dz-manifest.json +200 -168
  2. package/README.md +130 -2
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +32 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/agentdb-snapshot.d.ts +14 -1
  7. package/dist/agentdb-snapshot.d.ts.map +1 -1
  8. package/dist/agentdb-snapshot.js +100 -4
  9. package/dist/agentdb-snapshot.js.map +1 -1
  10. package/dist/apply-leg.d.ts +9 -1
  11. package/dist/apply-leg.d.ts.map +1 -1
  12. package/dist/apply-leg.js +26 -4
  13. package/dist/apply-leg.js.map +1 -1
  14. package/dist/architecture.d.ts +0 -5
  15. package/dist/architecture.d.ts.map +1 -1
  16. package/dist/architecture.js +25 -2
  17. package/dist/architecture.js.map +1 -1
  18. package/dist/codex-rollouts.d.ts +35 -7
  19. package/dist/codex-rollouts.d.ts.map +1 -1
  20. package/dist/codex-rollouts.js +201 -113
  21. package/dist/codex-rollouts.js.map +1 -1
  22. package/dist/cost-ledger.d.ts +42 -3
  23. package/dist/cost-ledger.d.ts.map +1 -1
  24. package/dist/cost-ledger.js +478 -15
  25. package/dist/cost-ledger.js.map +1 -1
  26. package/dist/feature-adr-routing.d.ts +16 -0
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +21 -0
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard.d.ts +7 -0
  31. package/dist/guard.d.ts.map +1 -1
  32. package/dist/guard.js +48 -0
  33. package/dist/guard.js.map +1 -1
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +10 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/loop-plan.d.ts +4 -0
  39. package/dist/loop-plan.d.ts.map +1 -1
  40. package/dist/loop-plan.js +4 -0
  41. package/dist/loop-plan.js.map +1 -1
  42. package/dist/mutation-gate.d.ts +36 -1
  43. package/dist/mutation-gate.d.ts.map +1 -1
  44. package/dist/mutation-gate.js +109 -19
  45. package/dist/mutation-gate.js.map +1 -1
  46. package/dist/npm-homepage.d.ts +64 -0
  47. package/dist/npm-homepage.d.ts.map +1 -0
  48. package/dist/npm-homepage.js +109 -0
  49. package/dist/npm-homepage.js.map +1 -0
  50. package/dist/operations.d.ts +6 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +150 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/pack-inventory.d.ts.map +1 -1
  55. package/dist/pack-inventory.js +6 -1
  56. package/dist/pack-inventory.js.map +1 -1
  57. package/dist/parity.d.ts.map +1 -1
  58. package/dist/parity.js +4 -1
  59. package/dist/parity.js.map +1 -1
  60. package/dist/publish-sibling-drift.d.ts +43 -1
  61. package/dist/publish-sibling-drift.d.ts.map +1 -1
  62. package/dist/publish-sibling-drift.js +269 -13
  63. package/dist/publish-sibling-drift.js.map +1 -1
  64. package/dist/publish.d.ts +5 -1
  65. package/dist/publish.d.ts.map +1 -1
  66. package/dist/publish.js +18 -1
  67. package/dist/publish.js.map +1 -1
  68. package/dist/qe-bridge.d.ts +63 -1
  69. package/dist/qe-bridge.d.ts.map +1 -1
  70. package/dist/qe-bridge.js +82 -0
  71. package/dist/qe-bridge.js.map +1 -1
  72. package/dist/release-package-audit.d.ts +86 -0
  73. package/dist/release-package-audit.d.ts.map +1 -0
  74. package/dist/release-package-audit.js +272 -0
  75. package/dist/release-package-audit.js.map +1 -0
  76. package/dist/release.d.ts +8 -1
  77. package/dist/release.d.ts.map +1 -1
  78. package/dist/release.js +58 -32
  79. package/dist/release.js.map +1 -1
  80. package/dist/round.d.ts +10 -1
  81. package/dist/round.d.ts.map +1 -1
  82. package/dist/round.js +5 -1
  83. package/dist/round.js.map +1 -1
  84. package/dist/run-records.d.ts +11 -0
  85. package/dist/run-records.d.ts.map +1 -1
  86. package/dist/run-records.js +124 -20
  87. package/dist/run-records.js.map +1 -1
  88. package/dist/setup-memory-deps.d.ts +14 -0
  89. package/dist/setup-memory-deps.d.ts.map +1 -0
  90. package/dist/setup-memory-deps.js +210 -0
  91. package/dist/setup-memory-deps.js.map +1 -0
  92. package/dist/setup.d.ts +2 -0
  93. package/dist/setup.d.ts.map +1 -1
  94. package/dist/setup.js +379 -378
  95. package/dist/setup.js.map +1 -1
  96. package/dist/stage-usage.d.ts +248 -0
  97. package/dist/stage-usage.d.ts.map +1 -0
  98. package/dist/stage-usage.js +511 -0
  99. package/dist/stage-usage.js.map +1 -0
  100. package/dist/statusline.d.ts +23 -0
  101. package/dist/statusline.d.ts.map +1 -1
  102. package/dist/statusline.js +167 -1
  103. package/dist/statusline.js.map +1 -1
  104. package/dist/workflow-run-dispatch.d.ts +36 -19
  105. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  106. package/dist/workflow-run-dispatch.js +255 -102
  107. package/dist/workflow-run-dispatch.js.map +1 -1
  108. package/dist/workflow-run.d.ts +19 -3
  109. package/dist/workflow-run.d.ts.map +1 -1
  110. package/dist/workflow-run.js +93 -2
  111. package/dist/workflow-run.js.map +1 -1
  112. package/package.json +3 -3
  113. package/sbom.json +303 -223
  114. package/src/agentdb-index.ts +31 -2
  115. package/src/agentdb-snapshot.ts +105 -4
  116. package/src/apply-leg.ts +26 -4
  117. package/src/architecture.ts +15 -2
  118. package/src/codex-rollouts.ts +155 -147
  119. package/src/cost-ledger.ts +308 -19
  120. package/src/feature-adr-routing.ts +22 -0
  121. package/src/guard.ts +52 -0
  122. package/src/index.ts +15 -3
  123. package/src/loop-plan.ts +8 -0
  124. package/src/mutation-gate.ts +153 -19
  125. package/src/npm-homepage.ts +142 -0
  126. package/src/operations.ts +177 -22
  127. package/src/pack-inventory.ts +6 -1
  128. package/src/parity.ts +4 -1
  129. package/src/publish-sibling-drift.ts +214 -11
  130. package/src/publish.ts +20 -1
  131. package/src/qe-bridge.ts +86 -1
  132. package/src/release-package-audit.ts +264 -0
  133. package/src/release.ts +55 -22
  134. package/src/round.ts +8 -0
  135. package/src/run-records.ts +101 -22
  136. package/src/setup-memory-deps.ts +166 -0
  137. package/src/setup.ts +95 -98
  138. package/src/stage-usage.ts +385 -0
  139. package/src/statusline.ts +149 -1
  140. package/src/workflow-run-dispatch.ts +197 -93
  141. package/src/workflow-run.ts +101 -5
  142. package/dist/ledger-cost-fill.d.ts +0 -58
  143. package/dist/ledger-cost-fill.d.ts.map +0 -1
  144. package/dist/ledger-cost-fill.js +0 -78
  145. package/dist/ledger-cost-fill.js.map +0 -1
  146. package/dist/retro.d.ts +0 -131
  147. package/dist/retro.d.ts.map +0 -1
  148. package/dist/retro.js +0 -207
  149. package/dist/retro.js.map +0 -1
  150. package/dist/sbom.d.ts +0 -42
  151. package/dist/sbom.d.ts.map +0 -1
  152. package/dist/sbom.js +0 -120
  153. package/dist/sbom.js.map +0 -1
@@ -12,7 +12,7 @@
12
12
  * @packageDocumentation
13
13
  */
14
14
 
15
- import { existsSync, mkdirSync, copyFileSync, realpathSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
15
+ import { existsSync, mkdirSync, copyFileSync, realpathSync, readFileSync, writeFileSync, renameSync, unlinkSync, lstatSync, rmSync, constants as fsConstants } from 'node:fs';
16
16
  import { join, dirname, resolve, relative, isAbsolute, basename } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import { createRequire } from 'node:module';
@@ -1408,6 +1408,31 @@ export async function reindexAgentdbRows(
1408
1408
  if (rel.startsWith('..') || isAbsolute(rel)) {
1409
1409
  return { reembedded: 0, error: `opts.backupPath must stay inside ${dbDir}, got: ${opts.backupPath}` };
1410
1410
  }
1411
+ // agentdb-backup-no-symlink-escape (backlog c2a85b540a293ea2): the check above resolves only the
1412
+ // PARENT physically and re-joins the basename lexically, so a LAST component that is itself a
1413
+ // symlink out of the store passed it and the snapshot was written through the link (MEASURED on
1414
+ // HEAD: a dangling link got a snapshot created outside, a link to an empty outside file had it
1415
+ // overwritten). The leaf is lstat'ed — never followed — and refused by name. The snapshot writer
1416
+ // repeats this check and writes via a staging dir + rename, so a link planted after this point is
1417
+ // replaced, not followed (agentdb-snapshot.ts, stageThenRename).
1418
+ // Review r1 (MINOR): only ENOENT means "absent" — EACCES/EIO/ENOTDIR are a path whose kind cannot
1419
+ // be determined, refused by name rather than read as free.
1420
+ let leaf: ReturnType<typeof lstatSync> | undefined;
1421
+ try {
1422
+ leaf = lstatSync(candidate);
1423
+ } catch (err) {
1424
+ const code = (err as NodeJS.ErrnoException)?.code;
1425
+ if (code !== 'ENOENT') {
1426
+ return { reembedded: 0, error: `opts.backupPath cannot be inspected (${code ?? 'unknown error'}): ${opts.backupPath}` };
1427
+ }
1428
+ leaf = undefined;
1429
+ }
1430
+ if (leaf?.isSymbolicLink()) {
1431
+ return { reembedded: 0, error: `opts.backupPath is a symbolic link — refusing to follow it: ${opts.backupPath}` };
1432
+ }
1433
+ if (leaf !== undefined && !leaf.isFile()) {
1434
+ return { reembedded: 0, error: `opts.backupPath exists and is not a regular file: ${opts.backupPath}` };
1435
+ }
1411
1436
  }
1412
1437
 
1413
1438
  // FR-3: sqlite resolves BEFORE any snapshot is taken — an unavailable dependency must abort with
@@ -1474,7 +1499,11 @@ export async function reindexAgentdbRows(
1474
1499
  snapshotMethod = outcome.method;
1475
1500
  snapshotNote = outcome.note;
1476
1501
  if (existsSync(`${dbFile}.embed-manifest.json`)) {
1477
- copyFileSync(`${dbFile}.embed-manifest.json`, `${backupPath}.embed-manifest.json`);
1502
+ // no-symlink-escape (FR-5): remove whatever sits at the sibling (rmSync removes a symlink
1503
+ // itself, never its target), then copy with COPYFILE_EXCL — O_CREAT|O_EXCL refuses a link
1504
+ // planted in between (EEXIST) instead of writing through it.
1505
+ rmSync(`${backupPath}.embed-manifest.json`, { force: true });
1506
+ copyFileSync(`${dbFile}.embed-manifest.json`, `${backupPath}.embed-manifest.json`, fsConstants.COPYFILE_EXCL);
1478
1507
  }
1479
1508
  }
1480
1509
  } catch (snapErr) {
@@ -43,6 +43,18 @@
43
43
  * bug, a backup that looked complete but carried a wrong-generation `-wal`. It now refuses up front
44
44
  * (before `VACUUM INTO` or any copy) when `backupPath` already exists and is non-empty.
45
45
  *
46
+ * **agentdb-backup-no-symlink-escape (backlog c2a85b540a293ea2).** A `backupPath` that is itself a
47
+ * symbolic link used to be written THROUGH: `statSync` in AM-4 follows the link (so a link to a
48
+ * non-empty file was refused, by accident), but a dangling link got a fresh snapshot created at its
49
+ * target and a link to an EMPTY file had it overwritten — outside the store (MEASURED on HEAD, /var/tmp
50
+ * probe, features/agentdb-backup-no-symlink-escape/00_complexity_assessment.md). Two layers now:
51
+ * {@link assertBackupLeafWritable} refuses a symlink or non-regular leaf by name before anything is
52
+ * written, and {@link stageThenRename} writes the snapshot (main file and any `-wal`) into a fresh
53
+ * private `mkdtempSync` directory next to the target and moves it into place with `renameSync`.
54
+ * `rename(2)` replaces a directory ENTRY — a link planted at `backupPath` between the check and the
55
+ * write is replaced by the snapshot, never followed. `O_NOFOLLOW` was not an option: the main path
56
+ * writes through sqlite's `VACUUM INTO`, which takes no open flags.
57
+ *
46
58
  * {@link restoreSqliteSnapshot} (FR-4) is the paired rollback. MEASURED (same repro): copying the
47
59
  * old main file back over `dbFile` WITHOUT removing a `-wal` left over from the aborted operation
48
60
  * — reopening the "restored" db returned ZERO rows, not the restored one, because sqlite replayed
@@ -61,7 +73,8 @@
61
73
  * @packageDocumentation
62
74
  */
63
75
 
64
- import { copyFileSync, existsSync, rmSync, statSync } from 'node:fs';
76
+ import { copyFileSync, existsSync, lstatSync, mkdtempSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs';
77
+ import { basename, dirname, join } from 'node:path';
65
78
 
66
79
  /** How a snapshot was actually taken — always reported by the caller, never assumed. */
67
80
  export type SnapshotMethod = 'vacuum-into' | 'copy+wal' | 'copy';
@@ -130,6 +143,77 @@ function clearBackupSidecars(backupPath: string): void {
130
143
  rmSync(`${backupPath}-shm`, { force: true });
131
144
  }
132
145
 
146
+ /**
147
+ * no-symlink-escape (FR-1/FR-2/FR-4): refuse a `backupPath` whose LAST component is a symbolic link
148
+ * (whatever it points at) or an existing non-regular file (directory, FIFO, socket, device). `lstatSync`
149
+ * never follows the link; only a confirmed `ENOENT` means "free". Any other lstat error propagates —
150
+ * a target whose kind cannot be determined is not written to.
151
+ */
152
+ function assertBackupLeafWritable(backupPath: string): void {
153
+ let st: ReturnType<typeof lstatSync>;
154
+ try {
155
+ st = lstatSync(backupPath);
156
+ } catch (err) {
157
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return;
158
+ throw err;
159
+ }
160
+ if (st.isSymbolicLink()) {
161
+ throw new Error(`snapshot target is a symbolic link — refusing to follow it: ${backupPath}`);
162
+ }
163
+ if (!st.isFile()) {
164
+ throw new Error(`snapshot target exists and is not a regular file: ${backupPath}`);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * no-symlink-escape (FR-3, the TOCTOU half): run `write` against a path inside a fresh private staging
170
+ * directory (`mkdtempSync` — atomic, unpredictable name, mode 0700) next to `backupPath`, then move the
171
+ * result into place with `renameSync`: the main file first, then its `-wal` only when the outcome says
172
+ * `'copy+wal'` (AM-1: decided by the explicit method, never by what happens to exist on disk). A symlink
173
+ * planted at the target after {@link assertBackupLeafWritable} is REPLACED by the rename, never
174
+ * followed. The staging directory is always removed; one left by a crashed process is named
175
+ * `.<basename>.staging-XXXXXX`, which the rotation pattern (`<db>.pre-reindex-<ms>.bak…`) never matches.
176
+ */
177
+ function stageThenRename(backupPath: string, write: (staged: string) => SnapshotOutcome): SnapshotOutcome {
178
+ const staging = mkdtempSync(join(dirname(backupPath), `.${basename(backupPath)}.staging-`));
179
+ try {
180
+ const staged = join(staging, basename(backupPath));
181
+ const outcome = write(staged);
182
+ // Order main -> wal is deliberate: publishing the -wal FIRST would put a foreign WAL next to
183
+ // whatever main file sits at backupPath, which sqlite could apply to it.
184
+ const stagedId = lstatSync(staged); // identity of the file we are about to publish
185
+ renameSync(staged, backupPath);
186
+ if (outcome.method === 'copy+wal') {
187
+ try {
188
+ renameSync(`${staged}-wal`, `${backupPath}-wal`);
189
+ } catch (cause) {
190
+ // Review r1 (MAJOR): a published main file without its -wal is a half snapshot that reads
191
+ // as complete. Withdraw it — only if the entry is still the very file we just renamed
192
+ // (same dev+ino, regular file) — and fail loudly under a named reason.
193
+ let withdrawn = false;
194
+ try {
195
+ const now = lstatSync(backupPath);
196
+ if (now.isFile() && now.dev === stagedId.dev && now.ino === stagedId.ino) {
197
+ unlinkSync(backupPath);
198
+ withdrawn = true;
199
+ }
200
+ } catch { /* withdrawn stays false and is reported below */ }
201
+ const why = cause instanceof Error ? cause.message : String(cause);
202
+ throw new Error(
203
+ `snapshot-wal-publish-failed: could not move the -wal sidecar to ${backupPath}-wal (${why}); ` +
204
+ (withdrawn
205
+ ? 'the just-published main file was withdrawn — no half snapshot left'
206
+ : `the main file at ${backupPath} could NOT be confirmed as ours and was left in place`),
207
+ { cause },
208
+ );
209
+ }
210
+ }
211
+ return outcome;
212
+ } finally {
213
+ rmSync(staging, { recursive: true, force: true });
214
+ }
215
+ }
216
+
133
217
  function copyWithWal(dbFile: string, backupPath: string, note: string): SnapshotOutcome {
134
218
  // AM-2: statSync directly; only a confirmed ENOENT means "no -wal to copy" — any other stat error
135
219
  // propagates out of this function (and out of snapshotSqliteDatabase) as a thrown exception.
@@ -158,7 +242,8 @@ function copyWithWal(dbFile: string, backupPath: string, note: string): Snapshot
158
242
  *
159
243
  * Throws (no snapshot taken, or an incomplete one left in a fully-cleared state) when: `backupPath`
160
244
  * already names an existing, non-empty file (AM-4); or a `-wal` stat probe hits a non-ENOENT error
161
- * (AM-2, inside the fallback path). Never silently overwrites, never mis-reports a lesser guarantee
245
+ * (AM-2, inside the fallback path); or `backupPath` is a symbolic link or an existing non-regular file
246
+ * (no-symlink-escape). Never silently overwrites, never mis-reports a lesser guarantee
162
247
  * as a stronger one.
163
248
  */
164
249
  export function snapshotSqliteDatabase(
@@ -167,8 +252,25 @@ export function snapshotSqliteDatabase(
167
252
  backupPath: string,
168
253
  opts: { strategy?: 'vacuum-into' | 'copy+wal' } = {},
169
254
  ): SnapshotOutcome {
255
+ assertBackupLeafWritable(backupPath); // no-symlink-escape — a link or non-file leaf is never written through
170
256
  assertBackupTargetFree(backupPath); // AM-4 — before any write, on either strategy
171
257
  clearBackupSidecars(backupPath); // AM-1 — clean slate before either strategy writes anything
258
+ const outcome = stageThenRename(backupPath, (staged) => writeSnapshot(Database, dbFile, staged, opts));
259
+ // AM-1 — VACUUM INTO's output never has a matching -wal of its own: clear again once it is in place.
260
+ if (outcome.method === 'vacuum-into') clearBackupSidecars(backupPath);
261
+ return outcome;
262
+ }
263
+
264
+ /**
265
+ * The two snapshot strategies, writing to `backupPath` — which {@link snapshotSqliteDatabase} always
266
+ * passes as the STAGED path inside its private staging directory, never the final target.
267
+ */
268
+ function writeSnapshot(
269
+ Database: SnapshotDbCtor,
270
+ dbFile: string,
271
+ backupPath: string,
272
+ opts: { strategy?: 'vacuum-into' | 'copy+wal' },
273
+ ): SnapshotOutcome {
172
274
  if (opts.strategy === 'copy+wal') {
173
275
  return copyWithWal(dbFile, backupPath, 'snapshotStrategy=copy+wal forced by caller');
174
276
  }
@@ -179,8 +281,7 @@ export function snapshotSqliteDatabase(
179
281
  } finally {
180
282
  db.close();
181
283
  }
182
- clearBackupSidecars(backupPath); // AM-1 — VACUUM INTO's output never has a matching -wal of its own
183
- return { method: 'vacuum-into' };
284
+ return { method: 'vacuum-into' }; // AM-1 sidecar clear runs in snapshotSqliteDatabase, after the rename
184
285
  } catch (err) {
185
286
  return copyWithWal(dbFile, backupPath, `VACUUM INTO failed: ${err instanceof Error ? err.message : String(err)}`);
186
287
  }
package/src/apply-leg.ts CHANGED
@@ -208,8 +208,16 @@ export function probeRecallEngine(socketPath: string, timeoutMs = 1000): Promise
208
208
  * skipped entirely in own-fallback mode. (c) `answerRecall`'s `embed(prompt)` call is now wrapped so
209
209
  * either failure mode degrades to the SAME honest `cosine-fallback` reply shape as every other
210
210
  * failure, never a bare protocol `{error}`.
211
+ *
212
+ * Bumped 12→13 (`retro-debt-sentinel-per-session`): the hook reads its admission-debt sentinel from
213
+ * `.dz/retro/<sessionId>/` (entry added retroactively for the record by the next bump).
214
+ *
215
+ * Bumped 13→14 (`daemon-install-root-from-self`, ADR-001, backlog 6dd464d5d6ee917f): the daemon's
216
+ * `INSTALL_ROOT` is two levels above its own directory (it was one short — `<root>/.claude` — so the
217
+ * install-root link never fired and the daemon served, and resolved its deps from, `cwd`); the hook's
218
+ * `reviveDaemon()` spawns it with `cwd: PROJECT` and `DZ_PROJECT_ROOT: PROJECT`.
211
219
  */
212
- export const APPLY_LEG_VERSION = 13;
220
+ export const APPLY_LEG_VERSION = 14;
213
221
 
214
222
  /**
215
223
  * Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
@@ -595,7 +603,17 @@ function reviveDaemon() {
595
603
  const { spawn } = require('child_process');
596
604
  const daemon = path.join(PROJECT, '.claude', 'helpers', 'dz-embed-daemon.mjs');
597
605
  if (!fs.existsSync(daemon)) return;
598
- const child = spawn(process.execPath, [daemon], { detached: true, stdio: 'ignore' });
606
+ // daemon-install-root-from-self (ADR-001 p.2): the daemon serves exactly the store this hook just
607
+ // checked. \`cwd\` so a self-healed daemon never inherits the caller's directory (the orphan with
608
+ // cwd=…/other from the 2026-09-15 field report); \`DZ_PROJECT_ROOT\` because the daemon ranks it
609
+ // ABOVE its own install root, so a stale value inherited by this hook would otherwise still
610
+ // re-route it (same class as AM-3 of apply-leg-install-root, for the SessionStart command).
611
+ const child = spawn(process.execPath, [daemon], {
612
+ cwd: PROJECT,
613
+ env: { ...process.env, DZ_PROJECT_ROOT: PROJECT },
614
+ detached: true,
615
+ stdio: 'ignore',
616
+ });
599
617
  child.unref();
600
618
  }, undefined);
601
619
  }
@@ -922,7 +940,7 @@ export function embedDaemonSource(coreDistDir: string | null = null): string {
922
940
 
923
941
  import { createServer } from 'node:net';
924
942
  import { existsSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync } from 'node:fs';
925
- import { join, dirname } from 'node:path';
943
+ import { join, dirname, resolve } from 'node:path';
926
944
  import { createRequire } from 'node:module';
927
945
  import { connect } from 'node:net';
928
946
  import { createHash } from 'node:crypto';
@@ -938,7 +956,11 @@ console.log = (...a) => console.error(...a);
938
956
  // recallHookSource's own PROJECT comment) — DZ_PROJECT_ROOT stays the TOP override for the daemon
939
957
  // (a caller that explicitly names a project root always wins), then INSTALL_ROOT (this file's own
940
958
  // location, when it owns a \`.dz/\`), then cwd.
941
- const INSTALL_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
959
+ // daemon-install-root-from-self (ADR-001 p.1): this file lives at <root>/.claude/helpers/, so the
960
+ // install root is TWO levels above its directory — the hook's own rule (path.resolve(__dirname, '..',
961
+ // '..')), written for ESM. The previous dirname(dirname(<file>)) stopped at <root>/.claude, so the
962
+ // install-root link never fired and the daemon always served (and resolved its deps from) cwd.
963
+ const INSTALL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
942
964
  const PROJECT = process.env['DZ_PROJECT_ROOT'] ?? (existsSync(join(INSTALL_ROOT, '.dz')) ? INSTALL_ROOT : process.cwd());
943
965
 
944
966
  // ADR-001 (hook-recall-hybrid-parity, D1): the SAME candidate-list resolution the hook uses for its
@@ -9,7 +9,7 @@
9
9
  * helpers at the bottom do the disk I/O for the CLI and the feature-adr end-of-run auto-update.
10
10
  */
11
11
 
12
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
 
15
15
  /** One curated subsystem — the top-layer intent node. */
@@ -192,13 +192,26 @@ export function renderDriftReport(report: DriftReport): string {
192
192
  * I/O helper (lazy `require`, never throws) shared by the CLI and the feature-adr end-of-run auto-update
193
193
  * (FR-3), so both build the map from the identical scan. Deterministic: output is sorted.
194
194
  */
195
+ function isDirFollowingLinks(path: string): boolean {
196
+ try { return statSync(path).isDirectory(); } catch { return false; }
197
+ }
198
+
195
199
  export function scanWorkspacePackages(repoRoot: string): ScannedPackage[] {
196
200
  try {
197
201
  const base = join(repoRoot, 'packages', '@dzhechkov');
198
202
  if (!existsSync(base)) return [];
199
203
  const out: ScannedPackage[] = [];
204
+ const seenReal = new Set<string>();
200
205
  for (const entry of readdirSync(base, { withFileTypes: true })) {
201
- if (!entry.isDirectory()) continue;
206
+ // A symlink to a package directory is a package directory: `dz mutation-gate`'s shadow tree
207
+ // symlinks every sibling, and pnpm links workspace packages the same way. Dirent reports such
208
+ // an entry as a symlink, never as a directory, so follow it; a dangling link is skipped.
209
+ if (!entry.isDirectory() && !(entry.isSymbolicLink() && isDirFollowingLinks(join(base, entry.name)))) continue;
210
+ // An alias link to a directory already scanned is the SAME package — count it once.
211
+ let real: string;
212
+ try { real = realpathSync(join(base, entry.name)); } catch { continue; }
213
+ if (seenReal.has(real)) continue;
214
+ seenReal.add(real);
202
215
  const pj = join(base, entry.name, 'package.json');
203
216
  if (!existsSync(pj)) continue;
204
217
  let json: { name?: string; dependencies?: Record<string, string>; peerDependencies?: Record<string, string>; optionalDependencies?: Record<string, string> };