@kendoo.agentdesk/agentdesk 0.26.0 → 0.28.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/bin/agentdesk.mjs +35 -45
  3. package/cli/agents.mjs +4 -256
  4. package/cli/bootstrap.mjs +40 -59
  5. package/cli/config.mjs +29 -4
  6. package/cli/daemon.mjs +148 -66
  7. package/cli/dotenv.mjs +96 -13
  8. package/cli/engine/agents/index.mjs +151 -0
  9. package/cli/engine/claude-auth.mjs +72 -0
  10. package/cli/engine/env.mjs +56 -0
  11. package/cli/engine/events.mjs +214 -0
  12. package/cli/engine/hooks.mjs +112 -0
  13. package/cli/engine/phases/EXECUTION.md +45 -0
  14. package/cli/engine/phases/INTAKE.md +34 -0
  15. package/cli/engine/phases/PLAN.md +26 -0
  16. package/cli/engine/phases/REVIEW.md +21 -0
  17. package/cli/engine/phases/SOLO.md +115 -0
  18. package/cli/engine/phases/SUMMARY.md +23 -0
  19. package/cli/engine/prompts.mjs +181 -0
  20. package/cli/engine/query.mjs +63 -0
  21. package/cli/engine/schemas.mjs +180 -0
  22. package/cli/engine/session.mjs +285 -0
  23. package/cli/engine/spawn.mjs +83 -0
  24. package/cli/engine/tracker/github.md +19 -0
  25. package/cli/engine/tracker/jira.md +23 -0
  26. package/cli/engine/tracker/linear.md +24 -0
  27. package/cli/engine/verdict.mjs +83 -0
  28. package/cli/init.mjs +295 -149
  29. package/cli/login.mjs +52 -6
  30. package/cli/phase-loop.mjs +78 -0
  31. package/cli/proc.mjs +131 -0
  32. package/cli/project-key.mjs +56 -0
  33. package/cli/projects.mjs +41 -6
  34. package/cli/prompt.mjs +9 -503
  35. package/cli/prompts.mjs +20 -1
  36. package/cli/security-check.mjs +1 -1
  37. package/cli/session-isolation.mjs +65 -9
  38. package/cli/session-sandbox.mjs +13 -1
  39. package/cli/setup-helpers.mjs +83 -36
  40. package/cli/team.mjs +41 -34
  41. package/cli/tracker-check.mjs +12 -2
  42. package/cli/tracker-project.mjs +93 -0
  43. package/cli/update-check.mjs +62 -0
  44. package/package.json +12 -3
  45. package/cli/orchestrator.mjs +0 -461
  46. package/cli/stream-parser.mjs +0 -216
  47. package/prompts/phased.md +0 -549
  48. package/prompts/team.md +0 -505
package/cli/init.mjs CHANGED
@@ -20,14 +20,18 @@
20
20
  import { existsSync, readFileSync, writeFileSync } from "fs";
21
21
  import { join } from "path";
22
22
  import { loadConfig, pushConfig } from "./config.mjs";
23
- import { getStoredApiKey } from "./login.mjs";
23
+ import { deriveProjectKey, resolveProjectKey } from "./project-key.mjs";
24
+ import { getTrackerProject, setTrackerProject, resolveTrackerProject, trackerProjectNoun } from "./tracker-project.mjs";
25
+ import { getStoredApiKey, ensureAccountIdentity } from "./login.mjs";
24
26
  import { registerLocalProject } from "./projects.mjs";
25
27
  import { checkTrackerPermissions } from "./tracker-check.mjs";
26
28
  import { autoMatchProject } from "./bootstrap.mjs";
27
- import { select as promptSelect, ask, promptRequired } from "./prompts.mjs";
29
+ import { select as promptSelect, ask, promptRequired, promptSecret } from "./prompts.mjs";
28
30
  import {
29
31
  cloneRepoWithToken,
30
32
  detectGitRepo,
33
+ findExistingClone,
34
+ ensureGitignored,
31
35
  fetchGitHubLogin,
32
36
  loadDotEnv,
33
37
  parseGitHubRef,
@@ -35,10 +39,87 @@ import {
35
39
  readLocalConfig,
36
40
  runAccessChecks,
37
41
  saveEnvVar,
42
+ stripRemoteCredentials,
38
43
  } from "./setup-helpers.mjs";
39
44
 
40
45
  const SERVER = process.env.AGENTDESK_SERVER || "https://agentdesk.live";
41
46
 
47
+ // ---------- Credential prompts ----------
48
+
49
+ // Prompt for a GitHub token and verify it before accepting. Returns
50
+ // { githubToken, githubLogin } or { cancelled: true }.
51
+ //
52
+ // The old flow saved the token to .env *before* checking it and, on failure,
53
+ // printed "continuing anyway" — so a mistyped token was persisted and the
54
+ // user only found out when the first session failed to push.
55
+ async function promptGitHubTokenVerified({ ownerRepo }) {
56
+ const repoHint = ownerRepo ? ` (needed to push to ${ownerRepo})` : "";
57
+ console.log(` GitHub token${repoHint} — create one at https://github.com/settings/tokens, scope: repo`);
58
+ while (true) {
59
+ const token = await promptSecret("GitHub token");
60
+ const login = await fetchGitHubLogin(token);
61
+ if (login) {
62
+ console.log(` ✓ Token authenticates as @${login}`);
63
+ return { githubToken: token, githubLogin: login };
64
+ }
65
+ console.log(" ✗ Token did not authenticate with GitHub.");
66
+ const next = await promptSelect({
67
+ message: "What now?",
68
+ choices: [
69
+ { name: "Re-enter the token", value: "retry" },
70
+ { name: "Use it anyway (couldn't verify — maybe offline)", value: "use" },
71
+ { name: "Cancel", value: "cancel" },
72
+ ],
73
+ });
74
+ if (next === "cancel") return { cancelled: true };
75
+ if (next === "use") return { githubToken: token, githubLogin: null };
76
+ }
77
+ }
78
+
79
+ // Verify tracker credentials, offering re-entry on failure. Same loop for new
80
+ // and existing projects — existing mode used to print "failed" and carry on,
81
+ // leaving broken credentials saved with no path to fix them in the wizard.
82
+ //
83
+ // Returns { status: "ok" | "skipped" | "cancelled", creds, identity? }.
84
+ async function verifyTrackerWithRetry({ tracker, config, cwd, creds }) {
85
+ const current = { ...creds };
86
+ process.stdout.write(" Verifying tracker access... ");
87
+ while (true) {
88
+ // Credentials only: the team/project is resolved right after this loop.
89
+ const check = await checkTrackerPermissions({ tracker, config, credentials: current, requireProject: false });
90
+ if (check.ok) {
91
+ console.log("✓");
92
+ if (check.identity?.name || check.identity?.email) {
93
+ console.log(` ✓ Posting as: ${check.identity.name || check.identity.email}`);
94
+ }
95
+ console.log("");
96
+ return { status: "ok", creds: current, identity: check.identity };
97
+ }
98
+ console.log("failed");
99
+ for (const e of check.errors || []) console.log(` • ${e}`);
100
+ const next = await promptSelect({
101
+ message: "What now?",
102
+ choices: [
103
+ { name: "Re-enter credentials and try again", value: "retry" },
104
+ { name: "Save anyway (fix later in .env)", value: "skip" },
105
+ { name: "Cancel", value: "cancel" },
106
+ ],
107
+ });
108
+ if (next === "cancel") return { status: "cancelled", creds: current };
109
+ if (next === "skip") { console.log(""); return { status: "skipped", creds: current }; }
110
+ if (tracker === "linear") {
111
+ current.LINEAR_API_KEY = await promptSecret("Linear API key");
112
+ saveEnvVar(cwd, "LINEAR_API_KEY", current.LINEAR_API_KEY);
113
+ } else if (tracker === "jira") {
114
+ current.JIRA_EMAIL = await promptRequired("Atlassian login email");
115
+ current.JIRA_API_TOKEN = await promptSecret("Jira API token");
116
+ saveEnvVar(cwd, "JIRA_EMAIL", current.JIRA_EMAIL);
117
+ saveEnvVar(cwd, "JIRA_API_TOKEN", current.JIRA_API_TOKEN);
118
+ }
119
+ process.stdout.write(" Re-verifying tracker access... ");
120
+ }
121
+ }
122
+
42
123
  // ---------- Tracker list helpers (for step 9 in new mode) ----------
43
124
 
44
125
  async function listTrackerProjects({ tracker, creds, location }) {
@@ -87,11 +168,12 @@ async function fetchAccountProjects(apiKey) {
87
168
 
88
169
  // ---------- Legacy migration ----------
89
170
 
90
- async function autoMatchLegacy(cwd, apiKey, localConfig) {
171
+ // Every project on the account paired with its settings. Settings fetches
172
+ // are best-effort — a project whose settings can't be read is returned with
173
+ // `{}` so the caller still sees it exists.
174
+ async function fetchProjectsWithSettings(apiKey) {
91
175
  const projects = await fetchAccountProjects(apiKey);
92
- if (!projects.length) return null;
93
-
94
- const settingsList = await Promise.all(projects.map(async p => {
176
+ const settings = await Promise.all(projects.map(async p => {
95
177
  try {
96
178
  const r = await fetch(`${SERVER}/api/projects/${p.id}/settings`, {
97
179
  headers: { "x-api-key": apiKey }, signal: AbortSignal.timeout(5000),
@@ -99,14 +181,18 @@ async function autoMatchLegacy(cwd, apiKey, localConfig) {
99
181
  return r.ok ? await r.json() : {};
100
182
  } catch { return {}; }
101
183
  }));
184
+ return projects.map((project, i) => ({ project, settings: settings[i] || {} }));
185
+ }
186
+
187
+ async function autoMatchLegacy(cwd, apiKey, localConfig) {
188
+ const rows = await fetchProjectsWithSettings(apiKey);
189
+ if (!rows.length) return null;
102
190
 
103
191
  const legacyRepo = (localConfig.github?.repo || "").toLowerCase();
104
192
  const legacyTeamKey = (localConfig.linear?.teamKey || "").toLowerCase();
105
193
  const legacyJiraProject = (localConfig.jira?.project || "").toLowerCase();
106
194
 
107
- for (let i = 0; i < projects.length; i++) {
108
- const p = projects[i];
109
- const s = settingsList[i];
195
+ for (const { project: p, settings: s } of rows) {
110
196
  if (legacyRepo && (s?.github?.repo || "").toLowerCase() === legacyRepo) {
111
197
  return { projectKey: p.id, name: p.name, reason: `github.repo matches ${s.github.repo}` };
112
198
  }
@@ -138,7 +224,10 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
138
224
  let serverConfig = null;
139
225
  if (isExisting) {
140
226
  process.stdout.write(" Fetching project settings from agentdesk.live... ");
141
- serverConfig = await loadConfig(currentCwd, { apiKey, serverUrl: SERVER, projectName: projectKey, silent: true });
227
+ // readOnly: this is a read at the top of a wizard the user may still
228
+ // cancel. Without it, loadConfig rewrote .agentdesk.json and pushed a
229
+ // heal-sync to the server before anything had been confirmed.
230
+ serverConfig = await loadConfig(currentCwd, { apiKey, serverUrl: SERVER, projectName: projectKey, silent: true, readOnly: true });
142
231
  console.log("✓");
143
232
  console.log(` Project: ${projectKey}`);
144
233
  console.log(` Tracker: ${serverConfig.tracker || "(not set — change in agentdesk.live)"}`);
@@ -150,6 +239,19 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
150
239
  // Step 1 — detect git repo (silent).
151
240
  const git = detectGitRepo(currentCwd);
152
241
 
242
+ // Repair clones made by older versions, which embedded the GitHub token in
243
+ // the origin URL — leaving it in plaintext in .git/config indefinitely.
244
+ // Sessions authenticate through a credential helper; the remote never
245
+ // needed the token.
246
+ if (git.inRepo) {
247
+ try {
248
+ if (stripRemoteCredentials(currentCwd)) {
249
+ console.log(" ✓ Removed embedded credentials from the git remote URL (.git/config)");
250
+ console.log("");
251
+ }
252
+ } catch {}
253
+ }
254
+
153
255
  // Step 2 — GitHub repo.
154
256
  let ownerRepo = git.ownerRepo;
155
257
  if (isExisting) {
@@ -171,42 +273,35 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
171
273
  }
172
274
 
173
275
  // Step 3 — GitHub token (always editable, per-machine).
276
+ //
277
+ // Nothing is written to disk in this step. The token is persisted once, after
278
+ // step 4, to whichever directory ends up being the project. Previously a
279
+ // freshly entered token was saved to the *current* directory's .env, and
280
+ // then again to the clone's — leaving a copy in a directory that isn't the
281
+ // project at all.
174
282
  let env = loadDotEnv(currentCwd);
175
- let githubToken = env.GITHUB_TOKEN;
176
- let githubLogin = null;
283
+ let githubToken = env.GITHUB_TOKEN || null;
284
+ let githubLogin = githubToken ? await fetchGitHubLogin(githubToken) : null;
177
285
 
178
- if (githubToken) {
179
- githubLogin = await fetchGitHubLogin(githubToken);
180
- if (githubLogin) {
181
- const choice = await promptSelect({
182
- message: `GitHub token — currently authenticates as @${githubLogin}`,
183
- default: "keep",
184
- choices: [
185
- { name: "Keep current token", value: "keep" },
186
- { name: "Replace with a new token", value: "new" },
187
- ],
188
- });
189
- if (choice === "new") {
190
- githubToken = await promptRequired("GitHub token");
191
- saveEnvVar(currentCwd, "GITHUB_TOKEN", githubToken);
192
- githubLogin = await fetchGitHubLogin(githubToken);
193
- if (githubLogin) console.log(` ✓ Token authenticates as @${githubLogin}`);
194
- }
195
- } else {
196
- console.log(" ⚠ GITHUB_TOKEN in .env doesn't authenticate with GitHub — enter a new one.");
197
- githubToken = await promptRequired("GitHub token");
198
- saveEnvVar(currentCwd, "GITHUB_TOKEN", githubToken);
199
- githubLogin = await fetchGitHubLogin(githubToken);
200
- if (githubLogin) console.log(` ✓ Token authenticates as @${githubLogin}`);
286
+ if (githubToken && githubLogin) {
287
+ const choice = await promptSelect({
288
+ message: `GitHub token — currently authenticates as @${githubLogin}`,
289
+ default: "keep",
290
+ choices: [
291
+ { name: "Keep current token", value: "keep" },
292
+ { name: "Replace with a new token", value: "new" },
293
+ ],
294
+ });
295
+ if (choice === "new") {
296
+ const r = await promptGitHubTokenVerified({ ownerRepo });
297
+ if (r.cancelled) return;
298
+ ({ githubToken, githubLogin } = r);
201
299
  }
202
300
  } else {
203
- const repoHint = ownerRepo ? ` (needed to push to ${ownerRepo})` : "";
204
- console.log(` GitHub token${repoHint} create one at https://github.com/settings/tokens, scope: repo`);
205
- githubToken = await promptRequired("GitHub token");
206
- saveEnvVar(currentCwd, "GITHUB_TOKEN", githubToken);
207
- githubLogin = await fetchGitHubLogin(githubToken);
208
- if (githubLogin) console.log(` ✓ Token authenticates as @${githubLogin}`);
209
- else console.log(" ⚠ Token couldn't be verified against the GitHub API (continuing anyway)");
301
+ if (githubToken) console.log(" ⚠ GITHUB_TOKEN in .env doesn't authenticate with GitHub enter a new one.");
302
+ const r = await promptGitHubTokenVerified({ ownerRepo });
303
+ if (r.cancelled) return;
304
+ ({ githubToken, githubLogin } = r);
210
305
  }
211
306
  console.log("");
212
307
 
@@ -216,11 +311,37 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
216
311
  if (parsed) {
217
312
  const expected = `${parsed.owner}/${parsed.repo}`.toLowerCase();
218
313
  const haveHere = (git.ownerRepo || "").toLowerCase();
219
- if (!git.inRepo || haveHere !== expected) {
314
+ const needClone = !git.inRepo || haveHere !== expected;
315
+ const reuse = needClone
316
+ ? findExistingClone({ cwd: currentCwd, owner: parsed.owner, repo: parsed.repo })
317
+ : null;
318
+ if (reuse) {
319
+ // The wizard was run from the directory that contains the clone
320
+ // (the parent of ./<repo>). Use it rather than failing with
321
+ // "already exists and is not empty".
322
+ console.log(` ✓ ./${parsed.repo} is already a clone of ${parsed.owner}/${parsed.repo} — using it.`);
323
+ currentCwd = reuse;
324
+ try {
325
+ if (stripRemoteCredentials(currentCwd)) {
326
+ console.log(" ✓ Removed embedded credentials from the git remote URL (.git/config)");
327
+ }
328
+ } catch {}
329
+ console.log("");
330
+ } else if (needClone) {
331
+ if (git.inRepo) {
332
+ // Cloning here nests one git repo inside another — almost always a
333
+ // sign the user ran init from the wrong directory. Confirm first.
334
+ const here = git.ownerRepo ? ` (${git.ownerRepo})` : "";
335
+ console.log(` This directory is already a git repo${here}, not ${parsed.owner}/${parsed.repo}.`);
336
+ const go = (await ask(` Clone ${parsed.owner}/${parsed.repo} into ./${parsed.repo} here anyway? [y/N]: `)).trim().toLowerCase();
337
+ if (go !== "y" && go !== "yes") {
338
+ console.log(" Cancelled — cd to the directory that should contain the clone and re-run `agentdesk init`.");
339
+ return;
340
+ }
341
+ }
220
342
  console.log(` Cloning ${parsed.owner}/${parsed.repo} into ./${parsed.repo}...`);
221
343
  try {
222
344
  currentCwd = cloneRepoWithToken({ cwd: currentCwd, owner: parsed.owner, repo: parsed.repo, token: githubToken });
223
- saveEnvVar(currentCwd, "GITHUB_TOKEN", githubToken);
224
345
  } catch (err) {
225
346
  console.log(` ✗ ${err.message}`);
226
347
  return;
@@ -230,6 +351,10 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
230
351
  }
231
352
  }
232
353
 
354
+ // Persist the GitHub token exactly once, to the directory that is the
355
+ // project. Also tightens an existing .env to 0600.
356
+ if (githubToken) saveEnvVar(currentCwd, "GITHUB_TOKEN", githubToken);
357
+
233
358
  // Step 5 — Tracker type.
234
359
  let tracker;
235
360
  if (isExisting) {
@@ -290,12 +415,12 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
290
415
  ],
291
416
  });
292
417
  if (choice === "new") {
293
- linearApiKey = await promptRequired("Linear API key");
418
+ linearApiKey = await promptSecret("Linear API key");
294
419
  saveEnvVar(currentCwd, "LINEAR_API_KEY", linearApiKey);
295
420
  }
296
421
  } else {
297
422
  console.log(" (Get a Linear API key: https://linear.app/settings/api)");
298
- linearApiKey = await promptRequired("Linear API key");
423
+ linearApiKey = await promptSecret("Linear API key");
299
424
  saveEnvVar(currentCwd, "LINEAR_API_KEY", linearApiKey);
300
425
  }
301
426
  console.log("");
@@ -311,7 +436,7 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
311
436
  });
312
437
  if (choice === "new") {
313
438
  jiraEmail = await promptRequired("Atlassian login email");
314
- jiraApiToken = await promptRequired("Jira API token");
439
+ jiraApiToken = await promptSecret("Jira API token");
315
440
  saveEnvVar(currentCwd, "JIRA_EMAIL", jiraEmail);
316
441
  saveEnvVar(currentCwd, "JIRA_API_TOKEN", jiraApiToken);
317
442
  }
@@ -322,7 +447,7 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
322
447
  saveEnvVar(currentCwd, "JIRA_EMAIL", jiraEmail);
323
448
  }
324
449
  if (!jiraApiToken) {
325
- jiraApiToken = await promptRequired("Jira API token");
450
+ jiraApiToken = await promptSecret("Jira API token");
326
451
  saveEnvVar(currentCwd, "JIRA_API_TOKEN", jiraApiToken);
327
452
  }
328
453
  }
@@ -356,7 +481,7 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
356
481
 
357
482
  // Build the in-memory config (used for verification + summary; written
358
483
  // to disk only in new mode).
359
- const config = {
484
+ let config = {
360
485
  tracker,
361
486
  github: { repo: ownerRepo || null, login: githubLogin || null },
362
487
  };
@@ -366,85 +491,45 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
366
491
 
367
492
  let trackerTeamOrProjectId = null;
368
493
  let trackerTeamOrProjectName = null;
494
+ // Existing mode: set when the server had no team/project and the user
495
+ // supplied one here — the one project-wide field init is allowed to
496
+ // fill in, because leaving it empty breaks every session.
497
+ let trackerProjectFilledIn = false;
369
498
 
370
- // Step 9 — Verify tracker + (new mode) pick team/project.
499
+ // Step 9 — Verify tracker + resolve team/project.
371
500
  if (tracker && tracker !== "github") {
372
- if (isExisting) {
373
- trackerTeamOrProjectId = serverConfig.linear?.teamKey || serverConfig.jira?.project || null;
374
- trackerTeamOrProjectName = trackerTeamOrProjectId;
375
- if (tracker === "linear" && trackerTeamOrProjectId) config.linear.teamKey = trackerTeamOrProjectId;
376
- if (tracker === "jira" && trackerTeamOrProjectId) config.jira.project = trackerTeamOrProjectId;
377
-
378
- process.stdout.write(" Verifying tracker access... ");
379
- const creds = { LINEAR_API_KEY: linearApiKey, JIRA_EMAIL: jiraEmail, JIRA_API_TOKEN: jiraApiToken };
380
- const check = await checkTrackerPermissions({ tracker, config, credentials: creds });
381
- if (check.ok) {
382
- console.log("✓");
383
- if (check.identity?.name || check.identity?.email) {
384
- console.log(` ✓ Posting as: ${check.identity.name || check.identity.email}`);
385
- }
386
- } else {
387
- console.log("failed");
388
- for (const e of check.errors || []) console.log(` • ${e}`);
389
- }
390
- console.log("");
391
- } else {
392
- process.stdout.write(" Verifying tracker access... ");
393
- let verified = false;
394
- while (!verified) {
395
- const creds = { LINEAR_API_KEY: linearApiKey, JIRA_EMAIL: jiraEmail, JIRA_API_TOKEN: jiraApiToken };
396
- const check = await checkTrackerPermissions({ tracker, config, credentials: creds });
397
- if (check.ok) {
398
- console.log("✓");
399
- if (check.identity?.name || check.identity?.email) {
400
- console.log(` ✓ Posting as: ${check.identity.name || check.identity.email}`);
401
- }
402
- verified = true;
403
- break;
404
- }
405
- console.log("failed");
406
- for (const e of check.errors || []) console.log(` • ${e}`);
407
- const next = await promptSelect({
408
- message: "What now?",
409
- choices: [
410
- { name: "Re-enter credentials and try again", value: "retry" },
411
- { name: "Save anyway (fix later in .env)", value: "skip" },
412
- { name: "Cancel", value: "cancel" },
413
- ],
414
- });
415
- if (next === "cancel") return;
416
- if (next === "skip") break;
417
- if (tracker === "linear") {
418
- linearApiKey = await promptRequired("Linear API key");
419
- saveEnvVar(currentCwd, "LINEAR_API_KEY", linearApiKey);
420
- } else if (tracker === "jira") {
421
- jiraEmail = await promptRequired("Atlassian login email");
422
- jiraApiToken = await promptRequired("Jira API token");
423
- saveEnvVar(currentCwd, "JIRA_EMAIL", jiraEmail);
424
- saveEnvVar(currentCwd, "JIRA_API_TOKEN", jiraApiToken);
425
- }
426
- process.stdout.write(" Re-verifying tracker access... ");
427
- }
501
+ const serverProject = isExisting ? getTrackerProject(tracker, serverConfig) : null;
502
+ if (serverProject) {
503
+ config = setTrackerProject(tracker, config, serverProject);
504
+ }
428
505
 
429
- if (verified) {
430
- const creds = { LINEAR_API_KEY: linearApiKey, JIRA_EMAIL: jiraEmail, JIRA_API_TOKEN: jiraApiToken };
431
- const items = await listTrackerProjects({ tracker, creds, location: jiraBaseUrl });
432
- if (items && items.length > 0) {
433
- const pickedId = await promptSelect({
434
- message: `Pick the ${tracker === "linear" ? "team" : "project"} this project maps to`,
435
- choices: items.map(it => ({ name: it.name, value: it.id })),
436
- });
437
- trackerTeamOrProjectId = pickedId;
438
- trackerTeamOrProjectName = items.find(it => it.id === pickedId)?.name || pickedId;
439
- } else {
440
- trackerTeamOrProjectId = await promptRequired(tracker === "linear" ? "Linear team key (e.g. KEN)" : "Jira project key (e.g. PROJ)");
441
- trackerTeamOrProjectName = trackerTeamOrProjectId;
442
- }
443
- if (tracker === "linear") config.linear = { ...config.linear, teamKey: trackerTeamOrProjectId };
444
- if (tracker === "jira") config.jira = { ...config.jira, project: trackerTeamOrProjectId };
445
- console.log("");
446
- }
506
+ // Credentials only at this point — the project is picked right after,
507
+ // so the check must not demand it yet.
508
+ const outcome = await verifyTrackerWithRetry({
509
+ tracker, config, cwd: currentCwd,
510
+ creds: { LINEAR_API_KEY: linearApiKey, JIRA_EMAIL: jiraEmail, JIRA_API_TOKEN: jiraApiToken },
511
+ });
512
+ if (outcome.status === "cancelled") return;
513
+ ({ LINEAR_API_KEY: linearApiKey, JIRA_EMAIL: jiraEmail, JIRA_API_TOKEN: jiraApiToken } = outcome.creds);
514
+
515
+ if (isExisting && !serverProject) {
516
+ console.log(` ⚠ This project has no ${trackerProjectNoun(tracker)} configured on agentdesk.live — pick one now.`);
447
517
  }
518
+ const resolved = await resolveTrackerProject({
519
+ tracker, isExisting, serverProject,
520
+ verified: outcome.status === "ok",
521
+ listProjects: () => listTrackerProjects({ tracker, creds: outcome.creds, location: jiraBaseUrl }),
522
+ promptSelect, promptRequired,
523
+ });
524
+ trackerTeamOrProjectId = resolved.id;
525
+ trackerTeamOrProjectName = resolved.name;
526
+ config = setTrackerProject(tracker, config, resolved.id);
527
+ if (resolved.source === "server") {
528
+ console.log(` ${tracker === "linear" ? "Team: " : "Project: "} ${resolved.id} (configured — change in agentdesk.live)`);
529
+ } else if (isExisting) {
530
+ trackerProjectFilledIn = true;
531
+ }
532
+ console.log("");
448
533
  }
449
534
 
450
535
  // Derive projectName + projectKey (only used in new mode).
@@ -457,8 +542,20 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
457
542
  projectName = trackerTeamOrProjectName
458
543
  ? `${repoShort} — ${String(trackerTeamOrProjectName).split(" (")[0]}`
459
544
  : repoShort;
460
- const keySource = trackerTeamOrProjectId || repoShort;
461
- computedKey = String(keySource).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
545
+
546
+ // The tracker-derived key is not unique across repos: a second repo on
547
+ // the same Linear team used to get the same key, and its settings push
548
+ // overwrote the first repo's. Check the account before committing to it.
549
+ const baseKey = deriveProjectKey({ trackerId: trackerTeamOrProjectId, repoShort });
550
+ const existing = (await fetchProjectsWithSettings(apiKey))
551
+ .map(({ project, settings }) => ({ id: project.id, repo: settings?.github?.repo || null }));
552
+ const resolved = resolveProjectKey({ baseKey, repo: ownerRepo, repoShort, existing });
553
+ computedKey = resolved.key;
554
+ if (resolved.renamed) {
555
+ console.log(` ⚠ Project id "${baseKey}" already belongs to ${resolved.collidedWith.repo} on your account.`);
556
+ console.log(` Using "${computedKey}" for this repo so the two don't overwrite each other.`);
557
+ console.log("");
558
+ }
462
559
  }
463
560
  config.projectKey = computedKey;
464
561
 
@@ -477,10 +574,24 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
477
574
  console.log(` .env: ${envNames.join(", ") || "(none)"}`);
478
575
  console.log("");
479
576
 
480
- const save = (await ask(" Save this config? [Y/n]: ")).trim().toLowerCase();
481
- if (save && save !== "y" && save !== "yes") {
482
- console.log(" Cancelled.");
483
- return;
577
+ // Default the confirmation to "no" when any access check failed. Saving a
578
+ // config that can't reach GitHub or the tracker just moves the failure to
579
+ // the first session, where it is much harder to diagnose.
580
+ const anyFailed = !results.agentdesk?.ok || !results.claude?.ok || !results.github?.ok
581
+ || (results.tracker && !results.tracker.ok && !results.tracker.skipped);
582
+ if (anyFailed) {
583
+ console.log(" ⚠ Some access checks failed. Sessions will not run until they pass.");
584
+ const save = (await ask(" Save this config anyway? [y/N]: ")).trim().toLowerCase();
585
+ if (save !== "y" && save !== "yes") {
586
+ console.log(" Cancelled.");
587
+ return;
588
+ }
589
+ } else {
590
+ const save = (await ask(" Save this config? [Y/n]: ")).trim().toLowerCase();
591
+ if (save && save !== "y" && save !== "yes") {
592
+ console.log(" Cancelled.");
593
+ return;
594
+ }
484
595
  }
485
596
  console.log("");
486
597
 
@@ -497,50 +608,85 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
497
608
  if (existsSync(configPath)) {
498
609
  try { existingOnDisk = JSON.parse(readFileSync(configPath, "utf-8")); } catch {}
499
610
  }
500
- const merged = { ...existingOnDisk, projectKey: computedKey };
611
+ let merged = { ...existingOnDisk, projectKey: computedKey };
612
+ if (trackerProjectFilledIn) merged = setTrackerProject(tracker, merged, trackerTeamOrProjectId);
501
613
  writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
502
614
  } else {
503
615
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
504
616
  }
505
617
  console.log(" ✓ Saved .agentdesk.json");
506
618
 
507
- // Ensure .agentdesk/ is gitignored (runtime state).
508
- const gitignorePath = join(currentCwd, ".gitignore");
619
+ // .env now holds a GitHub token and tracker credentials; .agentdesk/ is
620
+ // runtime state. Neither may be committed. Only .agentdesk/ used to be
621
+ // added here — on a fresh clone with no .gitignore, the next `git add .`
622
+ // would have committed every token this wizard just collected.
509
623
  try {
510
- const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
511
- if (!existing.split("\n").some(line => line.trim() === ".agentdesk/" || line.trim() === ".agentdesk")) {
512
- const nl = existing.endsWith("\n") || !existing ? "" : "\n";
513
- writeFileSync(gitignorePath, `${existing}${nl}.agentdesk/\n`);
514
- console.log(" ✓ Added .agentdesk/ to .gitignore");
515
- }
624
+ const added = ensureGitignored(currentCwd, [".env", ".agentdesk/"]);
625
+ for (const entry of added) console.log(` ✓ Added ${entry} to .gitignore`);
516
626
  } catch {}
517
627
 
518
- // Server sync — new mode only. Existing mode doesn't push anything
519
- // because every project-wide field was locked and unchanged.
628
+ // Server sync.
629
+ // Existing mode pushes nothing — every project-wide field was locked
630
+ // and unchanged — except when the server had no team/project and the
631
+ // user just filled it in. That one field goes up, merged into the
632
+ // server's own settings so nothing else is touched.
633
+ // • New mode registers the project and pushes the full config.
634
+ if (isExisting && trackerProjectFilledIn) {
635
+ const payload = setTrackerProject(tracker, serverConfig, trackerTeamOrProjectId);
636
+ const push = await pushConfig(apiKey, SERVER, computedKey, payload);
637
+ if (push.ok) {
638
+ console.log(` ✓ Saved ${trackerProjectNoun(tracker)} "${trackerTeamOrProjectId}" to agentdesk.live`);
639
+ } else if (push.error !== "missing_auth") {
640
+ console.log(` ⚠ Could not save the ${trackerProjectNoun(tracker)} to agentdesk.live: ${push.error}`);
641
+ console.log(" It is in .agentdesk.json; the next session will retry the sync.");
642
+ }
643
+ }
520
644
  if (!isExisting) {
521
645
  try {
522
- await fetch(`${SERVER}/api/projects`, {
646
+ const reg = await fetch(`${SERVER}/api/projects`, {
523
647
  method: "POST",
524
648
  headers: { "Content-Type": "application/json", "x-api-key": apiKey },
525
649
  body: JSON.stringify({ id: computedKey, name: projectName, path: currentCwd, tracker }),
650
+ signal: AbortSignal.timeout(8000),
526
651
  });
527
- const push = await pushConfig(apiKey, SERVER, computedKey, config);
528
- if (!push.ok && push.error !== "missing_auth") {
529
- console.log(` ⚠ Could not push settings to agentdesk.live: ${push.error}`);
652
+ if (!reg.ok) {
653
+ // This response used to go unchecked, so a 403 ("id belongs to
654
+ // another account") still ended with "✓ Registered".
655
+ let detail = `HTTP ${reg.status}`;
656
+ try { const d = await reg.json(); if (d?.error) detail = d.error; } catch {}
657
+ console.log(` ✗ Could not register "${computedKey}" on agentdesk.live: ${detail}`);
658
+ if (reg.status === 403) {
659
+ console.log(" That project id belongs to another account. Re-run `agentdesk init --force-full`");
660
+ console.log(" and pick a different tracker team/project, or ask the owner to add you.");
661
+ }
662
+ console.log(" Local config saved; nothing was pushed to the server.");
530
663
  } else {
531
- console.log(" ✓ Registered with agentdesk.live");
664
+ const push = await pushConfig(apiKey, SERVER, computedKey, config);
665
+ if (!push.ok && push.error !== "missing_auth") {
666
+ console.log(` ⚠ Registered, but could not push settings to agentdesk.live: ${push.error}`);
667
+ } else {
668
+ console.log(" ✓ Registered with agentdesk.live");
669
+ }
532
670
  }
533
671
  } catch {
534
672
  console.log(" ⚠ Couldn't reach agentdesk.live — local config saved, retry later.");
535
673
  }
536
674
  }
537
675
 
538
- registerLocalProject(computedKey, projectName, currentCwd);
676
+ // AD-64: tag the registry entry with the account so the daemon can scope
677
+ // its project list per login.
678
+ const accountId = (await ensureAccountIdentity())?.accountId || null;
679
+ registerLocalProject(computedKey, projectName, currentCwd, accountId);
539
680
 
540
681
  console.log("");
541
- const cmdPrefix = trackerTeamOrProjectId || computedKey.toUpperCase();
542
682
  console.log(" Ready. Run a team session:");
543
- console.log(` agentdesk team ${cmdPrefix}-123`);
683
+ if (trackerTeamOrProjectId) {
684
+ console.log(` agentdesk team ${trackerTeamOrProjectId}-123`);
685
+ } else if (tracker === "github") {
686
+ console.log(" agentdesk team 123 (a GitHub issue number)");
687
+ } else {
688
+ console.log(' agentdesk team -d "Describe the task"');
689
+ }
544
690
  console.log("");
545
691
  }
546
692