drafted 1.19.20 → 1.19.23

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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * What Drafted address does this working directory name?
3
+ *
4
+ * The MCP runs as a stdio process spawned in the user's repo. Until now it had
5
+ * no idea whether that repo was linked to a Drafted folder, so an agent working
6
+ * in a linked checkout would write to whatever org the session happened to
7
+ * inherit. The comment above `workingOrgId()` in server.mjs records where that
8
+ * ends: "trusting it is what wrote a Beoflow-bound agent's wiki page into
9
+ * Personal."
10
+ *
11
+ * Two rules shape this module:
12
+ *
13
+ * 1. It answers a PATH (/o/<org>/<folder>), never an orgId to stash. A stored
14
+ * org becomes an inherited default, which is the bug we are fixing, not a
15
+ * fix for it.
16
+ * 2. It does NOT normalize git URLs. Remotes go to the server raw and
17
+ * parseGitHubUrl (server/lib/repo-ingest.mjs) stays the single normalizer.
18
+ * A second normalizer that drifts from the first would reintroduce the
19
+ * split-master problem inside the fix itself.
20
+ *
21
+ * Side-effect free and network-free: probe() shells out to git, resolve() is
22
+ * pure. That split is what makes this testable without a repo or a server.
23
+ */
24
+
25
+ import { execFileSync } from 'node:child_process';
26
+ import { readFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ /** Run a git command in `cwd`, or null if git fails for any reason. Never
30
+ * throws: not-a-repo, no git binary, and a detached HEAD are all ordinary. */
31
+ function git(args, cwd) {
32
+ try {
33
+ const out = execFileSync('git', args, {
34
+ cwd,
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ timeout: 2000,
38
+ });
39
+ return out.trim() || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Read the git identity of a working directory.
47
+ *
48
+ * `--show-toplevel` is what makes a monorepo subdirectory and a worktree behave:
49
+ * any cwd inside the tree normalizes to the repo root, and a worktree reports
50
+ * its own root while sharing the remote.
51
+ *
52
+ * Every remote is returned, origin first. A fork whose `origin` Drafted has
53
+ * never seen may still have an `upstream` that is linked, and resolving that is
54
+ * strictly better than reporting nothing.
55
+ */
56
+ export function probeRepo(cwd = process.cwd()) {
57
+ const root = git(['rev-parse', '--show-toplevel'], cwd);
58
+ if (!root) return null;
59
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
60
+ const remotes = parseRemotes(git(['remote', '-v'], cwd));
61
+ return {
62
+ root,
63
+ // A detached HEAD reports the literal "HEAD"; that is not a branch name and
64
+ // must not be matched against repos.branch.
65
+ branch: branch && branch !== 'HEAD' ? branch : null,
66
+ remotes,
67
+ declared: readDeclaration(root),
68
+ };
69
+ }
70
+
71
+ /** `git remote -v` → [{ name, url }], deduped, origin first then upstream. */
72
+ export function parseRemotes(text) {
73
+ const seen = new Map();
74
+ for (const line of String(text || '').split('\n')) {
75
+ const m = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
76
+ if (m && !seen.has(m[1])) seen.set(m[1], m[2]);
77
+ }
78
+ const rank = (n) => (n === 'origin' ? 0 : n === 'upstream' ? 1 : 2);
79
+ return [...seen.entries()]
80
+ .map(([name, url]) => ({ name, url }))
81
+ .sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
82
+ }
83
+
84
+ /**
85
+ * The optional committed declaration, `<repo root>/.drafted/config.json`.
86
+ *
87
+ * This file SELECTS among candidates the server returned; it never grants
88
+ * access to anything. That distinction is the whole reason it is safe to
89
+ * commit: "which org is this repo?" is a per-USER question (membership differs
90
+ * per collaborator) and a committed file is a per-REPO answer. As a filter it
91
+ * is correct; as an authority it would be a way for a forked repo to point your
92
+ * session at someone else's org.
93
+ *
94
+ * Malformed or missing is not an error — it is the normal case.
95
+ */
96
+ export function readDeclaration(root) {
97
+ try {
98
+ const raw = readFileSync(join(root, '.drafted', 'config.json'), 'utf8');
99
+ const parsed = JSON.parse(raw);
100
+ if (!parsed || typeof parsed !== 'object') return null;
101
+ const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
102
+ const declared = { org: pick(parsed.org), folder: pick(parsed.folder), gitUrl: pick(parsed.gitUrl) };
103
+ return declared.org || declared.folder ? declared : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Turn the server's candidate list into one address, or an honest non-answer.
111
+ *
112
+ * States, and why each exists:
113
+ * none — not a git repo. Behaves exactly as today.
114
+ * unlinked — a remote exists and matched nothing. An EXPLICIT negative: a
115
+ * bare "not linked" is indistinguishable from "I failed to parse
116
+ * your remote", and that ambiguity is this feature's most likely
117
+ * silent failure, so the probed URLs are always echoed.
118
+ * linked — exactly one candidate, or the declaration picked one.
119
+ * ambiguous — several member orgs link this repo. Adopt NOTHING. This is the
120
+ * case where a wrong write is both most plausible and least
121
+ * detectable, so it is never guessed.
122
+ */
123
+ export function resolveAddress(probe, matches = []) {
124
+ if (!probe) return { state: 'none' };
125
+ const base = {
126
+ root: probe.root,
127
+ branch: probe.branch,
128
+ remote: probe.remotes[0]?.url ?? null,
129
+ };
130
+ if (!matches.length) {
131
+ return { ...base, state: 'unlinked', probed: probe.remotes.map((r) => r.url) };
132
+ }
133
+
134
+ const picked = matches.length === 1 ? matches[0] : selectByDeclaration(probe.declared, matches);
135
+ if (!picked) {
136
+ return { ...base, state: 'ambiguous', candidates: matches.map((m) => m.path) };
137
+ }
138
+
139
+ // The branch is reported, never enforced: reading a folder whose index tracks
140
+ // another branch is legitimate, it just has to be visible.
141
+ const onTrackedBranch = !probe.branch || picked.branch === probe.branch;
142
+ return {
143
+ ...base,
144
+ state: 'linked',
145
+ path: picked.path,
146
+ org: picked.orgName,
147
+ orgId: picked.orgId,
148
+ orgSlug: picked.orgSlug,
149
+ folder: picked.folderName,
150
+ trackedBranch: picked.branch,
151
+ onTrackedBranch,
152
+ viaDeclaration: matches.length > 1,
153
+ };
154
+ }
155
+
156
+ /** Match a declaration against candidates by org name/slug and folder name.
157
+ * Returns null unless it selects exactly one — an ambiguous declaration is no
158
+ * better than no declaration, and must not break the tie by accident. */
159
+ function selectByDeclaration(declared, matches) {
160
+ if (!declared) return null;
161
+ const eq = (a, b) => typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
162
+ const hits = matches.filter(
163
+ (m) =>
164
+ (!declared.org || eq(declared.org, m.orgName) || eq(declared.org, m.orgSlug)) &&
165
+ (!declared.folder || eq(declared.folder, m.folderName))
166
+ );
167
+ return hits.length === 1 ? hits[0] : null;
168
+ }
169
+
170
+ /** The prose an agent reads. Written to TEACH, like the 409 repo_owned gate:
171
+ * it names the address to use and the fact that other orgs stay reachable. */
172
+ export function addressNote(address, workingOrgId) {
173
+ if (!address || address.state === 'none') return null;
174
+ if (address.state === 'unknown') {
175
+ // Say WHICH question went unanswered. "Not linked" and "could not ask" look
176
+ // identical to an agent, and only one of them means it is safe to proceed.
177
+ return `This is a git checkout (${address.remote || 'no remote'}) but Drafted could not be asked whether it is linked (${address.reason}). Nothing is defaulted — address writes explicitly, and re-check with whoami once signed in.`;
178
+ }
179
+ if (address.state === 'unlinked') {
180
+ return `This checkout (${address.probed.join(', ') || 'no remote'}) is not linked to any Drafted folder you can see. Nothing is defaulted — address writes explicitly.`;
181
+ }
182
+ if (address.state === 'ambiguous') {
183
+ return `This checkout is linked in more than one org you belong to (${address.candidates.join(', ')}). Nothing is defaulted — pass org= or an absolute /o/<org>/... path.`;
184
+ }
185
+ const parts = [`Your working directory is the repo linked to ${address.path}.`];
186
+ if (workingOrgId && workingOrgId !== address.orgId) {
187
+ parts.push(`This session's working org is a DIFFERENT org — address work under ${address.path}/... unless the user asked for another org.`);
188
+ }
189
+ if (!address.onTrackedBranch) {
190
+ parts.push(`Note: that folder tracks branch ${address.trackedBranch} and you are on ${address.branch}, so its index describes another branch.`);
191
+ }
192
+ parts.push(`That folder's wiki and skills are git-owned: writes return 409 repo_owned and belong in a commit. Other orgs stay reachable with org= or /o/<org>/... .`);
193
+ return parts.join(' ');
194
+ }
195
+
196
+ // Self-check: parsing and resolution, no git and no network.
197
+ export function demo() {
198
+ const ok = (c, m) => { if (!c) throw new Error(m); };
199
+ const remotes = parseRemotes('upstream\tgit@github.com:a/b.git (fetch)\nupstream\tx (push)\norigin\thttps://github.com/c/d (fetch)');
200
+ ok(remotes[0].name === 'origin', 'origin ranks first');
201
+ ok(remotes.length === 2, 'deduped to two remotes');
202
+
203
+ ok(resolveAddress(null).state === 'none', 'no repo -> none');
204
+ const probe = { root: '/r', branch: 'main', remotes: [{ name: 'origin', url: 'u' }], declared: null };
205
+ ok(resolveAddress(probe, []).state === 'unlinked', 'no matches -> unlinked');
206
+
207
+ const one = { path: '/o/x/F', orgName: 'X', orgSlug: 'x', orgId: 'o1', folderName: 'F', branch: 'main' };
208
+ ok(resolveAddress(probe, [one]).state === 'linked', 'single match -> linked');
209
+
210
+ const two = { ...one, path: '/o/y/G', orgName: 'Y', orgSlug: 'y', orgId: 'o2', folderName: 'G' };
211
+ ok(resolveAddress(probe, [one, two]).state === 'ambiguous', 'two matches -> ambiguous');
212
+ ok(resolveAddress({ ...probe, declared: { org: 'Y' } }, [one, two]).path === '/o/y/G', 'declaration breaks the tie');
213
+ ok(resolveAddress({ ...probe, declared: { org: 'Z' } }, [one, two]).state === 'ambiguous', 'declaration naming a non-candidate grants nothing');
214
+ console.log('repo-address self-check OK');
215
+ }
package/mcp/server.mjs CHANGED
@@ -24,6 +24,7 @@ import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/ex
24
24
  import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
25
25
  import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
26
26
  import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
27
+ import { probeRepo, resolveAddress, addressNote } from './repo-address.mjs';
27
28
 
28
29
  // Frame actions that mutate content — gated by G1 (wiki search before editing).
29
30
  // Read-style actions (read, search, versions, get_*/read_*) are exempt.
@@ -398,7 +399,7 @@ const TOOL_ANNOTATIONS = {
398
399
  auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL immediately and starts background approval polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval.' },
399
400
 
400
401
  // Identity — read-only introspection of THIS agent's session
401
- whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not — never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
402
+ whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not — never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. PLUS `repo` — whether your WORKING DIRECTORY is a git checkout linked to a Drafted folder, and if so its address (`/o/<org>/<folder>`): when `repo.state` is "linked", address work under `repo.path` unless the user asked otherwise, and read `repo.note`. `workingOrg` reports the org that actually ADDRESSES your requests and where it came from (`switch`/`project`/`repo`/`none`) — this is NOT always the same as `orgId`, which is the server session\'s org. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
402
403
 
403
404
  // Session naming — the name-before-work gate: every agent session must set a short
404
405
  // name describing the work before any other tool call succeeds.
@@ -417,7 +418,7 @@ const TOOL_ANNOTATIONS = {
417
418
  minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: checklist-driven intake surfaces that guide a consumer through a checklist (via a shareable /c/<slug> link) and write a producible into the project. Dispatch by `action`: meta, list, get, create, update, enable, disable, delete. QA your own Minions with test_start/test_say/test_resolve — drive the checklist conversation yourself (works even when disabled). Requires the agent allowlist.' },
418
419
  trigger: { title: 'Inbound triggers', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Manage inbound webhook triggers for the ACTIVE PROJECT: an external system (AppSheet bot, GitHub, form tool) POSTs to the trigger URL and the server runs an agent conversation in the project from the stored prompt template + payload. Dispatch by `action`: create (returns URL + secret token ONCE — relay it to the user immediately, not retrievable later), list, update (enable/disable, edit template, daily limit, executor), rotate (new token), test (fire a synthetic delivery), deliveries (audit log), delete; for executor="queue" triggers, pending/claim/complete let a LOCAL agent poll and work queued deliveries. Requires the agent allowlist.' },
419
420
  fs: { title: 'Filesystem', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Navigate Drafted like a local filesystem: /wiki/<path> pages, /skills/<slug> procedures, /projects/<folder?>/<project>/<layer>/<lane>/<file> frames. Verbs: ls, read, write, edit, mv, rm, search, link, unlink, links.' },
420
- repo: { title: 'Git repos', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills; EVERY repo connected to ANY folder in the org is searchable and usable org-wide (no org-level repo — the union is the library). Skills from connected repos are readable via fs(read, path="/skills/<slug>") — fetched from git at read time, always fresh. Authoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo. Dispatch by `action`: list (paginated, compact mode), add (link a repo to a folder, --branch optional), rescan (re-fetch the tracked branch), entries (search the index). Content stays in git; Drafted keeps a read-only index.' },
421
+ repo: { title: 'Git repos', readOnlyHint: false, destructiveHint: true, openWorldHint: true, description: 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills; EVERY repo connected to ANY folder in the org is searchable and usable org-wide (no org-level repo — the union is the library). Skills from connected repos are readable via fs(read, path="/skills/<slug>") — fetched from git at read time, always fresh. Authoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo. Dispatch by `action`: list (paginated, compact mode), rescan (re-fetch the tracked branch), entries (search the index). Linking and unlinking are NOT here — a human does them in the Drafted UI. Content stays in git; Drafted keeps a read-only index.' },
421
422
  };
422
423
 
423
424
  function isMutatingToolCall(name, args = {}) {
@@ -434,6 +435,36 @@ function isMutatingToolCall(name, args = {}) {
434
435
  }
435
436
  }
436
437
 
438
+ // Refuse to guess the destination, ONCE, when a write would land outside the
439
+ // org this checkout is linked to.
440
+ //
441
+ // Shaped exactly like the name-before-work gate: it does not block, it teaches
442
+ // and then gets out of the way. Repeating the same call confirms the intent and
443
+ // the gate never fires again this process. Report-only was not enough — the
444
+ // failure being fixed IS an agent not consulting available context, so adding
445
+ // more context it may not read reproduces the bug with better documentation.
446
+ //
447
+ // Only fires when the agent named NO org: an explicit `org=` or an absolute
448
+ // /o/<org>/... path is a deliberate address and is never second-guessed.
449
+ let crossOrgWarned = false;
450
+ async function getCrossOrgWriteWarning(name, args = {}) {
451
+ if (crossOrgWarned || mcpMode() !== 'stdio') return null;
452
+ if (!isMutatingToolCall(name, args)) return null;
453
+ if (args?.org || String(args?.path || '').startsWith('/o/')) return null;
454
+
455
+ const address = await getRepoAddress();
456
+ if (address.state !== 'linked') return null;
457
+ const current = workingOrgId();
458
+ if (!current || current === address.orgId) return null;
459
+
460
+ crossOrgWarned = true;
461
+ return [
462
+ `Refusing to guess the destination — this write names no org.`,
463
+ `Your working directory is the repo linked to ${address.path} (org "${address.org}"), but this session addresses a different org (source: ${workingOrgSource()}).`,
464
+ `Re-issue with the full path — e.g. ${name}(path="${address.path}/wiki/<page>") — or repeat this exact call to confirm the current org is what you meant.`,
465
+ ].join(' ');
466
+ }
467
+
437
468
  async function getRequiredMcpUpdateError(name, args = {}) {
438
469
  if (mcpMode() !== 'stdio') return null;
439
470
  if (!isMutatingToolCall(name, args)) return null;
@@ -514,6 +545,8 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
514
545
  try {
515
546
  const requiredUpdateError = await getRequiredMcpUpdateError(name, args?.[0] || {});
516
547
  if (requiredUpdateError) return err(new Error(requiredUpdateError));
548
+ const crossOrgWarning = await getCrossOrgWriteWarning(name, args?.[0] || {});
549
+ if (crossOrgWarning) return err(new Error(crossOrgWarning));
517
550
  // Reverse the remote JSON-string encoding (see REMOTE_JSON_STRING_PARAMS)
518
551
  // so handlers receive the same object/array shapes they get on stdio.
519
552
  if (isRemote && args?.[0] && typeof args[0] === 'object') {
@@ -1072,7 +1105,29 @@ async function serverFetch(url, opts = {}) {
1072
1105
  // landed, so the two can never disagree.
1073
1106
  function workingOrgId() {
1074
1107
  const session = getSessionState();
1075
- return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
1108
+ return session.boundOrgId
1109
+ || session.activeProjectMeta?.orgId
1110
+ || getState().projectMeta?.orgId
1111
+ // Lowest rung, and only when the linked repo is UNAMBIGUOUS. It can only
1112
+ // ever replace null — every explicit act above outranks it, so detection
1113
+ // can never trap a session or retarget deliberate work. Null today falls
1114
+ // through to the server session's inherited org, which is the least
1115
+ // justified value in the chain: the user chose this checkout and linked
1116
+ // this repo; nobody chose the inherited org.
1117
+ || session.repoOrgId
1118
+ || null;
1119
+ }
1120
+
1121
+ /** Source of the value workingOrgId() returned, for echoing in whoami. Without
1122
+ * this, whoami reports the SERVER SESSION's org while writes are addressed by
1123
+ * workingOrgId() — two different values, which is how whoami can confidently
1124
+ * name an org a write will not go to. */
1125
+ function workingOrgSource() {
1126
+ const session = getSessionState();
1127
+ if (session.boundOrgId) return 'switch';
1128
+ if (session.activeProjectMeta?.orgId || getState().projectMeta?.orgId) return 'project';
1129
+ if (session.repoOrgId) return 'repo';
1130
+ return 'none';
1076
1131
  }
1077
1132
 
1078
1133
  // One `ls` level of folders, from the org's folder list. Defined inside the
@@ -1127,7 +1182,10 @@ async function api(method, path, body, extraHeaders = {}, _retried = false, _org
1127
1182
  }
1128
1183
  const boundOrg = workingOrgId();
1129
1184
  const hasExplicitOrg = Object.keys(headers).some((k) => k.toLowerCase() === 'x-drafted-org');
1130
- if (boundOrg && !hasExplicitOrg && !path.startsWith('/auth/')) {
1185
+ // /api/repos/resolve is exempt: it asks which org a WORKING DIRECTORY belongs
1186
+ // to, so scoping it by the session's current org would answer a different
1187
+ // question — and the session's org is exactly what it exists to correct.
1188
+ if (boundOrg && !hasExplicitOrg && !path.startsWith('/auth/') && !path.startsWith('/api/repos/resolve')) {
1131
1189
  headers['X-Drafted-Org'] = boundOrg;
1132
1190
  }
1133
1191
  const opts = { method, headers };
@@ -2338,17 +2396,56 @@ async function getGoogleDriveAvailability() {
2338
2396
  }
2339
2397
  }
2340
2398
 
2399
+ // ── Repo address: which Drafted folder is this working directory? ──
2400
+ //
2401
+ // The MCP is spawned in the user's repo but never knew whether that repo was
2402
+ // linked, so an agent in a linked checkout wrote to whatever org the session
2403
+ // inherited — see the comment above workingOrgId(). This resolves the cwd to a
2404
+ // PATH (/o/<org>/<folder>), which is an address the agent can use, rather than
2405
+ // an orgId to stash, which would just be a better-sourced inherited default.
2406
+ //
2407
+ // Cached for the process: the stdio MCP has exactly one cwd, fixed at spawn.
2408
+ let repoAddressCache;
2409
+ async function getRepoAddress() {
2410
+ if (repoAddressCache !== undefined) return repoAddressCache;
2411
+ const probe = probeRepo();
2412
+ // `none` means EXACTLY one thing: this is not a git checkout. It must never
2413
+ // be the catch-all, or "I could not reach the server" is reported as "not a
2414
+ // repo" — a confident wrong negative, which is worse than no detection.
2415
+ if (!probe) return (repoAddressCache = { state: 'none' });
2416
+ try {
2417
+ const params = new URLSearchParams();
2418
+ for (const r of probe.remotes) params.append('gitUrl', r.url);
2419
+ if (probe.branch) params.set('branch', probe.branch);
2420
+ // Cross-org by design, so it must NOT carry this session's org header —
2421
+ // that is the very context we are trying to correct.
2422
+ const out = await api('GET', `/api/repos/resolve?${params}`);
2423
+ return (repoAddressCache = resolveAddress(probe, out?.matches || []));
2424
+ } catch (e) {
2425
+ // Detection never breaks a session — but it does not lie about why either,
2426
+ // and it is NOT cached: an unauthenticated boot must re-ask once signed in.
2427
+ return { state: 'unknown', root: probe.root, branch: probe.branch,
2428
+ remote: probe.remotes[0]?.url ?? null, reason: e?.message || 'lookup failed' };
2429
+ }
2430
+ }
2431
+
2341
2432
  // Identity + server health: report THIS agent session's own surface identity, server
2342
2433
  // reachability, and installed-MCP staleness in ONE bootstrap call. The update data is
2343
2434
  // cached (5min), so repeat `whoami` calls are free; the server-side update gate still
2344
2435
  // blocks mutating calls on its own, independent of this tool. Read-only — no state changed.
2345
- tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not — never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only.', {}, async () => {
2436
+ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state (`authState` says whether this session is signed in, and `authNote` names the exact call that fixes it when it is not — never diagnose a null userId yourself) — PLUS server health, the installed MCP version/update status (cached ~5min), and `googleDrive` — whether the working org has Google Drive connected: when `googleDrive.connected` is true, strongly prefer Google Workspace frames (.google-doc/.google-sheet/.google-slide) for docs, sheets, and decks; when false they cannot be created at all. PLUS `repo` — whether your WORKING DIRECTORY is a git checkout linked to a Drafted folder, and if so its address (`/o/<org>/<folder>`): when `repo.state` is "linked", address work under `repo.path` unless the user asked otherwise, and read `repo.note`. `workingOrg` reports the org that actually ADDRESSES your requests and where it came from (`switch`/`project`/`repo`/`none`) — this is NOT always the same as `orgId`, which is the server session\'s org. Call once per session, right after starting, so a required update surfaces before you act on stale tool behavior. Read-only.', {}, async () => {
2346
2437
  try {
2347
2438
  // Ensure the child clone exists BEFORE reading identity — otherwise the /auth/me
2348
2439
  // fallback (pre-WS-ack) queries the ROOT session and reports the wrong naming state.
2349
2440
  await ensureSession();
2350
2441
  const block = await sessionSurfaceBlock();
2351
- const [mcpUpdate, googleDrive] = await Promise.all([getCachedMcpUpdateMetadata(), getGoogleDriveAvailability()]);
2442
+ const [mcpUpdate, googleDrive, repoAddress] = await Promise.all([
2443
+ getCachedMcpUpdateMetadata(), getGoogleDriveAvailability(), getRepoAddress(),
2444
+ ]);
2445
+ // Adopt an unambiguous linked repo as the lowest-precedence org. Done here
2446
+ // rather than at connect because whoami is the bootstrap call every agent
2447
+ // makes, and adopting can only ever fill a null (see workingOrgId).
2448
+ if (repoAddress.state === 'linked') getSessionState().repoOrgId = repoAddress.orgId;
2352
2449
  // Tell the agent to actually surface its name to the user — returning `name` in the JSON isn't
2353
2450
  // enough; without an explicit instruction agents rarely say which session they are, so users
2354
2451
  // can't match them to their tab on the Drafted surface.
@@ -2377,6 +2474,10 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
2377
2474
  editor: (process.env.DRAFTED_AGENT_NAME || '').trim() || null,
2378
2475
  agentLabel: getAgentLabel(),
2379
2476
  googleDrive,
2477
+ repo: { ...repoAddress, note: addressNote(repoAddress, workingOrgId()) },
2478
+ // Echo the org that ADDRESSES requests, not the one the server session
2479
+ // happens to hold — `block.orgId` is the latter, and the two can differ.
2480
+ workingOrg: { orgId: workingOrgId(), source: workingOrgSource() },
2380
2481
  ...block,
2381
2482
  ...(instruction ? { instruction } : {}),
2382
2483
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.19.20",
3
+ "version": "1.19.23",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [