@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.
@@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
36
36
  register(contribution: HostContribution): TypertDisposer;
37
37
  }
38
38
  }
39
- import type { BugSeverity, BugStatus, DeletePreview, DeleteReport, DeleteTarget, DocKind, RequirementSessionKind, RequirementStatus, RunKind, RunStatus, StorySize, StoryStatus } from '../store/model.ts';
39
+ import type { BugSeverity, BugStatus, DeletePreview, DiscussionSubjectType, DeleteReport, DeleteTarget, DocKind, RequirementSessionKind, RequirementStatus, RunKind, RunStatus, StorySize, StoryStatus } from '../store/model.ts';
40
40
  /** npm package that owns these Remote methods. */
41
41
  export declare const PACKAGE_NAME = "@xirang-ai/dsh-teamflow";
42
42
  /**
@@ -89,6 +89,8 @@ export interface SnapshotStory {
89
89
  readonly completedAt: string | null;
90
90
  /** Conversation that created the story; null when it was not recorded. */
91
91
  readonly createSessionId: string | null;
92
+ /** Confirmed discussions of this story, newest first. */
93
+ readonly sessions: readonly SnapshotDiscussionSession[];
92
94
  }
93
95
  /** One 详细设计 document; its body is fetched on demand by {@link readDoc}. */
94
96
  export interface SnapshotDoc {
@@ -121,6 +123,8 @@ export interface SnapshotBug {
121
123
  readonly completedAt: string | null;
122
124
  /** Conversation that created the defect; null when it was not recorded. */
123
125
  readonly createSessionId: string | null;
126
+ /** Confirmed discussions of this defect, newest first. */
127
+ readonly sessions: readonly SnapshotDiscussionSession[];
124
128
  }
125
129
  /** One development or fix run; `sessionId` is the conversation it happened in. */
126
130
  export interface SnapshotRun {
@@ -148,6 +152,17 @@ export interface SnapshotRequirementSession {
148
152
  readonly linkedAt: string | null;
149
153
  readonly createdAt: string;
150
154
  }
155
+ /** One discussion conversation attached to a story or a defect. */
156
+ export interface SnapshotDiscussionSession {
157
+ readonly sessionId: string;
158
+ /** `discuss` for stories and defects; requirements keep their own kinds. */
159
+ readonly kind: string;
160
+ /** Browser preparation time; null only for conversations that skipped it. */
161
+ readonly preparedAt: string | null;
162
+ /** Agent confirmation time; null while the discussion is pending. */
163
+ readonly linkedAt: string | null;
164
+ readonly createdAt: string;
165
+ }
151
166
  /** Everything the list, the design tree and the defect list need in one frame. */
152
167
  export interface WorkbenchSnapshot {
153
168
  /** When the Host read the rows; the panel shows it next to the refresh button. */
@@ -226,6 +241,16 @@ export interface RequirementSessionReply {
226
241
  readonly preparedAt: string | null;
227
242
  readonly linkedAt: string | null;
228
243
  }
244
+ /** One discussion row as stored; `linkedAt` is null until the Agent confirms. */
245
+ export interface DiscussionSessionReply {
246
+ readonly subjectType: DiscussionSubjectType;
247
+ readonly subjectId: string;
248
+ readonly sessionId: string;
249
+ readonly kind: string;
250
+ readonly preparedAt: string | null;
251
+ readonly linkedAt: string | null;
252
+ readonly createdAt: string;
253
+ }
229
254
  /** Success or failure envelope the browser mounts produce. */
230
255
  export type RemoteResult<Value> = {
231
256
  readonly ok: true;
@@ -323,6 +348,13 @@ export interface WorkbenchRemote {
323
348
  * of the snapshot until `teamflow_link_requirement_session` writes `linkedAt`.
324
349
  */
325
350
  prepareRequirementSession(requirementId: string, sessionId: string, kind: RequirementSessionKind, locale?: string): Promise<RemoteResult<RequirementSessionReply>>;
351
+ /**
352
+ * Register a newly created session as a pending discussion of any subject.
353
+ *
354
+ * Same two-phase contract as {@link prepareRequirementSession}, extended to
355
+ * stories and defects; this method never writes `linkedAt`.
356
+ */
357
+ prepareDiscussionSession(subjectType: DiscussionSubjectType, subjectId: string, sessionId: string, kind: string, locale?: string): Promise<RemoteResult<DiscussionSessionReply>>;
326
358
  /**
327
359
  * Read the tier a conversation was started with, for the session header.
328
360
  *
@@ -357,6 +389,7 @@ export declare const METHODS: {
357
389
  readonly cancelStory: "cancelStory";
358
390
  readonly completeBug: "completeBug";
359
391
  readonly prepareRequirementSession: "prepareRequirementSession";
392
+ readonly prepareDiscussionSession: "prepareDiscussionSession";
360
393
  readonly sessionPermissionTier: "sessionPermissionTier";
361
394
  readonly previewDelete: "previewDelete";
362
395
  readonly deleteRecords: "deleteRecords";
@@ -22,6 +22,7 @@ export const METHODS = {
22
22
  cancelStory: 'cancelStory',
23
23
  completeBug: 'completeBug',
24
24
  prepareRequirementSession: 'prepareRequirementSession',
25
+ prepareDiscussionSession: 'prepareDiscussionSession',
25
26
  sessionPermissionTier: 'sessionPermissionTier',
26
27
  previewDelete: 'previewDelete',
27
28
  deleteRecords: 'deleteRecords',
@@ -170,6 +171,21 @@ export const DESCRIPTORS = Object.freeze([
170
171
  ]),
171
172
  result: JSON_CODEC,
172
173
  }),
174
+ Object.freeze({
175
+ id: `${PACKAGE_NAME}#${METHODS.prepareDiscussionSession}`,
176
+ service: SERVICE_KEY,
177
+ namespace: NAMESPACE,
178
+ method: METHODS.prepareDiscussionSession,
179
+ invocation: { kind: 'direct' },
180
+ parameters: Object.freeze([
181
+ parameter('subjectType', 'Discussion subject: requirement, story or bug'),
182
+ parameter('subjectId', 'Requirement, story or defect the discussion belongs to'),
183
+ parameter('sessionId', 'Newly created conversation to link'),
184
+ parameter('kind', 'Discussion kind: discuss for stories and defects, the requirement kinds otherwise'),
185
+ LOCALE_PARAMETER,
186
+ ]),
187
+ result: JSON_CODEC,
188
+ }),
173
189
  Object.freeze({
174
190
  id: `${PACKAGE_NAME}#${METHODS.sessionPermissionTier}`,
175
191
  service: SERVICE_KEY,
@@ -10,9 +10,9 @@
10
10
  */
11
11
  import type { Context } from '@deepseek-ai/cordis';
12
12
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
13
- import type { DeletePreview, DeleteReport, DeleteTarget, RequirementSessionKind } from '../store/model.ts';
13
+ import type { DeletePreview, DeleteReport, DeleteTarget, DiscussionSubjectType, RequirementSessionKind } from '../store/model.ts';
14
14
  import type { WorkflowStore } from '../store/workflow.ts';
15
- import type { AttachmentPolicy, BugRunReply, BugStatusReply, CancelRunReply, DocBody, RequirementSessionReply, RequirementStatusReply, SessionTierReply, StartRunReply, StoryStatusReply, WorkbenchSnapshot } from './contract.ts';
15
+ import type { AttachmentPolicy, BugRunReply, BugStatusReply, CancelRunReply, DiscussionSessionReply, DocBody, RequirementSessionReply, RequirementStatusReply, SessionTierReply, StartRunReply, StoryStatusReply, WorkbenchSnapshot } from './contract.ts';
16
16
  /**
17
17
  * Remote face of the workbench.
18
18
  *
@@ -134,6 +134,19 @@ export declare class WorkbenchService extends TypertRemoteService {
134
134
  * @returns the stored pending row.
135
135
  */
136
136
  prepareRequirementSession(requirementId: string, sessionId: string, kind?: RequirementSessionKind, locale?: string): RequirementSessionReply;
137
+ /**
138
+ * Register a newly created conversation as a pending discussion of any subject.
139
+ *
140
+ * Same two-phase contract as {@link prepareRequirementSession}: the Agent
141
+ * confirms with `teamflow_link_session`, and this method never writes the
142
+ * confirmation time itself.
143
+ * @param subjectType - requirement, story or bug.
144
+ * @param subjectId - record the discussion belongs to.
145
+ * @param sessionId - session created by the browser for this discussion.
146
+ * @param kind - `discuss` for stories and defects; requirement kinds otherwise.
147
+ * @returns the stored pending row.
148
+ */
149
+ prepareDiscussionSession(subjectType: DiscussionSubjectType, subjectId: string, sessionId: string, kind: string, locale?: string): DiscussionSessionReply;
137
150
  /**
138
151
  * Compute deletion impact without writing anything.
139
152
  * @param targets - records the user selected in the panel.
@@ -12,10 +12,22 @@ import { NAMESPACE, SERVICE_KEY } from "./contract.js";
12
12
  * `WorkflowStore.readContent` returns `null` for a missing file, `''` for an
13
13
  * empty one, and throws for any other I/O failure.
14
14
  */
15
- function readBody(workflow, workspaceId, path) {
16
- const body = workflow.readContent(workspaceId, path);
15
+ function readBody(workflow, path) {
16
+ const body = workflow.readContent(path);
17
17
  return body === null ? { body: '', bodyMissing: true } : { body, bodyMissing: false };
18
18
  }
19
+ /** Confirmed discussions of one story or defect, newest first. */
20
+ function discussionsFor(rows, subjectType, subjectId) {
21
+ return rows
22
+ .filter((row) => row.subjectType === subjectType && row.subjectId === subjectId)
23
+ .map((row) => ({
24
+ sessionId: row.sessionId,
25
+ kind: row.kind,
26
+ preparedAt: row.preparedAt,
27
+ linkedAt: row.linkedAt,
28
+ createdAt: row.createdAt,
29
+ }));
30
+ }
19
31
  /**
20
32
  * Rebuild a failure in the caller's language.
21
33
  *
@@ -84,7 +96,7 @@ export class WorkbenchService extends TypertRemoteService {
84
96
  status: requirement.status,
85
97
  updatedAt: requirement.updatedAt,
86
98
  path: requirement.contentPath,
87
- ...readBody(this.workflow, requirement.workspaceId, requirement.contentPath),
99
+ ...readBody(this.workflow, requirement.contentPath),
88
100
  completedAt: requirement.completedAt,
89
101
  })),
90
102
  stories: rows.stories.map((story) => ({
@@ -98,10 +110,11 @@ export class WorkbenchService extends TypertRemoteService {
98
110
  createdAt: story.createdAt,
99
111
  updatedAt: story.updatedAt,
100
112
  path: story.contentPath,
101
- ...readBody(this.workflow, story.workspaceId, story.contentPath),
113
+ ...readBody(this.workflow, story.contentPath),
102
114
  doneAt: story.doneAt,
103
115
  completedAt: story.completedAt,
104
116
  createSessionId: story.createSessionId,
117
+ sessions: discussionsFor(rows.discussionSessions, 'story', story.id),
105
118
  })),
106
119
  docs: rows.docs.map((doc) => ({
107
120
  id: doc.id,
@@ -122,10 +135,11 @@ export class WorkbenchService extends TypertRemoteService {
122
135
  status: bug.status,
123
136
  resolution: bug.resolution,
124
137
  updatedAt: bug.updatedAt,
125
- ...readBody(this.workflow, bug.workspaceId, bug.contentPath),
138
+ ...readBody(this.workflow, bug.contentPath),
126
139
  fixedAt: bug.fixedAt,
127
140
  completedAt: bug.completedAt,
128
141
  createSessionId: bug.createSessionId,
142
+ sessions: discussionsFor(rows.discussionSessions, 'bug', bug.id),
129
143
  })),
130
144
  runs: rows.runs.map((run) => ({
131
145
  id: run.id,
@@ -400,6 +414,35 @@ export class WorkbenchService extends TypertRemoteService {
400
414
  throw localizedFailure(error, locale);
401
415
  }
402
416
  }
417
+ /**
418
+ * Register a newly created conversation as a pending discussion of any subject.
419
+ *
420
+ * Same two-phase contract as {@link prepareRequirementSession}: the Agent
421
+ * confirms with `teamflow_link_session`, and this method never writes the
422
+ * confirmation time itself.
423
+ * @param subjectType - requirement, story or bug.
424
+ * @param subjectId - record the discussion belongs to.
425
+ * @param sessionId - session created by the browser for this discussion.
426
+ * @param kind - `discuss` for stories and defects; requirement kinds otherwise.
427
+ * @returns the stored pending row.
428
+ */
429
+ prepareDiscussionSession(subjectType, subjectId, sessionId, kind, locale) {
430
+ try {
431
+ const row = this.workflow.prepareDiscussionSession(subjectType, subjectId, sessionId, kind);
432
+ return {
433
+ subjectType: row.subjectType,
434
+ subjectId: row.subjectId,
435
+ sessionId: row.sessionId,
436
+ kind: row.kind,
437
+ preparedAt: row.preparedAt,
438
+ linkedAt: row.linkedAt,
439
+ createdAt: row.createdAt,
440
+ };
441
+ }
442
+ catch (error) {
443
+ throw localizedFailure(error, locale);
444
+ }
445
+ }
403
446
  /**
404
447
  * Compute deletion impact without writing anything.
405
448
  * @param targets - records the user selected in the panel.
@@ -459,7 +502,7 @@ export class WorkbenchService extends TypertRemoteService {
459
502
  return {
460
503
  id: doc.id,
461
504
  title: doc.title,
462
- ...readBody(this.workflow, requirement.workspaceId, doc.contentPath),
505
+ ...readBody(this.workflow, doc.contentPath),
463
506
  };
464
507
  }
465
508
  catch (error) {
@@ -42,3 +42,26 @@ export declare function presetForRequirementDiscussion(kind: RequirementDiscussi
42
42
  * @returns the first message of the discussion, carrying the supplement slot.
43
43
  */
44
44
  export declare function buildRequirementDiscussionPrompt(kind?: RequirementDiscussionKind): string;
45
+ /** Preset for a story discussion (reuses the standalone story agent). */
46
+ export declare const STORY_DISCUSSION_PRESET = "xirang-story";
47
+ /** Preset for a defect discussion. */
48
+ export declare const BUG_DISCUSSION_PRESET = "xirang-bug";
49
+ /** A record kind whose detail page can start a discussion. */
50
+ export type RecordDiscussionKind = 'story' | 'bug';
51
+ /**
52
+ * Pick the preset a story or defect discussion runs under.
53
+ * @param kind - `story` or `bug`.
54
+ * @returns the preset id declared in `cordis.patch.yml`.
55
+ */
56
+ export declare function presetForRecordDiscussion(kind: RecordDiscussionKind): string;
57
+ /**
58
+ * Build the prompt sent when the user starts a discussion from a story or defect page.
59
+ *
60
+ * The title travels in the prompt because it is what the reader recognises;
61
+ * the id stays out of it, just like requirement discussions: the tools resolve
62
+ * the record through the session binding.
63
+ * @param kind - `story` or `bug`.
64
+ * @param title - story or defect title shown to the reader.
65
+ * @returns the first message of the discussion, carrying the supplement slot.
66
+ */
67
+ export declare function buildRecordDiscussionPrompt(kind: RecordDiscussionKind, title: string): string;
@@ -58,3 +58,29 @@ export function buildRequirementDiscussionPrompt(kind = 'design') {
58
58
  }
59
59
  return ['【继续讨论需求与概要设计】', '', '我的补充:'].join('\n');
60
60
  }
61
+ /** Preset for a story discussion (reuses the standalone story agent). */
62
+ export const STORY_DISCUSSION_PRESET = 'xirang-story';
63
+ /** Preset for a defect discussion. */
64
+ export const BUG_DISCUSSION_PRESET = 'xirang-bug';
65
+ /**
66
+ * Pick the preset a story or defect discussion runs under.
67
+ * @param kind - `story` or `bug`.
68
+ * @returns the preset id declared in `cordis.patch.yml`.
69
+ */
70
+ export function presetForRecordDiscussion(kind) {
71
+ return kind === 'bug' ? BUG_DISCUSSION_PRESET : STORY_DISCUSSION_PRESET;
72
+ }
73
+ /**
74
+ * Build the prompt sent when the user starts a discussion from a story or defect page.
75
+ *
76
+ * The title travels in the prompt because it is what the reader recognises;
77
+ * the id stays out of it, just like requirement discussions: the tools resolve
78
+ * the record through the session binding.
79
+ * @param kind - `story` or `bug`.
80
+ * @param title - story or defect title shown to the reader.
81
+ * @returns the first message of the discussion, carrying the supplement slot.
82
+ */
83
+ export function buildRecordDiscussionPrompt(kind, title) {
84
+ const heading = kind === 'bug' ? '【继续讨论缺陷】' : '【继续讨论故事】';
85
+ return [heading, title, '', '我的补充:'].join('\n');
86
+ }
package/lib/store/db.d.ts CHANGED
@@ -43,6 +43,10 @@ export interface StartupReport {
43
43
  readonly file: string;
44
44
  /** Database file rendered for logs (`$DSH_HOME/...`). */
45
45
  readonly displayFile: string;
46
+ /** Absolute document root that holds `requirements/`, `stories/` and `bugs/`. */
47
+ readonly docsDir: string;
48
+ /** Document root rendered for logs (`$DSH_HOME/...`). */
49
+ readonly displayDocsDir: string;
46
50
  /** Version stored before this start, `null` on the first start. */
47
51
  readonly fromVersion: string | null;
48
52
  /** Version now running. */
@@ -89,10 +93,11 @@ export interface OpenDatabaseOptions {
89
93
  /** `Config.dataDir` override; empty uses the plugin home below `$DSH_HOME`. */
90
94
  readonly dataDir?: string;
91
95
  /**
92
- * Where documents are written when a workspace directory is unknown yet.
93
- * Production passes the workspace path via `registerWorkspace` instead.
96
+ * `Config.docsDir` override for the document root; empty uses `<dataDir>/docs`.
97
+ * Relative paths and `~/` resolve like {@link resolveDataDir}; an invalid value
98
+ * throws instead of falling back to the default.
94
99
  */
95
- readonly docsRoot?: string;
100
+ readonly docsDir?: string;
96
101
  /** Resolver consulted before the registry, for the Host's live workspace list. */
97
102
  readonly workspaceRoot?: (workspaceId: string) => string | undefined;
98
103
  /** SQLite busy timeout in milliseconds. */
package/lib/store/db.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { DatabaseSync } from 'node:sqlite';
12
12
  import { contextualError, describeError } from "../errors.js";
13
13
  import { readPluginVersion } from "../version.js";
14
- import { DATABASE_FILENAME, databaseFile, ensureDataDir, resolveDataDir, toDisplayPath, } from "./paths.js";
14
+ import { DATABASE_FILENAME, databaseFile, ensureDataDir, ensureDocsDir, resolveDataDir, resolveDocsDir, toDisplayPath, } from "./paths.js";
15
15
  import { BUILTIN_ROLES, MIGRATIONS, TARGET_SCHEMA_VERSION, syncSchemaComments, } from "./schema.js";
16
16
  import { withTransaction } from "./transaction.js";
17
17
  import { runUpgrades } from "./upgrades.js";
@@ -36,6 +36,31 @@ export const META_KEYS = {
36
36
  /** Schema version, mirrored from `PRAGMA user_version` for inspection. */
37
37
  schemaVersion: 'schema_version',
38
38
  };
39
+ /** Prefix of legacy content paths, which were relative to a workspace root. */
40
+ const LEGACY_DOC_PREFIX = '.xirang-ai/dsh-teamflow/';
41
+ /**
42
+ * Rewrite legacy workspace-relative `content_path` values onto the document
43
+ * root. Idempotent: rows already on the new basis do not match the prefix, so
44
+ * a restart after a partial failure simply finishes the job.
45
+ *
46
+ * This is a data-basis normalization, not a schema migration: the table layout
47
+ * and `user_version` stay untouched.
48
+ *
49
+ * @returns how many rows were rewritten.
50
+ */
51
+ function normalizeContentPaths(db) {
52
+ const tables = ['requirements', 'design_docs', 'stories', 'bugs'];
53
+ return withTransaction(db, () => {
54
+ let changed = 0;
55
+ for (const table of tables) {
56
+ const result = db
57
+ .prepare(`UPDATE ${table} SET content_path = substr(content_path, ?) WHERE content_path LIKE ?`)
58
+ .run(LEGACY_DOC_PREFIX.length + 1, `${LEGACY_DOC_PREFIX}%`);
59
+ changed += Number(result.changes);
60
+ }
61
+ return changed;
62
+ });
63
+ }
39
64
  function configure(db, busyTimeoutMs) {
40
65
  const timeout = Number.isFinite(busyTimeoutMs) ? Math.max(0, Math.trunc(busyTimeoutMs)) : 0;
41
66
  // WAL keeps readers unblocked while a writer holds the lock; NORMAL is the
@@ -175,6 +200,7 @@ export function openTeamflowDatabase(options = {}) {
175
200
  const log = options.log ?? SILENT_LOGGER;
176
201
  const version = options.version ?? readPluginVersion();
177
202
  const dataDir = resolveDataDir(options.dataDir);
203
+ const docsDir = ensureDocsDir(resolveDocsDir(options.docsDir, dataDir));
178
204
  const file = databaseFile(dataDir);
179
205
  try {
180
206
  ensureDataDir(dataDir);
@@ -194,10 +220,16 @@ export function openTeamflowDatabase(options = {}) {
194
220
  const appliedMigrations = migrate(db, log);
195
221
  syncSchemaComments(db);
196
222
  seedBuiltinRoles(db);
223
+ const normalizedPaths = normalizeContentPaths(db);
224
+ if (normalizedPaths > 0) {
225
+ log.info(`normalized ${normalizedPaths} document path(s) onto the document root ${toDisplayPath(docsDir)}`);
226
+ }
197
227
  const reconciliation = reconcileVersion(db, version, log);
198
228
  const report = {
199
229
  file,
200
230
  displayFile: toDisplayPath(file),
231
+ docsDir,
232
+ displayDocsDir: toDisplayPath(docsDir),
201
233
  fromVersion: reconciliation.fromVersion,
202
234
  toVersion: version,
203
235
  outcome: reconciliation.outcome,
@@ -225,7 +257,7 @@ export function openTeamflowDatabase(options = {}) {
225
257
  db,
226
258
  report,
227
259
  workflow: new WorkflowStore(db, {
228
- docsRoot: options.docsRoot,
260
+ docsRoot: docsDir,
229
261
  workspaceRoot: options.workspaceRoot,
230
262
  warn: (message) => log.warn(message),
231
263
  }),
@@ -1,9 +1,7 @@
1
- /** 文档根目录名,工作区内的隐藏目录。 */
2
- export declare const DOC_ROOT = ".xirang-ai/dsh-teamflow";
3
1
  /** 文档种类:决定落盘路径。 */
4
2
  export type DocKind = 'requirement' | 'designDoc' | 'story' | 'bug';
5
3
  /**
6
- * 计算一份文档在工作区内的相对路径。
4
+ * 计算一份文档在文档根内的相对路径。
7
5
  *
8
6
  * @param kind - 文档种类
9
7
  * @param ids - 该种类需要的标识(designDoc 需要需求 id 与文档 id)
@@ -14,19 +12,19 @@ export declare function docPath(kind: DocKind, ids: {
14
12
  requirementId?: string;
15
13
  }): string;
16
14
  /**
17
- * 把相对路径拼到工作区根上;只接受插件自己生成的、以 `/` 分隔的工作区相对路径。
15
+ * 把相对路径拼到文档根上;只接受插件自己生成的、以 `/` 分隔的文档相对路径。
18
16
  *
19
17
  * 拒绝 POSIX 绝对路径、Windows 盘符/UNC/反斜杠路径与任何 `..`,最后再做一次
20
18
  * 词法 containment 校验,确保结果仍落在 `root` 之内。
21
19
  */
22
20
  export declare function resolveDocPath(root: string, relative: string): string;
23
- /** 工作区路径 + 相对路径的展示形式(日志与工具结果用,始终 `/`)。 */
21
+ /** 文档相对路径的展示形式(日志与工具结果用,始终 `/`)。 */
24
22
  export declare function displayPath(relative: string): string;
25
23
  /**
26
- * 首次为一个工作区准备文档目录:建目录、写 `.gitignore`、必要时把 `.xirang-ai/`
27
- * 追加进工作区自己的 `.gitignore`。
24
+ * 首次为文档根准备共享目录:只建 `requirements/`、`stories/`、`bugs/`,
25
+ * 不碰工作区,也不写 `.gitignore`。
28
26
  *
29
- * @param root - 工作区根目录(绝对路径)
27
+ * @param root - 文档根目录(绝对路径)
30
28
  * @returns 实际创建的目录(相对路径),便于工具结果里说明
31
29
  */
32
30
  export declare function ensureDocTree(root: string): string[];
@@ -46,7 +44,7 @@ export declare function writeDoc(root: string, relative: string, body: string):
46
44
  export declare function removeDoc(root: string, relative: string): void;
47
45
  /** 已挪出正式路径、等待随数据库事务一起确认删除的文档。 */
48
46
  export interface StagedDoc {
49
- /** 原来的工作区相对路径;事务失败时据此还原。 */
47
+ /** 原来的文档相对路径;事务失败时据此还原。 */
50
48
  readonly relative: string;
51
49
  /** 临时绝对路径,与正式文件同目录,保证 rename 是原子的。 */
52
50
  readonly staged: string;
@@ -58,8 +56,8 @@ export interface StagedDoc {
58
56
  * 到临时名,再在 SQLite 事务里删记录:事务失败就把文件改回来,事务成功才真正
59
57
  * `unlink`。这样任何时刻都不会出现「记录已经没了、正式路径上的文件还在」的悬挂态。
60
58
  *
61
- * @param root - 工作区根目录
62
- * @param relative - 文档的工作区相对路径
59
+ * @param root - 文档根目录
60
+ * @param relative - 文档相对路径
63
61
  * @returns 暂存信息;文件本来就不存在时返回 `null`
64
62
  */
65
63
  export declare function stageDocDeletion(root: string, relative: string): StagedDoc | null;
package/lib/store/docs.js CHANGED
@@ -1,23 +1,20 @@
1
1
  /**
2
- * 文档落盘:正文不进 SQLite,只记工作区内的相对路径。
2
+ * 文档落盘:正文不进 SQLite,只记文档根内的相对路径。
3
3
  *
4
- * 目录结构(相对工作区根):
4
+ * 目录结构(相对文档根,默认 `$DSH_HOME/xirang-ai/dsh-teamflow/docs`):
5
5
  *
6
- * .xirang-ai/dsh-teamflow/
7
- * requirements/<req_id>/requirement.md 需求 + 概要设计正文
8
- * requirements/<req_id>/docs/<doc_id>.md 详细设计文档(一份需求可以有很多份)
9
- * stories/<sty_id>/story.md 故事说明 + 验收标准
10
- * bugs/<bug_id>/bug.md 缺陷正文(描述、复现、期望、实际、环境)
6
+ * requirements/<req_id>/requirement.md 需求 + 概要设计正文
7
+ * requirements/<req_id>/docs/<doc_id>.md 详细设计文档(一份需求可以有很多份)
8
+ * stories/<sty_id>/story.md 故事说明 + 验收标准
9
+ * bugs/<bug_id>/bug.md 缺陷正文(描述、复现、期望、实际、环境)
11
10
  *
12
11
  * 所有写操作走「临时文件 + rename」,避免中途崩溃留下半截文档。
13
12
  */
14
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
15
- import { dirname, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path';
14
+ import { dirname, isAbsolute, posix, resolve, sep, win32 } from 'node:path';
16
15
  import { describeError } from "../errors.js";
17
- /** 文档根目录名,工作区内的隐藏目录。 */
18
- export const DOC_ROOT = '.xirang-ai/dsh-teamflow';
19
16
  /**
20
- * 计算一份文档在工作区内的相对路径。
17
+ * 计算一份文档在文档根内的相对路径。
21
18
  *
22
19
  * @param kind - 文档种类
23
20
  * @param ids - 该种类需要的标识(designDoc 需要需求 id 与文档 id)
@@ -27,17 +24,17 @@ export function docPath(kind, ids) {
27
24
  const id = requireId(ids.id);
28
25
  switch (kind) {
29
26
  case 'requirement':
30
- return `${DOC_ROOT}/requirements/${id}/requirement.md`;
27
+ return `requirements/${id}/requirement.md`;
31
28
  case 'story':
32
- return `${DOC_ROOT}/stories/${id}/story.md`;
29
+ return `stories/${id}/story.md`;
33
30
  case 'bug':
34
- return `${DOC_ROOT}/bugs/${id}/bug.md`;
31
+ return `bugs/${id}/bug.md`;
35
32
  case 'designDoc':
36
- return `${DOC_ROOT}/requirements/${requireId(ids.requirementId)}/docs/${id}.md`;
33
+ return `requirements/${requireId(ids.requirementId)}/docs/${id}.md`;
37
34
  }
38
35
  }
39
36
  /**
40
- * 把相对路径拼到工作区根上;只接受插件自己生成的、以 `/` 分隔的工作区相对路径。
37
+ * 把相对路径拼到文档根上;只接受插件自己生成的、以 `/` 分隔的文档相对路径。
41
38
  *
42
39
  * 拒绝 POSIX 绝对路径、Windows 盘符/UNC/反斜杠路径与任何 `..`,最后再做一次
43
40
  * 词法 containment 校验,确保结果仍落在 `root` 之内。
@@ -46,10 +43,10 @@ export function resolveDocPath(root, relative) {
46
43
  if (relative.length === 0)
47
44
  throw new Error('teamflow: document path is empty');
48
45
  if (relative.includes('\0') || relative.includes('\\')) {
49
- throw new Error(`teamflow: document path must be workspace-relative, got "${relative}"`);
46
+ throw new Error(`teamflow: document path must be relative to the document root, got "${relative}"`);
50
47
  }
51
48
  if (isAbsolute(relative) || win32.isAbsolute(relative) || /^[A-Za-z]:/.test(relative)) {
52
- throw new Error(`teamflow: document path must be workspace-relative, got "${relative}"`);
49
+ throw new Error(`teamflow: document path must be relative to the document root, got "${relative}"`);
53
50
  }
54
51
  const segments = relative.split('/');
55
52
  if (segments.some((part) => part === '..' || part === '.' || part.length === 0)) {
@@ -58,27 +55,23 @@ export function resolveDocPath(root, relative) {
58
55
  const base = resolve(root);
59
56
  const absolute = resolve(base, ...segments);
60
57
  if (absolute !== base && !absolute.startsWith(`${base}${sep}`)) {
61
- throw new Error(`teamflow: document path escapes the workspace, got "${relative}"`);
58
+ throw new Error(`teamflow: document path escapes the document root, got "${relative}"`);
62
59
  }
63
60
  return absolute;
64
61
  }
65
- /** 工作区路径 + 相对路径的展示形式(日志与工具结果用,始终 `/`)。 */
62
+ /** 文档相对路径的展示形式(日志与工具结果用,始终 `/`)。 */
66
63
  export function displayPath(relative) {
67
64
  return relative.split(sep).join(posix.sep);
68
65
  }
69
66
  /**
70
- * 首次为一个工作区准备文档目录:建目录、写 `.gitignore`、必要时把 `.xirang-ai/`
71
- * 追加进工作区自己的 `.gitignore`。
67
+ * 首次为文档根准备共享目录:只建 `requirements/`、`stories/`、`bugs/`,
68
+ * 不碰工作区,也不写 `.gitignore`。
72
69
  *
73
- * @param root - 工作区根目录(绝对路径)
70
+ * @param root - 文档根目录(绝对路径)
74
71
  * @returns 实际创建的目录(相对路径),便于工具结果里说明
75
72
  */
76
73
  export function ensureDocTree(root) {
77
- const dirs = [
78
- `${DOC_ROOT}/requirements`,
79
- `${DOC_ROOT}/stories`,
80
- `${DOC_ROOT}/bugs`,
81
- ];
74
+ const dirs = ['requirements', 'stories', 'bugs'];
82
75
  const created = [];
83
76
  for (const dir of dirs) {
84
77
  const absolute = resolveDocPath(root, dir);
@@ -87,11 +80,6 @@ export function ensureDocTree(root) {
87
80
  created.push(dir);
88
81
  }
89
82
  }
90
- const localIgnore = resolveDocPath(root, `${DOC_ROOT}/.gitignore`);
91
- if (!existsSync(localIgnore)) {
92
- writeFileSync(localIgnore, '*\n', 'utf8');
93
- }
94
- appendWorkspaceGitignore(root);
95
83
  return created;
96
84
  }
97
85
  /**
@@ -144,8 +132,8 @@ let stagedSequence = 0;
144
132
  * 到临时名,再在 SQLite 事务里删记录:事务失败就把文件改回来,事务成功才真正
145
133
  * `unlink`。这样任何时刻都不会出现「记录已经没了、正式路径上的文件还在」的悬挂态。
146
134
  *
147
- * @param root - 工作区根目录
148
- * @param relative - 文档的工作区相对路径
135
+ * @param root - 文档根目录
136
+ * @param relative - 文档相对路径
149
137
  * @returns 暂存信息;文件本来就不存在时返回 `null`
150
138
  */
151
139
  export function stageDocDeletion(root, relative) {
@@ -182,10 +170,10 @@ export function dropStagedDoc(staged) {
182
170
  * 只删空目录:目录里还有别的东西时立刻停下,避免误删用户手工放进来的文件。
183
171
  */
184
172
  export function pruneEmptyDirectories(root, relative) {
185
- const boundary = resolveDocPath(root, DOC_ROOT);
173
+ const boundary = resolve(root);
186
174
  let directory = dirname(resolveDocPath(root, relative));
187
175
  // Stop before the shared `requirements/`, `stories/`, `bugs/` roots: those
188
- // belong to the workspace tree, not to the record being deleted.
176
+ // belong to the document tree, not to the record being deleted.
189
177
  while (directory !== boundary && dirname(directory) !== boundary && directory.startsWith(`${boundary}${sep}`)) {
190
178
  try {
191
179
  if (readdirSync(directory).length > 0)
@@ -211,18 +199,3 @@ function requireId(id) {
211
199
  }
212
200
  return id;
213
201
  }
214
- /** 把 `.xirang-ai/` 追加到工作区 `.gitignore`;没有该文件就创建一个只含这一行的。 */
215
- function appendWorkspaceGitignore(root) {
216
- const ignore = join(root, '.gitignore');
217
- const entry = '.xirang-ai/';
218
- if (!existsSync(ignore)) {
219
- writeFileSync(ignore, `${entry}\n`, 'utf8');
220
- return;
221
- }
222
- const current = readFileSync(ignore, 'utf8');
223
- const lines = current.split('\n').map((line) => line.trim());
224
- if (lines.includes(entry) || lines.includes('.xirang-ai'))
225
- return;
226
- const suffix = current.endsWith('\n') || current.length === 0 ? '' : '\n';
227
- writeFileSync(ignore, `${current}${suffix}${entry}\n`, 'utf8');
228
- }
@@ -198,6 +198,27 @@ export interface RequirementSession {
198
198
  readonly linkedAt: string | null;
199
199
  readonly createdAt: string;
200
200
  }
201
+ /** Which kind of record a discussion conversation belongs to. */
202
+ export declare const DISCUSSION_SUBJECT_TYPES: readonly ["requirement", "story", "bug"];
203
+ export type DiscussionSubjectType = (typeof DISCUSSION_SUBJECT_TYPES)[number];
204
+ /** Discussion kind used by stories and defects; requirements keep their own kinds. */
205
+ export declare const GENERAL_DISCUSSION_KIND = "discuss";
206
+ /**
207
+ * One conversation registered against a requirement, story or defect.
208
+ *
209
+ * Rows start unconfirmed (`linkedAt` null) when the browser prepares a
210
+ * discussion and become visible once the Agent confirms with `linkedAt`.
211
+ */
212
+ export interface DiscussionSession {
213
+ readonly subjectType: DiscussionSubjectType;
214
+ readonly subjectId: string;
215
+ readonly sessionId: string;
216
+ /** `create` / `design` / `detail` / `split` for requirements, `discuss` otherwise. */
217
+ readonly kind: string;
218
+ readonly preparedAt: string | null;
219
+ readonly linkedAt: string | null;
220
+ readonly createdAt: string;
221
+ }
201
222
  /**
202
223
  * One consistent read of every table the workbench renders.
203
224
  *
@@ -214,6 +235,8 @@ export interface BoardRows {
214
235
  readonly docStories: readonly DocStoryLink[];
215
236
  /** Requirement conversations, newest first; design rows may still be unconfirmed. */
216
237
  readonly requirementSessions: readonly RequirementSession[];
238
+ /** Confirmed story and defect discussions, newest first. */
239
+ readonly discussionSessions: readonly DiscussionSession[];
217
240
  }
218
241
  /** Input for {@link WorkflowStore.createDesignDoc}. */
219
242
  export interface CreateDesignDocInput {