@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.
- package/dist/commands/router.d.ts +37 -0
- package/dist/index.js +209 -14
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +209 -14
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-work-queue.d.ts +1 -0
- package/dist/repo-mesh-types.d.ts +6 -0
- package/package.json +2 -2
- package/src/commands/router.ts +153 -1
- package/src/mesh/mesh-work-queue.ts +201 -9
- package/src/repo-mesh-types.ts +6 -0
|
@@ -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.
|
|
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.
|
|
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",
|
package/src/commands/router.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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=${
|
|
220
|
-
`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
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -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;
|