@adhdev/daemon-core 0.9.82-rc.386 → 0.9.82-rc.387

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.386",
3
+ "version": "0.9.82-rc.387",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.386",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.387",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -422,6 +422,8 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
422
422
  worktreeCleanupFallback: typeof worktreeCleanup?.fallback === 'string' ? worktreeCleanup.fallback : undefined,
423
423
  forced: worktreeCleanup?.forced === true ? true : undefined,
424
424
  forceFallbackReason: typeof worktreeCleanup?.reason === 'string' ? worktreeCleanup.reason : undefined,
425
+ branchRefDeleted: typeof worktreeCleanup?.branchRefDeleted === 'boolean' ? worktreeCleanup.branchRefDeleted : undefined,
426
+ branchRefReason: typeof worktreeCleanup?.branchRefReason === 'string' ? worktreeCleanup.branchRefReason : undefined,
425
427
  },
426
428
  });
427
429
  } catch { /* ledger append is best-effort */ }
@@ -434,6 +436,14 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
434
436
  ? worktreeCleanup.residueWarning
435
437
  : undefined;
436
438
 
439
+ // Surface a preserved-branch warning at the top level so callers see
440
+ // that the branch ref was intentionally NOT deleted (unmerged work is
441
+ // never silently dropped). When the branch ref WAS deleted, the nested
442
+ // worktreeCleanup.branchRefDeleted flag already records it.
443
+ const branchRefWarning = typeof worktreeCleanup?.branchRefWarning === 'string'
444
+ ? worktreeCleanup.branchRefWarning
445
+ : undefined;
446
+
437
447
  // Orphan guard: if the session cleanup still left any LIVE session skipped
438
448
  // (e.g. a future skip reason, or a workspace-only session on a base node),
439
449
  // surface it at the top level so the caller knows a manual mesh_cleanup_sessions
@@ -451,6 +461,7 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
451
461
  success: true,
452
462
  removed,
453
463
  ...(residueWarning ? { residueWarning } : {}),
464
+ ...(branchRefWarning ? { branchRefWarning } : {}),
454
465
  ...(sessionCleanup ? { sessionCleanup } : {}),
455
466
  ...(worktreeCleanup ? { worktreeCleanup } : {}),
456
467
  ...(orphanedSessionsRemaining
@@ -829,7 +829,7 @@ export class DaemonCommandRouter {
829
829
  node: any;
830
830
  nodeId: string;
831
831
  force?: boolean;
832
- }): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string; fallback?: string; forced?: boolean; convergence?: Record<string, unknown>; recovered?: boolean; residue?: boolean; residueWarning?: string; residueError?: string } | { success: false; code: string; error: string; recoveryHint: string; convergence?: Record<string, unknown> }> {
832
+ }): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string; fallback?: string; forced?: boolean; convergence?: Record<string, unknown>; recovered?: boolean; residue?: boolean; residueWarning?: string; residueError?: string; branchRefDeleted?: boolean; branchRefReason?: string; branchRefForced?: boolean; branchRefWarning?: string } | { success: false; code: string; error: string; recoveryHint: string; convergence?: Record<string, unknown> }> {
833
833
  const workspace = typeof args.node?.workspace === 'string' ? args.node.workspace.trim() : '';
834
834
  if (!workspace) {
835
835
  return {
@@ -929,19 +929,62 @@ export class DaemonCommandRouter {
929
929
  };
930
930
  }
931
931
 
932
+ // Always evaluate real merge convergence so we can decide whether the
933
+ // branch ref is safe to delete after removal — even on the force path,
934
+ // where `force_override` only authorizes the *worktree* removal and must
935
+ // NOT be taken as proof the branch is merged (that would risk work loss).
936
+ const mergeConvergence = await this.getWorktreeForceCleanupConvergence({ repoRoot, workspace, node: args.node });
932
937
  const forceFallbackConvergence = args.force
933
938
  ? { allow: true, status: 'force_override', source: 'caller_force_flag' }
934
- : await this.getWorktreeForceCleanupConvergence({ repoRoot, workspace, node: args.node });
939
+ : mergeConvergence;
940
+
941
+ // After the worktree is removed, delete the branch ref iff the branch is
942
+ // fully merged into the default ref (no work loss). Otherwise preserve it
943
+ // and surface a warning. `mergeConvergence` (NOT the force override) is the
944
+ // authority on merged-ness.
945
+ const deleteBranchIfMerged = async () => {
946
+ const branch = String(args.node.worktreeBranch).trim();
947
+ const status = mergeConvergence.allow ? (mergeConvergence.status || '') : '';
948
+ const MERGED_STATUSES = new Set([
949
+ 'merged_to_main', 'merged_pushed', 'merged_to_default_ref', 'cleanup_candidate',
950
+ ]);
951
+ const PATCH_EQUIV_STATUS = 'patch_equivalent_to_default_ref';
952
+ if (!branch) {
953
+ return { branchRefDeleted: false, branchRefReason: 'empty_branch_name' };
954
+ }
955
+ if (!mergeConvergence.allow || (!MERGED_STATUSES.has(status) && status !== PATCH_EQUIV_STATUS)) {
956
+ return {
957
+ branchRefDeleted: false,
958
+ branchRefReason: `branch_not_merged_preserved: ${mergeConvergence.error || mergeConvergence.status || 'convergence_unverified'}`,
959
+ branchRefWarning: `Branch ref '${branch}' was preserved (not deleted) because it is not confirmed merged into the default ref — no work was lost. Merge it (or pass a verified branchConvergence final state) and re-run cleanup, or delete it manually after confirming.`,
960
+ };
961
+ }
962
+ const { deleteBranchRef } = await import('../git/git-worktree.js');
963
+ // `-d` can detect a true fast-forward/merge; patch-equivalent landings
964
+ // (squash/cherry-pick) are invisible to `-d`, so allow the verified `-D`
965
+ // fallback only for the patch-equivalence status.
966
+ const res = await deleteBranchRef(repoRoot, branch, { safeDeleteOnly: status !== PATCH_EQUIV_STATUS });
967
+ return {
968
+ branchRefDeleted: res.deleted,
969
+ branchRefReason: res.reason,
970
+ ...(res.forced ? { branchRefForced: true } : {}),
971
+ ...(res.deleted ? {} : {
972
+ branchRefWarning: `Branch ref '${branch}' could not be deleted (${res.reason}); it was preserved so no work is lost.`,
973
+ }),
974
+ };
975
+ };
935
976
 
936
977
  try {
937
978
  const result = await removeWorktree(repoRoot, workspace, {
938
979
  requireClean: !args.force,
939
980
  allowSubmoduleForceFallback: forceFallbackConvergence.allow,
940
981
  });
982
+ const branchOutcome = await deleteBranchIfMerged();
941
983
  return {
942
984
  success: true,
943
985
  removedPath: result.removedPath,
944
986
  repoRoot,
987
+ ...branchOutcome,
945
988
  ...(result.fallback ? {
946
989
  fallback: result.fallback,
947
990
  forced: result.forced,
@@ -969,10 +1012,12 @@ export class DaemonCommandRouter {
969
1012
  await execFileAsync('git', ['worktree', 'remove', '--force', workspace], {
970
1013
  cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_CLEANUP, maxBuffer: GIT_MAX_BUFFER_CLEANUP, windowsHide: true,
971
1014
  });
1015
+ const branchOutcome = await deleteBranchIfMerged();
972
1016
  return {
973
1017
  success: true,
974
1018
  removedPath: workspace,
975
1019
  repoRoot,
1020
+ ...branchOutcome,
976
1021
  fallback: 'git_worktree_remove_submodule_deinit' as const,
977
1022
  forced: true,
978
1023
  reason: 'working_trees_containing_submodules' as const,
@@ -990,10 +1035,12 @@ export class DaemonCommandRouter {
990
1035
  cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_CLEANUP, maxBuffer: GIT_MAX_BUFFER_CLEANUP, windowsHide: true,
991
1036
  });
992
1037
  } catch { /* prune is best-effort */ }
1038
+ const branchOutcome = await deleteBranchIfMerged();
993
1039
  return {
994
1040
  success: true,
995
1041
  removedPath: workspace,
996
1042
  repoRoot,
1043
+ ...branchOutcome,
997
1044
  fallback: 'fs_rm_worktree_prune' as const,
998
1045
  forced: true,
999
1046
  reason: 'working_trees_containing_submodules' as const,
@@ -257,6 +257,74 @@ export function parseWorktreeListOutput(output: string): WorktreeEntry[] {
257
257
  return entries;
258
258
  }
259
259
 
260
+ // ─── Branch ref deletion ────────────────────────
261
+
262
+ export interface BranchRefDeleteResult {
263
+ /** True if the branch ref no longer exists after this call. */
264
+ deleted: boolean;
265
+ /** Why it was (not) deleted, for surfacing in the cleanup result. */
266
+ reason: string;
267
+ /** True when a forced delete (`-D`) was needed (e.g. squash/patch-equivalent merge). */
268
+ forced?: boolean;
269
+ }
270
+
271
+ /**
272
+ * Delete a local branch ref after its worktree was removed.
273
+ *
274
+ * SAFETY: this is only meant to be called once the caller has independently
275
+ * verified that the branch is fully merged / its content is contained in the
276
+ * default ref (no work loss). It first tries the safe `git branch -d`, which
277
+ * refuses to delete a branch git itself does not consider merged. If
278
+ * `safeDeleteOnly` is false (the caller proved containment by patch-equivalence,
279
+ * which `-d` cannot see), it falls back to `git branch -D`. When the branch is
280
+ * already gone, this reports `deleted: true` idempotently.
281
+ */
282
+ export async function deleteBranchRef(
283
+ repoRoot: string,
284
+ branch: string,
285
+ opts: { safeDeleteOnly?: boolean } = {},
286
+ ): Promise<BranchRefDeleteResult> {
287
+ const name = (branch || '').trim();
288
+ if (!name) return { deleted: false, reason: 'empty_branch_name' };
289
+
290
+ // Idempotent: nothing to delete if the ref does not exist.
291
+ try {
292
+ await execFileAsync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], {
293
+ cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
294
+ });
295
+ } catch {
296
+ return { deleted: true, reason: 'branch_ref_absent' };
297
+ }
298
+
299
+ // Try the safe delete first — git refuses if it cannot see the branch as merged.
300
+ try {
301
+ await execFileAsync('git', ['branch', '-d', name], {
302
+ cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
303
+ });
304
+ return { deleted: true, reason: 'safe_deleted_merged_branch' };
305
+ } catch (error: any) {
306
+ const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
307
+ const notMerged = /not fully merged/i.test(stderr) || /not fully merged/i.test(String(error?.message || ''));
308
+ if (!notMerged) {
309
+ return { deleted: false, reason: `branch_delete_failed: ${stderr.trim() || error?.message || 'unknown error'}` };
310
+ }
311
+ // git -d refused. Only force when the caller proved containment another way
312
+ // (e.g. squash/cherry-pick/patch-equivalent merge that -d cannot detect).
313
+ if (opts.safeDeleteOnly) {
314
+ return { deleted: false, reason: 'branch_not_merged_per_git_safe_delete_only' };
315
+ }
316
+ try {
317
+ await execFileAsync('git', ['branch', '-D', name], {
318
+ cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
319
+ });
320
+ return { deleted: true, reason: 'force_deleted_patch_equivalent_branch', forced: true };
321
+ } catch (forceError: any) {
322
+ const fErr = typeof forceError?.stderr === 'string' ? forceError.stderr : forceError?.message;
323
+ return { deleted: false, reason: `branch_force_delete_failed: ${String(fErr || 'unknown error').trim()}` };
324
+ }
325
+ }
326
+ }
327
+
260
328
  // ─── Prune ──────────────────────────────────────
261
329
 
262
330
  async function pruneWorktrees(repoRoot: string): Promise<void> {
@@ -130,6 +130,18 @@ function isMutationKeywordInCommandContext(text: string, matchStart: number, mat
130
130
  // Line start (only whitespace before the keyword on this line).
131
131
  if (/^\s*$/.test(linePrefix)) return true;
132
132
 
133
+ // Executed script at command position: the keyword sits inside a run-prefix
134
+ // path token (`./scripts/version-bump.sh`, `~/bin/deploy.sh`) that is the
135
+ // leading token of the line or follows a shell connective — the script is
136
+ // being invoked, so the keyword is a real command. (Path *arguments* like
137
+ // `list build/Release` are excluded earlier by isInsidePathSegment.)
138
+ let tokStart = matchStart;
139
+ while (tokStart > lineStart && !/\s/.test(text[tokStart - 1])) tokStart--;
140
+ const beforeToken = text.slice(lineStart, tokStart);
141
+ const tokenLead = text.slice(tokStart, matchStart);
142
+ const atCmdPos = /^\s*$/.test(beforeToken) || /(?:&&|\|\||\||;)\s*$/.test(beforeToken);
143
+ if (atCmdPos && /^(?:\.\/|\.\.\/|~\/|\/)/.test(tokenLead)) return true;
144
+
133
145
  // After a shell connective or imperative connective introducing a command.
134
146
  // We look at what immediately precedes the keyword on the same line.
135
147
  if (/(?:&&|\|\||\||;)\s*$/.test(linePrefix)) return true;
@@ -171,15 +183,111 @@ function isInsideBackticksOrFence(text: string, matchStart: number, matchEnd: nu
171
183
  return inlineCount % 2 === 1;
172
184
  }
173
185
 
186
+ /**
187
+ * True if the matched keyword is part of a filesystem-path-like token rather than
188
+ * a standalone word/command. A "release" inside `build/Release`, `dist/release/`,
189
+ * or `packages\release` is a directory/file name, not a deploy instruction. We
190
+ * look at the characters immediately adjacent to the match: if either side is a
191
+ * path separator (`/` or `\`) joining it to another path segment, the keyword is
192
+ * a path component.
193
+ *
194
+ * IMPORTANT: a path token that is itself being *executed* — i.e. the leading
195
+ * token of a command line such as `./scripts/version-bump.sh patch` — is NOT a
196
+ * suppressible path; that is a real command. We therefore exclude the case where
197
+ * the path token sits at command-invocation position (line start, optionally with
198
+ * a leading `./` / `/` / `~/`, or right after a shell connective), which means it
199
+ * is the program being run rather than an argument being inspected.
200
+ */
201
+ function isInsidePathSegment(text: string, matchStart: number, matchEnd: number): boolean {
202
+ const prev = matchStart > 0 ? text[matchStart - 1] : '';
203
+ const next = matchEnd < text.length ? text[matchEnd] : '';
204
+ const isSep = (c: string) => c === '/' || c === '\\';
205
+ const segChar = (c: string) => /[A-Za-z0-9._~-]/.test(c);
206
+ const inPath =
207
+ (isSep(prev) && (next === '' || isSep(next) || segChar(next) || /\s/.test(next))) ||
208
+ (isSep(next) && (prev === '' || isSep(prev) || segChar(prev) || /\s/.test(prev)));
209
+ if (!inPath) return false;
210
+
211
+ // Find the whole whitespace-delimited path token containing the match and the
212
+ // text on its line before it. If the token is the first thing on the line
213
+ // (after an optional `./`, `/`, `~/`, or `../` prefix) or directly follows a
214
+ // shell connective, it is being executed → not a suppressible path.
215
+ const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
216
+ let tokStart = matchStart;
217
+ while (tokStart > lineStart && !/\s/.test(text[tokStart - 1])) tokStart--;
218
+ const linePrefixBeforeToken = text.slice(lineStart, tokStart);
219
+ const tokenPrefix = text.slice(tokStart, matchStart);
220
+ // The path token is in command position when nothing but whitespace (or a
221
+ // shell connective) precedes it on the line, AND the token itself is a
222
+ // run-prefix path (`./`, `/`, `~/`, `../`). A bare `build/Release` argument
223
+ // after a verb ("list build/Release") is NOT command position.
224
+ const atLineStart = /^\s*$/.test(linePrefixBeforeToken);
225
+ const afterConnective = /(?:&&|\|\||\||;)\s*$/.test(linePrefixBeforeToken);
226
+ const isRunPrefixPath = /^(?:\.\/|\.\.\/|~\/|\/)/.test(tokenPrefix);
227
+ if ((atLineStart || afterConnective) && isRunPrefixPath) return false;
228
+ return true;
229
+ }
230
+
231
+ /**
232
+ * True if [matchStart, matchEnd) lies inside a quoted span — straight quotes
233
+ * (`"..."`, `'...'`), typographic quotes (`“...”`, `‘...’`), or CJK brackets
234
+ * (`「...」`, `『...』`, `《...》`). Commit-message citations and other quoted prose
235
+ * (e.g. quoting a `chore: ... version-bump ...` log line, or the Korean
236
+ * "포인터 bump" phrase) are descriptive, not command invocations, so a forbidden
237
+ * keyword inside such a quote must not trip the guardrail. Backticks are excluded
238
+ * here on purpose — they denote shell/code snippets and are handled as command
239
+ * context in {@link isInsideBackticksOrFence}.
240
+ */
241
+ function isInsideQuotedSpan(text: string, matchStart: number, matchEnd: number): boolean {
242
+ // Paired quotes: an opening char before the match without its closer in
243
+ // between, and the matching closer after the match.
244
+ const pairs: Array<[string, string]> = [
245
+ ['“', '”'], ['‘', '’'], ['「', '」'], ['『', '』'], ['《', '》'],
246
+ ];
247
+ for (const [open, close] of pairs) {
248
+ const openIdx = text.lastIndexOf(open, matchStart - 1);
249
+ if (openIdx < 0) continue;
250
+ // No closer between the opener and the match → still open at the match.
251
+ if (text.indexOf(close, openIdx + open.length) >= matchEnd) return true;
252
+ }
253
+ // Symmetric quotes (" and '): odd count of the quote char before the match,
254
+ // within the same line (a quote does not span newlines), and a closing quote
255
+ // later on the line.
256
+ const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
257
+ const lineEnd = (() => { const i = text.indexOf('\n', matchEnd); return i < 0 ? text.length : i; })();
258
+ for (const q of ['"', "'"]) {
259
+ let count = 0;
260
+ for (let i = lineStart; i < matchStart; i++) if (text[i] === q) count++;
261
+ if (count % 2 === 1 && text.indexOf(q, matchEnd) >= 0 && text.indexOf(q, matchEnd) < lineEnd) {
262
+ // For "'" guard against the apostrophe-in-prose case (e.g. "don't"):
263
+ // require the opening quote to be preceded by a boundary (start/space/
264
+ // open-paren) so a contraction apostrophe is not read as a quote opener.
265
+ if (q === '"') return true;
266
+ // find the opening quote (the one making the count odd)
267
+ let openPos = -1, c = 0;
268
+ for (let i = lineStart; i < matchStart; i++) { if (text[i] === q) { c++; if (c % 2 === 1) openPos = i; } }
269
+ const beforeOpen = openPos > lineStart ? text[openPos - 1] : ' ';
270
+ if (/[\s(\[{>]/.test(beforeOpen) || openPos === lineStart) return true;
271
+ }
272
+ }
273
+ return false;
274
+ }
275
+
174
276
  /**
175
277
  * Decides whether a forbidden keyword match at [matchStart, matchEnd) should
176
278
  * count as a real violation. A match counts only when it is in command context
177
- * and not negated. Negation always wins (even inside a code block), per the
178
- * conservative rule: code-block + negation exclude; other code-block keep.
279
+ * and not negated, and is NOT merely a path segment or quoted (descriptive)
280
+ * citation. Negation always wins (even inside a code block), per the conservative
281
+ * rule: code-block + negation → exclude; other code-block → keep.
179
282
  */
180
283
  function isRealMutationMatch(text: string, matchStart: number, matchEnd: number): boolean {
181
284
  if (hasNegationBefore(text, matchStart)) return false;
182
285
  if (hasTrailingNegation(text, matchEnd)) return false;
286
+ // A keyword that is part of a file path (build/Release, dist/release/) or
287
+ // sits inside quoted prose (a commit-message citation, "포인터 bump") is a
288
+ // description, not a command — suppress it even if it lands at line-start.
289
+ if (isInsidePathSegment(text, matchStart, matchEnd)) return false;
290
+ if (isInsideQuotedSpan(text, matchStart, matchEnd)) return false;
183
291
  return isMutationKeywordInCommandContext(text, matchStart, matchEnd);
184
292
  }
185
293