@xirang-ai/dsh-teamflow 0.1.1 → 0.1.2

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.
@@ -1,5 +1,5 @@
1
1
  import { isPermissionTier } from "../permission-tier.js";
2
- import { docPath, dropStagedDoc, ensureDocTree, pruneEmptyDirectories, readDoc, restoreStagedDoc, stageDocDeletion, writeDoc, } from "./docs.js";
2
+ import { docPath, dropStagedDoc, pruneEmptyDirectories, readDoc, restoreStagedDoc, stageDocDeletion, writeDoc, } from "./docs.js";
3
3
  import { newId } from "./ids.js";
4
4
  import { BUG_SEVERITIES, BUG_STATUSES, DELETABLE_KINDS, DOC_KINDS, REQUIREMENT_STATUSES, RUN_STATUSES, STORY_SIZES, STORY_STATUSES, WorkflowError, } from "./model.js";
5
5
  import { describeError } from "../errors.js";
@@ -69,9 +69,20 @@ function toDesignDoc(row) {
69
69
  updatedAt: text(row, 'updated_at'),
70
70
  };
71
71
  }
72
+ function toDiscussionSession(row) {
73
+ return {
74
+ subjectType: text(row, 'subject_type'),
75
+ subjectId: text(row, 'subject_id'),
76
+ sessionId: text(row, 'session_id'),
77
+ kind: text(row, 'kind'),
78
+ preparedAt: nullableText(row, 'prepared_at'),
79
+ linkedAt: nullableText(row, 'linked_at'),
80
+ createdAt: text(row, 'created_at'),
81
+ };
82
+ }
72
83
  function toRequirementSession(row) {
73
84
  return {
74
- requirementId: text(row, 'requirement_id'),
85
+ requirementId: text(row, 'subject_id'),
75
86
  sessionId: text(row, 'session_id'),
76
87
  kind: text(row, 'kind'),
77
88
  preparedAt: nullableText(row, 'prepared_at'),
@@ -195,68 +206,55 @@ export class WorkflowStore {
195
206
  #docsRoot;
196
207
  #workspaceRoot;
197
208
  #warn;
198
- constructor(db, options = {}) {
209
+ constructor(db, options) {
199
210
  this.#db = db;
200
211
  this.#now = options.now ?? (() => new Date().toISOString());
201
- this.#docsRoot = options.docsRoot ?? '';
212
+ this.#docsRoot = options.docsRoot;
202
213
  this.#workspaceRoot = options.workspaceRoot ?? (() => undefined);
203
214
  this.#warn = options.warn ?? (() => { });
204
215
  }
205
216
  /**
206
- * Remember where a workspace lives on disk. Documents are written into that
207
- * directory, so this must be called before the first file-backed write;
208
- * repeated calls with the same path are no-ops.
217
+ * Remember where a workspace lives on disk. Document bodies no longer live
218
+ * there, but the path is still recorded for display and for future
219
+ * workspace-scoped features; repeated calls with the same path are no-ops.
209
220
  */
210
221
  registerWorkspace(workspaceId, path) {
211
222
  const id = requiredText(workspaceId, 'workspaceId');
212
223
  const root = requiredText(path, 'path');
213
- ensureDocTree(root);
214
224
  const now = this.#now();
215
225
  this.#db
216
226
  .prepare(`INSERT INTO workspaces (workspace_id, path, created_at, updated_at) VALUES (?, ?, ?, ?)
217
227
  ON CONFLICT(workspace_id) DO UPDATE SET path = excluded.path, updated_at = excluded.updated_at`)
218
228
  .run(id, root, now, now);
219
229
  }
220
- /** Directory a workspace's documents live in, or null when unknown. */
230
+ /** Directory a registered workspace lives in, or null when unknown. */
221
231
  workspacePath(workspaceId) {
222
232
  const row = this.#db.prepare('SELECT path FROM workspaces WHERE workspace_id = ?').get(workspaceId);
223
233
  if (row !== undefined)
224
234
  return text(row, 'path');
225
- return this.#workspaceRoot(workspaceId) ?? (this.#docsRoot.length === 0 ? null : this.#docsRoot);
235
+ return this.#workspaceRoot(workspaceId) ?? null;
226
236
  }
227
237
  /**
228
- * Read a document body by its workspace-relative path.
238
+ * Read a document body by its document-root-relative path.
229
239
  *
230
240
  * `null` means the file does not exist; `''` means it exists and is empty.
231
- * A workspace without a directory is a configuration error, not a missing
232
- * document, so it is reported as `file_error` instead of being folded into
233
- * either empty result.
241
+ * Every record resolves against the one configured document root, so a
242
+ * legacy path simply misses instead of falling back to a workspace.
234
243
  */
235
- readContent(workspaceId, relativePath) {
236
- const root = this.workspacePath(workspaceId);
237
- if (root === null) {
238
- throw new WorkflowError('file_error', `workspace ${workspaceId} has no directory yet; call registerWorkspace first`);
239
- }
240
- return readDoc(root, relativePath);
244
+ readContent(relativePath) {
245
+ return readDoc(this.#docsRoot, relativePath);
241
246
  }
242
247
  /** Rewrite a document body; the path comes from the row, so it is always current. */
243
- writeContent(workspaceId, relativePath, body) {
244
- writeDoc(this.#root(workspaceId), relativePath, body);
245
- }
246
- #root(workspaceId) {
247
- const root = this.workspacePath(workspaceId);
248
- if (root === null) {
249
- throw new WorkflowError('invalid_input', `workspace ${workspaceId} has no directory yet; call registerWorkspace first`);
250
- }
251
- return root;
248
+ writeContent(relativePath, body) {
249
+ writeDoc(this.#docsRoot, relativePath, body);
252
250
  }
253
251
  /**
254
252
  * Write one entity's body to its canonical path and return that path.
255
253
  * Empty bodies still create the file so the address in the database resolves.
256
254
  */
257
- #persistDoc(kind, workspaceId, ids, body) {
255
+ #persistDoc(kind, ids, body) {
258
256
  const relative = docPath(kind, ids);
259
- writeDoc(this.#root(workspaceId), relative, body);
257
+ writeDoc(this.#docsRoot, relative, body);
260
258
  return relative;
261
259
  }
262
260
  // ---------------------------------------------------------------- queries
@@ -284,47 +282,121 @@ export class WorkflowStore {
284
282
  /**
285
283
  * Register a browser-created session as a pending discussion for a requirement.
286
284
  *
285
+ * Kept as a thin wrapper over {@link prepareDiscussionSession} so the
286
+ * requirement page and its tests keep their existing shape.
287
+ */
288
+ prepareRequirementSession(requirementId, sessionId, kind = 'design') {
289
+ const session = this.prepareDiscussionSession('requirement', requirementId, sessionId, kind);
290
+ return {
291
+ requirementId: session.subjectId,
292
+ sessionId: session.sessionId,
293
+ kind: session.kind,
294
+ preparedAt: session.preparedAt,
295
+ linkedAt: session.linkedAt,
296
+ createdAt: session.createdAt,
297
+ };
298
+ }
299
+ /**
300
+ * Register a browser-created session as a pending discussion for a subject.
301
+ *
287
302
  * The browser creates the session first, then calls this before the user can
288
303
  * send the first message. The row stays invisible in `listBoard` until the
289
- * Agent confirms it through {@link linkRequirementSession}.
304
+ * Agent confirms it through `teamflow_link_session`; this method never writes
305
+ * `linked_at`, so the client cannot skip Agent confirmation.
290
306
  */
291
- prepareRequirementSession(requirementId, sessionId, kind = 'design') {
307
+ prepareDiscussionSession(subjectType, subjectId, sessionId, kind) {
308
+ if (subjectType !== 'requirement' && subjectType !== 'story' && subjectType !== 'bug') {
309
+ throw new WorkflowError('invalid_input', `prepareDiscussionSession 参数非法:subjectType=${String(subjectType)},期望 requirement|story|bug`);
310
+ }
311
+ const subject = (subjectId ?? '').trim();
312
+ if (subject.length === 0) {
313
+ throw new WorkflowError('invalid_input', 'prepareDiscussionSession 参数非法:subjectId 为空');
314
+ }
315
+ const session = (sessionId ?? '').trim();
316
+ if (session.length === 0) {
317
+ throw new WorkflowError('invalid_input', 'prepareDiscussionSession 参数非法:sessionId 为空');
318
+ }
319
+ const discussionKind = (kind ?? '').trim();
320
+ if (discussionKind.length === 0) {
321
+ throw new WorkflowError('invalid_input', 'prepareDiscussionSession 参数非法:kind 为空');
322
+ }
323
+ if (subjectType === 'requirement' &&
324
+ discussionKind !== 'design' &&
325
+ discussionKind !== 'detail' &&
326
+ discussionKind !== 'split') {
327
+ throw new WorkflowError('invalid_input', `kind must be design, detail or split, got ${String(kind)}`);
328
+ }
292
329
  return withTransaction(this.#db, () => {
293
- const requirement = this.#requireRequirement(requirementId);
294
- const session = requiredText(sessionId, 'sessionId');
295
- if (kind !== 'design' && kind !== 'detail' && kind !== 'split') {
296
- throw new WorkflowError('invalid_input', `kind must be design, detail or split, got ${String(kind)}`);
330
+ this.#requireSubject(subjectType, subject);
331
+ const rows = this.#db
332
+ .prepare('SELECT * FROM discussion_sessions WHERE session_id = ?')
333
+ .all(session);
334
+ if (rows.length > 1) {
335
+ throw new WorkflowError('invalid_input', `session ${session} is already bound to ${rows.length} discussions`);
297
336
  }
298
- const existing = this.#db
299
- .prepare('SELECT * FROM requirement_sessions WHERE session_id = ?')
300
- .get(session);
337
+ const existing = rows[0];
301
338
  if (existing !== undefined) {
302
- const row = toRequirementSession(existing);
303
- if (row.requirementId !== requirement.id) {
304
- throw new WorkflowError('invalid_input', `session ${session} already belongs to requirement ${row.requirementId}`);
339
+ const row = toDiscussionSession(existing);
340
+ if (row.subjectType !== subjectType || row.subjectId !== subject) {
341
+ throw new WorkflowError('invalid_input', `session ${session} already belongs to ${row.subjectType} ${row.subjectId}`);
305
342
  }
306
343
  if (row.kind === 'create') {
307
344
  throw new WorkflowError('invalid_input', `session ${session} created its requirement, so it cannot also be a discussion`);
308
345
  }
309
- if (row.kind !== kind) {
346
+ if (row.kind !== discussionKind) {
310
347
  throw new WorkflowError('invalid_input', `session ${session} is already prepared as a ${row.kind} discussion`);
311
348
  }
349
+ // Repeated prepares keep the original timestamp; only the first write
350
+ // records when the browser started the conversation.
312
351
  return row;
313
352
  }
314
353
  const now = this.#now();
315
354
  this.#db
316
- .prepare(`INSERT INTO requirement_sessions
317
- (requirement_id, session_id, kind, prepared_at, linked_at, created_at)
318
- VALUES (?, ?, ?, ?, NULL, ?)`)
319
- .run(requirement.id, session, kind, now, now);
320
- return this.#requireRequirementSession(session);
355
+ .prepare(`INSERT INTO discussion_sessions
356
+ (subject_type, subject_id, session_id, kind, prepared_at, linked_at, created_at)
357
+ VALUES (?, ?, ?, ?, ?, NULL, ?)
358
+ ON CONFLICT (subject_type, subject_id, session_id)
359
+ DO UPDATE SET prepared_at = excluded.prepared_at`)
360
+ .run(subjectType, subject, session, discussionKind, now, now);
361
+ return this.#requireDiscussionSession(session, subjectType, subject);
362
+ });
363
+ }
364
+ /**
365
+ * Confirm the current session as the discussion it was prepared for.
366
+ *
367
+ * Called by `teamflow_link_session`; repeated calls are idempotent so a model
368
+ * retry cannot rewrite the confirmation time.
369
+ */
370
+ linkDiscussionSession(sessionId) {
371
+ return withTransaction(this.#db, () => {
372
+ const session = requiredText(sessionId, 'sessionId');
373
+ const rows = this.#db
374
+ .prepare('SELECT * FROM discussion_sessions WHERE session_id = ?')
375
+ .all(session);
376
+ if (rows.length === 0) {
377
+ throw new WorkflowError('not_found', `teamflow_link_session 未能登记:当前会话 ${session} 没有与任何需求、故事或缺陷绑定`);
378
+ }
379
+ if (rows.length > 1) {
380
+ throw new WorkflowError('invalid_input', `teamflow_link_session 未能登记:数据异常:当前会话 ${session} 绑定到 ${rows.length} 个对象`);
381
+ }
382
+ const current = toDiscussionSession(rows[0]);
383
+ if (current.kind === 'create') {
384
+ throw new WorkflowError('invalid_input', `session ${session} created a requirement and is not a discussion`);
385
+ }
386
+ if (current.linkedAt !== null)
387
+ return current;
388
+ this.#db
389
+ .prepare(`UPDATE discussion_sessions SET linked_at = ?
390
+ WHERE subject_type = ? AND subject_id = ? AND session_id = ?`)
391
+ .run(this.#now(), current.subjectType, current.subjectId, session);
392
+ return this.#requireDiscussionSession(session, current.subjectType, current.subjectId);
321
393
  });
322
394
  }
323
395
  /**
324
- * Confirm the current session as a discussion of the requirement prepared for it.
396
+ * Confirm the current session as a requirement discussion.
325
397
  *
326
- * Called by `teamflow_link_requirement_session`; repeated calls are idempotent
327
- * so a model retry cannot create duplicate history rows.
398
+ * Kept for `teamflow_link_requirement_session`; new callers use
399
+ * {@link linkDiscussionSession}.
328
400
  */
329
401
  linkRequirementSession(sessionId) {
330
402
  return withTransaction(this.#db, () => {
@@ -336,36 +408,64 @@ export class WorkflowStore {
336
408
  if (current.linkedAt !== null)
337
409
  return current;
338
410
  this.#db
339
- .prepare("UPDATE requirement_sessions SET linked_at = ? WHERE session_id = ? AND kind IN ('design','detail','split')")
411
+ .prepare("UPDATE discussion_sessions SET linked_at = ? WHERE subject_type = 'requirement' AND session_id = ? AND kind IN ('design','detail','split')")
340
412
  .run(this.#now(), session);
341
413
  return this.#requireRequirementSession(session);
342
414
  });
343
415
  }
344
- /** Pending or linked conversation prepared for one session, or null. */
416
+ /** Pending or linked requirement conversation prepared for one session, or null. */
345
417
  getRequirementSession(sessionId) {
346
418
  const row = this.#db
347
- .prepare('SELECT * FROM requirement_sessions WHERE session_id = ?')
419
+ .prepare("SELECT * FROM discussion_sessions WHERE subject_type = 'requirement' AND session_id = ?")
348
420
  .get(sessionId);
349
421
  return row === undefined ? null : toRequirementSession(row);
350
422
  }
351
423
  /** The requirement linked to a session, whichever kind of link it is. */
352
424
  getRequirementBySession(sessionId) {
353
425
  const row = this.#db
354
- .prepare('SELECT requirement_id FROM requirement_sessions WHERE session_id = ?')
426
+ .prepare("SELECT subject_id FROM discussion_sessions WHERE subject_type = 'requirement' AND session_id = ?")
355
427
  .get(sessionId);
356
- return row === undefined ? null : this.getRequirement(text(row, 'requirement_id'));
428
+ return row === undefined ? null : this.getRequirement(text(row, 'subject_id'));
429
+ }
430
+ /** The discussion row bound to one session, or null when none exists. */
431
+ getDiscussionSession(sessionId) {
432
+ const rows = this.#db
433
+ .prepare('SELECT * FROM discussion_sessions WHERE session_id = ?')
434
+ .all(sessionId);
435
+ return rows.length === 1 ? toDiscussionSession(rows[0]) : null;
357
436
  }
358
437
  /**
359
- * Conversations under one requirement, newest linked first.
438
+ * Discussions under one subject, newest linked first.
360
439
  *
361
- * Pending design rows are excluded by default so the workbench never shows a
362
- * session the Agent has not started; tests and diagnostics can opt in.
440
+ * Pending rows are excluded by default so the workbench never shows a session
441
+ * the Agent has not started; tests and diagnostics can opt in.
442
+ */
443
+ listDiscussionSessions(subjectType, subjectId, options = {}) {
444
+ const values = [subjectType, subjectId];
445
+ let kindsClause = '';
446
+ if (options.kinds !== undefined && options.kinds.length > 0) {
447
+ kindsClause = ` AND kind IN (${options.kinds.map(() => '?').join(',')})`;
448
+ values.push(...options.kinds);
449
+ }
450
+ const rows = this.#db
451
+ .prepare(`SELECT * FROM discussion_sessions
452
+ WHERE subject_type = ? AND subject_id = ?
453
+ AND (linked_at IS NOT NULL OR ? = 1)${kindsClause}
454
+ ORDER BY COALESCE(linked_at, prepared_at, created_at) DESC, session_id`)
455
+ .all(...values.slice(0, 2), options.includeUnlinked === true ? 1 : 0, ...values.slice(2));
456
+ return rows.map(toDiscussionSession);
457
+ }
458
+ /**
459
+ * Requirement conversations under one requirement, newest linked first.
460
+ *
461
+ * The `create` row is always included: it explains where the requirement came
462
+ * from even though the Agent never confirms it.
363
463
  */
364
464
  listRequirementSessions(requirementId, options = {}) {
365
465
  this.#requireRequirement(requirementId);
366
466
  const rows = this.#db
367
- .prepare(`SELECT * FROM requirement_sessions
368
- WHERE requirement_id = ?
467
+ .prepare(`SELECT * FROM discussion_sessions
468
+ WHERE subject_type = 'requirement' AND subject_id = ?
369
469
  AND (kind = 'create' OR linked_at IS NOT NULL OR ? = 1)
370
470
  ORDER BY kind, COALESCE(linked_at, prepared_at, created_at) DESC, session_id`)
371
471
  .all(requirementId, options.includePending === true ? 1 : 0);
@@ -506,10 +606,15 @@ export class WorkflowStore {
506
606
  .prepare('SELECT doc_id, story_id FROM design_doc_stories ORDER BY doc_id, created_at, story_id')
507
607
  .all();
508
608
  const requirementSessions = this.#db
509
- .prepare(`SELECT * FROM requirement_sessions
510
- WHERE kind = 'create' OR linked_at IS NOT NULL
609
+ .prepare(`SELECT * FROM discussion_sessions
610
+ WHERE subject_type = 'requirement' AND (kind = 'create' OR linked_at IS NOT NULL)
511
611
  ORDER BY COALESCE(linked_at, created_at) DESC, session_id`)
512
612
  .all();
613
+ const discussionSessions = this.#db
614
+ .prepare(`SELECT * FROM discussion_sessions
615
+ WHERE subject_type IN ('story', 'bug') AND linked_at IS NOT NULL
616
+ ORDER BY COALESCE(linked_at, prepared_at, created_at) DESC, session_id`)
617
+ .all();
513
618
  return {
514
619
  requirements: requirements.map(toRequirement),
515
620
  stories: stories.map(toStory),
@@ -518,6 +623,7 @@ export class WorkflowStore {
518
623
  docs: docs.map(toDesignDoc),
519
624
  docStories: docStories.map((row) => ({ docId: text(row, 'doc_id'), storyId: text(row, 'story_id') })),
520
625
  requirementSessions: requirementSessions.map(toRequirementSession),
626
+ discussionSessions: discussionSessions.map(toDiscussionSession),
521
627
  };
522
628
  }
523
629
  /** One detailed-design document by id, or null when it does not exist. */
@@ -531,7 +637,7 @@ export class WorkflowStore {
531
637
  const now = this.#now();
532
638
  const id = newId('requirement');
533
639
  const workspaceId = requiredText(input.workspaceId, 'workspaceId');
534
- const contentPath = this.#persistDoc('requirement', workspaceId, { id }, optionalText(input.content));
640
+ const contentPath = this.#persistDoc('requirement', { id }, optionalText(input.content));
535
641
  const requirement = {
536
642
  id,
537
643
  workspaceId,
@@ -634,7 +740,7 @@ export class WorkflowStore {
634
740
  }
635
741
  const now = this.#now();
636
742
  const id = newId('designDoc');
637
- const contentPath = this.#persistDoc('designDoc', requirement.workspaceId, { id, requirementId: requirement.id }, optionalText(input.content));
743
+ const contentPath = this.#persistDoc('designDoc', { id, requirementId: requirement.id }, optionalText(input.content));
638
744
  const doc = {
639
745
  id,
640
746
  requirementId: requirement.id,
@@ -662,8 +768,7 @@ export class WorkflowStore {
662
768
  return withTransaction(this.#db, () => {
663
769
  const current = this.#requireDesignDoc(id);
664
770
  if (input.content !== undefined) {
665
- const requirement = this.#requireRequirement(current.requirementId);
666
- writeDoc(this.#root(requirement.workspaceId), current.contentPath, optionalText(input.content));
771
+ writeDoc(this.#docsRoot, current.contentPath, optionalText(input.content));
667
772
  }
668
773
  const next = {
669
774
  ...current,
@@ -762,7 +867,7 @@ export class WorkflowStore {
762
867
  : this.#inheritWorkspace(input.workspaceId, workspaceId, 'story');
763
868
  const now = this.#now();
764
869
  const id = newId('story');
765
- const contentPath = this.#persistDoc('story', storyWorkspaceId, { id }, optionalText(input.content));
870
+ const contentPath = this.#persistDoc('story', { id }, optionalText(input.content));
766
871
  const sessionId = optionalText(input.sessionId);
767
872
  const story = {
768
873
  id,
@@ -802,7 +907,7 @@ export class WorkflowStore {
802
907
  const size = input.size === undefined ? current.size : sizeOf(input.size);
803
908
  const sortOrder = input.sortOrder === undefined ? current.sortOrder : sortOrderOf(input.sortOrder);
804
909
  if (input.content !== undefined) {
805
- writeDoc(this.#root(current.workspaceId), current.contentPath, optionalText(input.content));
910
+ writeDoc(this.#docsRoot, current.contentPath, optionalText(input.content));
806
911
  }
807
912
  this.#db
808
913
  .prepare('UPDATE stories SET title = ?, size = ?, sort_order = ?, updated_at = ? WHERE id = ?')
@@ -1058,7 +1163,7 @@ export class WorkflowStore {
1058
1163
  const bugWorkspaceId = requiredText(input.workspaceId, 'workspaceId');
1059
1164
  const now = this.#now();
1060
1165
  const id = newId('bug');
1061
- const contentPath = this.#persistDoc('bug', bugWorkspaceId, { id }, optionalText(input.content));
1166
+ const contentPath = this.#persistDoc('bug', { id }, optionalText(input.content));
1062
1167
  const sessionId = optionalText(input.sessionId);
1063
1168
  const bug = {
1064
1169
  id,
@@ -1093,7 +1198,7 @@ export class WorkflowStore {
1093
1198
  const title = input.title === undefined ? current.title : requiredText(input.title, 'title');
1094
1199
  const severity = input.severity === undefined ? current.severity : severityOf(input.severity);
1095
1200
  if (input.content !== undefined) {
1096
- writeDoc(this.#root(current.workspaceId), current.contentPath, optionalText(input.content));
1201
+ writeDoc(this.#docsRoot, current.contentPath, optionalText(input.content));
1097
1202
  }
1098
1203
  this.#db
1099
1204
  .prepare('UPDATE bugs SET title = ?, severity = ?, updated_at = ? WHERE id = ?')
@@ -1184,15 +1289,14 @@ export class WorkflowStore {
1184
1289
  if (!current.deletable) {
1185
1290
  throw new WorkflowError(current.reasonCode ?? 'invalid_transition', current.reason ?? `${target.kind} ${target.id} cannot be deleted`);
1186
1291
  }
1187
- for (const file of this.#filesToDelete(target)) {
1188
- const root = this.#root(file.workspaceId);
1292
+ for (const relative of this.#filesToDelete(target)) {
1189
1293
  try {
1190
- const doc = stageDocDeletion(root, file.relative);
1294
+ const doc = stageDocDeletion(this.#docsRoot, relative);
1191
1295
  if (doc !== null)
1192
- staged.push({ root, doc });
1296
+ staged.push({ root: this.#docsRoot, doc });
1193
1297
  }
1194
1298
  catch (error) {
1195
- throw new WorkflowError('file_error', `could not stage ${file.relative} for deletion: ${describeError(error)}`);
1299
+ throw new WorkflowError('file_error', `could not stage ${relative} for deletion: ${describeError(error)}`);
1196
1300
  }
1197
1301
  }
1198
1302
  if (target.kind === 'story') {
@@ -1409,23 +1513,16 @@ export class WorkflowStore {
1409
1513
  runCount: 0,
1410
1514
  };
1411
1515
  }
1412
- /** Files (workspace + relative path) that a successful delete must remove. */
1516
+ /** Document-root-relative paths that a successful delete must remove. */
1413
1517
  #filesToDelete(target) {
1414
- if (target.kind === 'story') {
1415
- const story = this.#requireStory(target.id);
1416
- return [{ workspaceId: story.workspaceId, relative: story.contentPath }];
1417
- }
1418
- if (target.kind === 'bug') {
1419
- const bug = this.#requireBug(target.id);
1420
- return [{ workspaceId: bug.workspaceId, relative: bug.contentPath }];
1421
- }
1518
+ if (target.kind === 'story')
1519
+ return [this.#requireStory(target.id).contentPath];
1520
+ if (target.kind === 'bug')
1521
+ return [this.#requireBug(target.id).contentPath];
1422
1522
  const requirement = this.#requireRequirement(target.id);
1423
1523
  return [
1424
- { workspaceId: requirement.workspaceId, relative: requirement.contentPath },
1425
- ...this.listDesignDocs(requirement.id).map((doc) => ({
1426
- workspaceId: requirement.workspaceId,
1427
- relative: doc.contentPath,
1428
- })),
1524
+ requirement.contentPath,
1525
+ ...this.listDesignDocs(requirement.id).map((doc) => doc.contentPath),
1429
1526
  ];
1430
1527
  }
1431
1528
  #normalizeTargets(targets) {
@@ -1493,9 +1590,15 @@ export class WorkflowStore {
1493
1590
  throw notFound('requirement', id);
1494
1591
  return toRequirement(row);
1495
1592
  }
1593
+ #requireSubject(subjectType, subjectId) {
1594
+ const table = subjectType === 'requirement' ? 'requirements' : subjectType === 'story' ? 'stories' : 'bugs';
1595
+ const row = this.#db.prepare(`SELECT id FROM ${table} WHERE id = ?`).get(subjectId);
1596
+ if (row === undefined)
1597
+ throw notFound(subjectType, subjectId);
1598
+ }
1496
1599
  #bindRequirementSessionInTx(requirementId, sessionId, createdAt) {
1497
1600
  const existing = this.#db
1498
- .prepare('SELECT * FROM requirement_sessions WHERE session_id = ?')
1601
+ .prepare("SELECT * FROM discussion_sessions WHERE subject_type = 'requirement' AND session_id = ?")
1499
1602
  .get(sessionId);
1500
1603
  if (existing !== undefined) {
1501
1604
  const row = toRequirementSession(existing);
@@ -1505,9 +1608,9 @@ export class WorkflowStore {
1505
1608
  return row;
1506
1609
  }
1507
1610
  this.#db
1508
- .prepare(`INSERT INTO requirement_sessions
1509
- (requirement_id, session_id, kind, prepared_at, linked_at, created_at)
1510
- VALUES (?, ?, 'create', NULL, NULL, ?)`)
1611
+ .prepare(`INSERT INTO discussion_sessions
1612
+ (subject_type, subject_id, session_id, kind, prepared_at, linked_at, created_at)
1613
+ VALUES ('requirement', ?, ?, 'create', NULL, NULL, ?)`)
1511
1614
  .run(requirementId, sessionId, createdAt);
1512
1615
  return {
1513
1616
  requirementId,
@@ -1520,13 +1623,23 @@ export class WorkflowStore {
1520
1623
  }
1521
1624
  #requireRequirementSession(sessionId) {
1522
1625
  const row = this.#db
1523
- .prepare('SELECT * FROM requirement_sessions WHERE session_id = ?')
1626
+ .prepare("SELECT * FROM discussion_sessions WHERE subject_type = 'requirement' AND session_id = ?")
1524
1627
  .get(sessionId);
1525
1628
  if (row === undefined) {
1526
1629
  throw new WorkflowError('not_found', `session ${sessionId} is not prepared for a requirement discussion; start again from the requirement page`);
1527
1630
  }
1528
1631
  return toRequirementSession(row);
1529
1632
  }
1633
+ #requireDiscussionSession(sessionId, subjectType, subjectId) {
1634
+ const row = this.#db
1635
+ .prepare(`SELECT * FROM discussion_sessions
1636
+ WHERE subject_type = ? AND subject_id = ? AND session_id = ?`)
1637
+ .get(subjectType, subjectId, sessionId);
1638
+ if (row === undefined) {
1639
+ throw new WorkflowError('not_found', `session ${sessionId} is not prepared for a ${subjectType} discussion`);
1640
+ }
1641
+ return toDiscussionSession(row);
1642
+ }
1530
1643
  #requireStory(id) {
1531
1644
  const row = this.#db.prepare('SELECT * FROM stories WHERE id = ?').get(id);
1532
1645
  if (row === undefined)
@@ -1550,7 +1663,7 @@ export class WorkflowStore {
1550
1663
  const title = input.title === undefined ? current.title : requiredText(input.title, 'title');
1551
1664
  const summary = input.summary === undefined ? current.summary : optionalText(input.summary);
1552
1665
  if (input.content !== undefined) {
1553
- writeDoc(this.#root(current.workspaceId), current.contentPath, optionalText(input.content));
1666
+ writeDoc(this.#docsRoot, current.contentPath, optionalText(input.content));
1554
1667
  }
1555
1668
  this.#db
1556
1669
  .prepare('UPDATE requirements SET title = ?, summary = ?, updated_at = ? WHERE id = ?')
@@ -24,7 +24,7 @@ export function readBug(workflow, rawId) {
24
24
  const bug = workflow.getBug(id);
25
25
  if (bug === null)
26
26
  throw new Error(`teamflow: 缺陷 ${id} 不存在,无法读取正文。`);
27
- const body = workflow.readContent(bug.workspaceId, bug.contentPath);
27
+ const body = workflow.readContent(bug.contentPath);
28
28
  return {
29
29
  id: bug.id,
30
30
  title: bug.title,
@@ -33,7 +33,7 @@ export function readDesignDoc(workflow, rawId) {
33
33
  if (requirement === null) {
34
34
  throw new Error(`teamflow: 详细设计 ${id} 所属的需求 ${doc.requirementId} 不存在。`);
35
35
  }
36
- const body = workflow.readContent(requirement.workspaceId, doc.contentPath);
36
+ const body = workflow.readContent(doc.contentPath);
37
37
  return {
38
38
  id: doc.id,
39
39
  requirementId: doc.requirementId,
@@ -110,7 +110,7 @@ export function getRequirementTool(environment) {
110
110
  }
111
111
  const docs = environment.database.workflow.listDesignDocs(requirement.id);
112
112
  const stories = environment.database.workflow.listStories({ requirementId: requirement.id });
113
- const body = environment.database.workflow.readContent(requirement.workspaceId, requirement.contentPath);
113
+ const body = environment.database.workflow.readContent(requirement.contentPath);
114
114
  return {
115
115
  requirementId: requirement.id,
116
116
  title: requirement.title,
@@ -47,7 +47,7 @@ export function readStory(workflow, rawId) {
47
47
  path: doc.contentPath,
48
48
  coversStory: linked.has(doc.id),
49
49
  }));
50
- const body = workflow.readContent(story.workspaceId, story.contentPath);
50
+ const body = workflow.readContent(story.contentPath);
51
51
  return {
52
52
  id: story.id,
53
53
  title: story.title,
@@ -12,6 +12,7 @@ import { getDesignDocTool } from "./get-design-doc.js";
12
12
  import { getRequirementTool } from "./get-requirement.js";
13
13
  import { getStoryTool } from "./get-story.js";
14
14
  import { linkRequirementSessionTool } from "./link-requirement-session.js";
15
+ import { linkSessionTool } from "./link-session.js";
15
16
  import { listRecordsTool } from "./list-records.js";
16
17
  import { listWorkspacesTool } from "./list-workspaces.js";
17
18
  import { createToolEnvironment } from "./shared.js";
@@ -43,6 +44,7 @@ export const TOOL_PROFILES = [
43
44
  */
44
45
  export const PROFILE_TOOL_NAMES = {
45
46
  requirement: [
47
+ 'teamflow_link_session',
46
48
  'teamflow_link_requirement_session',
47
49
  'teamflow_create_requirement',
48
50
  'teamflow_get_requirement',
@@ -51,6 +53,7 @@ export const PROFILE_TOOL_NAMES = {
51
53
  'teamflow_list_workspaces',
52
54
  ],
53
55
  detail: [
56
+ 'teamflow_link_session',
54
57
  'teamflow_link_requirement_session',
55
58
  'teamflow_get_requirement',
56
59
  'teamflow_get_design_doc',
@@ -60,6 +63,7 @@ export const PROFILE_TOOL_NAMES = {
60
63
  'teamflow_list_workspaces',
61
64
  ],
62
65
  split: [
66
+ 'teamflow_link_session',
63
67
  'teamflow_link_requirement_session',
64
68
  'teamflow_get_requirement',
65
69
  'teamflow_get_design_doc',
@@ -80,6 +84,7 @@ export const PROFILE_TOOL_NAMES = {
80
84
  'teamflow_cancel_run',
81
85
  ],
82
86
  bug: [
87
+ 'teamflow_link_session',
83
88
  'teamflow_create_bug',
84
89
  'teamflow_get_bug',
85
90
  'teamflow_update_bug',
@@ -94,6 +99,7 @@ export const PROFILE_TOOL_NAMES = {
94
99
  'teamflow_update_design_doc',
95
100
  'teamflow_get_design_doc',
96
101
  'teamflow_link_requirement_session',
102
+ 'teamflow_link_session',
97
103
  'teamflow_create_story',
98
104
  'teamflow_get_story',
99
105
  'teamflow_update_story',
@@ -138,6 +144,7 @@ export function apply(ctx, config) {
138
144
  updateDesignDocTool(environment),
139
145
  getDesignDocTool(environment),
140
146
  linkRequirementSessionTool(environment),
147
+ linkSessionTool(environment),
141
148
  createStoryTool(environment),
142
149
  getStoryTool(environment),
143
150
  updateStoryTool(environment),
@@ -0,0 +1,3 @@
1
+ import type { ToolEnvironment } from './shared.ts';
2
+ /** Build the generic session-linking tool bound to one Host context. */
3
+ export declare function linkSessionTool(environment: ToolEnvironment): import("@deepseek-ai/dsh-tools").ToolDefinition;