@thincoder/core 0.9.1

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 (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
package/log.mjs ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * log.mjs — 诊断事件日志(thincoder-cli/docs/design/LOGGING.md 权威规格)。
3
+ * 核内单一实现(CLI 与 VS Code 扩展共用本模块——共享 ~/.thincoder/logs/、同格式、
4
+ * 同事件面——F-L6)。
5
+ *
6
+ * 常驻骨架日志:回合/LLM/工具/子代理/挂起的关键事件——每行一个 JSON 事件,按天轮转
7
+ * (agent-YYYY-MM-DD.log),保留 1 天——用户零手动维护(F-L3/NF-L4)。问题发生时
8
+ * 直接 tail/grep 定位断点(根治"临时插桩再删"循环)。
9
+ *
10
+ * 纪律(实现侧逐条落实):
11
+ * - fire-and-forget:logEvent 失败静默降级(NF-L1)——主流程零影响。进程内首次写失败
12
+ * 即置死(_dead),当日不再尝试(磁盘满/权限错不逐事件空转)。
13
+ * - 单事件行 <512 字符(NF-L2):head(LLM ≤300 / 工具 ≤200)、err ≤200 由调用方截断,
14
+ * 本模块对任意字符串字段做上限兜底(head 300 / err 200 / 其余 120)+ 超长时丢可选字段。
15
+ * - 敏感字段零落盘(NF-L3/§2.4,2026-09-03 评审 refinement #7 匹配语义定稿):
16
+ * ① 字段名精确匹配黑名单(apiKey/designToken/password/secret/token,大小写不敏感;
17
+ * 另加防御性名称 authorization/proxy/proxyUri——防凭据型 URL/头字段泄漏——§2.5)
18
+ * → 整个字段丢弃;
19
+ * ② 内容只扫密钥形态(sk-xxx / Bearer xxx / key=… 等)→ 截断到形态之前
20
+ * ——宁可丢信息不漏密钥。
21
+ * 工具事件不记 args;URL 不入事件(llm/tool 事件从不携带 URL;err 文本经形态扫描)。
22
+ * - 摘要截断(B 方案,2026-09-03 用户裁定):截断处带 "…" 标记(截后仍 ≤上限)。
23
+ * - 测试隔离:node --test 进程(NODE_TEST_CONTEXT)默认不写盘——防测试事件污染真实
24
+ * 诊断日志(两端测试套件都会跑真实 agent 管线);显式设置 THINCODER_LOG_DIR 强制
25
+ * 写入该目录(log.test.mjs 用它隔离临时目录——refinement #6)。
26
+ * - 轮转清理(refinement #3——长驻进程覆盖):非仅启动时——每进程每日**首次写事件**
27
+ * 时顺带清理 >1 天的 agent-*.log(extension host 可长驻数月,启动清理覆盖不到)。
28
+ * - seq:每进程单调计数器。双端同写一个文件时 seq 会各自重复——定位同文件时序以
29
+ * ts 为准,seq 仅进程内参照(refinement #8)。
30
+ */
31
+
32
+ import { appendFileSync, mkdirSync, readdirSync, rmSync, existsSync, statSync } from "node:fs"
33
+ import { homedir } from "node:os"
34
+ import { join } from "node:path"
35
+
36
+ /** 单行长度硬上限(NF-L2) */
37
+ export const MAX_LINE = 512
38
+ /** 字段名精确黑名单(NF-L3/§2.4——2026-09-03 评审 refinement #7:与 §2.4 词表一致含 token) */
39
+ const BLACKLIST_FIELDS = new Set(["apikey", "designtoken", "password", "secret", "token", "authorization", "proxyuri", "proxy"])
40
+ /** 内容密钥形态扫描(大小写不敏感)——命中即截断到形态前 */
41
+ const SECRET_FORM = /(\bsk-[A-Za-z0-9_-]{6,}|\bBearer\s+[A-Za-z0-9._~+/=-]{6,}|\b(?:api[_-]?key|key|token|secret|password|pwd|passwd)\s*[=:]\s*['"]?[A-Za-z0-9._~+/=-]{6,})/i
42
+ /** 字符串字段长度兜底:head ≤300 / err ≤200 / 其余 ≤120 */
43
+ const FIELD_CAPS = { head: 300, err: 200 }
44
+
45
+ let _seq = 0
46
+ let _dead = false // 进程内写失败即死(NF-L1 静默降级——不逐事件重复空转)
47
+ let _cleanupDate = null // 本进程已执行过清理的日期(每日首次写时清一次)
48
+
49
+ /** 日志目录:THINCODER_LOG_DIR(测试隔离/override)> ~/.thincoder/logs(与 sessions/ 同域) */
50
+ export function logsDir() {
51
+ return process.env.THINCODER_LOG_DIR ?? join(homedir(), ".thincoder", "logs")
52
+ }
53
+
54
+ /** 今日日志文件路径(agent-YYYY-MM-DD.log) */
55
+ export function todayLogPath(now = new Date()) {
56
+ const ymd = now.toISOString().slice(0, 10)
57
+ return join(logsDir(), `agent-${ymd}.log`)
58
+ }
59
+
60
+ /** 写门(测试隔离):test runner 进程(NODE_TEST_CONTEXT)默认跳过——除显式
61
+ * THINCODER_LOG_DIR override(log.test.mjs 隔离临时目录——refinement #6)。 */
62
+ function writeEnabled() {
63
+ if (_dead) return false
64
+ if (process.env.NODE_TEST_CONTEXT && !process.env.THINCODER_LOG_DIR) return false
65
+ return true
66
+ }
67
+
68
+ /** 清理 >1 天的 agent-*.log(NF-L4——refinement #3:机会式,非仅启动时)。
69
+ * 保留窗口(2026-09-03 code review #1 修正):以文件名日期的**当日结束**为龄基准——
70
+ * 今天+昨天的文件保留(任何事件至少留存 24h、最多 48h),早于昨天的删除。
71
+ * 无法按名解析日期的 agent-*.log 按 mtime 兜底(>1 天未写的删除)。
72
+ * 导出供测试直呼(T-L7/T-L7b)。静默:任何失败都不影响主流程。 */
73
+ export const LOG_RETENTION_MS = 24 * 60 * 60 * 1000
74
+ export function cleanupOldLogs(now = new Date()) {
75
+ const dir = logsDir()
76
+ if (!existsSync(dir)) return
77
+ let names
78
+ try {
79
+ names = readdirSync(dir)
80
+ } catch {
81
+ return
82
+ }
83
+ for (const name of names) {
84
+ if (!name.startsWith("agent-") || !name.endsWith(".log")) continue
85
+ const m = name.match(/^agent-(\d{4}-\d{2}-\d{2})\.log$/)
86
+ const ts = m ? Date.parse(m[1] + "T00:00:00Z") : NaN
87
+ // 文件名日期 → 龄以该日结束(ts+24h)计:事件在删除时至少已留存 24h
88
+ const ageMs = Number.isFinite(ts) ? now - (ts + LOG_RETENTION_MS) : (tryStatAge(dir, name, now) ?? Infinity)
89
+ if (ageMs > LOG_RETENTION_MS) {
90
+ try { rmSync(join(dir, name), { force: true }) } catch { /* 静默 */ }
91
+ }
92
+ }
93
+ }
94
+
95
+ function tryStatAge(dir, name, now) {
96
+ try {
97
+ return now - statSync(join(dir, name)).mtimeMs
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 写入一条事件(fire-and-forget)。fields 内所有字符串都经黑名单/截断处理。
105
+ * 事件行结构:{"ts":ISO,"ev":kind,"seq":N,...fields}——单行 JSON <512 字符。
106
+ */
107
+ export function logEvent(kind, fields = {}) {
108
+ if (!writeEnabled()) return
109
+ const now = new Date()
110
+ // 机会式轮转清理:每进程每日首次写事件时执行一次(refinement #3——长驻进程覆盖)
111
+ const ymd = now.toISOString().slice(0, 10)
112
+ if (_cleanupDate !== ymd) {
113
+ _cleanupDate = ymd
114
+ cleanupOldLogs(now)
115
+ }
116
+ const entry = { ts: now.toISOString(), ev: kind, seq: ++_seq }
117
+ for (const [k, v] of Object.entries(fields ?? {})) {
118
+ if (v === undefined || v === null) continue
119
+ // ① 字段名精确黑名单 → 丢弃(token 族永不落盘)
120
+ if (BLACKLIST_FIELDS.has(String(k).toLowerCase())) continue
121
+ if (typeof v === "string") entry[k] = sanitizeString(k, v)
122
+ else if (typeof v === "number" || typeof v === "boolean") entry[k] = v
123
+ }
124
+ let line = JSON.stringify(entry)
125
+ // ② 防御性收尾(NF-L2):字段上限后仍超长 → 丢可选字段(保留 ts/ev/seq)直至 <512
126
+ while (line.length > MAX_LINE && Object.keys(entry).length > 3) {
127
+ for (const k of Object.keys(entry)) {
128
+ if (k !== "ts" && k !== "ev" && k !== "seq") { delete entry[k]; break }
129
+ }
130
+ line = JSON.stringify(entry)
131
+ }
132
+ try {
133
+ const dir = logsDir()
134
+ mkdirSync(dir, { recursive: true })
135
+ appendFileSync(join(dir, `agent-${ymd}.log`), line + "\n", "utf8")
136
+ } catch {
137
+ _dead = true // NF-L1:写失败静默降级——主流程零影响
138
+ }
139
+ }
140
+
141
+ /** 共享脱敏(§18.6 D-TR2——trace-store 复用;logEvent 自身保持"丢弃字段"语义,
142
+ * 本函数提供"遮蔽标记"语义——同一字段名黑名单 + 同一 SECRET_FORM 形态扫描,
143
+ * 不发明新遮蔽模式)。字段名命中黑名单 → "[REDACTED](整个字段遮蔽);内容命中
144
+ * 密钥形态 → 截断到形态前 + "[redacted]" 标记(宁可丢信息不漏密钥——§2.4)。 */
145
+ export function redactSecret(fieldKey, value) {
146
+ const s = String(value)
147
+ if (BLACKLIST_FIELDS.has(String(fieldKey).toLowerCase())) return "[REDACTED]"
148
+ const hit = s.match(SECRET_FORM)
149
+ return hit ? s.slice(0, hit.index) + "[redacted]" : s
150
+ }
151
+
152
+ /** 内容级净化:密钥形态截断到形态前(②);超长截断 + "…" 标记。 */
153
+ export function sanitizeString(key, value) {
154
+ let s = String(value)
155
+ // 密钥形态 → 截断到形态前(宁可丢信息不漏密钥——§2.4)
156
+ const hit = s.match(SECRET_FORM)
157
+ if (hit) s = s.slice(0, hit.index)
158
+ const cap = FIELD_CAPS[key] ?? 120
159
+ if (s.length > cap) s = s.slice(0, cap - 1) + "…"
160
+ return s
161
+ }
162
+
163
+ /** 错误文本提取:message + cause 链首条、压成单行、≤max(默认 200——NF-L2/§2.2) */
164
+ export function errText(err, max = 200) {
165
+ let msg = err?.message ?? String(err ?? "")
166
+ if (!msg && err?.cause) msg = String(err.cause)
167
+ if (err?.cause?.message && !/^LLM API error/.test(msg)) msg += ` (${err.cause.message})`
168
+ msg = msg.replace(/\s*\n\s*/g, " ").trim()
169
+ if (msg.length > max) msg = msg.slice(0, max - 1) + "…"
170
+ return msg
171
+ }
172
+
173
+ /** 错误 kind 分类(llm:error 字段):timeout(网关超时/超时信号)/ abort(用户中止)/
174
+ * error(其余——API/网络/未分类)。signal 为请求中止信号(chat 调用点直传)。 */
175
+ export function classifyErr(err, signal) {
176
+ const msg = String(err?.message ?? "").toLowerCase() + " " + String(err?.cause?.message ?? "").toLowerCase()
177
+ if (/timeout|timed ?out|etimedout|time limit exceeded/i.test(msg)) return "timeout"
178
+ if (err?.name === "AbortError" || signal?.aborted) {
179
+ return signal?.reason?.name === "TimeoutError" ? "timeout" : "abort"
180
+ }
181
+ return "error"
182
+ }
183
+
184
+ /** 文本头截取:前 max 字符、单行化(JSON 单行约束)、截断带 "…" 标记。
185
+ * paragraph=true(llm:done 响应头):取首段(首个空行前)——B 方案"首段文本"语义。 */
186
+ export function headText(text, max, { paragraph = false } = {}) {
187
+ let s = String(text ?? "")
188
+ if (paragraph) {
189
+ const p = s.split(/\n\s*\n/, 1)[0]
190
+ if (p.length < s.length) s = p
191
+ }
192
+ s = s.replace(/\s*\n+\s*/g, " ").trim()
193
+ if (s.length > max) s = s.slice(0, max - 1) + "…"
194
+ return s
195
+ }
package/manifest.mjs ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * manifest.mjs — M1 项目状态档(ENGINEERING-MODE v2 基础模块)。
3
+ * 权威设计 = docs/core/design/MANIFEST.md §2
4
+ * (机制代码在核,操作对象 = 被开发项目 cwd 根的 PROJECT-MANIFEST.json 数据档)。
5
+ *
6
+ * 契约速览(全量见模块设计 §2.2):
7
+ * - readManifest(cwd) → { ok, manifest, missingKeys, errors, reason }:
8
+ * 读 + validateManifest(obj);缺键 → 产 missingKeys(非拒)→ 补默认值 →
9
+ * 再校验通过;整档缺失 → { ok:false, reason:'missing' }(不静默 fallback)。
10
+ * - validateManifest(obj) → { ok, errors, missingKeys }:枚举 / version 数值 /
11
+ * docRoot 子键值形态(串 | 数组,F7)——**纯函数、零 fs**;不落盘。
12
+ * - isValidDocRootValue(value) / docRootPaths(value, cwd)(F7 判据单源 KD-M1-8):值形态谓词
13
+ * + 值 → 绝对路径数组(展开 / trim + `\` 归一 / 基数 = 项目根 / 去重保序)。
14
+ * - requireManifest(cwd) → 装配钩子入口 = readManifest(cwd)。
15
+ * - discoverRepos(cwd) → { kind: self|unique|none|ambiguous, root, candidates }:仓发现**单源**
16
+ * (KD-M1-22——manifest 面与 `git` 工具共用;`candidates` 按名排序);`resolveProjectRoot` = 其薄包装。
17
+ * - resolveEngineeringManifest(cwd, { writer, init }) → 入口决策树(**非抛错**——KD-M1-20):
18
+ * 两端入口钩子(抛错薄包装)与翻转面(拒翻)共用同一张树(判据单源;§2.8 F1)。
19
+ * - manifestFilePath(cwd) → 数据档绝对路径(档路径单源 KD-M1-18——读 / 写 / mtime 门控三处同源)。
20
+ * - initManifest(cwd, { writer = 'subagent' } = {}) → 经写门写 DEFAULT_MANIFEST,缺省拒。
21
+ * - writeManifest(cwd, manifest, { writer = 'subagent' } = {}) → 落盘前先校验(ok:false 拒
22
+ * 落盘);仅 writer === 'main' 落盘(fail-closed 缺省拒)。
23
+ *
24
+ * N1 零依赖:仅 node:fs / node:path + 标准库 JSON.parse——无第三方解析器、无 node:sqlite。
25
+ * N3 可迁移:本模块不硬编码任何本仓路径(docRoot / checkConfig 由被开发项目声明)。
26
+ */
27
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
28
+ import { join, resolve } from "node:path"
29
+
30
+ /** 数据档文件名(每个 git 仓一份——项目根 = 仓根;2026-09-17 用户裁定)。 */
31
+ export const MANIFEST_REL = "PROJECT-MANIFEST.json"
32
+
33
+ /** 测试注入:强制项目根(测试 tmp 非 git 仓——同 ledger `_setLedgerDirForTest` 先例)。 */
34
+ let _projectRootOverride = null
35
+ export function _setProjectRootForTest(dir) { _projectRootOverride = dir }
36
+ export function _resetProjectRootForTest() { _projectRootOverride = null }
37
+
38
+ /**
39
+ * 仓发现(**单源**——KD-M1-22 / 设计 `docs/core/design/MANIFEST.md` §2.2 · `docs/core/design/TOOLS.md` §6.13;
40
+ * 2026-09-17 用户裁定 + #62 批):判据 = git 目录(`.git`)+ 数据档存在性——
41
+ * ① `_projectRootOverride` 在场 ⇒ `self` + 覆盖值(测试注入面,短路先于真判据);
42
+ * ② 锚(cwd 的 `resolve`)自身含 `.git` ⇒ `self`(锚即仓根);
43
+ * ③ 否则**向下看锚的直接子目录一层**(不递归、不向上):含 `.git` **∧** 含 `MANIFEST_REL`(只判存在性、
44
+ * 不解析档内容)——恰一 ⇒ `unique`;零 ⇒ `none`;≥2 ⇒ `ambiguous`(全列候选,不猜)。
45
+ * `candidates` 一律**按名排序**(歧义消息确定可判)——`self` / `none` ⇒ `[]`。
46
+ * 纯 fs(不读档内容 / 不查模式 / 不依赖 agent)· **不抛**(锚不可读 ⇒ `none`)· 不缓存。
47
+ * @param {string} [cwd] 会话锚(缺省 → 进程 cwd)
48
+ * @returns {{kind:'self'|'unique'|'none'|'ambiguous', root:string|null, candidates:string[]}}
49
+ */
50
+ export function discoverRepos(cwd) {
51
+ if (_projectRootOverride) return { kind: "self", root: resolve(_projectRootOverride), candidates: [] }
52
+ const anchor = resolve(cwd ?? ".")
53
+ if (existsSync(join(anchor, ".git"))) return { kind: "self", root: anchor, candidates: [] }
54
+ let found = []
55
+ try {
56
+ found = readdirSync(anchor, { withFileTypes: true })
57
+ .filter((e) => e.isDirectory())
58
+ .map((e) => e.name)
59
+ .sort()
60
+ .map((e) => join(anchor, e))
61
+ .filter((d) => existsSync(join(d, ".git")) && existsSync(join(d, MANIFEST_REL)))
62
+ } catch { /* 不可读 → 空(none) */ }
63
+ if (found.length === 1) return { kind: "unique", root: found[0], candidates: [...found] }
64
+ if (found.length === 0) return { kind: "none", root: null, candidates: [] }
65
+ return { kind: "ambiguous", root: null, candidates: found }
66
+ }
67
+
68
+ /**
69
+ * 项目根解析(2026-09-17 用户裁定,**最终定案:纯向下,绝不向上**)——`discoverRepos(cwd).root` 的
70
+ * **薄包装**(零语义——KD-M1-22:`self` / `unique` ⇒ 路径,`none` / `ambiguous` ⇒ `null`,与批前逐字同)。
71
+ * 判据本体 = 上(单源);调用方(`manifestFilePath` / `docRootBase` / `ledger-db.mjs`)零改。
72
+ * @returns {string|null} 项目根绝对路径 / null
73
+ */
74
+ export function resolveProjectRoot(cwd) {
75
+ return discoverRepos(cwd).root
76
+ }
77
+
78
+ /**
79
+ * docRoot 路径解析基数(单点——manifest 的路径值一律相对**项目根**解析,非原始 cwd):
80
+ * 会话锚在容器根时,manifest 读的是子仓内的档,其 docRoot 值(如 docs/core/design)相对子仓根;
81
+ * 消费面必须用项目根当基数,否则解析到容器根下不存在的目录(2026-09-17 实证:评审五档全拒)。
82
+ * @returns {string} 项目根绝对路径(无项目根时回退 resolve(cwd))
83
+ */
84
+ export function docRootBase(cwd) {
85
+ return resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")
86
+ }
87
+
88
+ /**
89
+ * `docRoot` 子键**值形态判据**(F7 / KD-M1-6 / KD-M1-8——判据单源):非空字符串(`trim` 后
90
+ * 非空),或非空数组且元素皆非空字符串(**同款口径**:元素 `trim` 后非空);其余(空串 /
91
+ * 空白串 / 空数组 / 数组含非串 / 空串 / 空白串元素 / 非串非数组)为非法。
92
+ * @param {unknown} value docRoot 某子键的值
93
+ * @returns {boolean}
94
+ */
95
+ export function isValidDocRootValue(value) {
96
+ if (typeof value === "string") return value.trim() !== ""
97
+ if (!Array.isArray(value) || value.length === 0) return false
98
+ return value.every((p) => typeof p === "string" && p.trim() !== "")
99
+ }
100
+
101
+ /**
102
+ * `docRoot` 子键值 → **绝对路径数组**(F7 / §2.7 解析管线——全在本模块一处):展开(串 /
103
+ * 数组统一成列表)→ 逐元素 `trim` + `\` 归一 → `resolve(docRootBase(cwd), p)`(基数 =
104
+ * 项目根,不回退原始 cwd)→ 去重(保序)。非法形态 → `[]`(零根——拒面在 `validateManifest`,
105
+ * 本函数不判错;两处共用 `isValidDocRootValue`,判据单源)。
106
+ * @param {unknown} value docRoot 某子键的值(串 | 数组)
107
+ * @param {string} [cwd] 会话锚(基数由 docRootBase 解析为项目根)
108
+ * @returns {string[]} 绝对路径(去重保序)
109
+ */
110
+ export function docRootPaths(value, cwd) {
111
+ if (!isValidDocRootValue(value)) return []
112
+ const base = docRootBase(cwd)
113
+ const list = (Array.isArray(value) ? value : [value]).map((p) => resolve(base, p.trim().replace(/\\/g, "/")))
114
+ return [...new Set(list)]
115
+ }
116
+
117
+ /** 落盘根:git 根优先(仓内任意子目录调用都落仓根——防错层);无 .git(临时项目 / 测试)→ 退回 cwd
118
+ * (旧行为,不更坏)。防「写进 monorepo 容器根」的主闸 = 提示词(主 agent 会话锚必须进仓内)。 */
119
+ function writeRoot(cwd) {
120
+ return resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")
121
+ }
122
+
123
+ /**
124
+ * 数据档路径(**单源**——KD-M1-18):`join(writeRoot(cwd), MANIFEST_REL)`。读(`readManifest`)/ 写
125
+ * (`writeManifest`)/ mtime 门控(`agent/setup-reminders.mjs` ③b)三处同源——调用方不重写根
126
+ * 解析式(「档在哪」与「项目根在哪」是同一判据,重写即判据双源)。
127
+ * @param {string} [cwd] 会话锚(缺省 → 进程 cwd)
128
+ * @returns {string} 数据档绝对路径
129
+ */
130
+ export function manifestFilePath(cwd) {
131
+ return join(writeRoot(cwd), MANIFEST_REL)
132
+ }
133
+
134
+ /** 默认五键(架构 §2.3 E1 逐字)——整档初始化的写源与缺键 fallback 的补源。 */
135
+ export const DEFAULT_MANIFEST = Object.freeze({
136
+ version: 1,
137
+ phase: "initial-dev",
138
+ docRoot: Object.freeze({
139
+ requirements: "docs/requirements",
140
+ specs: "docs/requirements/specs",
141
+ design: "docs/design",
142
+ modules: "docs/design/modules",
143
+ batches: "docs/batches",
144
+ }),
145
+ promptsLanding: "thincoder-core/prompts",
146
+ checkConfig: Object.freeze({
147
+ scanDirs: Object.freeze(["docs"]),
148
+ lineWidth: 300,
149
+ anchors: Object.freeze({ domain: "docs", exclude: Object.freeze(["_archive", "batches"]) }),
150
+ exemptions: Object.freeze([]),
151
+ }),
152
+ })
153
+
154
+ /** 校验判据(模块设计 §2.2)——枚举 / 键存在(fallback 用);判据单源(KD-M1-4)。 */
155
+ export const MANIFEST_SCHEMA = Object.freeze({
156
+ $anchor: "docs/core/design/MANIFEST.md",
157
+ enum: Object.freeze({
158
+ phase: Object.freeze(["initial-dev", "production"]),
159
+ }),
160
+ keys: Object.freeze(Object.keys(DEFAULT_MANIFEST)),
161
+ nestedKeys: Object.freeze({
162
+ docRoot: Object.freeze(Object.keys(DEFAULT_MANIFEST.docRoot)),
163
+ checkConfig: Object.freeze(Object.keys(DEFAULT_MANIFEST.checkConfig)),
164
+ }),
165
+ })
166
+
167
+ /**
168
+ * 形状校验(模块设计 §2.2)——枚举 / version 数值恒做;**纯函数、零 fs**(原 `{ cwd }`
169
+ * 指针腿已随字段整链裁撤——KD-M1-5 墓志);不落盘。键存在性入 missingKeys(非拒):
170
+ * 缺键 = 便利 fallback(补默认值),不是错(与整档缺失两分——KD-M1-2)。
171
+ * @param {object} obj 待校验 manifest 对象
172
+ * @returns {{ok:boolean, errors:string[], missingKeys:string[]}}
173
+ */
174
+ export function validateManifest(obj) {
175
+ const errors = []
176
+ const missingKeys = []
177
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
178
+ return { ok: false, errors: ["manifest 顶层必须是 JSON 对象"], missingKeys }
179
+ }
180
+ for (const key of MANIFEST_SCHEMA.keys) {
181
+ if (!(key in obj)) missingKeys.push(key)
182
+ }
183
+ for (const [nested, subkeys] of Object.entries(MANIFEST_SCHEMA.nestedKeys)) {
184
+ const value = obj[nested]
185
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
186
+ missingKeys.push(nested) // 整键缺失/非对象 → 与整键缺同语义(整键补默认)
187
+ continue
188
+ }
189
+ for (const sub of subkeys) {
190
+ if (!(sub in value)) { missingKeys.push(`${nested}.${sub}`); continue } // 子键路径(AC-3 / T3b)
191
+ // docRoot 子键值形态(F7 / KD-M1-7):串 | 非空串数组;非法 → 拒(不静默跳过)。
192
+ if (nested === "docRoot" && !isValidDocRootValue(value[sub])) {
193
+ errors.push(`docRoot.${sub} 值形态非法:${JSON.stringify(value[sub])}(须为非空字符串或非空字符串数组——KD-M1-6 / KD-M1-7)`)
194
+ }
195
+ }
196
+ }
197
+ if (obj.phase !== undefined && !MANIFEST_SCHEMA.enum.phase.includes(obj.phase)) {
198
+ errors.push(`phase 取值非法:"${obj.phase}"(允许:${MANIFEST_SCHEMA.enum.phase.join(" | ")})`)
199
+ }
200
+ if (obj.version !== undefined && (typeof obj.version !== "number" || !Number.isFinite(obj.version))) {
201
+ errors.push(`version 非法:${JSON.stringify(obj.version)}(须为数值)`)
202
+ }
203
+ return { ok: errors.length === 0, errors, missingKeys }
204
+ }
205
+
206
+ /** 缺键补默认值(module 设计 §2.2 管线):顶层缺键补默认、docRoot/checkConfig 子键补默认
207
+ * (与整键缺同语义);不覆写既有值。深拷贝默认源(structuredClone)——冻结常量永不外泄引用。 */
208
+ function fillDefaults(obj) {
209
+ const out = structuredClone(DEFAULT_MANIFEST)
210
+ for (const key of MANIFEST_SCHEMA.keys) {
211
+ if (!(key in obj)) continue
212
+ if (key === "docRoot" || key === "checkConfig") {
213
+ const src = obj[key]
214
+ if (src === null || typeof src !== "object" || Array.isArray(src)) continue // 整键非对象 → 保持默认
215
+ for (const sub of Object.keys(out[key])) {
216
+ if (sub in src) out[key][sub] = src[sub]
217
+ }
218
+ } else {
219
+ out[key] = obj[key]
220
+ }
221
+ }
222
+ return out
223
+ }
224
+
225
+ /**
226
+ * 读 cwd 根数据档(模块设计 §2.2):读 + validateManifest(obj) → 缺键产
227
+ * missingKeys(非拒)→ 补默认值 → 再校验通过。整档缺失 → { ok:false, reason:'missing' }
228
+ * (绝不静默 fallback——KD-M1-2);JSON 非法 / 顶层非对象 → reason:'invalid'。
229
+ * @returns {{ok:boolean, manifest:object|null, missingKeys:string[], errors:string[], reason?:string}}
230
+ */
231
+ export function readManifest(cwd) {
232
+ let raw
233
+ try {
234
+ raw = readFileSync(manifestFilePath(cwd), "utf8")
235
+ } catch (e) {
236
+ if (e.code === "ENOENT") {
237
+ return { ok: false, reason: "missing", errors: [], missingKeys: [], manifest: null }
238
+ }
239
+ throw e // 权限/目录等其他读错——fail-closed 上抛,不伪装成缺失
240
+ }
241
+ let obj
242
+ try {
243
+ obj = JSON.parse(raw)
244
+ } catch {
245
+ return { ok: false, reason: "invalid", errors: [`${MANIFEST_REL} 不是合法 JSON`], missingKeys: [], manifest: null }
246
+ }
247
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
248
+ return { ok: false, reason: "invalid", errors: ["manifest 顶层必须是 JSON 对象"], missingKeys: [], manifest: null }
249
+ }
250
+ const first = validateManifest(obj)
251
+ const manifest = fillDefaults(obj)
252
+ const final = validateManifest(manifest)
253
+ return { ok: final.ok, manifest, missingKeys: first.missingKeys, errors: final.errors, reason: final.ok ? undefined : "invalid" }
254
+ }
255
+
256
+ /**
257
+ * 装配钩子入口(模块设计 §2.2)——同 readManifest(cwd)。ok:true 返回补默认值后的 manifest;
258
+ * reason:'missing' 由调用方走初始化分支(壳面拒进正常循环直至初始化完成)。
259
+ */
260
+ export function requireManifest(cwd) {
261
+ return readManifest(cwd)
262
+ }
263
+
264
+ /**
265
+ * 入口决策树(**非抛错**形态——KD-M1-20;docs/core/design/MANIFEST.md §2.8 F1):三面共用——
266
+ * ① CLI 装配 / 重估薄包装 ② VSC `hydrateRun` 钩子块 ③ 翻转面(`eng` 工具 / `/eng` /
267
+ * `/session` / ACP——「先判后翻」,拒翻分支零副作用)。判据树只此一处(判据单源——KD-M1-8 同族)。
268
+ *
269
+ * 分支(四出口):
270
+ * 档合法 → { ok:true, manifest, created:false }(manifest = 补默认值后的档内容)
271
+ * 缺档 + init → 根可解析 ? `initManifest(cwd, { writer })`(抛错 → `init-failed`)
272
+ * : { ok:false, code:'root-unresolvable', message }
273
+ * 缺档 + !init → { ok:false, code:'missing' }(不拒——调用方自决;VSC depth>0 分支用)
274
+ * 档非法 → { ok:false, code:'invalid', message, errors }
275
+ *
276
+ * `writer` 由调用点**显式**传(生产调用点全传 `'main'`):缺省 `'subagent'` 是写门 fail-closed
277
+ * 缺省(KD-M1-3),误用缺省 ⇒「缺档 + 根可解析」退化为 `init-failed`(拒翻——与 §2.8 F2 /
278
+ * AC-20② 语义相反;T38 反证格)。
279
+ * 边界:`readManifest` 的非 ENOENT 读错(权限 / 目录等)按既有契约**上抛**(不伪装成缺失——
280
+ * readManifest 语义零改);本树只承诺上列四出口。
281
+ * @param {string} [cwd] 会话锚(缺省 → 进程 cwd)
282
+ * @returns {{ok:true, manifest:object, created:boolean}
283
+ * | {ok:false, code:'missing'|'invalid'|'root-unresolvable'|'init-failed', message?:string, errors?:string[]}}
284
+ */
285
+ export function resolveEngineeringManifest(cwd, { writer = "subagent", init = true } = {}) {
286
+ const m = readManifest(cwd)
287
+ if (m.ok) return { ok: true, manifest: m.manifest, created: false }
288
+ if (m.reason === "invalid") {
289
+ return {
290
+ ok: false, code: "invalid", errors: m.errors,
291
+ message: `${MANIFEST_REL} 非法(fail-closed 拒进正常循环):${m.errors.join(";")}`,
292
+ }
293
+ }
294
+ if (!init) return { ok: false, code: "missing" }
295
+ // 项目根 = git 仓根(2026-09-17 用户裁定:判据 = .git,向下找;每个仓一份 manifest):
296
+ // cwd 非仓且子仓中无带 manifest 的仓(零个 = 无项目,多个 = 歧义)→ 不自动建档。
297
+ if (!resolveProjectRoot(cwd)) {
298
+ return {
299
+ ok: false, code: "root-unresolvable",
300
+ message:
301
+ `工程模式启动拒绝:会话锚 ${cwd} 不是 git 仓,且其子仓中带 ${MANIFEST_REL} 的不是恰好一个` +
302
+ `(零个 = 无项目;多个 = 歧义)——每个仓库一份 manifest,请锚在仓内或为子仓建档`,
303
+ }
304
+ }
305
+ try {
306
+ return { ok: true, manifest: initManifest(cwd, { writer }), created: true }
307
+ } catch (e) {
308
+ return { ok: false, code: "init-failed", message: e?.message ?? String(e) }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * 写门落盘(模块设计 §2.2 / KD-M1-3):仅 writer === 'main' 放行(fail-closed 缺省拒);
314
+ * 落盘前先 validateManifest(manifest)(ok:false → 拒落盘,防写非法档)。
315
+ * 不替被开发项目创建目录(§1.4 边界)——cwd 不存在时写失败自然上抛。
316
+ * @returns {object} 传入的 manifest(校验通过后原样落盘)
317
+ */
318
+ export function writeManifest(cwd, manifest, { writer = "subagent" } = {}) {
319
+ if (writer !== "main") {
320
+ throw new Error("writeManifest 拒绝:非主 agent 无写权(writer 须为 \"main\",fail-closed 缺省拒)")
321
+ }
322
+ const validated = validateManifest(manifest)
323
+ if (!validated.ok) {
324
+ throw new Error(`writeManifest 拒绝:manifest 校验不过——${validated.errors.join(";")}`)
325
+ }
326
+ writeFileSync(manifestFilePath(cwd), JSON.stringify(manifest, null, 2) + "\n")
327
+ return manifest
328
+ }
329
+
330
+ /**
331
+ * 初始化 = 写默认五键档(模块设计 §2.2):只提供机制,不包交互问答(问答归壳面)。
332
+ * 落盘走同一写门(内部 writeManifest(cwd, DEFAULT_MANIFEST, { writer }))——与 AC-M1-5
333
+ * 同一闸,无第二条写路径(KD-M1-4 / 评审 #12)。
334
+ * @returns {object} 写入的默认 manifest
335
+ */
336
+ export function initManifest(cwd, { writer = "subagent" } = {}) {
337
+ return writeManifest(cwd, DEFAULT_MANIFEST, { writer })
338
+ }
package/markdown.mjs ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * markdown.mjs — markdown + frontmatter format for memory entries
3
+ * Zero-dependency parsing/serialization. Entry format see ARCHITECTURE-v2.md.
4
+ */
5
+
6
+ const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
7
+
8
+ /**
9
+ * Parse a markdown entry.
10
+ * → { meta: { type, title, tags, author, created, embedding? }, content }
11
+ * Throws if frontmatter is missing or required fields absent.
12
+ */
13
+ export function parseEntry(text) {
14
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
15
+ if (!match) throw new Error("entry missing frontmatter (expected --- ... ---)")
16
+
17
+ const meta = parseFrontmatter(match[1])
18
+ const content = match[2].trim()
19
+
20
+ if (!VALID_TYPES.has(meta.type)) {
21
+ throw new Error(`invalid type "${meta.type}"; expected one of: ${[...VALID_TYPES].join(", ")}`)
22
+ }
23
+ if (!meta.title) throw new Error("frontmatter missing required field: title")
24
+
25
+ return {
26
+ meta: {
27
+ type: meta.type,
28
+ title: meta.title,
29
+ tags: Array.isArray(meta.tags) ? meta.tags : meta.tags ? [meta.tags] : [],
30
+ author: meta.author ?? "unknown",
31
+ created: meta.created ?? "",
32
+ ...(meta.embedding ? { embedding: meta.embedding } : {}),
33
+ },
34
+ content,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Serialize to markdown entry text.
40
+ */
41
+ export function serializeEntry(meta, content) {
42
+ if (!VALID_TYPES.has(meta.type)) throw new Error(`invalid type "${meta.type}"`)
43
+ if (!meta.title) throw new Error("meta.title is required")
44
+ // frontmatter scalars must be single-line: newlines in title/author would inject fake frontmatter rows
45
+ // (e.g. title "x\ntype: rule" would override real type when parsed), same for tags with newlines/commas
46
+ const tags = (meta.tags ?? []).map((t) => oneLine(t).replaceAll(",", " ")).join(", ")
47
+ const lines = [
48
+ "---",
49
+ `type: ${meta.type}`,
50
+ `title: ${oneLine(meta.title)}`,
51
+ `tags: [${tags}]`,
52
+ `author: ${oneLine(meta.author ?? "unknown")}`,
53
+ `created: ${oneLine(meta.created ?? new Date().toISOString().slice(0, 10))}`,
54
+ ]
55
+ if (meta.embedding) lines.push(`embedding: ${meta.embedding}`)
56
+ lines.push("---", "", content.trim(), "")
57
+ return lines.join("\n")
58
+ }
59
+
60
+ /** Convert title to filename slug: keep alphanumeric + CJK, convert rest to hyphens */
61
+ export function slugify(title) {
62
+ return title
63
+ .trim()
64
+ .toLowerCase()
65
+ .replace(/[^\w一-鿿]+/g, "-")
66
+ .replace(/^-+|-+$/g, "")
67
+ .slice(0, 50) || "untitled"
68
+ }
69
+
70
+ /** Generate entry filename: YYYYMMDD-<slug>-<rand4>.md */
71
+ export function entryFilename(title, date = new Date()) {
72
+ const ymd = date.toISOString().slice(0, 10).replaceAll("-", "")
73
+ const rand = Math.random().toString(36).slice(2, 6)
74
+ return `${ymd}-${slugify(title)}-${rand}.md`
75
+ }
76
+
77
+ // ---------------------------------------------------------------- internal
78
+
79
+ /** Collapse to single line (for frontmatter scalars): fold newlines into spaces, prevent injecting fake field lines */
80
+ function oneLine(v) {
81
+ return String(v).replace(/\s*\r?\n\s*/g, " ").trim()
82
+ }
83
+
84
+ /**
85
+ * Minimal YAML subset parser: only supports `key: value` and `key: [a, b, c]`.
86
+ * Our frontmatter is self-generated, no need for full YAML.
87
+ */
88
+ export function parseFrontmatter(text) {
89
+ const meta = {}
90
+ for (const line of text.split(/\r?\n/)) {
91
+ const m = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/)
92
+ if (!m) continue
93
+ const [, key, raw] = m
94
+ const value = raw.trim()
95
+ if (value.startsWith("[") && value.endsWith("]")) {
96
+ meta[key] = value
97
+ .slice(1, -1)
98
+ .split(",")
99
+ .map((s) => s.trim())
100
+ .filter(Boolean)
101
+ } else {
102
+ meta[key] = value
103
+ }
104
+ }
105
+ return meta
106
+ }