dsh-thesis 1.0.0

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.
Files changed (188) hide show
  1. package/DESIGN.md +390 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +10 -0
  4. package/README.md +253 -0
  5. package/cordis.patch.yml +17 -0
  6. package/docs/host-api.md +640 -0
  7. package/lib/codewalk/commands.d.ts +23 -0
  8. package/lib/codewalk/commands.js +168 -0
  9. package/lib/codewalk/engine.d.ts +108 -0
  10. package/lib/codewalk/engine.js +449 -0
  11. package/lib/codewalk/errors.d.ts +31 -0
  12. package/lib/codewalk/errors.js +20 -0
  13. package/lib/codewalk/fs-types.d.ts +34 -0
  14. package/lib/codewalk/fs-types.js +10 -0
  15. package/lib/codewalk/gate.d.ts +30 -0
  16. package/lib/codewalk/gate.js +60 -0
  17. package/lib/codewalk/host-types.d.ts +198 -0
  18. package/lib/codewalk/host-types.js +11 -0
  19. package/lib/codewalk/prestep.d.ts +17 -0
  20. package/lib/codewalk/prestep.js +62 -0
  21. package/lib/codewalk/register.d.ts +23 -0
  22. package/lib/codewalk/register.js +31 -0
  23. package/lib/codewalk/skill.d.ts +15 -0
  24. package/lib/codewalk/skill.js +57 -0
  25. package/lib/codewalk/tools.d.ts +18 -0
  26. package/lib/codewalk/tools.js +314 -0
  27. package/lib/codewalk/types.d.ts +151 -0
  28. package/lib/codewalk/types.js +9 -0
  29. package/lib/commands.d.ts +33 -0
  30. package/lib/commands.js +60 -0
  31. package/lib/config.d.ts +125 -0
  32. package/lib/config.js +139 -0
  33. package/lib/dedup/commands.d.ts +21 -0
  34. package/lib/dedup/commands.js +93 -0
  35. package/lib/dedup/index.d.ts +75 -0
  36. package/lib/dedup/index.js +331 -0
  37. package/lib/dedup/normalize.d.ts +125 -0
  38. package/lib/dedup/normalize.js +329 -0
  39. package/lib/dedup/report.d.ts +114 -0
  40. package/lib/dedup/report.js +309 -0
  41. package/lib/dedup/rewrite.d.ts +71 -0
  42. package/lib/dedup/rewrite.js +228 -0
  43. package/lib/dedup/similarity.d.ts +169 -0
  44. package/lib/dedup/similarity.js +323 -0
  45. package/lib/gates/dangerous.d.ts +22 -0
  46. package/lib/gates/dangerous.js +47 -0
  47. package/lib/gates/detect.d.ts +54 -0
  48. package/lib/gates/detect.js +106 -0
  49. package/lib/gates/messages.d.ts +39 -0
  50. package/lib/gates/messages.js +74 -0
  51. package/lib/gates/prompt.d.ts +21 -0
  52. package/lib/gates/prompt.js +56 -0
  53. package/lib/gates/writing-intent.d.ts +49 -0
  54. package/lib/gates/writing-intent.js +68 -0
  55. package/lib/index.d.ts +36 -0
  56. package/lib/index.js +76 -0
  57. package/lib/ingest/code.d.ts +71 -0
  58. package/lib/ingest/code.js +356 -0
  59. package/lib/ingest/commands.d.ts +23 -0
  60. package/lib/ingest/commands.js +74 -0
  61. package/lib/ingest/docx.d.ts +29 -0
  62. package/lib/ingest/docx.js +92 -0
  63. package/lib/ingest/index.d.ts +179 -0
  64. package/lib/ingest/index.js +640 -0
  65. package/lib/ingest/ooxml.d.ts +58 -0
  66. package/lib/ingest/ooxml.js +393 -0
  67. package/lib/ingest/pdf.d.ts +86 -0
  68. package/lib/ingest/pdf.js +1016 -0
  69. package/lib/ingest/pptx.d.ts +32 -0
  70. package/lib/ingest/pptx.js +116 -0
  71. package/lib/ingest/text.d.ts +25 -0
  72. package/lib/ingest/text.js +180 -0
  73. package/lib/ingest/types.d.ts +48 -0
  74. package/lib/ingest/types.js +47 -0
  75. package/lib/ingest/xlsx.d.ts +72 -0
  76. package/lib/ingest/xlsx.js +411 -0
  77. package/lib/ingest/zip.d.ts +76 -0
  78. package/lib/ingest/zip.js +393 -0
  79. package/lib/intake/definitions.d.ts +65 -0
  80. package/lib/intake/definitions.js +42 -0
  81. package/lib/intake/gate-rules.d.ts +57 -0
  82. package/lib/intake/gate-rules.js +118 -0
  83. package/lib/intake/gate.d.ts +24 -0
  84. package/lib/intake/gate.js +86 -0
  85. package/lib/intake/index.d.ts +76 -0
  86. package/lib/intake/index.js +468 -0
  87. package/lib/intake/questions.d.ts +169 -0
  88. package/lib/intake/questions.js +720 -0
  89. package/lib/intake/spec.d.ts +66 -0
  90. package/lib/intake/spec.js +221 -0
  91. package/lib/intake/state.d.ts +192 -0
  92. package/lib/intake/state.js +420 -0
  93. package/lib/memory/recall.d.ts +69 -0
  94. package/lib/memory/recall.js +177 -0
  95. package/lib/memory/register.d.ts +37 -0
  96. package/lib/memory/register.js +52 -0
  97. package/lib/memory/service.d.ts +39 -0
  98. package/lib/memory/service.js +39 -0
  99. package/lib/memory/store.d.ts +45 -0
  100. package/lib/memory/store.js +123 -0
  101. package/lib/memory/tools.d.ts +19 -0
  102. package/lib/memory/tools.js +195 -0
  103. package/lib/memory/types.d.ts +55 -0
  104. package/lib/memory/types.js +6 -0
  105. package/lib/paper/commands.d.ts +13 -0
  106. package/lib/paper/commands.js +84 -0
  107. package/lib/paper/lib/aicheck.d.ts +38 -0
  108. package/lib/paper/lib/aicheck.js +241 -0
  109. package/lib/paper/lib/bibtex.d.ts +21 -0
  110. package/lib/paper/lib/bibtex.js +114 -0
  111. package/lib/paper/lib/check.d.ts +32 -0
  112. package/lib/paper/lib/check.js +215 -0
  113. package/lib/paper/lib/defense.d.ts +48 -0
  114. package/lib/paper/lib/defense.js +220 -0
  115. package/lib/paper/lib/docx.d.ts +35 -0
  116. package/lib/paper/lib/docx.js +213 -0
  117. package/lib/paper/lib/layout.d.ts +41 -0
  118. package/lib/paper/lib/layout.js +572 -0
  119. package/lib/paper/lib/ledger.d.ts +112 -0
  120. package/lib/paper/lib/ledger.js +349 -0
  121. package/lib/paper/lib/lit-api.d.ts +59 -0
  122. package/lib/paper/lib/lit-api.js +296 -0
  123. package/lib/paper/lib/lit-cache.d.ts +32 -0
  124. package/lib/paper/lib/lit-cache.js +91 -0
  125. package/lib/paper/lib/project.d.ts +29 -0
  126. package/lib/paper/lib/project.js +93 -0
  127. package/lib/paper/lib/review.d.ts +37 -0
  128. package/lib/paper/lib/review.js +194 -0
  129. package/lib/paper/lib/zip.d.ts +14 -0
  130. package/lib/paper/lib/zip.js +124 -0
  131. package/lib/paper/register.d.ts +20 -0
  132. package/lib/paper/register.js +183 -0
  133. package/lib/paper/tools/aicheck.d.ts +6 -0
  134. package/lib/paper/tools/aicheck.js +38 -0
  135. package/lib/paper/tools/build.d.ts +27 -0
  136. package/lib/paper/tools/build.js +166 -0
  137. package/lib/paper/tools/check.d.ts +7 -0
  138. package/lib/paper/tools/check.js +79 -0
  139. package/lib/paper/tools/decision.d.ts +13 -0
  140. package/lib/paper/tools/decision.js +53 -0
  141. package/lib/paper/tools/defense.d.ts +11 -0
  142. package/lib/paper/tools/defense.js +74 -0
  143. package/lib/paper/tools/init.d.ts +23 -0
  144. package/lib/paper/tools/init.js +99 -0
  145. package/lib/paper/tools/lit.d.ts +27 -0
  146. package/lib/paper/tools/lit.js +236 -0
  147. package/lib/paper/tools/progress.d.ts +18 -0
  148. package/lib/paper/tools/progress.js +77 -0
  149. package/lib/paper/tools/review.d.ts +11 -0
  150. package/lib/paper/tools/review.js +41 -0
  151. package/lib/ppt/actions.d.ts +97 -0
  152. package/lib/ppt/actions.js +263 -0
  153. package/lib/ppt/check.d.ts +64 -0
  154. package/lib/ppt/check.js +185 -0
  155. package/lib/ppt/commands.d.ts +28 -0
  156. package/lib/ppt/commands.js +71 -0
  157. package/lib/ppt/convert.d.ts +146 -0
  158. package/lib/ppt/convert.js +423 -0
  159. package/lib/ppt/index.d.ts +45 -0
  160. package/lib/ppt/index.js +57 -0
  161. package/lib/ppt/marp.d.ts +74 -0
  162. package/lib/ppt/marp.js +165 -0
  163. package/lib/ppt/outline.d.ts +206 -0
  164. package/lib/ppt/outline.js +695 -0
  165. package/lib/shared/define-tool.d.ts +77 -0
  166. package/lib/shared/define-tool.js +235 -0
  167. package/lib/shared/fs-errors.d.ts +50 -0
  168. package/lib/shared/fs-errors.js +90 -0
  169. package/lib/shared/index.d.ts +31 -0
  170. package/lib/shared/index.js +32 -0
  171. package/lib/shared/spawn.d.ts +38 -0
  172. package/lib/shared/spawn.js +50 -0
  173. package/lib/shared/text.d.ts +14 -0
  174. package/lib/shared/text.js +26 -0
  175. package/package.json +82 -0
  176. package/skills/code-walkthrough/SKILL.md +94 -0
  177. package/skills/code-walkthrough/references/examples.md +129 -0
  178. package/skills/paper-dedup/SKILL.md +58 -0
  179. package/skills/paper-intake/SKILL.md +64 -0
  180. package/skills/thesis-citation/SKILL.md +67 -0
  181. package/skills/thesis-defense/SKILL.md +87 -0
  182. package/skills/thesis-eng-design/SKILL.md +97 -0
  183. package/skills/thesis-inquiry/SKILL.md +55 -0
  184. package/skills/thesis-inquiry/references/intent-spec-template.md +39 -0
  185. package/skills/thesis-literature/SKILL.md +56 -0
  186. package/skills/thesis-opener/SKILL.md +77 -0
  187. package/skills/thesis-pipeline/SKILL.md +121 -0
  188. package/skills/thesis-writing/SKILL.md +86 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 意图规格:把追问状态渲染成给人和模型共读的 Markdown(默认 `00-管理/意图规格.md`)。
3
+ *
4
+ * 两个不可妥协的性质:
5
+ * 1. **幂等**:`renderSpec` 是状态的纯函数——同样状态渲染两次逐字节一致,
6
+ * 因此「每次回答后自动刷新规格」不会产生无意义的 diff。文档里不写渲染时间
7
+ * (时间只留在 `.paper/intake.json`),只写内容哈希作为版本指纹。
8
+ * 2. **未知即阻塞**:必答项未答、格式不合格、被跳过的,都显式列进「阻塞项」一节。
9
+ * 规格里不允许出现沉默的空白——后续写作/构建/降重/答辩只能依据本文件,
10
+ * 所以"没写"必须等于"看得见的缺口"。
11
+ *
12
+ * 来源标注只有三种:`用户回答` / `材料推断(待确认)` / `未知(需补)`。
13
+ *
14
+ * @module dsh-thesis/intake
15
+ */
16
+ import { type AnswerCheck, type IntakeContext, type IntakeQuestion } from './questions.ts';
17
+ import { type IntakeIo, type IntakeState } from './state.ts';
18
+ /** 来源标注:规格阅读者一眼看出这条信息可不可信。 */
19
+ export declare const SOURCE_USER = "\u7528\u6237\u56DE\u7B54";
20
+ /**
21
+ * 材料推断、但用户还没点头:字面量必须避开闸门的关键词。
22
+ *
23
+ * `intake/gate-rules.ts` 把 `待确认` 当作「规格未立稳」的标记,所以这里用
24
+ * 「材料推断(未确认)」——**规格收敛完成后文档里不得再出现该标记**,
25
+ * 否则入口闸门会永远认为规格没立稳(详见 spec 的幂等测试)。
26
+ */
27
+ export declare const SOURCE_MATERIALS_PENDING = "\u6750\u6599\u63A8\u65AD\uFF08\u672A\u786E\u8BA4\uFF09";
28
+ export declare const SOURCE_MATERIALS = "\u6750\u6599\u63A8\u65AD";
29
+ /** 必答项没答:这是阻塞项,闸门就是靠这个字面量识别「规格没立稳」。 */
30
+ export declare const SOURCE_UNKNOWN = "\u672A\u77E5\uFF08\u9700\u8865\uFF09";
31
+ /** 非必答项没答:标注清楚,但不阻塞收敛(否则规格永远立不稳)。 */
32
+ export declare const SOURCE_OPTIONAL_OPEN = "\u672A\u7B54\uFF08\u975E\u963B\u585E\uFF09";
33
+ /** 一条状态在规格里的呈现形态。 */
34
+ export interface SpecLine {
35
+ readonly question: IntakeQuestion;
36
+ readonly source: string;
37
+ readonly value: string;
38
+ readonly note?: string;
39
+ }
40
+ /** 该问题当前的来源标注与取值。 */
41
+ export declare function specLine(state: IntakeState, q: IntakeQuestion): SpecLine;
42
+ /** 阻塞项:必答但未齐全(未答 / 格式不合格)的问题。 */
43
+ export declare function blockers(state: IntakeState): IntakeQuestion[];
44
+ /** 规格内容指纹:前 12 位,用于回答「这份文档对应哪一版状态」。 */
45
+ export declare function specFingerprint(state: IntakeState): string;
46
+ /**
47
+ * 渲染意图规格全文(纯函数、幂等)。
48
+ *
49
+ * 结构:文档说明 → 进度摘要 → 阻塞项 → 六个分节(每节一张问答表 + 未答清单)
50
+ * → 附录(本轮已完成问答留痕)。
51
+ */
52
+ export declare function renderSpec(state: IntakeState, ctx: IntakeContext): string;
53
+ /** 规格渲染结果的摘要(工具返回值用)。 */
54
+ export interface SpecWriteResult {
55
+ readonly path: string;
56
+ readonly absPath: string;
57
+ readonly blockerIds: string[];
58
+ readonly bytes: number;
59
+ }
60
+ /**
61
+ * 渲染并原子落盘规格。返回相对路径与阻塞项 id,便于工具直接回报「还缺什么」。
62
+ * 中途失败会抛错(不吞):规格是最新真相,写不进去必须让用户知道。
63
+ */
64
+ export declare function writeSpec(io: IntakeIo, root: string, state: IntakeState, ctx: IntakeContext): Promise<SpecWriteResult>;
65
+ /** 供工具层复用的校验调用:问题没有 `validate` 时视为通过。 */
66
+ export declare function checkAnswer(q: IntakeQuestion, answer: string): AnswerCheck;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * 意图规格:把追问状态渲染成给人和模型共读的 Markdown(默认 `00-管理/意图规格.md`)。
3
+ *
4
+ * 两个不可妥协的性质:
5
+ * 1. **幂等**:`renderSpec` 是状态的纯函数——同样状态渲染两次逐字节一致,
6
+ * 因此「每次回答后自动刷新规格」不会产生无意义的 diff。文档里不写渲染时间
7
+ * (时间只留在 `.paper/intake.json`),只写内容哈希作为版本指纹。
8
+ * 2. **未知即阻塞**:必答项未答、格式不合格、被跳过的,都显式列进「阻塞项」一节。
9
+ * 规格里不允许出现沉默的空白——后续写作/构建/降重/答辩只能依据本文件,
10
+ * 所以"没写"必须等于"看得见的缺口"。
11
+ *
12
+ * 来源标注只有三种:`用户回答` / `材料推断(待确认)` / `未知(需补)`。
13
+ *
14
+ * @module dsh-thesis/intake
15
+ */
16
+ import * as nodePath from 'node:path';
17
+ import { createHash } from 'node:crypto';
18
+ import { blockingRequired, listRequired, materialInsight, questionById, QUESTIONS, INTAKE_SECTIONS, } from "./questions.js";
19
+ import { writeAtomic } from "./state.js";
20
+ /** 来源标注:规格阅读者一眼看出这条信息可不可信。 */
21
+ export const SOURCE_USER = '用户回答';
22
+ /**
23
+ * 材料推断、但用户还没点头:字面量必须避开闸门的关键词。
24
+ *
25
+ * `intake/gate-rules.ts` 把 `待确认` 当作「规格未立稳」的标记,所以这里用
26
+ * 「材料推断(未确认)」——**规格收敛完成后文档里不得再出现该标记**,
27
+ * 否则入口闸门会永远认为规格没立稳(详见 spec 的幂等测试)。
28
+ */
29
+ export const SOURCE_MATERIALS_PENDING = '材料推断(未确认)';
30
+ export const SOURCE_MATERIALS = '材料推断';
31
+ /** 必答项没答:这是阻塞项,闸门就是靠这个字面量识别「规格没立稳」。 */
32
+ export const SOURCE_UNKNOWN = '未知(需补)';
33
+ /** 非必答项没答:标注清楚,但不阻塞收敛(否则规格永远立不稳)。 */
34
+ export const SOURCE_OPTIONAL_OPEN = '未答(非阻塞)';
35
+ function isBlank(value) {
36
+ return value.trim() === '';
37
+ }
38
+ /** 该问题当前的来源标注与取值。 */
39
+ export function specLine(state, q) {
40
+ const rec = state.answers[q.id];
41
+ if (rec === undefined) {
42
+ const skipped = state.skipped.includes(q.id);
43
+ const why = state.skipReasons[q.id];
44
+ return {
45
+ question: q,
46
+ // 必答项没答 = 阻塞(用闸门识别的「未知」标记);非必答项没答不阻塞收敛。
47
+ source: q.required ? SOURCE_UNKNOWN : SOURCE_OPTIONAL_OPEN,
48
+ value: skipped ? `(已跳过:${why === undefined || why === '' ? '未给理由' : why})` : '(尚未回答)',
49
+ };
50
+ }
51
+ const line = {
52
+ question: q,
53
+ source: rec.source === 'materials'
54
+ ? (rec.confirmed ? SOURCE_MATERIALS : SOURCE_MATERIALS_PENDING)
55
+ : SOURCE_USER,
56
+ // 值里可能出现换行:统一压成单行,保证表格/列表不会被撑破。
57
+ value: rec.value.replace(/\s*\n+\s*/g, ';'),
58
+ };
59
+ if (rec.note !== undefined && rec.note !== '')
60
+ line.note = rec.note;
61
+ return line;
62
+ }
63
+ /** 阻塞项:必答但未齐全(未答 / 格式不合格)的问题。 */
64
+ export function blockers(state) {
65
+ return blockingRequired(state);
66
+ }
67
+ /** 规格内容指纹:前 12 位,用于回答「这份文档对应哪一版状态」。 */
68
+ export function specFingerprint(state) {
69
+ const canonical = listRequired()
70
+ .map(q => `${q.id}=${state.answers[q.id]?.value ?? ''}`)
71
+ .join('\u0000');
72
+ const digest = createHash('sha256').update(canonical).digest('hex');
73
+ return digest.slice(0, 12);
74
+ }
75
+ function sourceOf(state, q) {
76
+ return specLine(state, q).source;
77
+ }
78
+ /**
79
+ * 渲染意图规格全文(纯函数、幂等)。
80
+ *
81
+ * 结构:文档说明 → 进度摘要 → 阻塞项 → 六个分节(每节一张问答表 + 未答清单)
82
+ * → 附录(本轮已完成问答留痕)。
83
+ */
84
+ export function renderSpec(state, ctx) {
85
+ const insight = materialInsight(ctx.materials);
86
+ const pending = blockers(state);
87
+ const required = listRequired();
88
+ const answeredRequired = required.filter(q => state.answers[q.id] !== undefined && state.answers[q.id]?.valid !== false);
89
+ const L = [];
90
+ L.push('# 意图规格');
91
+ L.push('');
92
+ L.push('<!-- dsh-thesis:intake-spec');
93
+ L.push(`fingerprint: ${specFingerprint(state)}`);
94
+ L.push(`specPath: ${state.specPath}`);
95
+ L.push('-->');
96
+ L.push('');
97
+ L.push('> **这份文档是什么**:材料到手后,由 `thesis_intake` 逐题追问收敛出的"可证伪意图规格"。');
98
+ L.push('> 它把模糊的口头要求变成能被检查的条款:学校/学位、格式模板、字数、查重、AI 规定、时间线、数据真实性、引用格式、答辩形式。');
99
+ L.push('>');
100
+ L.push('> **谁维护**:`thesis_intake` 工具自动重绘——你每回答一个问题,本文件立即刷新。');
101
+ L.push('> 请不要手工编辑表格:手工改动会在下一次回答时被覆盖。要改答案,就用同一个问题重答一次(`thesis_intake action=answer`)。');
102
+ L.push('>');
103
+ L.push('> **怎么用**:后续写作、格式检查、降重、PPT、答辩准备**只依据本文件**。');
104
+ L.push('> 「来源」列告诉你每条信息的可信度:`用户回答` 是用户亲口确认的;`材料推断(未确认)` 是从材料里读出来、还没得到用户点头的;必答项没答会标成「未知」并进入下一节的阻塞项;非必答项没答标为「未答(非阻塞)」。');
105
+ L.push('> 「阻塞项」一节非空时不进入下一阶段:先把清单里的问题答完(非必答项可以一直留着,但规格会显示它们没定)。');
106
+ L.push('');
107
+ L.push('## 进度摘要');
108
+ L.push('');
109
+ L.push(`- 当前阶段:${ctx.stage}`);
110
+ L.push(`- 追问状态:${state.stage === 'done' ? '✅ 必答项已齐全,可进入下一阶段' : '⏳ 进行中'}`);
111
+ L.push(`- 必答项:${answeredRequired.length}/${required.length} 已答`);
112
+ L.push(`- 材料清单:${ctx.materials.found ? `已读取(${ctx.materials.items.length} 项)` : '未找到 00-管理/材料清单.md(未做材料感知)'}`);
113
+ L.push(`- 已跳过:${state.skipped.length} 项${state.skipped.length > 0 ? `(${state.skipped.join('、')})` : ''}`);
114
+ L.push(`- 内容指纹:\`${specFingerprint(state)}\``);
115
+ L.push('');
116
+ L.push('## 阻塞项(未答不可进入下一阶段)');
117
+ L.push('');
118
+ if (pending.length === 0) {
119
+ L.push('无。全部必答项已齐全。');
120
+ }
121
+ else {
122
+ L.push('| 问题 id | 分节 | 缺什么 | 影响产出 |');
123
+ L.push('|---|---|---|---|');
124
+ for (const q of pending) {
125
+ const rec = state.answers[q.id];
126
+ let missing;
127
+ if (rec === undefined) {
128
+ missing = state.skipped.includes(q.id) ? `已跳过:${state.skipReasons[q.id] ?? '未给理由'}` : '尚未回答';
129
+ }
130
+ else {
131
+ missing = `格式待修正:${rec.note ?? '校验未通过'}`;
132
+ }
133
+ L.push(`| \`${q.id}\` | ${q.section} | ${missing} | ${q.affects.join('、')} |`);
134
+ }
135
+ L.push('');
136
+ L.push('> 补法:`thesis_intake` 的 `action=answer` 逐题作答;确实无法回答的用 `action=skip` 并给理由,但在 `done` 之前它们仍是阻塞项。');
137
+ }
138
+ L.push('');
139
+ for (const section of INTAKE_SECTIONS) {
140
+ const qs = QUESTIONS.filter(q => q.section === section);
141
+ L.push(`## ${section}`);
142
+ L.push('');
143
+ if (qs.length === 0) {
144
+ L.push('(本节暂无问题)');
145
+ L.push('');
146
+ continue;
147
+ }
148
+ L.push('| 问题 | 结论 | 来源 | 备注 |');
149
+ L.push('|---|---|---|---|');
150
+ for (const q of qs) {
151
+ const line = specLine(state, q);
152
+ const note = line.note !== undefined
153
+ ? line.note
154
+ : (insight.suppress.has(q.id) ? '已由材料覆盖,未重复提问' : '');
155
+ L.push(`| ${q.ask} | ${line.value} | ${line.source} | ${note} |`);
156
+ }
157
+ L.push('');
158
+ const unknown = qs.filter(q => isBlank(state.answers[q.id]?.value ?? ''));
159
+ if (unknown.length > 0) {
160
+ L.push('未答清单:');
161
+ for (const q of unknown) {
162
+ L.push(`- ${q.required ? '**[必答]** ' : ''}\`${q.id}\` ${q.ask}`);
163
+ }
164
+ L.push('');
165
+ }
166
+ }
167
+ L.push('## 附:本轮问答留痕');
168
+ L.push('');
169
+ const asked = state.askedIds.filter(id => questionById(id) !== undefined);
170
+ if (asked.length === 0) {
171
+ L.push('(还没有问过任何问题)');
172
+ }
173
+ else {
174
+ L.push('| 顺序 | 问题 id | 来源 | 时间 | 校验 |');
175
+ L.push('|---|---|---|---|---|');
176
+ asked.forEach((id, i) => {
177
+ const q = questionById(id);
178
+ if (q === undefined)
179
+ return;
180
+ const rec = state.answers[id];
181
+ const source = rec === undefined ? '—(未答/已跳过)' : sourceOf(state, q);
182
+ const at = rec?.at ?? '—';
183
+ const check = rec === undefined ? '—' : (rec.valid === false ? `⚠️ ${rec.note ?? '未通过'}` : '✅');
184
+ L.push(`| ${i + 1} | \`${id}\` | ${source} | ${at} | ${check} |`);
185
+ });
186
+ }
187
+ L.push('');
188
+ L.push('---');
189
+ L.push('');
190
+ L.push('本文件由 `dsh-thesis` 的 `thesis_intake` 工具生成并维护;机器真相在 `.paper/intake.json`。');
191
+ L.push('');
192
+ return L.join('\n');
193
+ }
194
+ /**
195
+ * 渲染并原子落盘规格。返回相对路径与阻塞项 id,便于工具直接回报「还缺什么」。
196
+ * 中途失败会抛错(不吞):规格是最新真相,写不进去必须让用户知道。
197
+ */
198
+ export async function writeSpec(io, root, state, ctx) {
199
+ const rel = state.specPath === '' ? nodePath.join('00-管理', '意图规格.md') : state.specPath;
200
+ const abs = nodePath.join(root, rel);
201
+ const md = renderSpec(state, ctx);
202
+ await writeAtomic(io, abs, md);
203
+ return {
204
+ path: rel,
205
+ absPath: abs,
206
+ blockerIds: blockers(state).map(q => q.id),
207
+ bytes: md.length,
208
+ };
209
+ }
210
+ /** 供工具层复用的校验调用:问题没有 `validate` 时视为通过。 */
211
+ export function checkAnswer(q, answer) {
212
+ if (q.validate === undefined)
213
+ return { ok: true };
214
+ try {
215
+ return q.validate(answer);
216
+ }
217
+ catch (error) {
218
+ // 校验函数本身出错不应打断对话:记为"未通过"并说明原因。
219
+ return { ok: false, note: `校验过程出错(${error instanceof Error ? error.message : String(error)}),请人工核对。` };
220
+ }
221
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * 追问状态的落盘与读取:`.paper/intake.json`(相对论文工作区根)。
3
+ *
4
+ * 三条硬规则:
5
+ * 1. **原子写**:先写同目录临时文件,再改名覆盖目标。中途断电只会留下一个
6
+ * 可识别的临时文件,绝不会把状态写成半截 JSON。改名走可注入的
7
+ * {@link IntakeIo.rename} 接口——真机上是 `node:fs/promises` 的 rename,
8
+ * 测试里是内存实现,两条路径都必须走通。
9
+ * 2. **损坏时报错,不静默重置**:JSON 解析失败、版本不符、结构不对,一律抛出
10
+ * 带文件路径与修复建议的中文错误。悄悄重置会丢掉用户已经答过的每一个问题,
11
+ * 这比报错严重得多。
12
+ * 3. **断点续问**:状态是唯一的真相来源,新会话读回它就能从正确的问题继续。
13
+ *
14
+ * @module dsh-thesis/intake
15
+ */
16
+ import type { MaterialSummary } from './questions.ts';
17
+ /** 状态文件相对论文工作区根的路径。 */
18
+ export declare const STATE_REL = ".paper/intake.json";
19
+ /** 状态 schema 版本;结构不兼容变更时必须递增(旧状态将明确报错而非被猜读)。 */
20
+ export declare const STATE_VERSION = 1;
21
+ /** 追问阶段:`asking` 进行中;`done` 必答项已齐、可进入下一阶段。 */
22
+ export type IntakeStage = 'asking' | 'done';
23
+ /** 合法阶段集合(状态迁移校验用)。 */
24
+ export declare const INTAKE_STAGES: readonly IntakeStage[];
25
+ /** 一条答案的来源:用户亲口答的,还是材料推断出来的。 */
26
+ export type AnswerSource = 'user' | 'materials';
27
+ /** 一条已记录的答案。 */
28
+ export interface IntakeAnswer {
29
+ value: string;
30
+ /** 记录时刻(ISO 字符串)。 */
31
+ at: string;
32
+ source: AnswerSource;
33
+ /** 材料推断的答案必须经用户确认才计入必答项。 */
34
+ confirmed: boolean;
35
+ /** 轻校验是否通过(缺省视为通过)。 */
36
+ valid?: boolean;
37
+ /** 校验提示:格式不对时的具体说明。 */
38
+ note?: string;
39
+ }
40
+ /** 追问状态:`.paper/intake.json` 的全部内容。 */
41
+ export interface IntakeState {
42
+ version: number;
43
+ createdAt: string;
44
+ updatedAt: string;
45
+ stage: IntakeStage;
46
+ /**
47
+ * 本轮追问已经问过的问题 id(含被跳过的)。
48
+ * 这是 `maxQuestions` 上限的计分依据,也是断点续问的进度标记。
49
+ */
50
+ askedIds: string[];
51
+ answers: Record<string, IntakeAnswer>;
52
+ skipped: string[];
53
+ /** 跳过理由:`id → 理由`(`skip` 必须给理由)。 */
54
+ skipReasons: Record<string, string>;
55
+ specPath: string;
56
+ }
57
+ /** 建一份全新状态。 */
58
+ export declare function defaultState(now?: Date, specPath?: string): IntakeState;
59
+ /** 稳定序列化:键序固定、2 空格缩进、结尾换行(同样的状态 → 同样的字节)。 */
60
+ export declare function serializeState(state: IntakeState): string;
61
+ /** 状态损坏:带路径与修复建议的可读错误。 */
62
+ export declare class IntakeStateError extends Error {
63
+ readonly path: string;
64
+ constructor(path: string, detail: string);
65
+ }
66
+ /**
67
+ * 解析状态文本。任何结构性问题都抛出 {@link IntakeStateError},
68
+ * 绝不返回「看起来能用」的默认状态。
69
+ */
70
+ export declare function parseState(raw: string, path?: string): IntakeState;
71
+ /** 迁移检查结果。 */
72
+ export interface TransitionCheck {
73
+ readonly ok: boolean;
74
+ readonly reason: string;
75
+ }
76
+ /**
77
+ * 校验「把阶段推进到 to」是否被允许。
78
+ *
79
+ * 规则:`asking → done` 只能由 `done` 动作在必答项齐全后触发(由调用方保证);
80
+ * 已经 `done` 的状态允许重答问题,但不会被自动退回 `asking`——回退必须显式
81
+ * 重答一个必答项(见 {@link applyAnswer}),避免状态在两次读取间莫名倒退。
82
+ */
83
+ export declare function canTransition(state: IntakeState, to: IntakeStage): TransitionCheck;
84
+ /** 校验通过后写入阶段;不通过时返回原状态(调用方负责提示原因)。 */
85
+ export declare function setStage(state: IntakeState, to: IntakeStage, now?: Date): TransitionCheck;
86
+ /** 记录一次「已提问」(幂等:重复记同一个 id 不改变状态)。 */
87
+ export declare function markAsked(state: IntakeState, id: string, now?: Date): boolean;
88
+ /**
89
+ * 写入一条答案。**幂等**:`value` 与原值完全一致时不改动 `at`/`updatedAt`,
90
+ * 返回 `changed:false`——重复提交同一个答案不会污染历史。
91
+ */
92
+ export declare function applyAnswer(state: IntakeState, id: string, value: string, opts?: {
93
+ source?: AnswerSource;
94
+ confirmed?: boolean;
95
+ valid?: boolean;
96
+ note?: string;
97
+ now?: Date;
98
+ }): {
99
+ changed: boolean;
100
+ record: IntakeAnswer;
101
+ };
102
+ /** 记录一次跳过(必须带理由;跳过必答项会让 `done` 拒绝)。 */
103
+ export declare function applySkip(state: IntakeState, id: string, reason: string, now?: Date): void;
104
+ /** 宿主文件系统的最小面(`ctx.fs` 在运行期满足它)。 */
105
+ export interface FileSystemLike {
106
+ resolve(path: string, opts?: {
107
+ cwd?: string;
108
+ signal?: AbortSignal;
109
+ }): Promise<{
110
+ displayPath: string;
111
+ }>;
112
+ readText(target: {
113
+ displayPath: string;
114
+ }, signal?: AbortSignal): Promise<string>;
115
+ writeText(target: {
116
+ displayPath: string;
117
+ }, content: string, expected?: unknown, signal?: AbortSignal): Promise<unknown>;
118
+ stat(target: {
119
+ displayPath: string;
120
+ }, signal?: AbortSignal): Promise<{
121
+ isDirectory: boolean;
122
+ size: number;
123
+ } | undefined>;
124
+ listDir(target: {
125
+ displayPath: string;
126
+ }, signal?: AbortSignal): Promise<Array<{
127
+ name: string;
128
+ isDirectory: boolean;
129
+ }>>;
130
+ }
131
+ /**
132
+ * 追问模块用的窄 I/O 接口。
133
+ *
134
+ * `rename` 单独抽出来,是为了让「临时文件 + 改名」的原子写在假 fs 下也能走通:
135
+ * 真机实现用 `node:fs/promises`,测试可注入内存实现或干脆省略(走兜底复制路径)。
136
+ */
137
+ export interface IntakeIo {
138
+ read(path: string): Promise<string | undefined>;
139
+ write(path: string, content: string): Promise<void>;
140
+ exists(path: string): Promise<boolean>;
141
+ listDir(path: string): Promise<string[]>;
142
+ /** 原子改名的注入点;省略时 {@link writeAtomic} 退化为「写临时文件 → 复制 → 删临时文件」。 */
143
+ rename?(from: string, to: string): Promise<void>;
144
+ /** 可选:删掉临时文件;省略时临时文件会留下(不影响正确性)。 */
145
+ remove?(path: string): Promise<void>;
146
+ }
147
+ /**
148
+ * 用 `ctx.fs` 拼一个 I/O;默认改名走 `node:fs/promises`(真机路径)。
149
+ *
150
+ * **`read` 只把「确认不存在」折叠成 `undefined`**:权限与 IO 错误一律上抛。
151
+ * 否则调用方会以为状态还没建过,用默认态覆盖掉用户已答的每一个问题
152
+ * (详见 `shared/fs-errors.ts`)。
153
+ */
154
+ export declare function fsIo(fs: FileSystemLike, signal?: AbortSignal, rename?: (from: string, to: string) => Promise<void>): IntakeIo;
155
+ /** 真机改名:`node:fs/promises` 的 readFile + writeFile(同一目录内的替换)。 */
156
+ export declare const nodeRename: (from: string, to: string) => Promise<void>;
157
+ /**
158
+ * 原子写:先写临时文件,再改名覆盖目标。
159
+ *
160
+ * 有 `io.rename` 时是真改名;没有时退化为「写临时 → 写目标 → 清临时」。
161
+ * 任何一步失败都会尽力清掉临时文件,并把错误原样抛给调用方(不吞)。
162
+ */
163
+ export declare function writeAtomic(io: IntakeIo, path: string, content: string): Promise<void>;
164
+ /** 读取状态;文件不存在返回 `undefined`(由调用方决定是否初始化)。 */
165
+ export declare function loadState(io: IntakeIo, root: string): Promise<IntakeState | undefined>;
166
+ /** 原子落盘状态。 */
167
+ export declare function saveState(io: IntakeIo, root: string, state: IntakeState, now?: Date): Promise<void>;
168
+ /** `thesis_ingest` 的材料清单相对路径。 */
169
+ export declare const MATERIALS_REL = "00-\u7BA1\u7406/\u6750\u6599\u6E05\u5355.md";
170
+ /** 台账相对路径(复用 paper 模块的约定,但不 import 它,避免模块间耦合成环)。 */
171
+ declare const LEDGER_REL = "00-\u7BA1\u7406/\u8FDB\u5EA6\u53F0\u8D26.md";
172
+ /**
173
+ * 宽容解析材料清单 Markdown。
174
+ *
175
+ * 只认「一行的表格里有 `.扩展名` 的单元格」:取该行第一个(剥掉反引号/星号后)
176
+ * 含扩展名的单元格为路径,其余单元格里第一个非空、非数字的当类型。
177
+ * 表头、分隔行、段落一律跳过。
178
+ * 解析不出任何条目时返回空清单——宁可多问一个,也不要凭猜免问。
179
+ */
180
+ export declare function parseMaterials(md: string): MaterialSummary;
181
+ /** 读材料清单;缺失或为空返回 `{ found:false, items:[] }`。 */
182
+ export declare function loadMaterials(io: IntakeIo, root: string): Promise<MaterialSummary>;
183
+ /**
184
+ * 从进度台账推断当前阶段名。
185
+ *
186
+ * 台账的机器真相是 `<!-- thesis:state ... -->` 里的 `currentStage`(数字),
187
+ * 这里只做「读到就用、读不到就按开题前」的宽容推断,绝不因为台账异常而中断追问。
188
+ */
189
+ export declare function stageFromLedger(md: string | undefined): string | undefined;
190
+ /** 读台账并推断阶段(读不到返回 undefined,由 {@link makeContext} 落到默认值)。 */
191
+ export declare function loadStage(io: IntakeIo, root: string): Promise<string | undefined>;
192
+ export { LEDGER_REL };