@kendoo.agentdesk/agentdesk 0.20.2 → 0.20.4
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 +7 -0
- package/cli/config.mjs +55 -10
- package/cli/init.mjs +13 -4
- package/cli/prompt.mjs +6 -4
- package/cli/stream-parser.mjs +33 -11
- package/package.json +1 -1
- package/prompts/team.md +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,13 @@ Internal refactors, infrastructure changes, and architectural notes are not list
|
|
|
13
13
|
### Added
|
|
14
14
|
- `[UI]` Private-session sharing flow. When someone visits a session URL they don't own, they now see a friendly "Shh… this one's private" page with a one-click "Request access" button instead of a blank error. The session owner sees pending requests in a new header inbox and can grant viewer access for just that session or the whole project. Viewer-granted sessions show up in the teammate's sidebar tagged as a viewer.
|
|
15
15
|
|
|
16
|
+
## [0.20.3] — 2026-04-19
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- `[CLI]` Critical: `loadConfig` no longer lets a server row with `null` or missing fields override a fully-configured local `.agentdesk.json`. The merge now treats `null`/`undefined` from the server as "no value known" — only explicit non-null server values win. Previously a partially-synced project row on agentdesk.live would silently strip local tracker/repo/badge fields during the merge, and the cached-back write then persisted the stripped state to disk. On the next `init` run, the config looked empty even though the user had typed everything in.
|
|
20
|
+
- `[CLI]` Existing-mode save in `init` preserves the full local `.agentdesk.json` — it only ensures `projectKey` is set, never replaces other fields. The previous behavior wrote `{ "projectKey": "..." }` as the entire file, which destroyed any data not also on the server.
|
|
21
|
+
- `[CLI]` Auto-heal sync: when `loadConfig` sees a server row missing fields the local file has, it now pushes the merged config back to the server (fire-and-forget) so both sides converge. Projects that were set up before server-settings-push landed will heal themselves on next init.
|
|
22
|
+
|
|
16
23
|
## [0.20.2] — 2026-04-19
|
|
17
24
|
|
|
18
25
|
### Changed
|
package/cli/config.mjs
CHANGED
|
@@ -87,20 +87,32 @@ export async function loadConfig(dir, opts = {}) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// Merge: DEFAULTS ← local ← server
|
|
90
|
+
// Merge: DEFAULTS ← local ← server, where the "server overrides local"
|
|
91
|
+
// rule only kicks in when the server actually has a value. Null /
|
|
92
|
+
// undefined on the server means "the server doesn't know about this
|
|
93
|
+
// field yet" — local wins. This is what keeps a partially-configured
|
|
94
|
+
// server row from silently wiping a fully-configured local file on
|
|
95
|
+
// next read.
|
|
91
96
|
let config = { ...DEFAULTS };
|
|
92
97
|
if (localConfig) config = deepMerge(config, localConfig);
|
|
93
|
-
if (serverConfig) config =
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
if (serverConfig) config = mergeNonNull(config, serverConfig);
|
|
99
|
+
|
|
100
|
+
// Auto-heal sync: if local has fields the server is missing (either
|
|
101
|
+
// no row at all or a partial row), push the merged config up so the
|
|
102
|
+
// server catches up. Fire-and-forget — we already have the right
|
|
103
|
+
// answer in `config` for the current caller.
|
|
104
|
+
const shouldHeal = localConfig && apiKey && serverUrl && projectName && (
|
|
105
|
+
!serverConfig || hasFieldsServerLacks(localConfig, serverConfig)
|
|
106
|
+
);
|
|
107
|
+
if (shouldHeal) {
|
|
108
|
+
pushConfig(apiKey, serverUrl, projectName, stripCredentials(config)).catch(() => {});
|
|
98
109
|
}
|
|
99
110
|
|
|
100
|
-
// Cache the merged, credential-free config back to disk so the local
|
|
101
|
-
// stays a readable view of the authoritative state. Only when we
|
|
102
|
-
// fetched from the server — offline runs must not silently
|
|
103
|
-
// user's local file.
|
|
111
|
+
// Cache the merged, credential-free config back to disk so the local
|
|
112
|
+
// file stays a readable view of the authoritative state. Only when we
|
|
113
|
+
// actually fetched from the server — offline runs must not silently
|
|
114
|
+
// mutate the user's local file. With mergeNonNull above, we're
|
|
115
|
+
// guaranteed this write never strips existing local fields.
|
|
104
116
|
if (serverConfig) {
|
|
105
117
|
writeLocalConfig(configPath, config);
|
|
106
118
|
}
|
|
@@ -108,6 +120,23 @@ export async function loadConfig(dir, opts = {}) {
|
|
|
108
120
|
return config;
|
|
109
121
|
}
|
|
110
122
|
|
|
123
|
+
// True when `local` has any non-null leaf that's null/missing on `server`.
|
|
124
|
+
// Walked recursively for nested blocks (linear, jira, github). Used to
|
|
125
|
+
// decide whether it's worth pushing a heal sync.
|
|
126
|
+
function hasFieldsServerLacks(local, server) {
|
|
127
|
+
const isObj = v => v && typeof v === "object" && !Array.isArray(v);
|
|
128
|
+
for (const [key, v] of Object.entries(local || {})) {
|
|
129
|
+
if (v === null || v === undefined) continue;
|
|
130
|
+
const s = server?.[key];
|
|
131
|
+
if (isObj(v)) {
|
|
132
|
+
if (!isObj(s) || hasFieldsServerLacks(v, s)) return true;
|
|
133
|
+
} else {
|
|
134
|
+
if (s === null || s === undefined) return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
111
140
|
// Push a config object to the server. Caller is responsible for deciding when.
|
|
112
141
|
export async function pushConfig(apiKey, serverUrl, projectName, payload) {
|
|
113
142
|
if (!apiKey || !serverUrl || !projectName) return { ok: false, error: "missing_auth" };
|
|
@@ -185,3 +214,19 @@ function deepMerge(defaults, overrides) {
|
|
|
185
214
|
}
|
|
186
215
|
return result;
|
|
187
216
|
}
|
|
217
|
+
|
|
218
|
+
// Like deepMerge, but null/undefined on the overrides side never
|
|
219
|
+
// clobbers a non-null value already in base. Used for the local ← server
|
|
220
|
+
// step so an empty server row can't wipe a fully-populated local file.
|
|
221
|
+
function mergeNonNull(base, overrides) {
|
|
222
|
+
const result = { ...base };
|
|
223
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
224
|
+
if (value === null || value === undefined) continue;
|
|
225
|
+
if (value && typeof value === "object" && !Array.isArray(value) && base[key] && typeof base[key] === "object") {
|
|
226
|
+
result[key] = mergeNonNull(base[key], value);
|
|
227
|
+
} else {
|
|
228
|
+
result[key] = value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
package/cli/init.mjs
CHANGED
|
@@ -484,12 +484,21 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
|
|
|
484
484
|
}
|
|
485
485
|
console.log("");
|
|
486
486
|
|
|
487
|
-
// Write local .agentdesk.json.
|
|
488
|
-
//
|
|
489
|
-
//
|
|
487
|
+
// Write local .agentdesk.json.
|
|
488
|
+
// • Existing mode: preserve whatever's already on disk, ensuring
|
|
489
|
+
// only that `projectKey` is set. Never clobber tracker/github/
|
|
490
|
+
// badge fields the user may have locally even when the server
|
|
491
|
+
// hasn't caught up yet (auto-heal push in config.mjs will
|
|
492
|
+
// reconcile the server side).
|
|
493
|
+
// • New mode: write the full config we just built.
|
|
490
494
|
const configPath = join(currentCwd, ".agentdesk.json");
|
|
491
495
|
if (isExisting) {
|
|
492
|
-
|
|
496
|
+
let existingOnDisk = {};
|
|
497
|
+
if (existsSync(configPath)) {
|
|
498
|
+
try { existingOnDisk = JSON.parse(readFileSync(configPath, "utf-8")); } catch {}
|
|
499
|
+
}
|
|
500
|
+
const merged = { ...existingOnDisk, projectKey: computedKey };
|
|
501
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
|
|
493
502
|
} else {
|
|
494
503
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
495
504
|
}
|
package/cli/prompt.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function buildPrompt({ taskId, taskLink, description, createTask, tracker
|
|
|
68
68
|
} else {
|
|
69
69
|
createInstr += `- ⚠ No Linear team key is configured. Ask the user which team this issue should live in BEFORE creating it.\n`;
|
|
70
70
|
}
|
|
71
|
-
createInstr += `- Set the title based on the description below\n- After creation, use the returned identifier (e.g., ${linearTeamKey || "KEN"}-530) as the task ID for the rest of the session\n`;
|
|
71
|
+
createInstr += `- Set the title based on the description below\n- **Assign the issue to the connected user.** First fetch the viewer id with \`{ viewer { id } }\`, then pass that id as \`assigneeId\` in the \`issueCreate\` input.\n- After creation, use the returned identifier (e.g., ${linearTeamKey || "KEN"}-530) as the task ID for the rest of the session\n`;
|
|
72
72
|
} else if (tracker === "jira") {
|
|
73
73
|
const jiraProject = config.jira?.project;
|
|
74
74
|
createInstr += `Create a Jira issue:\n- Endpoint: ${config.jira?.baseUrl || ""}/rest/api/3/issue\n- Auth: Basic auth with $JIRA_EMAIL and $JIRA_API_TOKEN\n`;
|
|
@@ -77,9 +77,9 @@ export function buildPrompt({ taskId, taskLink, description, createTask, tracker
|
|
|
77
77
|
} else {
|
|
78
78
|
createInstr += `- ⚠ No Jira project key is configured. Ask the user which project this task should live in BEFORE creating it.\n`;
|
|
79
79
|
}
|
|
80
|
-
createInstr += `- Set the summary based on the description below\n- After creation, use the returned key (e.g., ${jiraProject || "PROJ"}-42) as the task ID for the rest of the session\n`;
|
|
80
|
+
createInstr += `- Set the summary based on the description below\n- **Assign the issue to the connected user.** First GET \`${config.jira?.baseUrl || ""}/rest/api/3/myself\` to fetch your \`accountId\`, then include \`fields.assignee = { "accountId": "<that id>" }\` in the create payload.\n- After creation, use the returned key (e.g., ${jiraProject || "PROJ"}-42) as the task ID for the rest of the session\n`;
|
|
81
81
|
} else if (tracker === "github") {
|
|
82
|
-
createInstr += `Create a GitHub issue:\n- Run: gh issue create --title "..." --body "..."\n- After creation, use the returned issue number as the task ID for the rest of the session\n`;
|
|
82
|
+
createInstr += `Create a GitHub issue:\n- Run: gh issue create --title "..." --body "..." --assignee @me\n- After creation, use the returned issue number as the task ID for the rest of the session\n`;
|
|
83
83
|
}
|
|
84
84
|
createInstr += `\nTask description: ${description}\n`;
|
|
85
85
|
createInstr += `\nAfter creating the task, Jane MUST:\n`;
|
|
@@ -461,14 +461,16 @@ export function buildPhasedPrompt({ phase, taskId, taskLink, description, create
|
|
|
461
461
|
if (linearTeamKey) {
|
|
462
462
|
createInstr += `**Team MUST be \`${linearTeamKey}\`.** Resolve its id via \`{ teams(filter: { key: { eq: "${linearTeamKey}" } }) { nodes { id } } }\` and pass as \`teamId\` in \`issueCreate\`. Do NOT pick any other team.\n`;
|
|
463
463
|
}
|
|
464
|
+
createInstr += `**Assign to the connected user:** fetch \`{ viewer { id } }\` and pass that id as \`assigneeId\` in \`issueCreate\`.\n`;
|
|
464
465
|
} else if (tracker === "jira") {
|
|
465
466
|
const jiraProject = config.jira?.project;
|
|
466
467
|
createInstr += `Create a Jira issue at ${config.jira?.baseUrl || ""}.\n`;
|
|
467
468
|
if (jiraProject) {
|
|
468
469
|
createInstr += `**Project MUST be \`${jiraProject}\`.** Set \`fields.project.key = "${jiraProject}"\` in the create payload. Do NOT pick any other project.\n`;
|
|
469
470
|
}
|
|
471
|
+
createInstr += `**Assign to the connected user:** GET \`${config.jira?.baseUrl || ""}/rest/api/3/myself\` to fetch your \`accountId\`, then include \`fields.assignee = { "accountId": "<that id>" }\` in the create payload.\n`;
|
|
470
472
|
} else if (tracker === "github") {
|
|
471
|
-
createInstr += `Create a GitHub issue: gh issue create --title "..." --body "..."\n`;
|
|
473
|
+
createInstr += `Create a GitHub issue: gh issue create --title "..." --body "..." --assignee @me\n`;
|
|
472
474
|
}
|
|
473
475
|
createInstr += `\nTask description: ${description}\n`;
|
|
474
476
|
createInstr += `\nAfter creating, output: TASK_ID: <identifier>\n`;
|
package/cli/stream-parser.mjs
CHANGED
|
@@ -39,11 +39,32 @@ export function createStreamParser({ teamNames, callbacks }) {
|
|
|
39
39
|
|
|
40
40
|
function detectPhase(text) {
|
|
41
41
|
const upper = text.trim().toUpperCase();
|
|
42
|
-
if (upper
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
if (!upper) return null;
|
|
43
|
+
|
|
44
|
+
const phaseAlt = PHASE_NAMES.join("|");
|
|
45
|
+
|
|
46
|
+
// Canonical signal: "# PHASE: EXECUTION" / "PHASE EXECUTION" / "## EXECUTION".
|
|
47
|
+
// Accepted anywhere in a line so it survives leading bullets, emoji, etc.
|
|
48
|
+
const explicit = upper.match(new RegExp(`(?:^|\\s)#*\\s*PHASE\\s*[:\\-]?\\s*(${phaseAlt})\\b`));
|
|
49
|
+
if (explicit) return explicit[1];
|
|
50
|
+
|
|
51
|
+
// Markdown heading line that names a phase: "# EXECUTION", "## PLAN", etc.
|
|
52
|
+
const headingMatch = upper.match(new RegExp(`^#+\\s*(${phaseAlt})\\b`));
|
|
53
|
+
if (headingMatch) return headingMatch[1];
|
|
54
|
+
|
|
55
|
+
// Divider followed by a phase name: "--- EXECUTION".
|
|
56
|
+
const dividerMatch = upper.match(new RegExp(`^-{3,}\\s*(${phaseAlt})\\b`));
|
|
57
|
+
if (dividerMatch) return dividerMatch[1];
|
|
58
|
+
|
|
59
|
+
// Natural-language transitions ("entering EXECUTION", "moving to PLAN",
|
|
60
|
+
// "now in REVIEW", "EXECUTION phase begins"). Defensive fallback for when
|
|
61
|
+
// the canonical marker is missed.
|
|
62
|
+
const verbMatch = upper.match(new RegExp(`\\b(?:ENTERING|MOVING\\s+TO|STARTING|BEGIN(?:NING)?|NOW\\s+IN|TRANSITION(?:ING)?\\s+TO|PROCEED(?:ING)?\\s+TO|ADVANCING\\s+TO)\\s+(?:THE\\s+)?(?:PHASE\\s+)?(${phaseAlt})\\b(?:\\s+PHASE)?`));
|
|
63
|
+
if (verbMatch) return verbMatch[1];
|
|
64
|
+
|
|
65
|
+
const phaseSuffixMatch = upper.match(new RegExp(`\\b(${phaseAlt})\\s+PHASE\\s+(?:BEGINS?|STARTS?|HAS\\s+BEGUN|IS\\s+UNDERWAY)\\b`));
|
|
66
|
+
if (phaseSuffixMatch) return phaseSuffixMatch[1];
|
|
67
|
+
|
|
47
68
|
return null;
|
|
48
69
|
}
|
|
49
70
|
|
|
@@ -86,12 +107,6 @@ export function createStreamParser({ teamNames, callbacks }) {
|
|
|
86
107
|
if (block.type === "text" && block.text.trim()) {
|
|
87
108
|
const text = block.text.trim().replace(/\*+/g, "");
|
|
88
109
|
|
|
89
|
-
const phase = detectPhase(text);
|
|
90
|
-
if (phase) {
|
|
91
|
-
callbacks.onPhaseChange?.({ phase, timestamp: timestamp() });
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
110
|
// Detect TASK_ID announcement
|
|
96
111
|
const taskIdMatch = text.match(/TASK_ID:\s*(\S+)/);
|
|
97
112
|
if (taskIdMatch) {
|
|
@@ -110,6 +125,13 @@ export function createStreamParser({ teamNames, callbacks }) {
|
|
|
110
125
|
const currentLine = textLines[i].trim();
|
|
111
126
|
if (!currentLine) { i++; continue; }
|
|
112
127
|
|
|
128
|
+
const phase = detectPhase(currentLine);
|
|
129
|
+
if (phase) {
|
|
130
|
+
callbacks.onPhaseChange?.({ phase, timestamp: timestamp() });
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
113
135
|
const detected = detectAgent(currentLine);
|
|
114
136
|
if (detected) {
|
|
115
137
|
let msg = detected.rest;
|
package/package.json
CHANGED
package/prompts/team.md
CHANGED
|
@@ -314,8 +314,22 @@ Keep comments to bullet points.
|
|
|
314
314
|
|
|
315
315
|
---
|
|
316
316
|
|
|
317
|
+
## PHASE SIGNALS (MANDATORY — DASHBOARD DEPENDS ON THIS)
|
|
318
|
+
|
|
319
|
+
Whenever the team transitions phases, Jane MUST print a single bare line in this exact form on its own line, with nothing else on that line:
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
# PHASE: <NAME>
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Where `<NAME>` is one of `INTAKE`, `PLAN`, `EXECUTION`, `REVIEW`, `SUMMARY`. Examples: `# PHASE: PLAN`, `# PHASE: EXECUTION`, `# PHASE: SUMMARY`. This must be printed BEFORE any agent dialogue for the new phase. Without this marker the dashboard timeline gets stuck and the user can't tell which phase the team is in. Emit it even if it feels redundant.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
317
329
|
## INTAKE
|
|
318
330
|
|
|
331
|
+
Begin this phase by printing `# PHASE: INTAKE` on its own line.
|
|
332
|
+
|
|
319
333
|
**If a CREATE TASK section exists above**, Dennis executes the tracker API call to create the task. Jane then announces the task ID and what the team will be working on. Output `TASK_ID: <identifier>`, set status to "In Progress". Only then continue.
|
|
320
334
|
|
|
321
335
|
**Reminder: Jane does NOT use tools or reference code during INTAKE. Dennis handles all tool calls (fetching tasks, reading files, checking branches). Jane interprets the findings in product terms.**
|
|
@@ -445,6 +459,8 @@ After Dennis reports his assessment findings, Jane evaluates whether the task is
|
|
|
445
459
|
|
|
446
460
|
## PLAN (1-2 rounds max)
|
|
447
461
|
|
|
462
|
+
Begin this phase by printing `# PHASE: PLAN` on its own line.
|
|
463
|
+
|
|
448
464
|
Jane restates the task in product terms (no code, no file names, no technical jargon). Then each agent contributes their perspective in one pass:
|
|
449
465
|
|
|
450
466
|
{{PLANNING_ORDER}}
|
|
@@ -457,12 +473,16 @@ After the first round, Jane asks for objections. If none, declare the plan final
|
|
|
457
473
|
|
|
458
474
|
## EXECUTION
|
|
459
475
|
|
|
476
|
+
Begin this phase by printing `# PHASE: EXECUTION` on its own line.
|
|
477
|
+
|
|
460
478
|
{{EXECUTION_STEPS}}
|
|
461
479
|
|
|
462
480
|
---
|
|
463
481
|
|
|
464
482
|
## SUMMARY
|
|
465
483
|
|
|
484
|
+
Begin this phase by printing `# PHASE: SUMMARY` on its own line.
|
|
485
|
+
|
|
466
486
|
Jane dictates the summary content in product terms; Dennis executes all tracker commands:
|
|
467
487
|
1. Verify Bart posted the PR link. If not, Dennis posts it now.
|
|
468
488
|
2. Dennis transitions the task to "In Review".
|