deepdebug-local-agent 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/docs/la-chat.md +35 -0
- package/package.json +1 -1
- package/src/dlp/rules.js +2 -2
- package/src/la-protocol-v2/chat/config.js +60 -0
- package/src/la-protocol-v2/chat/cursors.js +57 -0
- package/src/la-protocol-v2/chat/dlp.js +92 -0
- package/src/la-protocol-v2/chat/index.js +28 -0
- package/src/la-protocol-v2/chat/store.js +337 -0
- package/src/la-protocol-v2/chat/stream-source.js +23 -0
- package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
- package/src/la-protocol-v2/content-handlers/handler.js +10 -2
- package/src/la-protocol-v2/content-handlers/install.js +2 -2
- package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
- package/src/la-protocol-v2/contract/README.md +42 -0
- package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
- package/src/la-protocol-v2/contract/index.json +1 -1
- package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
- package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
- package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
- package/src/la-protocol-v2/jobs/git-exec.js +5 -4
- package/src/la-protocol-v2/jobs/manager.js +4 -2
- package/src/la-protocol-v2/jobs/workspace-materializer.js +35 -12
- package/src/la-protocol-v2/runtime.js +32 -3
- package/src/workspace-manager.js +3 -2
- package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
- package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
- package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
- package/test/la-protocol-v2/chat-la.test.js +490 -0
- package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
- package/test/la-protocol-v2/chat-store.test.js +301 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +6 -6
- package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
- package/test/la-protocol-v2/traces.test.js +3 -3
- package/test/la-protocol-v2/workspace-materializer-hostile.test.js +105 -0
|
@@ -41,7 +41,7 @@ export function jobErrorCodeOf(err) {
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Runner interface (F3-08c plugs the packaged agent loop in here):
|
|
44
|
-
* { name: string, kinds?: string[] (default: all of fix|implement|quality), needsLlm?: boolean (default true),
|
|
44
|
+
* { name: string, kinds?: string[] (default: all of fix|implement|quality; `chat` only when the runner lists it: contract 2.3, capability job.chat), needsLlm?: boolean (default true),
|
|
45
45
|
* start(job, ctx): Promise<{ outcome: "pr_opened"|"no_change"|"failed"|"cancelled", errorCode?, prUrl?, branch?, commitSha?, filesChangedCount?,
|
|
46
46
|
* diffSha256?, tests?, answer? }>,
|
|
47
47
|
* cancel(jobId): void|Promise<void> }
|
|
@@ -54,7 +54,7 @@ export function jobErrorCodeOf(err) {
|
|
|
54
54
|
*/
|
|
55
55
|
export function validateRunner(r) {
|
|
56
56
|
return !!r && typeof r === "object" && typeof r.name === "string" && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(r.name) && typeof r.start === "function" && typeof r.cancel === "function"
|
|
57
|
-
&& (r.kinds === undefined || (Array.isArray(r.kinds) && r.kinds.every((k) => ["fix", "implement", "quality"].includes(k))))
|
|
57
|
+
&& (r.kinds === undefined || (Array.isArray(r.kinds) && r.kinds.every((k) => ["fix", "implement", "quality", "chat"].includes(k))))
|
|
58
58
|
&& (r.supportsOptions === undefined || typeof r.supportsOptions === "boolean");
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -206,6 +206,8 @@ export function createJobManager({ session, tenantId, resolveWorkspace, material
|
|
|
206
206
|
stats, audit: auditor, events: ev,
|
|
207
207
|
get runner() { return theRunner; },
|
|
208
208
|
get activeCount() { return active.size; },
|
|
209
|
+
/** LA-3: true while a chat job with that id is queued, being prepared (workspace clone) or running: the ChatStore waits for such a job to appear instead of saying `not_found`. */
|
|
210
|
+
isActiveChat(jobId) { const rec = active.get(jobId); return !!rec && rec.kind === "chat" && !rec.finished; },
|
|
209
211
|
get running() { return running; },
|
|
210
212
|
get reservedBytes() { return reserved; },
|
|
211
213
|
/** F3-08c: the packaged agent loop replaces the runner (jobs already running keep the one they started with only as far as their own cancel). */
|
|
@@ -88,7 +88,7 @@ export function resolveMaterializeConfig(env = process.env, warn = console.warn)
|
|
|
88
88
|
/** Validates a repoRef and builds { host, org, repo, branch, key, url } or null. The url has NO credentials and is only https. */
|
|
89
89
|
export function repoTarget(repoRef) {
|
|
90
90
|
if (!repoRef || typeof repoRef !== "object") return null;
|
|
91
|
-
const host = HOSTS[repoRef.provider];
|
|
91
|
+
const host = typeof repoRef.provider === "string" && Object.hasOwn(HOSTS, repoRef.provider) ? HOSTS[repoRef.provider] : null;
|
|
92
92
|
const { org, repo, branch } = repoRef;
|
|
93
93
|
if (!host || typeof org !== "string" || typeof repo !== "string" || typeof branch !== "string") return null;
|
|
94
94
|
if (!SEG.test(org) || !SEG.test(repo) || org === "." || org === ".." || repo === "." || repo === "..") return null;
|
|
@@ -128,7 +128,7 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
128
128
|
const tenant = String(tenantId).toLowerCase();
|
|
129
129
|
const git = seams.runGit || runGit;
|
|
130
130
|
const stats = { admitted: 0, refused: {}, cloned: 0, reused: 0, failed: {} };
|
|
131
|
-
const inflight = new Map(); // workspaceId -> Promise<{root}
|
|
131
|
+
const inflight = new Map(); // workspaceId -> { p:Promise<{root}>, ctl, waiters }
|
|
132
132
|
let chain = Promise.resolve(); // one clone/fetch at a time on this agent
|
|
133
133
|
const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
|
|
134
134
|
const refuse = (why, extra) => { stats.refused[why] = (stats.refused[why] || 0) + 1; note("workspace_refused", { why, ...extra }); return null; };
|
|
@@ -179,7 +179,7 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
179
179
|
if (!tokenUsable()) fail("credential_unavailable");
|
|
180
180
|
try { askpass = ensureAskpassScript(root); } catch { fail("cache_unavailable"); }
|
|
181
181
|
}
|
|
182
|
-
const env2 = gitEnv(env, { askpass, tokenFile: config.tokenFile });
|
|
182
|
+
const env2 = { ...gitEnv(env, { askpass, tokenFile: config.tokenFile, host: t.host }), GIT_CEILING_DIRECTORIES: root }; // git never discovers a repository above the cache
|
|
183
183
|
const extraConfig = seams.gitConfig || [];
|
|
184
184
|
const remote = seams.remoteUrl ? seams.remoteUrl(t) : t.url;
|
|
185
185
|
let st = null;
|
|
@@ -189,6 +189,9 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
189
189
|
const sc = readWorkspaceSidecar(dir);
|
|
190
190
|
if (!sc || sc.id !== workspaceId) fail("foreign_directory");
|
|
191
191
|
if (!sc.tenantId || sc.tenantId.toLowerCase() !== tenant) fail("tenant_mismatch");
|
|
192
|
+
let gitSt = null;
|
|
193
|
+
try { gitSt = fs.lstatSync(path.join(dir, ".git")); } catch { gitSt = null; }
|
|
194
|
+
if (!gitSt || !gitSt.isDirectory()) fail("not_a_clone"); // a multi-repo parent or non-git workspace: never fetch/checkout/merge in whatever repo is above it
|
|
192
195
|
// existing clone: fetch the branch; move only a CLEAN tree (the previous job's local work is never reset)
|
|
193
196
|
const f = await git(["fetch", "--depth", String(config.depth), "--no-tags", "--", "origin", t.branch], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
194
197
|
if (!f.ok) fail(f.code === "failed" ? "fetch_failed" : f.code);
|
|
@@ -202,10 +205,21 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
202
205
|
const tmp = path.join(root, `.tmp-${workspaceId}-${crypto.randomBytes(4).toString("hex")}`);
|
|
203
206
|
let ok = false;
|
|
204
207
|
try {
|
|
205
|
-
|
|
208
|
+
// the size limit is enforced WHILE cloning (not only after): a watcher aborts git as soon as the partial clone passes the limit
|
|
209
|
+
const guard = new AbortController();
|
|
210
|
+
const onAbort = () => guard.abort();
|
|
211
|
+
if (signal) { if (signal.aborted) guard.abort(); else signal.addEventListener("abort", onAbort, { once: true }); }
|
|
212
|
+
let over = false;
|
|
213
|
+
const watch = setInterval(() => { if (dirBytes(tmp, config.maxBytes) > config.maxBytes) { over = true; guard.abort(); } }, 1000);
|
|
214
|
+
let c;
|
|
215
|
+
try { c = await git(["clone", "--branch", t.branch, "--single-branch", "--depth", String(config.depth), "--no-tags", "--", remote, tmp], { env: env2, cwd: root, timeoutMs: config.cloneTimeoutMs, signal: guard.signal, extraConfig }); }
|
|
216
|
+
finally { clearInterval(watch); if (signal) signal.removeEventListener("abort", onAbort); }
|
|
217
|
+
if (over) fail("too_large");
|
|
206
218
|
if (!c.ok) fail(c.code === "failed" ? "clone_failed" : c.code);
|
|
207
219
|
if (dirBytes(tmp, config.maxBytes) > config.maxBytes) fail("too_large");
|
|
208
220
|
// the remote of the clone is the credential-free URL we built; nothing else was ever written to its config
|
|
221
|
+
// a repository may ship names that wsManager.open() writes through (a symlink at `.vh-workspace.json*`): drop them before registering
|
|
222
|
+
for (const n of fs.readdirSync(tmp)) if (n.startsWith(".vh-workspace.json")) fs.rmSync(path.join(tmp, n), { recursive: true, force: true });
|
|
209
223
|
fs.renameSync(tmp, dir);
|
|
210
224
|
ok = true;
|
|
211
225
|
} finally {
|
|
@@ -231,14 +245,23 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
231
245
|
* One clone at a time; a second call for the same id shares the first one's promise.
|
|
232
246
|
*/
|
|
233
247
|
ensure(workspaceId, repoRef, signal) {
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
let shared = inflight.get(workspaceId);
|
|
249
|
+
if (!shared) {
|
|
250
|
+
// the clone has its OWN controller: it is aborted only when EVERY job waiting for it has been cancelled
|
|
251
|
+
const ctl = new AbortController();
|
|
252
|
+
const p = chain.then(() => { if (ctl.signal.aborted) throw new MaterializeError("aborted"); return doEnsure(workspaceId, repoRef, ctl.signal); });
|
|
253
|
+
chain = p.catch(() => {});
|
|
254
|
+
shared = { p, ctl, waiters: 0 };
|
|
255
|
+
inflight.set(workspaceId, shared);
|
|
256
|
+
const clear = () => { if (inflight.get(workspaceId) === shared) inflight.delete(workspaceId); };
|
|
257
|
+
p.then(clear, clear);
|
|
258
|
+
}
|
|
259
|
+
shared.waiters++;
|
|
260
|
+
if (signal) {
|
|
261
|
+
const left = () => { if (--shared.waiters <= 0) shared.ctl.abort(); };
|
|
262
|
+
if (signal.aborted) left(); else signal.addEventListener("abort", left, { once: true });
|
|
263
|
+
}
|
|
264
|
+
return shared.p;
|
|
242
265
|
},
|
|
243
266
|
};
|
|
244
267
|
}
|
|
@@ -13,6 +13,7 @@ import { resolveWebhookConfig } from "./webhooks/config.js";
|
|
|
13
13
|
import { resolveLlmClientConfig } from "./llm/config.js";
|
|
14
14
|
import { resolveJobsConfig } from "./jobs/config.js";
|
|
15
15
|
import { loadContract } from "./contract.js";
|
|
16
|
+
import { resolveChatConfig, chatRequested } from "./chat/config.js";
|
|
16
17
|
|
|
17
18
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
19
|
const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
|
|
@@ -86,6 +87,10 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
86
87
|
// ./jobs/agent2 is loaded. It needs jobs, the LLM client (agent2 reaches the model ONLY through this agent: DLP + key holder) and DLP enforce; when one is
|
|
87
88
|
// missing the job receiver is NOT installed (never silently another runner).
|
|
88
89
|
let agent2Runner = null;
|
|
90
|
+
// LA-1/LA-3 (contract 2.3, LOCAL_AGENT_CHAT, default off): chat on the Local Agent. It rides on the agent2 runner and on the content handlers (job.stream / chat.session are
|
|
91
|
+
// served by them), so it exists only when both do; otherwise it says why and stays off (never a half-working chat: `job.chat` is announced only by a complete one).
|
|
92
|
+
let chat = null;
|
|
93
|
+
let chatReported = false;
|
|
89
94
|
if (String(env.LOCAL_AGENT_RUNNER ?? "").trim().toLowerCase() === "agent2") {
|
|
90
95
|
const a2 = await import("./jobs/agent2/config.js");
|
|
91
96
|
const a2cfg = a2.resolveAgent2RunnerConfig(env, (m) => log.warn(m));
|
|
@@ -97,12 +102,23 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
97
102
|
else {
|
|
98
103
|
const { createAgent2Runner } = await import("./jobs/agent2/runner.js");
|
|
99
104
|
const { makeWorkspaceResolver } = await import("./jobs/install.js");
|
|
105
|
+
const chatCfg = resolveChatConfig(env, (m) => log.warn(m));
|
|
106
|
+
if (chatCfg.enabled) {
|
|
107
|
+
chatReported = true;
|
|
108
|
+
if (!contentCfg.enabled) err("[la-chat] ERROR: LOCAL_AGENT_CHAT=true but the content handlers are not on (LOCAL_AGENT_CONTENT_HANDLERS=true and LOCAL_AGENT_DLP_MODE=enforce): nothing could serve job.stream / chat.session. The chat stays off");
|
|
109
|
+
else {
|
|
110
|
+
const { createChat } = await import("./chat/index.js");
|
|
111
|
+
try { chat = createChat({ config: chatCfg, tenantId: tenant, env, log, isActive: (id) => !!(link.jobs && link.jobs.isActiveChat(id)) }); } catch { err("[la-chat] ERROR: the chat store could not be created (LOCAL_AGENT_CHAT_DIR): the chat stays off"); }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
a2cfg.chat = !!chat;
|
|
100
115
|
const resolveWs = makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId: tenant });
|
|
101
|
-
agent2Runner = createAgent2Runner({ config: a2cfg, tenantId: tenant, resolveWorkspaceRoot: (id) => resolveWs(id)?.root || null, getSession: () => link.session, signing, env, log });
|
|
116
|
+
agent2Runner = createAgent2Runner({ config: a2cfg, tenantId: tenant, resolveWorkspaceRoot: (id) => resolveWs(id)?.root || null, getSession: () => link.session, signing, env, log, chat });
|
|
102
117
|
jobRunner = agent2Runner;
|
|
103
118
|
if (jobsCfg.maxConcurrent !== 1) { log.warn("[la-agent2] agent2 runs one job at a time: LOCAL_AGENT_JOBS_MAX_CONCURRENT is forced to 1"); jobsCfg = { ...jobsCfg, maxConcurrent: 1 }; }
|
|
104
119
|
}
|
|
105
120
|
}
|
|
121
|
+
if (chatRequested(env) && !chat && !chatReported) err("[la-chat] ERROR: LOCAL_AGENT_CHAT=true needs LOCAL_AGENT_JOBS, LOCAL_AGENT_RUNNER=agent2 (with the LLM client and DLP enforce) and the content handlers: the chat stays off");
|
|
106
122
|
if (jobsCfg.enabled) {
|
|
107
123
|
const { validateRunner } = await import("./jobs/manager.js");
|
|
108
124
|
const given = jobRunner && validateRunner(jobRunner) ? jobRunner : null;
|
|
@@ -138,7 +154,10 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
138
154
|
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...policyCaps, ...v21Caps],
|
|
139
155
|
// contract 2.1 (e): `llm.key.held` is a snapshot taken at every connect (register and hello alike): announced only while the holder has a
|
|
140
156
|
// valid, unexpired key (a revoked or expired one is not announced from the next connection on). `link` is assigned below, before any attach.
|
|
141
|
-
|
|
157
|
+
// contract 2.3: `job.chat` (+ `job.inline.16k`, a prompt of 16 KiB) only from a complete chat: the store, the content handlers, the agent2 runner and an agent2 whose /health
|
|
158
|
+
// listed `chat` (probeChat below). The CP sends kind:"chat" and a long inline input only to a session that announced them.
|
|
159
|
+
dynamicCapabilities: () => [...(llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
|
|
160
|
+
...(chat && agent2Runner && runner === agent2Runner && agent2Runner.chatReady ? ["job.chat", "job.inline.16k"] : [])],
|
|
142
161
|
getWorkspaces: () => {
|
|
143
162
|
const open = (wsManager?.list?.() || []).map((w) => ({
|
|
144
163
|
workspaceId: String(w.id || ""),
|
|
@@ -158,7 +177,12 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
158
177
|
});
|
|
159
178
|
if (contentCfg.enabled) {
|
|
160
179
|
const { installContentHandlers } = await import("./content-handlers/install.js");
|
|
161
|
-
installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log });
|
|
180
|
+
installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log, chat: chat ? chat.content : null });
|
|
181
|
+
}
|
|
182
|
+
if (chat) {
|
|
183
|
+
link.chat = chat;
|
|
184
|
+
const closeBase = link.close.bind(link);
|
|
185
|
+
link.close = () => { chat.close(); closeBase(); };
|
|
162
186
|
}
|
|
163
187
|
if (llmCfg.enabled) {
|
|
164
188
|
const { installLlmKey } = await import("./llm-key/install.js");
|
|
@@ -182,6 +206,11 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
182
206
|
link.close = () => { closeJobs(); agent2Runner.close().catch(() => {}); };
|
|
183
207
|
}
|
|
184
208
|
}
|
|
209
|
+
if (chat && agent2Runner && runner === agent2Runner) {
|
|
210
|
+
// which kinds does agent2 run? (/health). Waits at most LOCAL_AGENT_CHAT_PROBE_MS so the first register already says `job.chat`; in spawn mode this starts agent2 now
|
|
211
|
+
const probe = agent2Runner.probeChat();
|
|
212
|
+
if (chat.config.probeWaitMs > 0) await Promise.race([probe, new Promise((r) => { const t = setTimeout(r, chat.config.probeWaitMs); if (t.unref) t.unref(); })]);
|
|
213
|
+
}
|
|
185
214
|
let webhookSource = null;
|
|
186
215
|
if (webhookCfg.enabled) {
|
|
187
216
|
const { createWebhookSource } = await import("../incident-detector/sources/webhook-source.js");
|
package/src/workspace-manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import crypto from "crypto";
|
|
3
4
|
import { exists, listRecursive, readFile, stat } from "./fs-utils.js";
|
|
4
5
|
import { WORKSPACE_SIDECAR, readWorkspaceSidecar } from "./utils/open-gate.js";
|
|
5
6
|
import { detectProject } from "./detectors.js";
|
|
@@ -87,13 +88,13 @@ export class WorkspaceManager {
|
|
|
87
88
|
try {
|
|
88
89
|
// Atomic (temp + rename) and owner-only: the sidecar's tenantId is
|
|
89
90
|
// read by ROOT-RECOVERY, so it must never be observed half-written.
|
|
90
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
91
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
|
|
91
92
|
fs.writeFileSync(tmp, JSON.stringify({
|
|
92
93
|
id: state.id,
|
|
93
94
|
tenantId: state.tenantId || null,
|
|
94
95
|
root: state.root,
|
|
95
96
|
openedAt: state.openedAt
|
|
96
|
-
}, null, 2) + "\n", { mode: 0o600 });
|
|
97
|
+
}, null, 2) + "\n", { mode: 0o600, flag: "wx" }); // wx: never write through a pre-existing file/symlink
|
|
97
98
|
fs.renameSync(tmp, file);
|
|
98
99
|
// Single-repo clones ARE git repos and the PR path runs `git add -A`:
|
|
99
100
|
// keep the sidecar out of git via .git/info/exclude (never touches
|