@yeaft/webchat-agent 1.0.76 → 1.0.78
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/connection/buffer.js +2 -6
- package/connection/message-router.js +1 -64
- package/conversation.js +4 -32
- package/history.js +2 -2
- package/index.js +0 -6
- package/package.json +1 -1
- package/providers/claude-code.js +1 -1
- package/yeaft/attachments.js +2 -2
- package/yeaft/config.js +1 -1
- package/yeaft/conversation/persist.js +458 -112
- package/yeaft/conversation/search.js +51 -15
- package/yeaft/session.js +27 -18
- package/yeaft/web-bridge.js +27 -15
- package/crew/builtin-actions.js +0 -154
- package/crew/context-loader.js +0 -171
- package/crew/control.js +0 -444
- package/crew/human-interaction.js +0 -195
- package/crew/persistence.js +0 -295
- package/crew/role-management.js +0 -182
- package/crew/role-output.js +0 -461
- package/crew/role-query.js +0 -406
- package/crew/role-states.js +0 -180
- package/crew/routing-fallback.js +0 -64
- package/crew/routing-metrics.js +0 -215
- package/crew/routing.js +0 -951
- package/crew/session.js +0 -648
- package/crew/shared-dir.js +0 -266
- package/crew/task-files.js +0 -554
- package/crew/ui-messages.js +0 -274
- package/crew/worktree.js +0 -130
- package/crew-i18n.js +0 -579
- package/crew.js +0 -48
|
@@ -8,14 +8,24 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
import { parseMessage, parseSeqFromId } from './persist.js';
|
|
10
10
|
|
|
11
|
+
function parseJsonLine(line) {
|
|
12
|
+
if (!line || !line.trim()) return null;
|
|
13
|
+
try {
|
|
14
|
+
const msg = JSON.parse(line);
|
|
15
|
+
return msg && typeof msg === 'object' ? msg : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
/**
|
|
12
|
-
* Search messages in a directory for a keyword.
|
|
22
|
+
* Search Markdown messages in a directory for a keyword.
|
|
13
23
|
*
|
|
14
24
|
* @param {string} dir — messages directory
|
|
15
25
|
* @param {string} keyword — search term (case-insensitive)
|
|
16
26
|
* @returns {object[]} — matching messages
|
|
17
27
|
*/
|
|
18
|
-
function
|
|
28
|
+
function searchMarkdownDir(dir, keyword) {
|
|
19
29
|
if (!existsSync(dir)) return [];
|
|
20
30
|
|
|
21
31
|
const lowerKeyword = keyword.toLowerCase();
|
|
@@ -35,6 +45,29 @@ function searchDir(dir, keyword) {
|
|
|
35
45
|
return results;
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
function searchSegmentDir(dir, keyword) {
|
|
49
|
+
if (!existsSync(dir)) return [];
|
|
50
|
+
const lowerKeyword = keyword.toLowerCase();
|
|
51
|
+
const files = readdirSync(dir)
|
|
52
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
53
|
+
.sort()
|
|
54
|
+
.reverse();
|
|
55
|
+
|
|
56
|
+
const results = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
59
|
+
if (!raw.toLowerCase().includes(lowerKeyword)) continue;
|
|
60
|
+
const lines = raw.split('\n');
|
|
61
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
if (!line || !line.toLowerCase().includes(lowerKeyword)) continue;
|
|
64
|
+
const msg = parseJsonLine(line);
|
|
65
|
+
if (msg) results.push(msg);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
38
71
|
function compareNewest(a, b) {
|
|
39
72
|
const sa = parseSeqFromId(a?.id);
|
|
40
73
|
const sb = parseSeqFromId(b?.id);
|
|
@@ -42,7 +75,7 @@ function compareNewest(a, b) {
|
|
|
42
75
|
return String(b?.time || '').localeCompare(String(a?.time || ''));
|
|
43
76
|
}
|
|
44
77
|
|
|
45
|
-
function
|
|
78
|
+
function sessionConversationDirs(dir) {
|
|
46
79
|
const dirs = [];
|
|
47
80
|
const seen = new Set();
|
|
48
81
|
for (const rootName of ['sessions', 'groups']) {
|
|
@@ -57,12 +90,9 @@ function sessionConversationMessageDirs(dir) {
|
|
|
57
90
|
}
|
|
58
91
|
|
|
59
92
|
const conversationDir = join(sessionDir, 'conversation');
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
seen.add(messagesDir);
|
|
64
|
-
dirs.push(messagesDir);
|
|
65
|
-
}
|
|
93
|
+
if (seen.has(conversationDir)) continue;
|
|
94
|
+
seen.add(conversationDir);
|
|
95
|
+
dirs.push(conversationDir);
|
|
66
96
|
}
|
|
67
97
|
}
|
|
68
98
|
return dirs;
|
|
@@ -79,17 +109,23 @@ function sessionConversationMessageDirs(dir) {
|
|
|
79
109
|
export function searchMessages(dir, keyword, limit = 20) {
|
|
80
110
|
if (!keyword || !keyword.trim()) return [];
|
|
81
111
|
|
|
82
|
-
const
|
|
83
|
-
join(dir, 'chat'
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
const conversationDirs = [
|
|
113
|
+
join(dir, 'chat'),
|
|
114
|
+
...sessionConversationDirs(dir),
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const markdownDirs = [
|
|
118
|
+
...conversationDirs.flatMap(d => [join(d, 'messages'), join(d, 'cold')]),
|
|
86
119
|
// Compatibility for profiles created before chat/session split.
|
|
87
120
|
join(dir, 'conversation', 'messages'),
|
|
88
121
|
join(dir, 'conversation', 'cold'),
|
|
89
122
|
];
|
|
123
|
+
const segmentDirs = conversationDirs.map(d => join(d, 'segments'));
|
|
90
124
|
|
|
91
|
-
return
|
|
92
|
-
.flatMap(d =>
|
|
125
|
+
return [
|
|
126
|
+
...segmentDirs.flatMap(d => searchSegmentDir(d, keyword)),
|
|
127
|
+
...markdownDirs.flatMap(d => searchMarkdownDir(d, keyword)),
|
|
128
|
+
]
|
|
93
129
|
.sort(compareNewest)
|
|
94
130
|
.slice(0, limit);
|
|
95
131
|
}
|
package/yeaft/session.js
CHANGED
|
@@ -45,7 +45,7 @@ import { TaskManager } from './tasks/manager.js';
|
|
|
45
45
|
// resumes with the same onDemand/recent membership it had on
|
|
46
46
|
// disconnect. Engine.#runQuery uses the registry to populate the
|
|
47
47
|
// AMS each turn and to run `memory/adjust.js` post-turn.
|
|
48
|
-
import { ensureDefaultSessionIfEmpty } from './sessions/session-crud.js';
|
|
48
|
+
import { ensureDefaultSessionIfEmpty, yeaftDirForWorkDir } from './sessions/session-crud.js';
|
|
49
49
|
import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
50
50
|
import { topUpDefaultVps } from './vp/seed-topup.js';
|
|
51
51
|
import { archiveLegacyScopes } from './memory/seed-backfill.js';
|
|
@@ -71,6 +71,7 @@ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
|
|
|
71
71
|
/**
|
|
72
72
|
* @typedef {Object} SessionOptions
|
|
73
73
|
* @property {string} [dir] — Yeaft data directory override (default: ~/.yeaft)
|
|
74
|
+
* @property {string} [workDir] — Session workDir; when provided, storage lives under <workDir>/.yeaft while config still comes from dir/defaults
|
|
74
75
|
* @property {string} [model] — Model override
|
|
75
76
|
* @property {string} [language] — Language override ('en' | 'zh')
|
|
76
77
|
* @property {boolean} [debug] — Debug mode override
|
|
@@ -136,6 +137,7 @@ function prepareToolStatsDir(yeaftDir) {
|
|
|
136
137
|
export async function loadSession(options = {}) {
|
|
137
138
|
const {
|
|
138
139
|
dir,
|
|
140
|
+
workDir,
|
|
139
141
|
model,
|
|
140
142
|
language,
|
|
141
143
|
debug,
|
|
@@ -146,21 +148,29 @@ export async function loadSession(options = {}) {
|
|
|
146
148
|
serverMode = false,
|
|
147
149
|
} = options;
|
|
148
150
|
|
|
149
|
-
// ─── 1. Determine
|
|
150
|
-
// Must happen BEFORE loadConfig so that first-run
|
|
151
|
-
//
|
|
151
|
+
// ─── 1. Determine config + store directories ─────────────
|
|
152
|
+
// Must happen BEFORE loadConfig so that first-run generates a
|
|
153
|
+
// default config.json that loadConfig can read. workDir-backed
|
|
154
|
+
// Sessions store conversation/session data under <workDir>/.yeaft,
|
|
155
|
+
// but runtime config still comes from the agent-local configDir.
|
|
152
156
|
const overrides = { ...configOverrides };
|
|
153
157
|
if (dir) overrides.dir = dir;
|
|
154
158
|
if (model) overrides.model = model;
|
|
155
159
|
if (language) overrides.language = language;
|
|
156
160
|
if (debug !== undefined) overrides.debug = debug;
|
|
157
161
|
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
const sessionWorkDir = typeof workDir === 'string' && workDir.trim() ? workDir.trim() : '';
|
|
163
|
+
const configDir = overrides.dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
164
|
+
const yeaftDir = sessionWorkDir ? yeaftDirForWorkDir(sessionWorkDir) : configDir;
|
|
165
|
+
const configInitResult = initYeaftDir(configDir);
|
|
166
|
+
const storeInitResult = yeaftDir === configDir ? configInitResult : initYeaftDir(yeaftDir);
|
|
167
|
+
overrides.dir = configDir;
|
|
168
|
+
|
|
169
|
+
// Log any warnings from directory initialization.
|
|
170
|
+
const initWarnings = yeaftDir === configDir
|
|
171
|
+
? configInitResult.warnings
|
|
172
|
+
: [...configInitResult.warnings, ...storeInitResult.warnings];
|
|
173
|
+
for (const w of initWarnings) {
|
|
164
174
|
console.warn(`[Yeaft] ${w}`);
|
|
165
175
|
}
|
|
166
176
|
|
|
@@ -212,7 +222,7 @@ export async function loadSession(options = {}) {
|
|
|
212
222
|
// ─── 2a. Permission pre-check ─────────────────────────
|
|
213
223
|
// If the data dir is not writable, mark session as read-only.
|
|
214
224
|
// Persistence (conversation, memory, dream) is skipped in this mode.
|
|
215
|
-
if (!
|
|
225
|
+
if (!storeInitResult.writable) {
|
|
216
226
|
config._readOnly = true;
|
|
217
227
|
console.warn(`[Yeaft] ${yeaftDir} is not writable — running in read-only mode`);
|
|
218
228
|
}
|
|
@@ -365,24 +375,23 @@ export async function loadSession(options = {}) {
|
|
|
365
375
|
}
|
|
366
376
|
|
|
367
377
|
// ─── 6. Load skills ────────────────────────────────────
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
const projectTierRoot = process.cwd();
|
|
378
|
+
// User/global runtime assets still come from configDir. workDir, when
|
|
379
|
+
// present, is only a project tier overlay plus the storage root.
|
|
380
|
+
const projectTierRoot = sessionWorkDir || process.cwd();
|
|
372
381
|
|
|
373
382
|
let skillManager;
|
|
374
383
|
if (skipSkills) {
|
|
375
384
|
// Pass the literal user-tier dir (matches the normal branch's tier 2)
|
|
376
385
|
// so any save/remove calls land in the same place users expect. New
|
|
377
386
|
// `SkillManager` API takes literal scan dirs — no auto-suffix of /skills.
|
|
378
|
-
skillManager = new SkillManager(join(
|
|
387
|
+
skillManager = new SkillManager(join(configDir, 'skills'));
|
|
379
388
|
// Don't call .load() — empty skill manager
|
|
380
389
|
} else {
|
|
381
|
-
skillManager = createSkillManager(
|
|
390
|
+
skillManager = createSkillManager(configDir, projectTierRoot);
|
|
382
391
|
}
|
|
383
392
|
|
|
384
393
|
// ─── 7. Connect MCP servers ────────────────────────────
|
|
385
|
-
const mcpConfig = loadMCPConfig(
|
|
394
|
+
const mcpConfig = loadMCPConfig(configDir, undefined, projectTierRoot);
|
|
386
395
|
const mcpManager = new MCPManager();
|
|
387
396
|
let mcpStatus = { connected: [], failed: [] };
|
|
388
397
|
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -12,10 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* task-330c lint guard:
|
|
14
14
|
* ⚠️ DO NOT introduce greedy `text.replace(/---ROUTE---[\s\S]*$/g, '')`
|
|
15
|
-
* style strips on incoming/outgoing message bodies.
|
|
16
|
-
* stripping is owned EXCLUSIVELY by `agent/crew/routing.js`
|
|
17
|
-
* `parseRoutes()` which returns `{routes, displayBody}` with exact
|
|
18
|
-
* ranges removed.
|
|
15
|
+
* style strips on incoming/outgoing message bodies.
|
|
19
16
|
*/
|
|
20
17
|
|
|
21
18
|
import { delimiter, join } from 'node:path';
|
|
@@ -53,6 +50,7 @@ import {
|
|
|
53
50
|
scanWorkdirSessions,
|
|
54
51
|
restoreSessionToRegistry,
|
|
55
52
|
readWorkDirRegistry,
|
|
53
|
+
yeaftDirForWorkDir,
|
|
56
54
|
} from './sessions/session-crud.js';
|
|
57
55
|
import { openSession, loadSessionMeta } from './sessions/session-store.js';
|
|
58
56
|
import { loadSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
|
|
@@ -414,6 +412,13 @@ function projectRuntimeKey(workDir) {
|
|
|
414
412
|
return normalizeSessionWorkDir(workDir) || '__agent_cwd__';
|
|
415
413
|
}
|
|
416
414
|
|
|
415
|
+
function resolveStoreYeaftDirForSession(defaultYeaftDir, { sessionId = null, sessionMeta = null, workDir = '' } = {}) {
|
|
416
|
+
const normalizedWorkDir = normalizeSessionWorkDir(workDir || sessionMeta?.workDir);
|
|
417
|
+
if (normalizedWorkDir) return yeaftDirForWorkDir(normalizedWorkDir);
|
|
418
|
+
if (sessionId) return resolveSessionYeaftDir(defaultYeaftDir, sessionId);
|
|
419
|
+
return defaultYeaftDir;
|
|
420
|
+
}
|
|
421
|
+
|
|
417
422
|
function createThreadId() {
|
|
418
423
|
return `thr_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
|
|
419
424
|
}
|
|
@@ -3583,7 +3588,7 @@ async function runYeaftSessionSend(msg) {
|
|
|
3583
3588
|
|
|
3584
3589
|
// ── Attachments (images + files) ───────────────────────────────
|
|
3585
3590
|
// Server has already resolved fileId → { name, mimeType, data:base64,
|
|
3586
|
-
// isImage } via the
|
|
3591
|
+
// isImage } via the client-conversation.js relay
|
|
3587
3592
|
// for `yeaft_*`). We persist files to disk under the agent's CWD so
|
|
3588
3593
|
// file-tools (file-read / bash) can pick them up with relative paths,
|
|
3589
3594
|
// and we build per-image content blocks for the LLM call. The
|
|
@@ -3880,6 +3885,11 @@ async function ensureSessionLoaded(opts = {}) {
|
|
|
3880
3885
|
sessionLoadPromise = (async () => {
|
|
3881
3886
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
3882
3887
|
const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
|
|
3888
|
+
const sessionYeaftDir = resolveStoreYeaftDirForSession(yeaftDir, {
|
|
3889
|
+
sessionId: opts?.sessionId || opts?.sessionMeta?.id || null,
|
|
3890
|
+
sessionMeta: opts?.sessionMeta || null,
|
|
3891
|
+
workDir: normalizedWorkDir,
|
|
3892
|
+
});
|
|
3883
3893
|
session = await loadSession({
|
|
3884
3894
|
...(yeaftDir && { dir: yeaftDir }),
|
|
3885
3895
|
...(normalizedWorkDir && { workDir: normalizedWorkDir }),
|
|
@@ -5640,12 +5650,22 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
5640
5650
|
const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
|
|
5641
5651
|
ensureYeaftConversationId();
|
|
5642
5652
|
|
|
5653
|
+
let sessionMetaForRuntime = null;
|
|
5654
|
+
let sessionYeaftDir = yeaftDir;
|
|
5655
|
+
if (sessionId) {
|
|
5656
|
+
try {
|
|
5657
|
+
sessionYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
5658
|
+
const metaDir = join(sessionsRoot(sessionYeaftDir), sessionId);
|
|
5659
|
+
sessionMetaForRuntime = loadSessionMeta(metaDir);
|
|
5660
|
+
} catch { /* best-effort metadata hint */ }
|
|
5661
|
+
}
|
|
5662
|
+
|
|
5643
5663
|
// First paint must not wait for full Yeaft runtime boot (MCP connects,
|
|
5644
|
-
// skill scans, memory index sync). The conversation
|
|
5664
|
+
// skill scans, memory index sync). The conversation segment store is the
|
|
5645
5665
|
// source of truth and can be opened cheaply, so replay the visible message
|
|
5646
5666
|
// window immediately, then finish loadSession below for actual turns.
|
|
5647
5667
|
const coldStoreStart = perfNowMs();
|
|
5648
|
-
const coldStore = new ConversationStore(
|
|
5668
|
+
const coldStore = new ConversationStore(sessionYeaftDir);
|
|
5649
5669
|
traceDuration('history.cold_store_open', coldStoreStart);
|
|
5650
5670
|
if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
|
|
5651
5671
|
let afterSeq = afterSeqRaw;
|
|
@@ -5675,14 +5695,6 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
5675
5695
|
}
|
|
5676
5696
|
historyAlreadyReplayed = true;
|
|
5677
5697
|
|
|
5678
|
-
let sessionMetaForRuntime = null;
|
|
5679
|
-
if (sessionId) {
|
|
5680
|
-
try {
|
|
5681
|
-
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
5682
|
-
const metaDir = join(sessionsRoot(groupYeaftDir), sessionId);
|
|
5683
|
-
sessionMetaForRuntime = loadSessionMeta(metaDir);
|
|
5684
|
-
} catch { /* best-effort metadata hint */ }
|
|
5685
|
-
}
|
|
5686
5698
|
// Full runtime boot can be expensive (memory FTS sync, skills, MCP, dream
|
|
5687
5699
|
// boot checks). It is not needed to render persisted history, so keep this
|
|
5688
5700
|
// request short and let message-send await the same single-flight boot when
|
package/crew/builtin-actions.js
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Crew — built-in role actions (task-330a §B)
|
|
3
|
-
*
|
|
4
|
-
* These two actions replace the "PM 给自己发闭环消息" anti-pattern that
|
|
5
|
-
* task-330a §A now rejects at the routing layer:
|
|
6
|
-
*
|
|
7
|
-
* • taskClose(session, { taskId, summary, fromRole })
|
|
8
|
-
* — directly mark the task complete on the kanban + broadcast a
|
|
9
|
-
* status card via sendCrewMessage. No ROUTE round-trip.
|
|
10
|
-
*
|
|
11
|
-
* • roleStandby(session, { role, reason, fromRole })
|
|
12
|
-
* — flip a role's runtime state to 'standby' and broadcast.
|
|
13
|
-
* Persistence to .crew/context/role-states.json is a 330d
|
|
14
|
-
* concern — we only define the in-memory contract here.
|
|
15
|
-
*
|
|
16
|
-
* Both functions are pure server-side helpers. They do NOT consume a
|
|
17
|
-
* routing turn (no session.round++, no dispatchToRole). Callers that
|
|
18
|
-
* choose to expose them as Claude tools (via canCallTool / mcp) must
|
|
19
|
-
* wire that separately — task-330a defines the contract; tool-binding
|
|
20
|
-
* lands in 330d alongside the role-state persistence layer.
|
|
21
|
-
*
|
|
22
|
-
* Red lines (per PM dispatch):
|
|
23
|
-
* - Do not mutate routing protocol shape (.routes / displayBody).
|
|
24
|
-
* - Do not break task-319/328 (parser tests stay green).
|
|
25
|
-
* - Old session replay must keep working — we only add new keys to
|
|
26
|
-
* session.roleStates[role], never remove.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { sendCrewMessage, sendStatusUpdate } from './ui-messages.js';
|
|
30
|
-
import { updateKanban, appendChangelog, updateFeatureIndex, isValidTaskId } from './task-files.js';
|
|
31
|
-
|
|
32
|
-
/** Valid standby reasons — extend with care; consumers may grep for these. */
|
|
33
|
-
export const STANDBY_REASONS = Object.freeze([
|
|
34
|
-
'task_closed', 'awaiting_input', 'manual', 'idle', 'paused',
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Mark a task as complete on the kanban + broadcast a status card.
|
|
39
|
-
*
|
|
40
|
-
* Mirrors the side-effects that role-output.js performs when it detects a
|
|
41
|
-
* completed TASKS block, so the two paths converge on the same kanban
|
|
42
|
-
* state regardless of which one fires.
|
|
43
|
-
*
|
|
44
|
-
* @param {object} session
|
|
45
|
-
* @param {{ taskId: string, summary?: string, fromRole?: string }} params
|
|
46
|
-
* @returns {Promise<{ ok: boolean, taskId: string, reason?: string }>}
|
|
47
|
-
*/
|
|
48
|
-
export async function taskClose(session, { taskId, summary, fromRole }) {
|
|
49
|
-
if (!session) {
|
|
50
|
-
return { ok: false, taskId: taskId || null, reason: 'no_session' };
|
|
51
|
-
}
|
|
52
|
-
if (!taskId || !isValidTaskId(taskId)) {
|
|
53
|
-
return { ok: false, taskId: taskId || null, reason: 'invalid_task_id' };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Mark in the in-memory completion set so future kanban rebuilds keep it.
|
|
57
|
-
if (!session._completedTaskIds) session._completedTaskIds = new Set();
|
|
58
|
-
const wasAlreadyCompleted = session._completedTaskIds.has(taskId);
|
|
59
|
-
session._completedTaskIds.add(taskId);
|
|
60
|
-
|
|
61
|
-
const feature = session.features?.get(taskId);
|
|
62
|
-
const taskTitle = feature?.taskTitle || taskId;
|
|
63
|
-
const cleanSummary = (summary && String(summary).trim()) || '已完成';
|
|
64
|
-
|
|
65
|
-
// Persist to the kanban file (best-effort; warns on failure).
|
|
66
|
-
try {
|
|
67
|
-
await updateKanban(session, { taskId, completed: true, summary: cleanSummary });
|
|
68
|
-
} catch (e) {
|
|
69
|
-
console.warn(`[Crew] taskClose: updateKanban failed for ${taskId}:`, e.message);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Append to features index + changelog only on first close (idempotent).
|
|
73
|
-
if (!wasAlreadyCompleted) {
|
|
74
|
-
updateFeatureIndex(session)
|
|
75
|
-
.catch(e => console.warn('[Crew] taskClose: updateFeatureIndex failed:', e.message));
|
|
76
|
-
appendChangelog(session, taskId, taskTitle)
|
|
77
|
-
.catch(e => console.warn(`[Crew] taskClose: appendChangelog failed for ${taskId}:`, e.message));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Broadcast status card so the UI reflects the close immediately.
|
|
81
|
-
try {
|
|
82
|
-
sendCrewMessage({
|
|
83
|
-
type: 'crew_task_closed',
|
|
84
|
-
sessionId: session.id,
|
|
85
|
-
taskId,
|
|
86
|
-
taskTitle,
|
|
87
|
-
summary: cleanSummary,
|
|
88
|
-
fromRole: fromRole || null,
|
|
89
|
-
timestamp: Date.now(),
|
|
90
|
-
});
|
|
91
|
-
sendStatusUpdate(session);
|
|
92
|
-
} catch (e) {
|
|
93
|
-
console.warn(`[Crew] taskClose: broadcast failed for ${taskId}:`, e.message);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return { ok: true, taskId };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Flip a role into 'standby' (in-memory) and broadcast.
|
|
101
|
-
*
|
|
102
|
-
* 330a defines the contract; 330d wires the .crew/context/role-states.json
|
|
103
|
-
* persistence. We DO mutate `session.roleStates[role].standby` here so the
|
|
104
|
-
* UI / status pipeline can reflect the change immediately, and 330d can
|
|
105
|
-
* snapshot it on the next debounced save.
|
|
106
|
-
*
|
|
107
|
-
* @param {object} session
|
|
108
|
-
* @param {{ role: string, reason?: string, fromRole?: string }} params
|
|
109
|
-
* @returns {{ ok: boolean, role: string, reason?: string }}
|
|
110
|
-
*/
|
|
111
|
-
export function roleStandby(session, { role, reason, fromRole }) {
|
|
112
|
-
if (!session) {
|
|
113
|
-
return { ok: false, role: role || null, reason: 'no_session' };
|
|
114
|
-
}
|
|
115
|
-
if (!role || typeof role !== 'string') {
|
|
116
|
-
return { ok: false, role: role || null, reason: 'invalid_role' };
|
|
117
|
-
}
|
|
118
|
-
if (!session.roles || !session.roles.has(role)) {
|
|
119
|
-
return { ok: false, role, reason: 'unknown_role' };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const normalizedReason = reason && STANDBY_REASONS.includes(reason)
|
|
123
|
-
? reason
|
|
124
|
-
: 'manual';
|
|
125
|
-
|
|
126
|
-
// Ensure the roleState bucket exists (cold-start safe).
|
|
127
|
-
let roleState = session.roleStates?.get?.(role);
|
|
128
|
-
if (!roleState) {
|
|
129
|
-
roleState = {};
|
|
130
|
-
session.roleStates?.set?.(role, roleState);
|
|
131
|
-
}
|
|
132
|
-
// New keys only — never delete legacy fields (replay safety).
|
|
133
|
-
roleState.standby = {
|
|
134
|
-
reason: normalizedReason,
|
|
135
|
-
since: Date.now(),
|
|
136
|
-
setBy: fromRole || null,
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
sendCrewMessage({
|
|
141
|
-
type: 'crew_role_standby',
|
|
142
|
-
sessionId: session.id,
|
|
143
|
-
role,
|
|
144
|
-
reason: normalizedReason,
|
|
145
|
-
fromRole: fromRole || null,
|
|
146
|
-
timestamp: Date.now(),
|
|
147
|
-
});
|
|
148
|
-
sendStatusUpdate(session);
|
|
149
|
-
} catch (e) {
|
|
150
|
-
console.warn(`[Crew] roleStandby: broadcast failed for ${role}:`, e.message);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return { ok: true, role, reason: normalizedReason };
|
|
154
|
-
}
|
package/crew/context-loader.js
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Crew Context Loader — detect and parse .crew/ directory structure.
|
|
3
|
-
*
|
|
4
|
-
* Reads .crew/CLAUDE.md, session.json, per-role CLAUDE.md files,
|
|
5
|
-
* kanban, and feature files. Used by the frontend to detect whether
|
|
6
|
-
* a project has a Crew setup and display role metadata.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
11
|
-
import ctx from '../context.js';
|
|
12
|
-
|
|
13
|
-
const MAX_CLAUDE_MD_LEN = 8192;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Read a file, returning null on any error.
|
|
17
|
-
* @param {string} filePath
|
|
18
|
-
* @returns {string|null}
|
|
19
|
-
*/
|
|
20
|
-
function readFileOrNull(filePath) {
|
|
21
|
-
try {
|
|
22
|
-
if (!existsSync(filePath)) return null;
|
|
23
|
-
return readFileSync(filePath, 'utf-8');
|
|
24
|
-
} catch {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Deduplicate Crew roles by roleType.
|
|
31
|
-
* Crew may have dev-1, dev-2, dev-3 — collapse to a single "dev" entry.
|
|
32
|
-
* Attaches per-role CLAUDE.md content.
|
|
33
|
-
*/
|
|
34
|
-
function deduplicateRoles(sessionRoles, roleClaudes) {
|
|
35
|
-
const byType = new Map();
|
|
36
|
-
const merged = [];
|
|
37
|
-
|
|
38
|
-
for (const r of sessionRoles) {
|
|
39
|
-
const type = r.roleType || r.name;
|
|
40
|
-
const claudeMd = roleClaudes[r.name] || '';
|
|
41
|
-
|
|
42
|
-
if (byType.has(type)) continue;
|
|
43
|
-
byType.set(type, true);
|
|
44
|
-
|
|
45
|
-
const name = type;
|
|
46
|
-
let displayName = r.displayName || name;
|
|
47
|
-
displayName = displayName.replace(/-\d+$/, '');
|
|
48
|
-
|
|
49
|
-
merged.push({
|
|
50
|
-
name,
|
|
51
|
-
displayName,
|
|
52
|
-
icon: r.icon || '',
|
|
53
|
-
description: r.description || '',
|
|
54
|
-
claudeMd: claudeMd.substring(0, MAX_CLAUDE_MD_LEN),
|
|
55
|
-
roleType: type,
|
|
56
|
-
isDecisionMaker: !!r.isDecisionMaker,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return merged;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Load .crew context from a project directory.
|
|
65
|
-
* Returns null if .crew/ doesn't exist.
|
|
66
|
-
*/
|
|
67
|
-
export function loadCrewContext(projectDir) {
|
|
68
|
-
const crewDir = join(projectDir, '.crew');
|
|
69
|
-
if (!existsSync(crewDir)) return null;
|
|
70
|
-
|
|
71
|
-
// 1. Shared CLAUDE.md
|
|
72
|
-
const sharedClaudeMd = readFileOrNull(join(crewDir, 'CLAUDE.md')) || '';
|
|
73
|
-
|
|
74
|
-
// 2. session.json → roles, teamType, language, features
|
|
75
|
-
let sessionRoles = [];
|
|
76
|
-
let teamType = 'dev';
|
|
77
|
-
let language = 'zh-CN';
|
|
78
|
-
let sessionFeatures = [];
|
|
79
|
-
const sessionPath = join(crewDir, 'session.json');
|
|
80
|
-
const sessionJson = readFileOrNull(sessionPath);
|
|
81
|
-
if (sessionJson) {
|
|
82
|
-
try {
|
|
83
|
-
const session = JSON.parse(sessionJson);
|
|
84
|
-
if (Array.isArray(session.roles)) sessionRoles = session.roles;
|
|
85
|
-
if (session.teamType) teamType = session.teamType;
|
|
86
|
-
if (session.language) language = session.language;
|
|
87
|
-
if (Array.isArray(session.features)) sessionFeatures = session.features;
|
|
88
|
-
} catch {
|
|
89
|
-
// Invalid JSON — ignore
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// 3. Per-role CLAUDE.md from .crew/roles/*/CLAUDE.md
|
|
94
|
-
const roleClaudes = {};
|
|
95
|
-
const rolesDir = join(crewDir, 'roles');
|
|
96
|
-
if (existsSync(rolesDir)) {
|
|
97
|
-
try {
|
|
98
|
-
const roleDirs = readdirSync(rolesDir, { withFileTypes: true })
|
|
99
|
-
.filter(d => d.isDirectory())
|
|
100
|
-
.map(d => d.name);
|
|
101
|
-
for (const dirName of roleDirs) {
|
|
102
|
-
const md = readFileOrNull(join(rolesDir, dirName, 'CLAUDE.md'));
|
|
103
|
-
if (md) roleClaudes[dirName] = md;
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
// Permission error or similar — ignore
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// 4. Merge roles: deduplicate by roleType, attach claudeMd
|
|
111
|
-
const roles = deduplicateRoles(sessionRoles, roleClaudes);
|
|
112
|
-
|
|
113
|
-
// 5. Kanban
|
|
114
|
-
const kanban = readFileOrNull(join(crewDir, 'context', 'kanban.md')) || '';
|
|
115
|
-
|
|
116
|
-
// 6. Feature files from context/features/*.md
|
|
117
|
-
const features = [];
|
|
118
|
-
const featuresDir = join(crewDir, 'context', 'features');
|
|
119
|
-
if (existsSync(featuresDir)) {
|
|
120
|
-
try {
|
|
121
|
-
const files = readdirSync(featuresDir)
|
|
122
|
-
.filter(f => f.endsWith('.md') && f !== 'index.md')
|
|
123
|
-
.sort();
|
|
124
|
-
for (const f of files) {
|
|
125
|
-
const content = readFileOrNull(join(featuresDir, f));
|
|
126
|
-
if (content) {
|
|
127
|
-
features.push({ name: f.replace('.md', ''), content });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
} catch {
|
|
131
|
-
// ignore
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return { sharedClaudeMd, roles, kanban, features, teamType, language, sessionFeatures };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Handle check_crew_context message — check if a project has .crew/ setup
|
|
140
|
-
* and return role metadata for the frontend.
|
|
141
|
-
*/
|
|
142
|
-
export function handleCheckCrewContext(msg) {
|
|
143
|
-
const { projectDir, requestId } = msg;
|
|
144
|
-
if (!projectDir) {
|
|
145
|
-
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
const crewContext = loadCrewContext(projectDir);
|
|
149
|
-
if (!crewContext) {
|
|
150
|
-
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
// Return a safe subset for the frontend (no full claudeMd content, just metadata)
|
|
154
|
-
ctx.sendToServer({
|
|
155
|
-
type: 'crew_context_result',
|
|
156
|
-
requestId,
|
|
157
|
-
found: true,
|
|
158
|
-
roles: crewContext.roles.map(r => ({
|
|
159
|
-
name: r.name,
|
|
160
|
-
displayName: r.displayName,
|
|
161
|
-
icon: r.icon,
|
|
162
|
-
description: r.description,
|
|
163
|
-
roleType: r.roleType,
|
|
164
|
-
isDecisionMaker: r.isDecisionMaker,
|
|
165
|
-
hasClaudeMd: !!(r.claudeMd && r.claudeMd.length > 0),
|
|
166
|
-
})),
|
|
167
|
-
teamType: crewContext.teamType,
|
|
168
|
-
language: crewContext.language,
|
|
169
|
-
featureCount: crewContext.features.length,
|
|
170
|
-
});
|
|
171
|
-
}
|