@yeaft/webchat-agent 0.1.791 → 0.1.792

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.791",
3
+ "version": "0.1.792",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/config.js CHANGED
@@ -42,6 +42,11 @@ const DEFAULTS = {
42
42
  // plumb the knob through so the UI can set it).
43
43
  unifyMaxConcurrentThreads: 6,
44
44
  unifyAutoArchiveIdleDays: 30,
45
+ // CLAUDE.md / AGENTS.md project-doc cap, in bytes. Mirrors Codex's
46
+ // `project_doc_max_bytes`. 0 disables the feature (no project-doc
47
+ // block is injected). Hand-edited values are NOT clamped — we let
48
+ // power users opt into larger docs at their own context-window risk.
49
+ projectDocMaxBytes: 32 * 1024,
45
50
  };
46
51
 
47
52
  // ─── config.json reader ─────────────────────────────────────────
@@ -215,6 +220,10 @@ function loadLegacyConfig(dir, overrides) {
215
220
  maxOutputTokens: overrides.maxOutputTokens ?? fileConfig.maxOutputTokens ?? DEFAULTS.maxOutputTokens,
216
221
  messageTokenBudget: overrides.messageTokenBudget ?? (env.YEAFT_MESSAGE_TOKEN_BUDGET ? parseInt(env.YEAFT_MESSAGE_TOKEN_BUDGET, 10) : null) ?? fileConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
217
222
  maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
223
+ // CLAUDE.md / AGENTS.md project-doc cap, in bytes. Legacy config
224
+ // has no field for this, so it always lands on the default unless
225
+ // explicitly overridden via CLI.
226
+ projectDocMaxBytes: overrides.projectDocMaxBytes ?? fileConfig.projectDocMaxBytes ?? DEFAULTS.projectDocMaxBytes,
218
227
  // task-318: legacy path never had the `unify` section — defaults.
219
228
  unify: normaliseUnifySection(null),
220
229
  providers: null,
@@ -309,6 +318,11 @@ export function loadConfig(overrides = {}) {
309
318
  messageTokenBudget: overrides.messageTokenBudget ?? jsonConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
310
319
  maxContinueTurns: overrides.maxContinueTurns ?? jsonConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
311
320
 
321
+ // CLAUDE.md / AGENTS.md project-doc cap, in bytes. Set to 0 to
322
+ // disable the feature entirely. Read from `projectDocMaxBytes` in
323
+ // ~/.yeaft/config.json or threaded through CLI overrides.
324
+ projectDocMaxBytes: overrides.projectDocMaxBytes ?? jsonConfig.projectDocMaxBytes ?? DEFAULTS.projectDocMaxBytes,
325
+
312
326
  // task-318: Unify runtime caps. `unify` is a nested section so we
313
327
  // don't pollute the flat config namespace used by chat/crew code.
314
328
  unify: normaliseUnifySection(jsonConfig.unify),
package/unify/engine.js CHANGED
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
21
21
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
22
22
  import { LLMContextError, LLMAbortError } from './llm/adapter.js';
23
23
  import { runMemoryPreflow, buildRelevantScopes } from './groups/pre-flow.js';
24
+ import { readProjectDoc, pickProjectDocFile, DEFAULT_PROJECT_DOC_MAX_BYTES } from './groups/project-doc.js';
24
25
  import { shouldConsolidate, partitionMessages } from './memory/consolidate.js';
25
26
  import { runCompact as runCompactOrchestrator } from './compact/orchestrator.js';
26
27
  import { evaluateCompactTriggers } from './compact/triggers.js';
@@ -323,6 +324,18 @@ export class Engine {
323
324
  /** @type {string|null} */
324
325
  #abortReason = null;
325
326
 
327
+ /**
328
+ * Per-engine cache of the resolved CLAUDE.md / AGENTS.md project doc.
329
+ * Shape: `{ workDir, path, mtimeMs, text } | null`. A non-null record
330
+ * means "for THIS workDir, the file at `path` with this `mtimeMs`
331
+ * resolved to `text`" — when the next turn's stat returns the same
332
+ * `(path, mtimeMs)` tuple, we skip the read entirely. mtime changes
333
+ * (or a different picked file, e.g. user added AGENTS.md) invalidate
334
+ * automatically because the `path`/`mtimeMs` comparison fails.
335
+ * @type {{ workDir: string, path: string, mtimeMs: number, text: string }|null}
336
+ */
337
+ #projectDocCache = null;
338
+
326
339
  /**
327
340
  * @param {{
328
341
  * adapter: import('./llm/adapter.js').LLMAdapter,
@@ -685,10 +698,11 @@ export class Engine {
685
698
  * @param {object} [args.vpPersona]
686
699
  * @param {object} [args.activeScope] — DESIGN-PROMPT §3 ④ structured scope summary
687
700
  * @param {string} [args.groupAnnouncement]
701
+ * @param {string} [args.projectDoc] — resolved CLAUDE.md / AGENTS.md text (already truncated)
688
702
  * @param {object} [args.taskCtx] — legacy task-context sub-block (optional)
689
703
  * @returns {string}
690
704
  */
691
- #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, groupAnnouncement, taskCtx } = {}) {
705
+ #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, groupAnnouncement, projectDoc, taskCtx } = {}) {
692
706
  // Get relevant skill content if SkillManager is wired
693
707
  let skillContent = '';
694
708
  if (this.#skillManager && prompt) {
@@ -708,6 +722,7 @@ export class Engine {
708
722
  vpPersona,
709
723
  activeScope,
710
724
  groupAnnouncement,
725
+ projectDoc,
711
726
  taskCtx,
712
727
  // Worker-shape harness is descriptive metadata for human inspection;
713
728
  // production prompts skip it to save tokens. Re-enable via env when
@@ -716,6 +731,76 @@ export class Engine {
716
731
  });
717
732
  }
718
733
 
734
+ /**
735
+ * Resolve the CLAUDE.md / AGENTS.md project doc text for the current
736
+ * group's working directory. mtime-cached on the engine so we only
737
+ * re-read the file when the user actually edited it.
738
+ *
739
+ * Cache strategy:
740
+ * 1. Cheap path: `pickProjectDocFile` (two stats, no read).
741
+ * 2. If the picked `(path, mtimeMs)` matches the cache → return
742
+ * cached text. No disk read, no UTF-8 decode, no truncation work.
743
+ * 3. Cache miss → call `readProjectDoc` (bounded `readSync` into a
744
+ * pre-sized buffer), refresh the cache, return the fresh text.
745
+ *
746
+ * Returns '' when:
747
+ * - workDir is empty / not a string
748
+ * - config.projectDocMaxBytes === 0 (feature disabled)
749
+ * - neither CLAUDE.md nor AGENTS.md exists in workDir
750
+ * - the picked file is empty after trim
751
+ *
752
+ * @param {string|undefined} workDir
753
+ * @returns {string}
754
+ */
755
+ #getProjectDocBlock(workDir) {
756
+ if (typeof workDir !== 'string' || !workDir.trim()) {
757
+ this.#projectDocCache = null;
758
+ return '';
759
+ }
760
+ const maxBytes = Number.isFinite(this.#config?.projectDocMaxBytes)
761
+ ? this.#config.projectDocMaxBytes
762
+ : DEFAULT_PROJECT_DOC_MAX_BYTES;
763
+ if (maxBytes === 0) {
764
+ this.#projectDocCache = null;
765
+ return '';
766
+ }
767
+
768
+ // Step 1 — stat-only check. Cheap (two `statSync` calls) and lets
769
+ // us short-circuit the read when the file hasn't moved.
770
+ const picked = pickProjectDocFile(workDir);
771
+ if (!picked) {
772
+ this.#projectDocCache = null;
773
+ return '';
774
+ }
775
+
776
+ // Step 2 — cache hit? Same workDir, same picked file, same mtime
777
+ // ⇒ the previously decoded text is still authoritative. Skip the
778
+ // file read entirely.
779
+ const cache = this.#projectDocCache;
780
+ if (
781
+ cache
782
+ && cache.workDir === workDir
783
+ && cache.path === picked.path
784
+ && cache.mtimeMs === picked.mtimeMs
785
+ ) {
786
+ return cache.text;
787
+ }
788
+
789
+ // Step 3 — cache miss. Read + decode, then refresh the cache.
790
+ const doc = readProjectDoc(workDir, { maxBytes });
791
+ if (!doc) {
792
+ this.#projectDocCache = null;
793
+ return '';
794
+ }
795
+ this.#projectDocCache = {
796
+ workDir,
797
+ path: doc.path,
798
+ mtimeMs: doc.mtimeMs,
799
+ text: doc.text,
800
+ };
801
+ return doc.text;
802
+ }
803
+
719
804
  /**
720
805
  * Build the full tool context for Phase 5 tools.
721
806
  *
@@ -1037,7 +1122,7 @@ export class Engine {
1037
1122
  * string-prompt shape (no regression for existing callers).
1038
1123
  * @yields {EngineEvent}
1039
1124
  */
1040
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null } = {}) {
1125
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null } = {}) {
1041
1126
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1042
1127
  yield {
1043
1128
  type: 'error',
@@ -1096,7 +1181,7 @@ export class Engine {
1096
1181
  const runSignal = abortCtrl.signal;
1097
1182
 
1098
1183
  try {
1099
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted, getCurrentTodos, setCurrentTodos });
1184
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos });
1100
1185
  } finally {
1101
1186
  if (signal) {
1102
1187
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1114,7 +1199,7 @@ export class Engine {
1114
1199
  * in a try/finally without indenting the whole loop.
1115
1200
  * @private
1116
1201
  */
1117
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null }) {
1202
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null }) {
1118
1203
 
1119
1204
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
1120
1205
  // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
@@ -1183,12 +1268,15 @@ export class Engine {
1183
1268
  envelope: inboundEnvelope || null,
1184
1269
  };
1185
1270
 
1271
+ const projectDoc = this.#getProjectDocBlock(workDir);
1272
+
1186
1273
  const systemPrompt = this.#buildSystemPrompt({
1187
1274
  prompt,
1188
1275
  memoryInjection,
1189
1276
  vpPersona,
1190
1277
  activeScope,
1191
1278
  groupAnnouncement,
1279
+ projectDoc,
1192
1280
  });
1193
1281
 
1194
1282
  // ─── Compact summary as messages-array head (DESIGN-PROMPT §4.3) ─
@@ -0,0 +1,148 @@
1
+ /**
2
+ * project-doc.js — Read CLAUDE.md / AGENTS.md from a group's workDir.
3
+ *
4
+ * Per-group, the user may park a project-level instructions file in the
5
+ * group's configured `workDir`. This module is the stateless reader for
6
+ * those files. The Engine owns the cache (per session, per VP) so that
7
+ * mtime-driven invalidation can happen without a singleton cache.
8
+ *
9
+ * File-selection rule (matches user spec):
10
+ * • If both `CLAUDE.md` and `AGENTS.md` exist, the one with the newer
11
+ * mtime wins. Tie → CLAUDE.md (deterministic; this is the project's
12
+ * own convention).
13
+ * • If only one exists, pick it.
14
+ * • If neither exists, return null. Caller skips the prompt block.
15
+ *
16
+ * Why two filenames? CLAUDE.md is this project's convention; AGENTS.md
17
+ * is the cross-tool convention adopted by Codex / OpenAI Codex CLI. Both
18
+ * carry the same kind of payload — long-form project instructions — and
19
+ * we want users coming from either ecosystem to "just work".
20
+ *
21
+ * Size cap. We read up to `maxBytes` and truncate larger files with a
22
+ * console warning. Default cap is 32 KB (`DEFAULT_PROJECT_DOC_MAX_BYTES`),
23
+ * mirroring Codex's `project_doc_max_bytes`. Setting the cap to 0 in the
24
+ * engine config disables the feature entirely (the caller short-circuits
25
+ * before reaching this module).
26
+ *
27
+ * NO module-level cache. The engine holds the cache so a mtime change
28
+ * between sessions doesn't ride along into a fresh engine instance, and
29
+ * tests can construct two engines without interfering with each other.
30
+ */
31
+
32
+ import { statSync, openSync, readSync, closeSync } from 'fs';
33
+ import { join } from 'path';
34
+
35
+ /** Filenames probed in `workDir`, in tie-break order (first wins on tie). */
36
+ export const PROJECT_DOC_FILENAMES = ['CLAUDE.md', 'AGENTS.md'];
37
+
38
+ /** Default max bytes pulled into the prompt block. Matches Codex. */
39
+ export const DEFAULT_PROJECT_DOC_MAX_BYTES = 32 * 1024;
40
+
41
+ /**
42
+ * Stat both candidate filenames in `workDir` and return whichever has the
43
+ * newer mtime, or null when neither exists / workDir is unusable.
44
+ *
45
+ * Pure stat — does NOT read file contents. Returns a lightweight stat
46
+ * record so the caller can compare against a cached `mtimeMs` before
47
+ * deciding to re-read.
48
+ *
49
+ * @param {string} workDir
50
+ * @returns {{ path: string, mtimeMs: number } | null}
51
+ */
52
+ export function pickProjectDocFile(workDir) {
53
+ if (typeof workDir !== 'string' || !workDir.trim()) return null;
54
+ try {
55
+ const dirStat = statSync(workDir);
56
+ if (!dirStat.isDirectory()) return null;
57
+ } catch {
58
+ // Non-existent / permission-denied / not a path we can stat.
59
+ return null;
60
+ }
61
+
62
+ let best = null;
63
+ for (const name of PROJECT_DOC_FILENAMES) {
64
+ const path = join(workDir, name);
65
+ let s;
66
+ try {
67
+ s = statSync(path);
68
+ } catch {
69
+ // Missing or unreadable; try the next candidate.
70
+ continue;
71
+ }
72
+ if (!s.isFile()) continue;
73
+ // Strict-greater so ties favor the order in PROJECT_DOC_FILENAMES.
74
+ if (!best || s.mtimeMs > best.mtimeMs) {
75
+ best = { path, mtimeMs: s.mtimeMs };
76
+ }
77
+ }
78
+ return best;
79
+ }
80
+
81
+ /**
82
+ * Read the picked project-doc file. Returns null when nothing is
83
+ * eligible (no workDir, no file, empty contents after trim).
84
+ *
85
+ * Bounded I/O. We allocate `maxBytes + 1` bytes and `readSync` once —
86
+ * never letting a runaway file balloon the agent's heap. The extra
87
+ * byte tells us whether the file was actually larger (so we know to
88
+ * warn about truncation).
89
+ *
90
+ * Codepoint-safe truncation. When we cut mid-byte inside a multi-byte
91
+ * UTF-8 sequence (very likely for `zh-CN` docs), we walk back to the
92
+ * last codepoint boundary before decoding, so the model sees clean
93
+ * text instead of a trailing `U+FFFD` replacement character.
94
+ *
95
+ * @param {string} workDir
96
+ * @param {{ maxBytes?: number }} [opts]
97
+ * @returns {{ path: string, mtimeMs: number, text: string } | null}
98
+ */
99
+ export function readProjectDoc(workDir, opts = {}) {
100
+ const maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes >= 0
101
+ ? opts.maxBytes
102
+ : DEFAULT_PROJECT_DOC_MAX_BYTES;
103
+ if (maxBytes === 0) return null;
104
+
105
+ const picked = pickProjectDocFile(workDir);
106
+ if (!picked) return null;
107
+
108
+ // Allocate one extra byte so a `bytesRead === maxBytes + 1` tells us
109
+ // there's more content beyond the cap — i.e. the file was truncated.
110
+ const cap = maxBytes + 1;
111
+ const buffer = Buffer.allocUnsafe(cap);
112
+ let fd;
113
+ let bytesRead = 0;
114
+ try {
115
+ fd = openSync(picked.path, 'r');
116
+ bytesRead = readSync(fd, buffer, 0, cap, 0);
117
+ } catch {
118
+ return null;
119
+ } finally {
120
+ if (fd !== undefined) {
121
+ try { closeSync(fd); } catch { /* ignore */ }
122
+ }
123
+ }
124
+
125
+ let useBytes = bytesRead;
126
+ const truncated = bytesRead > maxBytes;
127
+ if (truncated) {
128
+ useBytes = maxBytes;
129
+ // Walk back into the buffer until the cut isn't sitting in the
130
+ // middle of a multi-byte UTF-8 sequence. Each continuation byte
131
+ // matches the pattern `10xxxxxx` (0x80–0xBF). We scan back at most
132
+ // 3 bytes — UTF-8 codepoints are ≤ 4 bytes total.
133
+ let scan = 0;
134
+ while (scan < 3 && useBytes > 0 && (buffer[useBytes] & 0xC0) === 0x80) {
135
+ useBytes -= 1;
136
+ scan += 1;
137
+ }
138
+ }
139
+
140
+ const text = buffer.toString('utf8', 0, useBytes).trim();
141
+ if (truncated) {
142
+ console.warn(
143
+ `[unify/project-doc] ${picked.path} exceeds ${maxBytes} bytes — truncated.`,
144
+ );
145
+ }
146
+ if (!text) return null;
147
+ return { path: picked.path, mtimeMs: picked.mtimeMs, text };
148
+ }
package/unify/prompts.js CHANGED
@@ -195,6 +195,12 @@ const PROMPTS = {
195
195
  // DESIGN-PROMPT §3 ④ — Active Scope header
196
196
  activeScopeHeader: '## active_scope',
197
197
  groupAnnouncementHeader: '[Group Announcement]',
198
+ // Project-doc (CLAUDE.md / AGENTS.md) header + one-liner intro. Both
199
+ // filenames are recognized: CLAUDE.md is this project's convention,
200
+ // AGENTS.md is the cross-tool convention (Codex / OpenAI Codex CLI).
201
+ projectDocHeader: '[Project Doc]',
202
+ projectDocIntro:
203
+ 'The user keeps project-level instructions and context in `CLAUDE.md` or `AGENTS.md` at the group\'s working directory. Treat the content below as authoritative project context — coding conventions, task guidance, workflow rules, etc.',
198
204
  vpPersonaIntro: (name, role) =>
199
205
  `You ARE **${name}**${role ? ` (${role})` : ''}. Speak in the first person as ${name}; do not refer to yourself as "Yeaft" or as a generic AI assistant. The text below is your identity, expertise, and decision style.`,
200
206
  },
@@ -206,6 +212,10 @@ const PROMPTS = {
206
212
  // DESIGN-PROMPT §3 ④ — Active Scope header
207
213
  activeScopeHeader: '## active_scope',
208
214
  groupAnnouncementHeader: '[群组公告]',
215
+ // 项目文档块:CLAUDE.md / AGENTS.md(与 Codex 通用命名兼容)。
216
+ projectDocHeader: '[项目文档]',
217
+ projectDocIntro:
218
+ '用户把项目级的说明和上下文记录在群组工作目录下的 `CLAUDE.md` 或 `AGENTS.md` 中。下面的内容是权威的项目上下文 —— 编码规范、任务指导、工作流约定等,请遵循它来工作。',
209
219
  vpPersonaIntro: (name, role) =>
210
220
  `你就是 **${name}**${role ? `(${role})` : ''}。请以 ${name} 的第一人称发言;不要自称 "Yeaft" 或泛指的 AI 助手。下面的文字是你的身份、专业方向与判断风格。`,
211
221
  },
@@ -271,6 +281,7 @@ export function normalizePromptLanguage(language) {
271
281
  * activeScope?: object,
272
282
  * vpPersona?: object,
273
283
  * groupAnnouncement?: string,
284
+ * projectDoc?: string,
274
285
  * }} params
275
286
  * @returns {string}
276
287
  */
@@ -283,6 +294,7 @@ export function buildSystemPrompt({
283
294
  activeScope,
284
295
  vpPersona,
285
296
  groupAnnouncement = '',
297
+ projectDoc = '',
286
298
  } = {}) {
287
299
  // Normalize app locales like `zh-CN` to prompt dictionary/template keys.
288
300
  const effectiveLang = normalizePromptLanguage(language);
@@ -310,6 +322,21 @@ export function buildSystemPrompt({
310
322
  }
311
323
  }
312
324
 
325
+ // ─── 1.4 Project Doc (CLAUDE.md / AGENTS.md from group workDir) ───
326
+ // The group's working-directory may contain a project-level
327
+ // instructions file. The engine resolves "newest of CLAUDE.md vs
328
+ // AGENTS.md by mtime" and threads the resulting text through here.
329
+ // Empty/whitespace = no block emitted. Sits ABOVE the announcement
330
+ // because user-authored project files are higher signal than the
331
+ // group-level announcement (which is typically a short rule).
332
+ const docText = (typeof projectDoc === 'string') ? projectDoc.trim() : '';
333
+ if (docText) {
334
+ const docHeader = lang.projectDocHeader || '[Project Doc]';
335
+ const docIntro = lang.projectDocIntro || '';
336
+ const introLine = docIntro ? `${docIntro}\n\n` : '';
337
+ parts.push(`${docHeader}\n${introLine}${docText}`);
338
+ }
339
+
313
340
  // ─── 1.5 Group Announcement (CLAUDE.md-style shared prefix) ───
314
341
  // When a group has set an announcement, every VP in the group sees it
315
342
  // near the top of the system prompt — before tools, memory, mode-specific
@@ -1988,6 +1988,13 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope })
1988
1988
  if (groupMeta && typeof groupMeta.announcement === 'string') {
1989
1989
  out.groupAnnouncement = groupMeta.announcement;
1990
1990
  }
1991
+ // Surface the group's configured working directory so the engine can
1992
+ // resolve CLAUDE.md / AGENTS.md at that path and inject it as a
1993
+ // [Project Doc] block above the announcement. Groups with no workDir
1994
+ // skip the block silently (matches the announcement contract).
1995
+ if (groupMeta && typeof groupMeta.workDir === 'string' && groupMeta.workDir.trim()) {
1996
+ out.workDir = groupMeta.workDir.trim();
1997
+ }
1991
1998
  try {
1992
1999
  const vp = readVp(resolvedVpId);
1993
2000
  if (vp) {