@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.
@@ -41,6 +41,10 @@ export const BUG_SEVERITIES = ['blocker', 'critical', 'major', 'minor', 'trivial
41
41
  export const DOC_KINDS = ['flow', 'data', 'interface', 'page', 'rule'];
42
42
  /** Why a requirement is linked to a conversation. */
43
43
  export const REQUIREMENT_SESSION_KINDS = ['create', 'design', 'detail', 'split'];
44
+ /** Which kind of record a discussion conversation belongs to. */
45
+ export const DISCUSSION_SUBJECT_TYPES = ['requirement', 'story', 'bug'];
46
+ /** Discussion kind used by stories and defects; requirements keep their own kinds. */
47
+ export const GENERAL_DISCUSSION_KIND = 'discuss';
44
48
  /** A rejected domain operation, separate from SQLite or Host failures. */
45
49
  export class WorkflowError extends Error {
46
50
  /** Machine-readable reason for tools and the Web UI. */
@@ -2,6 +2,8 @@
2
2
  export declare const PLUGIN_HOME_SEGMENTS: readonly ["xirang-ai", "dsh-teamflow"];
3
3
  /** Database file name inside the plugin data directory. */
4
4
  export declare const DATABASE_FILENAME = "teamflow.sqlite";
5
+ /** Directory name of the markdown documents below the plugin data directory. */
6
+ export declare const DOCS_DIRNAME = "docs";
5
7
  /** Human-readable directory of the durable data, e.g. `$DSH_HOME/xirang-ai/dsh-teamflow`. */
6
8
  export declare function defaultDataDir(): string;
7
9
  /**
@@ -13,6 +15,24 @@ export declare function defaultDataDir(): string;
13
15
  * @returns the absolute data directory.
14
16
  */
15
17
  export declare function resolveDataDir(configured?: string): string;
18
+ /** Default document root: `<dataDir>/docs`, next to the database. */
19
+ export declare function defaultDocsDir(dataDir: string): string;
20
+ /**
21
+ * Resolve the document root: an explicit `Config.docsDir` wins, otherwise
22
+ * `<dataDir>/docs`. Relative overrides resolve against the process working
23
+ * directory and `~/` is expanded, mirroring {@link resolveDataDir}.
24
+ *
25
+ * Validation is loud on purpose: a malformed override or a path that is
26
+ * already a file fails plugin load with the field name and the accepted forms
27
+ * instead of silently falling back to the default directory.
28
+ *
29
+ * @param configured - `Config.docsDir`, possibly empty.
30
+ * @param dataDir - the resolved data directory.
31
+ * @returns the absolute document root.
32
+ */
33
+ export declare function resolveDocsDir(configured: string | undefined, dataDir: string): string;
34
+ /** Create the document root (owner-only) and return it. */
35
+ export declare function ensureDocsDir(docsDir: string): string;
16
36
  /** Absolute database file path for a resolved data directory. */
17
37
  export declare function databaseFile(dataDir: string): string;
18
38
  /** Create the data directory (owner-only) and return it. */
@@ -9,13 +9,16 @@
9
9
  * @module
10
10
  */
11
11
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
12
- import { mkdirSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, statSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
14
  import { join, resolve, sep } from 'node:path';
15
+ import { describeError } from "../errors.js";
15
16
  /** Path segments below `$DSH_HOME` that this plugin owns. */
16
17
  export const PLUGIN_HOME_SEGMENTS = ['xirang-ai', 'dsh-teamflow'];
17
18
  /** Database file name inside the plugin data directory. */
18
19
  export const DATABASE_FILENAME = 'teamflow.sqlite';
20
+ /** Directory name of the markdown documents below the plugin data directory. */
21
+ export const DOCS_DIRNAME = 'docs';
19
22
  /** Human-readable directory of the durable data, e.g. `$DSH_HOME/xirang-ai/dsh-teamflow`. */
20
23
  export function defaultDataDir() {
21
24
  return dshHomePath(...PLUGIN_HOME_SEGMENTS);
@@ -38,6 +41,56 @@ export function resolveDataDir(configured) {
38
41
  return join(homedir(), trimmed.slice(2));
39
42
  return resolve(trimmed);
40
43
  }
44
+ /** Default document root: `<dataDir>/docs`, next to the database. */
45
+ export function defaultDocsDir(dataDir) {
46
+ return join(dataDir, DOCS_DIRNAME);
47
+ }
48
+ /**
49
+ * Resolve the document root: an explicit `Config.docsDir` wins, otherwise
50
+ * `<dataDir>/docs`. Relative overrides resolve against the process working
51
+ * directory and `~/` is expanded, mirroring {@link resolveDataDir}.
52
+ *
53
+ * Validation is loud on purpose: a malformed override or a path that is
54
+ * already a file fails plugin load with the field name and the accepted forms
55
+ * instead of silently falling back to the default directory.
56
+ *
57
+ * @param configured - `Config.docsDir`, possibly empty.
58
+ * @param dataDir - the resolved data directory.
59
+ * @returns the absolute document root.
60
+ */
61
+ export function resolveDocsDir(configured, dataDir) {
62
+ const trimmed = configured?.trim() ?? '';
63
+ if (trimmed === '')
64
+ return defaultDocsDir(dataDir);
65
+ const expected = 'an absolute path, a path relative to the working directory, or a path starting with "~/"';
66
+ if (trimmed.includes('\0')) {
67
+ throw new Error(`teamflow: config "docsDir" must be ${expected}, got ${JSON.stringify(configured)}`);
68
+ }
69
+ let absolute;
70
+ if (trimmed === '~') {
71
+ absolute = homedir();
72
+ }
73
+ else if (trimmed.startsWith(`~${sep}`) || trimmed.startsWith('~/')) {
74
+ absolute = join(homedir(), trimmed.slice(2));
75
+ }
76
+ else {
77
+ absolute = resolve(trimmed);
78
+ }
79
+ if (existsSync(absolute) && !statSync(absolute).isDirectory()) {
80
+ throw new Error(`teamflow: config "docsDir" must point to a directory, but ${toDisplayPath(absolute)} is a file`);
81
+ }
82
+ return absolute;
83
+ }
84
+ /** Create the document root (owner-only) and return it. */
85
+ export function ensureDocsDir(docsDir) {
86
+ try {
87
+ mkdirSync(docsDir, { recursive: true, mode: 0o700 });
88
+ }
89
+ catch (error) {
90
+ throw new Error(`teamflow: could not create the docs directory ${toDisplayPath(docsDir)} (config "docsDir"): ${describeError(error)}`, { cause: error });
91
+ }
92
+ return docsDir;
93
+ }
41
94
  /** Absolute database file path for a resolved data directory. */
42
95
  export function databaseFile(dataDir) {
43
96
  return join(dataDir, DATABASE_FILENAME);
@@ -22,7 +22,12 @@
22
22
  */
23
23
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
24
  import { dirname, join } from 'node:path';
25
- import { DOC_ROOT } from "./docs.js";
25
+ /**
26
+ * Document root of the released v10 migration. Migrations are a historical
27
+ * contract: v10 moved bodies into the workspace, and that path must stay
28
+ * exactly as published even though current documents live elsewhere.
29
+ */
30
+ const LEGACY_DOC_ROOT = '.xirang-ai/dsh-teamflow';
26
31
  /**
27
32
  * Roles every fresh database starts with. `name` is the default label; the
28
33
  * Workbench UI localizes built-in keys through its locale dictionaries, so a
@@ -564,7 +569,7 @@ function relocateV10Bodies(db, designs, docs) {
564
569
  updateRequirement.run(primary.contentPath, requirementId);
565
570
  continue;
566
571
  }
567
- const target = `${DOC_ROOT}/requirements/${requirementId}/requirement.md`;
572
+ const target = `${LEGACY_DOC_ROOT}/requirements/${requirementId}/requirement.md`;
568
573
  try {
569
574
  let body = readFileSync(absolute(root, primary.contentPath), 'utf8');
570
575
  for (const extra of group.slice(1)) {
@@ -589,7 +594,7 @@ function relocateV10Bodies(db, designs, docs) {
589
594
  const root = roots.get(requirementId);
590
595
  if (root === undefined || doc.contentPath === '')
591
596
  continue;
592
- const target = `${DOC_ROOT}/requirements/${requirementId}/docs/${doc.id}.md`;
597
+ const target = `${LEGACY_DOC_ROOT}/requirements/${requirementId}/docs/${doc.id}.md`;
593
598
  try {
594
599
  if (!existsSync(absolute(root, doc.contentPath)))
595
600
  continue;
@@ -809,6 +814,50 @@ const SCHEMA_V16 = `
809
814
  ALTER TABLE stories ADD COLUMN create_session_id TEXT; -- 创建该故事的 DSH 会话 id;历史数据为 NULL
810
815
  ALTER TABLE bugs ADD COLUMN create_session_id TEXT; -- 创建该缺陷的 DSH 会话 id;历史数据为 NULL
811
816
  `;
817
+ /**
818
+ * v17:讨论会话登记泛化为需求 / 故事 / 缺陷三类对象。
819
+ *
820
+ * 需求专属的 `requirement_sessions` 只能挂需求,而故事与缺陷也要能从详情页
821
+ * 发起讨论并回写正文。这里换成一张按 `(subject_type, subject_id, session_id)`
822
+ * 唯一的通用表:需求侧原有的 kind(create/design/detail/split)原样迁入,
823
+ * 故事与缺陷用 `discuss`。时间列沿用旧表的 TEXT ISO-8601,迁移不换算。
824
+ * 对象删除不再级联:列表按 `(subject_type, subject_id)` 查询,孤儿行不进任何列表。
825
+ */
826
+ const SCHEMA_V17 = `
827
+ CREATE TABLE discussion_sessions ( -- 讨论对象与宿主会话的登记关系表
828
+ subject_type TEXT NOT NULL, -- 讨论对象类型:requirement / story / bug
829
+ subject_id TEXT NOT NULL, -- 讨论对象 id(需求 / 故事 / 缺陷的业务 id)
830
+ session_id TEXT NOT NULL, -- 宿主会话 id
831
+ kind TEXT NOT NULL DEFAULT 'discuss', -- 讨论类型:discuss 为通用讨论;需求侧沿用既有取值
832
+ prepared_at TEXT, -- 两段式登记第一段:客户端写入的「待开始」时刻(ISO-8601 UTC);创建会话行为 NULL
833
+ linked_at TEXT, -- 两段式登记第二段:Agent 确认后的「已登记」时刻(ISO-8601 UTC);NULL = 待开始
834
+ created_at TEXT NOT NULL, -- 行首次创建时刻(ISO-8601 UTC)
835
+ PRIMARY KEY (subject_type, subject_id, session_id)
836
+ );
837
+
838
+ CREATE INDEX idx_discussion_sessions_subject
839
+ ON discussion_sessions (subject_type, subject_id, linked_at DESC);
840
+ `;
841
+ /** Copy every legacy requirement row into the generic table and drop it. */
842
+ function migrateRequirementSessions(db) {
843
+ db.exec(SCHEMA_V17);
844
+ db.exec(`
845
+ INSERT INTO discussion_sessions
846
+ (subject_type, subject_id, kind, session_id, prepared_at, linked_at, created_at)
847
+ SELECT 'requirement', requirement_id, kind, session_id, prepared_at, linked_at, created_at
848
+ FROM requirement_sessions;
849
+ `);
850
+ const before = db.prepare('SELECT COUNT(*) AS count FROM requirement_sessions').get();
851
+ const after = db
852
+ .prepare("SELECT COUNT(*) AS count FROM discussion_sessions WHERE subject_type = 'requirement'")
853
+ .get();
854
+ const copied = Number(after?.['count'] ?? 0);
855
+ const expected = Number(before?.['count'] ?? 0);
856
+ if (copied !== expected) {
857
+ throw new Error(`teamflow: schema migration 17 copied ${copied} requirement session row(s) but the legacy table held ${expected}; rolling back`);
858
+ }
859
+ db.exec('DROP TABLE requirement_sessions');
860
+ }
812
861
  export const MIGRATIONS = [
813
862
  {
814
863
  version: 1,
@@ -944,6 +993,13 @@ export const MIGRATIONS = [
944
993
  db.exec(SCHEMA_V16);
945
994
  },
946
995
  },
996
+ {
997
+ version: 17,
998
+ description: 'generalize discussion sessions to requirements, stories and bugs',
999
+ up: (db) => {
1000
+ migrateRequirementSessions(db);
1001
+ },
1002
+ },
947
1003
  ];
948
1004
  /** Schema version this build expects; the last migration's version. */
949
1005
  export const TARGET_SCHEMA_VERSION = MIGRATIONS.at(-1)?.version ?? 0;
@@ -985,24 +1041,25 @@ export const SCHEMA_COMMENTS = [
985
1041
  { table: 'requirements', column: 'workspace_id', description: '所属 DSH 工作区 id(ctx.workspaces 的 WorkspaceView.workspaceId);空串表示 v4 迁移之前写入的历史数据。' },
986
1042
  { table: 'requirements', column: 'title', description: '需求标题。' },
987
1043
  { table: 'requirements', column: 'summary', description: '澄清并总结后的一句话结论,供详细设计和故事引用。' },
988
- { table: 'requirements', column: 'content_path', description: '正文(需求 + 概要设计)相对路径(工作区内,如 .xirang-ai/dsh-teamflow/requirements/<id>/requirement.md)。' },
1044
+ { table: 'requirements', column: 'content_path', description: '正文(需求 + 概要设计)相对路径(文档根内,如 requirements/<id>/requirement.md)。' },
989
1045
  { table: 'requirements', column: 'status', description: '状态:draft=进行中(仍在收集或修改),completed=用户确认完成,archived=已归档;完成不会自动发生。' },
990
1046
  { table: 'requirements', column: 'created_at', description: '创建时间(ISO-8601 UTC)。' },
991
1047
  { table: 'requirements', column: 'updated_at', description: '最后更新时间(ISO-8601 UTC)。' },
992
1048
  { table: 'requirements', column: 'completed_at', description: '用户确认完成需求的时间(ISO-8601 UTC),未完成为 NULL。' },
993
- { table: 'requirement_sessions', column: '', description: '需求与会话:创建需求(create)、概要设计讨论(design)、详细设计讨论(detail)和故事拆分讨论(split)都记在这里。' },
994
- { table: 'requirement_sessions', column: 'requirement_id', description: '所属需求;删除需求时级联清理关联。' },
995
- { table: 'requirement_sessions', column: 'session_id', description: 'DSH 会话 id,唯一;一个会话只属于一个需求。' },
996
- { table: 'requirement_sessions', column: 'kind', description: '会话类型:create=创建需求,design=讨论概要设计,detail=讨论详细设计,split=讨论故事拆分。' },
997
- { table: 'requirement_sessions', column: 'prepared_at', description: '浏览器创建讨论会话后写入待登记的时间(ISO-8601 UTC);创建需求的会话为 NULL。' },
998
- { table: 'requirement_sessions', column: 'linked_at', description: 'Agent 调用登记工具确认的时间(ISO-8601 UTC);NULL 表示待开始,不进入历史列表。' },
999
- { table: 'requirement_sessions', column: 'created_at', description: '首次绑定时间(ISO-8601 UTC)。' },
1049
+ { table: 'discussion_sessions', column: '', description: '讨论对象与宿主会话的登记关系:需求、故事、缺陷的讨论会话都记在这里,两段式登记(浏览器准备、Agent 确认)。' },
1050
+ { table: 'discussion_sessions', column: 'subject_type', description: '讨论对象类型:requirement=需求,story=故事,bug=缺陷;与 subject_id 一起标识对象。' },
1051
+ { table: 'discussion_sessions', column: 'subject_id', description: '讨论对象 id(需求 / 故事 / 缺陷的业务 id);对象删除时不级联清理。' },
1052
+ { table: 'discussion_sessions', column: 'session_id', description: 'DSH 会话 id;同一对象与同一会话只有一行。' },
1053
+ { table: 'discussion_sessions', column: 'kind', description: '讨论类型:create=创建需求,design=讨论概要设计,detail=讨论详细设计,split=讨论故事拆分,discuss=故事/缺陷的通用讨论。' },
1054
+ { table: 'discussion_sessions', column: 'prepared_at', description: '浏览器创建讨论会话后写入待登记的时间(ISO-8601 UTC);创建需求的会话为 NULL。' },
1055
+ { table: 'discussion_sessions', column: 'linked_at', description: 'Agent 调用登记工具确认的时间(ISO-8601 UTC);NULL 表示待开始,不进入历史列表。' },
1056
+ { table: 'discussion_sessions', column: 'created_at', description: '首次绑定时间(ISO-8601 UTC)。' },
1000
1057
  { table: 'stories', column: '', description: '故事:可独立开发与交付的单元;父级是需求,也可以独立存在。' },
1001
1058
  { table: 'stories', column: 'id', description: 'nanoid 业务主键(sty_ 前缀)。' },
1002
1059
  { table: 'stories', column: 'workspace_id', description: '所属 DSH 工作区 id:有父级时继承并校验一致,独立故事创建时指定。' },
1003
1060
  { table: 'stories', column: 'requirement_id', description: '所属需求;为空表示用户直接提出的独立故事。' },
1004
1061
  { table: 'stories', column: 'title', description: '故事标题。' },
1005
- { table: 'stories', column: 'content_path', description: '故事正文相对路径:说明与验收标准写在同一份 story.md 里。' },
1062
+ { table: 'stories', column: 'content_path', description: '故事正文相对路径(文档根内,如 stories/<id>/story.md):说明与验收标准写在同一份 story.md 里。' },
1006
1063
  { table: 'stories', column: 'status', description: '状态:ready=待开发,developing=开发中,self_testing=自测中,done=执行完毕(未完成),completed=用户确认完成,cancelled=已取消。' },
1007
1064
  { table: 'stories', column: 'size', description: '拆分时的工作量档位:xs/s/m/l/xl。' },
1008
1065
  { table: 'stories', column: 'sort_order', description: '同一需求下的展示顺序,升序;新建取当前最大值 + 1,运行与状态流转不改它。' },
@@ -1013,7 +1070,7 @@ export const SCHEMA_COMMENTS = [
1013
1070
  { table: 'design_docs', column: 'requirement_id', description: '所属需求;删除需求时一并删除其文档。' },
1014
1071
  { table: 'design_docs', column: 'kind', description: '文档主题:flow=流程,data=数据,interface=接口,page=页面,rule=规则。' },
1015
1072
  { table: 'design_docs', column: 'title', description: '文档标题,工作台列表中一行显示。' },
1016
- { table: 'design_docs', column: 'content_path', description: '详设正文相对路径;正文很长,只落盘不进库。' },
1073
+ { table: 'design_docs', column: 'content_path', description: '详设正文相对路径(文档根内,如 requirements/<req>/docs/<doc>.md);正文很长,只落盘不进库。' },
1017
1074
  { table: 'design_docs', column: 'sort_order', description: '同一需求下的展示顺序,升序;新建取当前最大值 + 1,页面只按它渲染。' },
1018
1075
  { table: 'design_docs', column: 'created_at', description: '创建时间(ISO-8601 UTC)。' },
1019
1076
  { table: 'design_docs', column: 'updated_at', description: '最后更新时间(ISO-8601 UTC)。' },
@@ -1028,7 +1085,7 @@ export const SCHEMA_COMMENTS = [
1028
1085
  { table: 'bugs', column: 'id', description: 'nanoid 业务主键(bug_ 前缀)。' },
1029
1086
  { table: 'bugs', column: 'workspace_id', description: '所属 DSH 工作区 id:bug 独立存在,创建时必须显式给出。' },
1030
1087
  { table: 'bugs', column: 'title', description: 'bug 标题。' },
1031
- { table: 'bugs', column: 'content_path', description: '缺陷正文相对路径:描述、复现、期望、实际、环境都在这份 bug.md 里。' },
1088
+ { table: 'bugs', column: 'content_path', description: '缺陷正文相对路径(文档根内,如 bugs/<id>/bug.md):描述、复现、期望、实际、环境都在这份 bug.md 里。' },
1032
1089
  { table: 'bugs', column: 'severity', description: '严重程度:blocker/critical/major/minor/trivial。' },
1033
1090
  { table: 'bugs', column: 'status', description: '状态:reported=待修复,fixing=修复中,fixed=执行完毕(未完成),completed=用户确认完成,wontfix=不修复。' },
1034
1091
  { table: 'bugs', column: 'resolution', description: '修复结论、不修复原因或关闭说明。' },
@@ -19,17 +19,16 @@
19
19
  */
20
20
  import type { DatabaseSync } from 'node:sqlite';
21
21
  import { type PermissionTier } from '../permission-tier.ts';
22
- import { type Bug, type BugFilter, type BoardRows, type DeletePreview, type DeleteReport, type DeleteTarget, type CreateBugInput, type CreateDesignDocInput, type CreateRequirementInput, type CreateStoryInput, type DesignDoc, type FinishRunInput, type Requirement, type RequirementSession, type RequirementSessionKind, type UpdateDesignDocInput, type RequirementFilter, type Run, type RunFilter, type StartRunInput, type Story, type StoryFilter, type StoryReadiness, type UpdateBugInput, type UpdateRequirementInput, type UpdateStoryInput } from './model.ts';
22
+ import { type Bug, type BugFilter, type BoardRows, type DeletePreview, type DeleteReport, type DeleteTarget, type DiscussionSession, type DiscussionSubjectType, type CreateBugInput, type CreateDesignDocInput, type CreateRequirementInput, type CreateStoryInput, type DesignDoc, type FinishRunInput, type Requirement, type RequirementSession, type RequirementSessionKind, type UpdateDesignDocInput, type RequirementFilter, type Run, type RunFilter, type StartRunInput, type Story, type StoryFilter, type StoryReadiness, type UpdateBugInput, type UpdateRequirementInput, type UpdateStoryInput } from './model.ts';
23
23
  /** Cap one batch so a single call cannot scan an unbounded id list. */
24
24
  export declare const MAX_DELETE_TARGETS = 50;
25
25
  /** Construction options for {@link WorkflowStore}. */
26
26
  export interface WorkflowStoreOptions {
27
27
  /**
28
- * Root that holds `requirements/`, `stories/` and `bugs/` when a workspace
29
- * directory is not known yet. Production wiring passes the
30
- * workspace path instead (see {@link WorkflowStore.registerWorkspace}).
28
+ * Absolute document root that holds `requirements/`, `stories/` and
29
+ * `bugs/`. Every body lives here; records only store the root-relative path.
31
30
  */
32
- readonly docsRoot?: string;
31
+ readonly docsRoot: string;
33
32
  /** Resolver used before the registry; lets the Host provide the live path. */
34
33
  readonly workspaceRoot?: (workspaceId: string) => string | undefined;
35
34
  /** Clock override for tests; must return an ISO-8601 UTC string. */
@@ -45,52 +44,78 @@ export interface WorkflowStoreOptions {
45
44
  */
46
45
  export declare class WorkflowStore {
47
46
  #private;
48
- constructor(db: DatabaseSync, options?: WorkflowStoreOptions);
47
+ constructor(db: DatabaseSync, options: WorkflowStoreOptions);
49
48
  /**
50
- * Remember where a workspace lives on disk. Documents are written into that
51
- * directory, so this must be called before the first file-backed write;
52
- * repeated calls with the same path are no-ops.
49
+ * Remember where a workspace lives on disk. Document bodies no longer live
50
+ * there, but the path is still recorded for display and for future
51
+ * workspace-scoped features; repeated calls with the same path are no-ops.
53
52
  */
54
53
  registerWorkspace(workspaceId: string, path: string): void;
55
- /** Directory a workspace's documents live in, or null when unknown. */
54
+ /** Directory a registered workspace lives in, or null when unknown. */
56
55
  workspacePath(workspaceId: string): string | null;
57
56
  /**
58
- * Read a document body by its workspace-relative path.
57
+ * Read a document body by its document-root-relative path.
59
58
  *
60
59
  * `null` means the file does not exist; `''` means it exists and is empty.
61
- * A workspace without a directory is a configuration error, not a missing
62
- * document, so it is reported as `file_error` instead of being folded into
63
- * either empty result.
60
+ * Every record resolves against the one configured document root, so a
61
+ * legacy path simply misses instead of falling back to a workspace.
64
62
  */
65
- readContent(workspaceId: string, relativePath: string): string | null;
63
+ readContent(relativePath: string): string | null;
66
64
  /** Rewrite a document body; the path comes from the row, so it is always current. */
67
- writeContent(workspaceId: string, relativePath: string, body: string): void;
65
+ writeContent(relativePath: string, body: string): void;
68
66
  getRequirement(id: string): Requirement | null;
69
67
  listRequirements(filter?: RequirementFilter): Requirement[];
70
68
  /**
71
69
  * Register a browser-created session as a pending discussion for a requirement.
72
70
  *
71
+ * Kept as a thin wrapper over {@link prepareDiscussionSession} so the
72
+ * requirement page and its tests keep their existing shape.
73
+ */
74
+ prepareRequirementSession(requirementId: string, sessionId: string, kind?: RequirementSessionKind): RequirementSession;
75
+ /**
76
+ * Register a browser-created session as a pending discussion for a subject.
77
+ *
73
78
  * The browser creates the session first, then calls this before the user can
74
79
  * send the first message. The row stays invisible in `listBoard` until the
75
- * Agent confirms it through {@link linkRequirementSession}.
80
+ * Agent confirms it through `teamflow_link_session`; this method never writes
81
+ * `linked_at`, so the client cannot skip Agent confirmation.
76
82
  */
77
- prepareRequirementSession(requirementId: string, sessionId: string, kind?: RequirementSessionKind): RequirementSession;
83
+ prepareDiscussionSession(subjectType: DiscussionSubjectType, subjectId: string, sessionId: string, kind: string): DiscussionSession;
78
84
  /**
79
- * Confirm the current session as a discussion of the requirement prepared for it.
85
+ * Confirm the current session as the discussion it was prepared for.
80
86
  *
81
- * Called by `teamflow_link_requirement_session`; repeated calls are idempotent
82
- * so a model retry cannot create duplicate history rows.
87
+ * Called by `teamflow_link_session`; repeated calls are idempotent so a model
88
+ * retry cannot rewrite the confirmation time.
89
+ */
90
+ linkDiscussionSession(sessionId: string): DiscussionSession;
91
+ /**
92
+ * Confirm the current session as a requirement discussion.
93
+ *
94
+ * Kept for `teamflow_link_requirement_session`; new callers use
95
+ * {@link linkDiscussionSession}.
83
96
  */
84
97
  linkRequirementSession(sessionId: string): RequirementSession;
85
- /** Pending or linked conversation prepared for one session, or null. */
98
+ /** Pending or linked requirement conversation prepared for one session, or null. */
86
99
  getRequirementSession(sessionId: string): RequirementSession | null;
87
100
  /** The requirement linked to a session, whichever kind of link it is. */
88
101
  getRequirementBySession(sessionId: string): Requirement | null;
102
+ /** The discussion row bound to one session, or null when none exists. */
103
+ getDiscussionSession(sessionId: string): DiscussionSession | null;
104
+ /**
105
+ * Discussions under one subject, newest linked first.
106
+ *
107
+ * Pending rows are excluded by default so the workbench never shows a session
108
+ * the Agent has not started; tests and diagnostics can opt in.
109
+ */
110
+ listDiscussionSessions(subjectType: DiscussionSubjectType, subjectId: string, options?: {
111
+ readonly includeUnlinked?: boolean;
112
+ readonly kinds?: readonly string[];
113
+ }): DiscussionSession[];
89
114
  /**
90
- * Conversations under one requirement, newest linked first.
115
+ * Requirement conversations under one requirement, newest linked first.
91
116
  *
92
- * Pending design rows are excluded by default so the workbench never shows a
93
- * session the Agent has not started; tests and diagnostics can opt in.
117
+ * The `create` row is always included: it explains where the requirement came
118
+ * from even though the Agent never confirms it.
94
119
  */
95
120
  listRequirementSessions(requirementId: string, options?: {
96
121
  readonly includePending?: boolean;