@adhdev/daemon-core 0.9.82-rc.323 → 0.9.82-rc.324

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.
@@ -83,6 +83,7 @@ export declare function buildMeshNodeCapabilityTags(node: {
83
83
  policy?: unknown;
84
84
  isLocalWorktree?: unknown;
85
85
  worktreeBranch?: unknown;
86
+ userOverrides?: unknown;
86
87
  } | undefined, providerType?: string): string[];
87
88
  export declare function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags: unknown): boolean;
88
89
  /**
@@ -296,7 +296,13 @@ export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<Repo
296
296
  */
297
297
  export declare function resolveProviderMaxParallel(nodePolicy: Pick<RepoMeshNodePolicy, 'providerRoles'> | null | undefined, providerType: string | null | undefined): number | undefined;
298
298
  export interface RepoMeshNodeCapabilities {
299
+ /** Node's OS, raw NodeJS.Platform value ("darwin"/"win32"/"linux"). For
300
+ * remote member nodes this is stamped by the member daemon at join time
301
+ * (its own process.platform) and drives os= capability-tag routing. */
299
302
  platform?: string;
303
+ /** Node's CPU architecture, raw process.arch value ("arm64"/"x64"). Stamped
304
+ * by the member daemon at join time; drives arch= capability-tag routing. */
305
+ arch?: string;
300
306
  packageManagers?: string[];
301
307
  detectedCommands?: DetectedCommand[];
302
308
  canRunLongJobs?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.323",
3
+ "version": "0.9.82-rc.324",
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.323",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.324",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -2002,6 +2002,122 @@ export async function runMeshRefinePatchEquivalenceGate(
2002
2002
  }
2003
2003
  }
2004
2004
 
2005
+ export type MeshWorktreePatchContainmentSummary = {
2006
+ /** True only when merging worktreeHead into ref introduces no new patch. */
2007
+ contained: boolean;
2008
+ ref: string;
2009
+ worktreeHead: string;
2010
+ mergeBase?: string;
2011
+ mergedTree?: string;
2012
+ /** patch-id of (ref -> synthesized merge tree); empty string when nothing new is added. */
2013
+ residualPatchId?: string;
2014
+ durationMs: number;
2015
+ /** Set when the check could not run (treated conservatively as NOT contained). */
2016
+ error?: string;
2017
+ };
2018
+
2019
+ /**
2020
+ * Patch-equivalence containment check for the worktree force-cleanup convergence
2021
+ * guard. Answers a narrower question than {@link runMeshRefinePatchEquivalenceGate}:
2022
+ * "are the worktree branch's changes ALREADY present in `ref` (e.g. origin/main),
2023
+ * even though the worktree HEAD's commit SHA is not an ancestor of ref?"
2024
+ *
2025
+ * This is the cherry-pick / squash / rebase case: the same content landed on the
2026
+ * default ref under a different commit SHA, so `merge-base --is-ancestor` (the
2027
+ * primary cleanup guard) reports the worktree as un-converged and refuses to
2028
+ * remove it. Refinery already accepts patch-equivalent landings via merge-tree +
2029
+ * patch-id; this brings the same notion of "convergence" to the cleanup guard.
2030
+ *
2031
+ * Mechanism: synthesize the merge of `worktreeHead` into `ref` (reusing the same
2032
+ * trivial-gitlink-fast-forward handling as the refine gate) and compute the
2033
+ * patch-id of (ref -> mergedTree). If that residual diff is EMPTY, merging the
2034
+ * worktree adds nothing new on top of ref — its changes are already present there
2035
+ * and the worktree is safe to remove. A non-empty residual means the worktree
2036
+ * still carries content not in ref, so it is NOT contained and must stay blocked.
2037
+ *
2038
+ * Conservative by construction: any merge-tree / patch-id failure, a genuine
2039
+ * (non-trivial) submodule conflict, or any thrown error yields `contained: false`
2040
+ * so an exception can never widen the cleanup allow-list.
2041
+ */
2042
+ export async function checkWorktreeChangesPatchEquivalentInRef(
2043
+ repoRoot: string,
2044
+ ref: string,
2045
+ worktreeHead: string,
2046
+ ): Promise<MeshWorktreePatchContainmentSummary> {
2047
+ const startedAt = Date.now();
2048
+ try {
2049
+ const { execFileSync } = await import('node:child_process');
2050
+ const git = (gitArgs: string[]) => execFileSync('git', gitArgs, {
2051
+ cwd: repoRoot,
2052
+ encoding: 'utf8',
2053
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
2054
+ });
2055
+ const mergeBase = git(['merge-base', ref, worktreeHead]).trim();
2056
+
2057
+ // Reuse the refine gate's trivial-gitlink-fast-forward handling: a clean
2058
+ // submodule pointer fast-forward must not block the cleanup, but a real
2059
+ // (non-ff) submodule divergence must keep it blocked.
2060
+ let mergedTree = '';
2061
+ try {
2062
+ mergedTree = git(['merge-tree', '--write-tree', ref, worktreeHead]).trim().split(/\s+/)[0] || '';
2063
+ } catch (mergeTreeErr: any) {
2064
+ const output = `${mergeTreeErr?.message || ''}\n${mergeTreeErr?.stdout || ''}\n${mergeTreeErr?.stderr || ''}`;
2065
+ const isSubmoduleConflict = /(submodule|160000)/i.test(output)
2066
+ || /Recursive merging with submodules/i.test(output);
2067
+ if (!isSubmoduleConflict) throw mergeTreeErr;
2068
+ const evaluation = evaluateGitlinkTrivialFastForward(repoRoot, ref, worktreeHead);
2069
+ if (!evaluation.trivial) {
2070
+ // A genuine submodule divergence (or unfetched objects): we cannot
2071
+ // prove containment, so block conservatively.
2072
+ return {
2073
+ contained: false,
2074
+ ref,
2075
+ worktreeHead,
2076
+ mergeBase: mergeBase || undefined,
2077
+ durationMs: Date.now() - startedAt,
2078
+ error: `merge-tree submodule conflict is not a trivial fast-forward: ${evaluation.reason || 'unknown'}`,
2079
+ };
2080
+ }
2081
+ mergedTree = synthesizeTrivialFastForwardMergeTree(repoRoot, ref, worktreeHead, evaluation.gitlinks) || '';
2082
+ }
2083
+
2084
+ if (!mergedTree) {
2085
+ return {
2086
+ contained: false,
2087
+ ref,
2088
+ worktreeHead,
2089
+ mergeBase: mergeBase || undefined,
2090
+ durationMs: Date.now() - startedAt,
2091
+ error: 'could not resolve synthetic merge tree for containment check',
2092
+ };
2093
+ }
2094
+
2095
+ // Exclude proven fast-forward gitlinks from the residual diff for the same
2096
+ // reason the refine gate does: advancing a submodule pointer to a strict
2097
+ // descendant is a safe fast-forward and must not count as "new content".
2098
+ const ffGitlinkExcludePaths = collectFastForwardGitlinkPaths(repoRoot, ref, worktreeHead);
2099
+ const residualPatchId = await computeGitPatchId(repoRoot, ref, mergedTree, ffGitlinkExcludePaths);
2100
+ const contained = residualPatchId === '';
2101
+ return {
2102
+ contained,
2103
+ ref,
2104
+ worktreeHead,
2105
+ mergeBase: mergeBase || undefined,
2106
+ mergedTree,
2107
+ residualPatchId,
2108
+ durationMs: Date.now() - startedAt,
2109
+ };
2110
+ } catch (e: any) {
2111
+ return {
2112
+ contained: false,
2113
+ ref,
2114
+ worktreeHead,
2115
+ durationMs: Date.now() - startedAt,
2116
+ error: e?.message || String(e),
2117
+ };
2118
+ }
2119
+ }
2120
+
2005
2121
  /**
2006
2122
  * No-op guard: detect a "silent no-op" merge before the Refinery merge runs.
2007
2123
  *
@@ -3303,13 +3419,26 @@ function buildMemberJoinNode(mesh: any, args: any, fallbackDaemonId?: string): R
3303
3419
  : typeof source?.nodeId === 'string' && source.nodeId.trim()
3304
3420
  ? source.nodeId.trim()
3305
3421
  : undefined;
3422
+ const baseOverrides = source?.userOverrides && typeof source.userOverrides === 'object' && !Array.isArray(source.userOverrides)
3423
+ ? source.userOverrides as Record<string, unknown>
3424
+ : {};
3425
+ // This payload is built ON THE MEMBER DAEMON, so process.platform/process.arch
3426
+ // are the member's OWN machine. Stamp them into userOverrides so the host
3427
+ // stores the member's real platform/arch on the node record — the coordinator's
3428
+ // buildMeshNodeCapabilityTags then advertises os=<member-os> instead of the
3429
+ // coordinator's own platform. Only fill values the operator hasn't already set.
3430
+ const userOverrides: Record<string, unknown> = {
3431
+ ...baseOverrides,
3432
+ ...(typeof baseOverrides.platform === 'string' && baseOverrides.platform.trim() ? {} : { platform: process.platform }),
3433
+ ...(typeof baseOverrides.arch === 'string' && baseOverrides.arch.trim() ? {} : { arch: process.arch }),
3434
+ };
3306
3435
  return {
3307
3436
  ...(nodeId ? { id: nodeId } : {}),
3308
3437
  workspace,
3309
3438
  ...(typeof source?.repoRoot === 'string' && source.repoRoot.trim() ? { repoRoot: source.repoRoot.trim() } : {}),
3310
3439
  ...(typeof source?.daemonId === 'string' && source.daemonId.trim() ? { daemonId: source.daemonId.trim() } : fallbackDaemonId ? { daemonId: fallbackDaemonId } : {}),
3311
3440
  ...(typeof source?.machineId === 'string' && source.machineId.trim() ? { machineId: source.machineId.trim() } : {}),
3312
- userOverrides: source?.userOverrides && typeof source.userOverrides === 'object' && !Array.isArray(source.userOverrides) ? source.userOverrides : {},
3441
+ userOverrides,
3313
3442
  policy: source?.policy && typeof source.policy === 'object' && !Array.isArray(source.policy) ? source.policy : {},
3314
3443
  role: 'member',
3315
3444
  };
@@ -3915,6 +4044,7 @@ export class DaemonCommandRouter {
3915
4044
 
3916
4045
  const seen = new Set<string>();
3917
4046
  const checkedRefs: string[] = [];
4047
+ const resolvedRefCommits: Array<{ ref: string; commit: string }> = [];
3918
4048
  for (const ref of candidateRefs) {
3919
4049
  if (!ref || seen.has(ref)) continue;
3920
4050
  seen.add(ref);
@@ -3925,6 +4055,7 @@ export class DaemonCommandRouter {
3925
4055
  continue;
3926
4056
  }
3927
4057
  checkedRefs.push(ref);
4058
+ resolvedRefCommits.push({ ref, commit });
3928
4059
  try {
3929
4060
  await runGit(['merge-base', '--is-ancestor', head, commit], args.repoRoot);
3930
4061
  return { allow: true, status: 'merged_to_default_ref', source: 'git_merge_base', ref };
@@ -3933,6 +4064,27 @@ export class DaemonCommandRouter {
3933
4064
  }
3934
4065
  }
3935
4066
 
4067
+ // SHA-reachability fallback: the worktree HEAD is not an ancestor of any
4068
+ // candidate ref, but its CONTENT may already be present via cherry-pick /
4069
+ // squash / rebase (a different commit SHA carrying the same patch). The
4070
+ // Refinery accepts such patch-equivalent landings; mirror that here so the
4071
+ // cleanup guard does not falsely block a converged worktree. This is the
4072
+ // heavier merge-tree/patch-id path, so it only runs after every ancestor
4073
+ // check has already failed. Any failure stays conservative (NOT contained).
4074
+ for (const { ref, commit } of resolvedRefCommits) {
4075
+ let containment: MeshWorktreePatchContainmentSummary;
4076
+ try {
4077
+ containment = await checkWorktreeChangesPatchEquivalentInRef(args.repoRoot, commit, head);
4078
+ } catch {
4079
+ // Defensive: the helper is already exception-safe, but never let a
4080
+ // thrown error escape into an allow.
4081
+ continue;
4082
+ }
4083
+ if (containment.contained) {
4084
+ return { allow: true, status: 'patch_equivalent_to_default_ref', source: 'git_patch_equivalence', ref };
4085
+ }
4086
+ }
4087
+
3936
4088
  return {
3937
4089
  allow: false,
3938
4090
  status: metadataStatus || undefined,
@@ -30,6 +30,170 @@ const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> =
30
30
  { label: 'container_mutation', pattern: /\b(docker\s+(?:build|run|exec|push|tag|rmi|rm|create|start|stop|kill)|kubectl\s+(?:apply|delete|patch|replace|create|scale))\b/i },
31
31
  ];
32
32
 
33
+ /**
34
+ * Negation cues that, when they appear shortly before a mutation keyword inside
35
+ * the same clause, mean the keyword is being *forbidden* or described rather
36
+ * than invoked (e.g. "do not git reset", "절대 push 하지 마세요"). Matched
37
+ * case-insensitively. The Korean cues intentionally include sub-string forms
38
+ * ("않", "금지", "말 것") so conjugated variants are caught.
39
+ */
40
+ const NEGATION_CUES: string[] = [
41
+ "don't", 'do not', 'never', 'avoid', 'without', 'no longer', 'not', 'forbidden',
42
+ '하지 마', '하지 마세요', '말 것', '금지', '없음', '않',
43
+ ];
44
+
45
+ /** How many whitespace-delimited tokens before a keyword we scan for negation. */
46
+ const NEGATION_WINDOW_TOKENS = 6;
47
+
48
+ /**
49
+ * Returns true if a negation cue appears within {@link NEGATION_WINDOW_TOKENS}
50
+ * tokens before `matchIndex`, staying inside the same clause — we stop at
51
+ * sentence/line/clause boundaries (newline, `.`/`!`/`?`, `;`) so a negation in a
52
+ * previous sentence does not suppress a real command in the next one.
53
+ */
54
+ function hasNegationBefore(text: string, matchIndex: number): boolean {
55
+ const before = text.slice(0, matchIndex);
56
+ // Restrict to the current clause: cut at the last clause/sentence/line break.
57
+ const clauseStart = Math.max(
58
+ before.lastIndexOf('\n'),
59
+ before.lastIndexOf('. '),
60
+ before.lastIndexOf('! '),
61
+ before.lastIndexOf('? '),
62
+ before.lastIndexOf(';'),
63
+ );
64
+ const clause = before.slice(clauseStart + 1);
65
+ const lower = clause.toLowerCase();
66
+ // Whitespace tokens immediately preceding the keyword, within the window.
67
+ const tokens = clause.split(/\s+/).filter(Boolean);
68
+ const windowTokens = tokens.slice(Math.max(0, tokens.length - NEGATION_WINDOW_TOKENS));
69
+ const windowText = windowTokens.join(' ').toLowerCase();
70
+ for (const cue of NEGATION_CUES) {
71
+ const c = cue.toLowerCase();
72
+ // ASCII cues are word-ish phrases — match in the bounded window only.
73
+ // CJK cues have no spaces, so the window-join can miss them; for those
74
+ // fall back to scanning the whole clause (still clause-bounded).
75
+ if (/[^\x00-\x7f]/.test(c)) {
76
+ if (lower.includes(c)) return true;
77
+ } else if (windowText.includes(c)) {
78
+ return true;
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Korean (and other) negation often trails the verb it negates ("git reset 하지
86
+ * 마세요" = "do not git reset"). Returns true if a CJK negation cue appears
87
+ * shortly after `matchEnd`, within the same clause. Scoped to CJK cues only —
88
+ * trailing ASCII words rarely negate a preceding command and would over-match.
89
+ */
90
+ function hasTrailingNegation(text: string, matchEnd: number): boolean {
91
+ const after = text.slice(matchEnd);
92
+ // Clause-bound the lookahead: stop at the next clause/line break.
93
+ const clauseEnd = (() => {
94
+ const stops = [after.indexOf('\n'), after.indexOf('. '), after.indexOf('; ')]
95
+ .filter(i => i >= 0);
96
+ return stops.length ? Math.min(...stops) : after.length;
97
+ })();
98
+ const clause = after.slice(0, clauseEnd).toLowerCase();
99
+ for (const cue of NEGATION_CUES) {
100
+ const c = cue.toLowerCase();
101
+ if (/[^\x00-\x7f]/.test(c) && clause.includes(c)) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Returns true when the keyword at [matchStart, matchEnd) looks like an actual
108
+ * command invocation rather than a plain-prose mention. Command context is:
109
+ * - inside a fenced code block (``` ... ```) or inline backticks (`...`)
110
+ * - on a shell-prompt line (starts with `$ ` or `> `)
111
+ * - at a command-call position: line start (leading whitespace allowed) or
112
+ * right after a shell connective (`&&`, `||`, `|`, `;`) or an imperative
113
+ * connective ("then"/"run"/"," ) that introduces a command.
114
+ * Plain mid-sentence prose mentions (no backticks, no command position) are not
115
+ * command context and are excluded from violations.
116
+ */
117
+ function isMutationKeywordInCommandContext(text: string, matchStart: number, matchEnd: number): boolean {
118
+ if (isInsideBackticksOrFence(text, matchStart, matchEnd)) return true;
119
+
120
+ // The line containing the match, and the portion of it before the keyword.
121
+ const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
122
+ const linePrefix = text.slice(lineStart, matchStart);
123
+
124
+ // Shell-prompt line: "$ ..." or "> ..." (leading whitespace allowed).
125
+ if (/^\s*[$>]\s/.test(text.slice(lineStart))) return true;
126
+
127
+ // Line start (only whitespace before the keyword on this line).
128
+ if (/^\s*$/.test(linePrefix)) return true;
129
+
130
+ // After a shell connective or imperative connective introducing a command.
131
+ // We look at what immediately precedes the keyword on the same line.
132
+ if (/(?:&&|\|\||\||;)\s*$/.test(linePrefix)) return true;
133
+ if (/(?:^|[\s,])(?:then|run|first)\s+$/i.test(linePrefix)) return true;
134
+ if (/,\s*$/.test(linePrefix)) return true;
135
+
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * True if [matchStart, matchEnd) lies inside an inline-backtick span or a fenced
141
+ * code block. Fenced blocks (```...```) take precedence; otherwise we count
142
+ * inline backticks before the match — an odd count means we are inside a span.
143
+ */
144
+ function isInsideBackticksOrFence(text: string, matchStart: number, matchEnd: number): boolean {
145
+ // Fenced code blocks: count ``` fences before the match.
146
+ const fenceRe = /```/g;
147
+ let fenceCount = 0;
148
+ let m: RegExpExecArray | null;
149
+ while ((m = fenceRe.exec(text)) !== null) {
150
+ if (m.index >= matchStart) break;
151
+ fenceCount++;
152
+ }
153
+ if (fenceCount % 2 === 1) return true;
154
+
155
+ // Inline backticks: count single backticks before the match start, ignoring
156
+ // those that are part of a ``` fence (handled above). An odd count → inside.
157
+ let inlineCount = 0;
158
+ for (let i = 0; i < matchStart; i++) {
159
+ if (text[i] === '`') {
160
+ // Skip triple-fence backticks.
161
+ if (text[i + 1] === '`' && text[i + 2] === '`') {
162
+ i += 2;
163
+ continue;
164
+ }
165
+ inlineCount++;
166
+ }
167
+ }
168
+ return inlineCount % 2 === 1;
169
+ }
170
+
171
+ /**
172
+ * Decides whether a forbidden keyword match at [matchStart, matchEnd) should
173
+ * count as a real violation. A match counts only when it is in command context
174
+ * and not negated. Negation always wins (even inside a code block), per the
175
+ * conservative rule: code-block + negation → exclude; other code-block → keep.
176
+ */
177
+ function isRealMutationMatch(text: string, matchStart: number, matchEnd: number): boolean {
178
+ if (hasNegationBefore(text, matchStart)) return false;
179
+ if (hasTrailingNegation(text, matchEnd)) return false;
180
+ return isMutationKeywordInCommandContext(text, matchStart, matchEnd);
181
+ }
182
+
183
+ /**
184
+ * Runs a global regex over `text` and returns true if any match is a real
185
+ * mutation (command context, not negated).
186
+ */
187
+ function patternHasRealMutation(pattern: RegExp, text: string): boolean {
188
+ const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
189
+ let match: RegExpExecArray | null;
190
+ while ((match = re.exec(text)) !== null) {
191
+ if (isRealMutationMatch(text, match.index, match.index + match[0].length)) return true;
192
+ if (match.index === re.lastIndex) re.lastIndex++; // avoid zero-width loop
193
+ }
194
+ return false;
195
+ }
196
+
33
197
  /**
34
198
  * Git subcommands that mutate the working tree, index, refs, or remote.
35
199
  * `stash` and `checkout` are intentionally absent here: they have read-only
@@ -59,25 +223,32 @@ function detectGitMutation(message: string): boolean {
59
223
  let match: RegExpExecArray | null;
60
224
  while ((match = re.exec(message)) !== null) {
61
225
  const sub = match[1].toLowerCase();
62
- if (GIT_MUTATION_SUBCOMMANDS.has(sub)) return true;
226
+ // Only treat a mutating `git <sub>` as a real violation when it appears
227
+ // as an actual command (code/command context) and is not negated. Plain
228
+ // prose mentions ("don't git reset", "we won't push") are ignored.
229
+ const isReal = () => isRealMutationMatch(message, match!.index, match!.index + match![0].length);
230
+ if (GIT_MUTATION_SUBCOMMANDS.has(sub)) {
231
+ if (isReal()) return true;
232
+ continue;
233
+ }
63
234
  if (sub === 'stash') {
64
235
  // Token following `git stash`; read-only only for list/show.
65
236
  const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
66
237
  const next = after ? after[1].toLowerCase() : '';
67
- if (!GIT_STASH_READONLY_SUBCOMMANDS.has(next)) return true; // bare stash = push, or pop/apply/drop/...
238
+ if (!GIT_STASH_READONLY_SUBCOMMANDS.has(next) && isReal()) return true; // bare stash = push, or pop/apply/drop/...
68
239
  } else if (sub === 'checkout') {
69
240
  // `git checkout <ref/path>` mutates; `git checkout-index` is matched
70
241
  // as its own token by the regex (sub === 'checkout-index') and is read-only.
71
- return true;
242
+ if (isReal()) return true;
72
243
  } else if (sub === 'submodule') {
73
244
  // `git submodule update` mutates; `git submodule status` is read-only.
74
245
  const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
75
246
  const next = after ? after[1].toLowerCase() : '';
76
- if (next === 'update' || next === 'add' || next === 'sync' || next === 'deinit') return true;
247
+ if ((next === 'update' || next === 'add' || next === 'sync' || next === 'deinit') && isReal()) return true;
77
248
  } else if (sub === 'worktree') {
78
249
  const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
79
250
  const next = after ? after[1].toLowerCase() : '';
80
- if (next === 'add' || next === 'remove' || next === 'move' || next === 'prune') return true;
251
+ if ((next === 'add' || next === 'remove' || next === 'move' || next === 'prune') && isReal()) return true;
81
252
  }
82
253
  // checkout-index, stash-with-no-next-already-handled, status/diff/log/show/
83
254
  // rev-parse/branch/submodule status fall through as read-only.
@@ -100,8 +271,11 @@ export function validateMeshTaskModeRequest(mode: unknown, message: string): Mes
100
271
  return { valid: true, taskMode, violations: [] };
101
272
  }
102
273
  const text = message || '';
274
+ // Only flag keywords that look like real commands (code/command context) and
275
+ // are not negated — descriptive/prohibitive prose ("don't run `npm publish`",
276
+ // "read-only, no deploy") must not trip the guardrail.
103
277
  const violations = LIVE_DEBUG_READONLY_FORBIDDEN
104
- .filter(rule => rule.pattern.test(text))
278
+ .filter(rule => patternHasRealMutation(rule.pattern, text))
105
279
  .map(rule => rule.label);
106
280
  if (detectGitMutation(text)) {
107
281
  violations.push('git_mutation');
@@ -204,8 +378,15 @@ function firstProviderPriority(policy: unknown): string | undefined {
204
378
  return raw.find(type => typeof type === 'string' && type.trim())?.trim();
205
379
  }
206
380
 
381
+ function readNodeOverride(node: { userOverrides?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
382
+ const overrides = node?.userOverrides;
383
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return null;
384
+ const value = (overrides as Record<string, unknown>)[key];
385
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
386
+ }
387
+
207
388
  export function buildMeshNodeCapabilityTags(
208
- node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown } | undefined,
389
+ node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown; userOverrides?: unknown } | undefined,
209
390
  providerType?: string,
210
391
  ): string[] {
211
392
  const provider = typeof providerType === 'string' && providerType.trim()
@@ -214,10 +395,21 @@ export function buildMeshNodeCapabilityTags(
214
395
  const worktreeBranch = typeof node?.worktreeBranch === 'string' && node.worktreeBranch.trim()
215
396
  ? node.worktreeBranch.trim()
216
397
  : null;
398
+ // Per-node platform/arch: prefer the value the remote daemon stamped into
399
+ // its node record (userOverrides.platform/arch) so a Windows member advertises
400
+ // os=win32 even though the COORDINATOR computing these tags runs on darwin.
401
+ // Fall back to process.platform/process.arch only when absent — that covers the
402
+ // local coordinator node and local worktree nodes, where process.* IS correct.
403
+ // Vocabulary is raw process.platform/process.arch ("darwin"/"win32"/"linux",
404
+ // "arm64"/"x64") on both the advertiser and the required_tags matcher, which
405
+ // compares with plain string equality (nodeSatisfiesRequiredTags) — so this
406
+ // keeps the win32/darwin/linux vocabulary the matcher already expects.
407
+ const os = readNodeOverride(node, 'platform') ?? process.platform;
408
+ const arch = readNodeOverride(node, 'arch') ?? process.arch;
217
409
  return normalizeMeshCapabilityTags([
218
410
  ...(Array.isArray(node?.capabilities) ? node.capabilities : []),
219
- `os=${process.platform}`,
220
- `arch=${process.arch}`,
411
+ `os=${os}`,
412
+ `arch=${arch}`,
221
413
  ...(provider ? [`provider=${provider}`] : []),
222
414
  // Worktree nodes automatically expose a "worktree=<branch>" tag so that
223
415
  // mesh_enqueue_task with required_tags: ["worktree=<branch>"] routes
@@ -398,7 +398,13 @@ export function resolveProviderMaxParallel(
398
398
  // ─── Capabilities ───────────────────────────────
399
399
 
400
400
  export interface RepoMeshNodeCapabilities {
401
+ /** Node's OS, raw NodeJS.Platform value ("darwin"/"win32"/"linux"). For
402
+ * remote member nodes this is stamped by the member daemon at join time
403
+ * (its own process.platform) and drives os= capability-tag routing. */
401
404
  platform?: string;
405
+ /** Node's CPU architecture, raw process.arch value ("arm64"/"x64"). Stamped
406
+ * by the member daemon at join time; drives arch= capability-tag routing. */
407
+ arch?: string;
402
408
  packageManagers?: string[];
403
409
  detectedCommands?: DetectedCommand[];
404
410
  canRunLongJobs?: boolean;