sanook-cli 0.5.1 → 0.5.5

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 (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,377 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { promisify } from 'node:util';
6
+ import { inlineValue, takeValue } from './cli-option-values.js';
7
+ const execFileAsync = promisify(execFile);
8
+ const TEMPLATE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'second-brain', 'Templates');
9
+ const FINAL_HEADINGS = [
10
+ '## 1. Objective / DoD Lock',
11
+ '## 2. Evidence-Backed Checklist',
12
+ '## 3. Status Matrix',
13
+ '## 4. Evidence Matrix',
14
+ '## 5. Residual Risk',
15
+ '## 6. Change Summary Audit',
16
+ '## 7. Final Answer Draft',
17
+ '## 8. Second-Brain Routing / Memory Closeout',
18
+ ];
19
+ const STATUS_TOKENS = new Set(['PASS', 'PARTIAL', 'FAIL', 'N/A', 'BLOCKED', 'TODO']);
20
+ const PASS_STATUS = 'PASS';
21
+ export function parseBrainFinalArgs(args) {
22
+ const positional = [];
23
+ const parsed = { fromDiff: false, lite: false, force: false };
24
+ for (let i = 0; i < args.length; i++) {
25
+ const arg = args[i];
26
+ if (arg === '--from-diff') {
27
+ parsed.fromDiff = true;
28
+ }
29
+ else if (arg === '--lite') {
30
+ parsed.lite = true;
31
+ }
32
+ else if (arg === '--force') {
33
+ parsed.force = true;
34
+ }
35
+ else if (arg === '--task' || arg.startsWith('--task=')) {
36
+ const next = arg === '--task' ? takeValue(args, i) : undefined;
37
+ const value = next ? next.value : inlineValue('--task', arg);
38
+ if (next)
39
+ i = next.nextIndex;
40
+ if (!value?.trim())
41
+ return { ok: false, message: 'ต้องระบุค่าให้ --task' };
42
+ if (parsed.task)
43
+ return { ok: false, message: 'ระบุ task ได้ครั้งเดียว: ใช้ --task เพียงครั้งเดียว' };
44
+ parsed.task = value.trim();
45
+ }
46
+ else if (arg === '--output') {
47
+ const next = takeValue(args, i);
48
+ const value = next.value;
49
+ i = next.nextIndex;
50
+ if (!value?.trim())
51
+ return { ok: false, message: 'ต้องระบุค่าให้ --output' };
52
+ if (parsed.output !== undefined)
53
+ return { ok: false, message: 'ระบุ output ได้ครั้งเดียว: ใช้ --output เพียงครั้งเดียว' };
54
+ parsed.output = value.trim();
55
+ }
56
+ else if (arg.startsWith('--output=')) {
57
+ const value = arg.slice('--output='.length).trim();
58
+ if (!value)
59
+ return { ok: false, message: 'ต้องระบุค่าให้ --output' };
60
+ if (parsed.output !== undefined)
61
+ return { ok: false, message: 'ระบุ output ได้ครั้งเดียว: ใช้ --output เพียงครั้งเดียว' };
62
+ parsed.output = value;
63
+ }
64
+ else if (arg === '--') {
65
+ positional.push(...args.slice(i + 1));
66
+ break;
67
+ }
68
+ else if (arg.startsWith('-')) {
69
+ return { ok: false, message: `ไม่รู้จัก option: ${arg}` };
70
+ }
71
+ else {
72
+ positional.push(arg);
73
+ }
74
+ }
75
+ const positionalTask = positional.join(' ').trim();
76
+ if (parsed.task && positionalTask)
77
+ return { ok: false, message: 'ระบุ task ได้ครั้งเดียว: ใช้ positional หรือ --task อย่างใดอย่างหนึ่ง' };
78
+ if (positionalTask)
79
+ parsed.task = positionalTask;
80
+ return { ok: true, value: parsed };
81
+ }
82
+ export async function createBrainFinal(options) {
83
+ const task = (options.task ?? 'current task').trim() || 'current task';
84
+ const template = options.lite ? 'final-lite.md' : 'final.md';
85
+ const warnings = [];
86
+ if (!options.brainPath) {
87
+ return { ok: false, task, template, fromDiff: !!options.fromDiff, diffFiles: [], indexed: false, warnings: ['No second-brain path is configured.'] };
88
+ }
89
+ const brainPath = resolve(options.brainPath);
90
+ if (!(await pathExistsAsDir(brainPath))) {
91
+ return {
92
+ ok: false,
93
+ brainPath,
94
+ task,
95
+ template,
96
+ fromDiff: !!options.fromDiff,
97
+ diffFiles: [],
98
+ indexed: false,
99
+ warnings: ['Configured second-brain path does not exist or is not a directory.'],
100
+ };
101
+ }
102
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
103
+ const slug = slugify(task || 'final-gate');
104
+ const output = outputPath(brainPath, options.output ?? join('Sessions', `${today}-${slug}-final.md`));
105
+ if (!output.ok) {
106
+ return { ok: false, brainPath, task, template, fromDiff: !!options.fromDiff, diffFiles: [], indexed: false, warnings: [output.message] };
107
+ }
108
+ if ((await fileExists(output.path)) && !options.force) {
109
+ return {
110
+ ok: false,
111
+ brainPath,
112
+ path: output.path,
113
+ relPath: vaultRel(brainPath, output.path),
114
+ task,
115
+ template,
116
+ fromDiff: !!options.fromDiff,
117
+ diffFiles: [],
118
+ indexed: false,
119
+ warnings: ['Final gate file already exists. Re-run with --force or choose --output.'],
120
+ };
121
+ }
122
+ const diffFiles = options.fromDiff
123
+ ? uniqueSorted(options.diffFiles ?? (await (options.diffProvider ?? defaultDiffFiles)()))
124
+ : [];
125
+ const raw = await readTemplate(brainPath, template);
126
+ let content = instantiateTemplate(raw, { today, task, template, fromDiff: !!options.fromDiff, diffFiles });
127
+ if (template === 'final-lite.md')
128
+ content = content.replace('note_type: template', 'note_type: final-gate-lite');
129
+ else
130
+ content = content.replace('note_type: template', 'note_type: final-gate');
131
+ await mkdir(dirname(output.path), { recursive: true });
132
+ await writeFile(output.path, content, 'utf8');
133
+ const relPath = vaultRel(brainPath, output.path);
134
+ const indexed = await maybeAppendSessionIndex(brainPath, relPath, task);
135
+ if (options.fromDiff && diffFiles.length === 0)
136
+ warnings.push('No git worktree changes were detected for --from-diff.');
137
+ return { ok: true, brainPath, path: output.path, relPath, task, template, fromDiff: !!options.fromDiff, diffFiles, indexed, warnings };
138
+ }
139
+ export function formatBrainFinalReport(report) {
140
+ const lines = ['Sanook brain final'];
141
+ lines.push(`vault: ${report.brainPath ?? '(not configured)'}`);
142
+ lines.push(`task: ${report.task}`);
143
+ lines.push(`template: ${report.template}`);
144
+ if (report.path)
145
+ lines.push(`created: ${report.path}`);
146
+ if (report.relPath)
147
+ lines.push(`link: [[${report.relPath.replace(/\.md$/i, '')}]]`);
148
+ if (report.fromDiff)
149
+ lines.push(`from-diff: ${report.diffFiles.length} file(s)`);
150
+ if (report.indexed)
151
+ lines.push('sessions-index: updated');
152
+ for (const warning of report.warnings)
153
+ lines.push(`warning: ${warning}`);
154
+ return lines.join('\n');
155
+ }
156
+ export async function listFinalGateFiles(brainPath) {
157
+ const sessionsDir = join(brainPath, 'Sessions');
158
+ const entries = await readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
159
+ const out = [];
160
+ for (const entry of entries) {
161
+ if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === '_Index.md')
162
+ continue;
163
+ const relPath = `Sessions/${entry.name}`;
164
+ const path = join(brainPath, relPath);
165
+ const content = await readText(path);
166
+ if (isFinalGateContent(content, entry.name))
167
+ out.push({ relPath, path, content });
168
+ }
169
+ return out.sort((a, b) => a.relPath.localeCompare(b.relPath));
170
+ }
171
+ export function validateFinalGateContent(content) {
172
+ const warnings = [];
173
+ for (const heading of FINAL_HEADINGS) {
174
+ if (!content.includes(heading))
175
+ warnings.push(`Missing final gate section: ${heading}`);
176
+ }
177
+ if (!content.includes('## Final Verdict'))
178
+ warnings.push('Missing final verdict section.');
179
+ if (!content.includes('If a row has no evidence'))
180
+ warnings.push('Missing explicit evidence rule: "If a row has no evidence".');
181
+ const lines = content.split('\n');
182
+ for (let i = 0; i < lines.length; i++) {
183
+ if (!isTableHeader(lines, i))
184
+ continue;
185
+ const headers = tableCells(lines[i]).map((cell) => normalizeHeader(cell));
186
+ const statusIndex = headers.findIndex((header) => header === 'status' || header === 'verdict');
187
+ const evidenceIndex = headers.findIndex((header) => header === 'evidence' || header === 'important output' || header === 'scope proven');
188
+ if (statusIndex < 0)
189
+ continue;
190
+ for (let j = i + 2; j < lines.length && lines[j].trim().startsWith('|'); j++) {
191
+ const cells = tableCells(lines[j]);
192
+ if (!cells.length || isSeparatorRow(cells))
193
+ continue;
194
+ const status = normalizeStatus(cells[statusIndex]);
195
+ if (!status)
196
+ continue;
197
+ const rowName = cells[0]?.replace(/`/g, '').trim() || `row ${j + 1}`;
198
+ if (status === 'TODO')
199
+ warnings.push(`TODO status remains in final gate row: ${rowName}`);
200
+ if (status === PASS_STATUS && evidenceIndex >= 0 && isPlaceholderEvidence(cells[evidenceIndex] ?? '')) {
201
+ warnings.push(`PASS row has no evidence: ${rowName}`);
202
+ }
203
+ }
204
+ }
205
+ const placeholders = content.match(/<[^>\n]+>/g) ?? [];
206
+ const meaningfulPlaceholders = placeholders.filter(isTemplatePlaceholder);
207
+ if (meaningfulPlaceholders.length)
208
+ warnings.push(`Unfilled placeholder(s) remain: ${uniqueSorted(meaningfulPlaceholders).slice(0, 5).join(', ')}`);
209
+ return { ok: warnings.length === 0, warnings };
210
+ }
211
+ function instantiateTemplate(raw, options) {
212
+ const titleTask = options.task;
213
+ let content = raw
214
+ .replaceAll('YYYY-MM-DD', options.today)
215
+ .replaceAll('<task/topic>', titleTask)
216
+ .replace('tags: [template, final-gate, verification, dod]', 'tags: [final-gate, verification, dod]')
217
+ .replace('tags: [template, final-gate, verification, lite]', 'tags: [final-gate, verification, lite]')
218
+ .replace('parent: "[[Templates/_Index]]"', 'parent: "[[Sessions/_Index]]"')
219
+ .replace('up:: [[Templates/_Index]]', 'up:: [[Sessions/_Index]]')
220
+ .replace('<paste owner request or goal text here>', options.task);
221
+ if (options.fromDiff)
222
+ content = injectDiffEvidence(content, options.diffFiles);
223
+ return content;
224
+ }
225
+ function injectDiffEvidence(content, diffFiles) {
226
+ const fileRows = diffFiles.length
227
+ ? diffFiles.map((file) => `| \`${file}\` | TODO: summarize change | \`git status --short\` / \`git diff -- ${file}\` |`).join('\n')
228
+ : '| `(no git worktree changes detected)` | N/A | `git status --short` |';
229
+ const commandRows = diffFiles.length
230
+ ? '| `git status --short` | TODO | Populated by `--from-diff`; review before marking PASS. | Current worktree changed paths. |'
231
+ : '| `git status --short` | N/A | No changed paths detected by `--from-diff`. | Worktree scan only. |';
232
+ return replaceFilesChangedRows(content.replace('| `<command>` | TODO | | |', commandRows), fileRows);
233
+ }
234
+ function replaceFilesChangedRows(content, fileRows) {
235
+ const fullPattern = /(Files changed:\n\n\| File\/path \| Change summary \| Evidence \|\n\|---\|---\|---\|\n)\| `(?:<path>|<file>)` \| \| \|/;
236
+ const litePattern = /(Changed files:\n\n\| File \| Change summary \| Evidence \|\n\|---\|---\|---\|\n)\| `(?:<path>|<file>)` \| \| \|/;
237
+ return content.replace(fullPattern, `$1${fileRows}`).replace(litePattern, `$1${fileRows}`);
238
+ }
239
+ async function readTemplate(brainPath, template) {
240
+ const vaultTemplate = join(brainPath, 'Templates', template);
241
+ const fromVault = await readText(vaultTemplate);
242
+ if (fromVault)
243
+ return fromVault;
244
+ return readFile(join(TEMPLATE_ROOT, template), 'utf8');
245
+ }
246
+ async function defaultDiffFiles() {
247
+ try {
248
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain=v1'], { cwd: process.cwd(), encoding: 'utf8' });
249
+ return stdout
250
+ .split('\n')
251
+ .map((line) => line.trimEnd())
252
+ .filter(Boolean)
253
+ .map(parsePorcelainPath)
254
+ .filter((path) => !!path);
255
+ }
256
+ catch {
257
+ return [];
258
+ }
259
+ }
260
+ function parsePorcelainPath(line) {
261
+ const raw = line.slice(3).trim();
262
+ if (!raw)
263
+ return undefined;
264
+ const renameIndex = raw.lastIndexOf(' -> ');
265
+ return renameIndex >= 0 ? raw.slice(renameIndex + 4) : raw;
266
+ }
267
+ function outputPath(brainPath, output) {
268
+ const path = resolve(isAbsolute(output) ? output : join(brainPath, output));
269
+ const rel = relative(brainPath, path);
270
+ if (rel.startsWith('..') || isAbsolute(rel))
271
+ return { ok: false, message: '--output must stay inside the configured second-brain vault.' };
272
+ return { ok: true, path };
273
+ }
274
+ async function maybeAppendSessionIndex(brainPath, relPath, task) {
275
+ if (!relPath.startsWith('Sessions/') || relPath.endsWith('/_Index.md'))
276
+ return false;
277
+ const indexPath = join(brainPath, 'Sessions', '_Index.md');
278
+ const content = await readText(indexPath);
279
+ if (!content)
280
+ return false;
281
+ const note = relPath.replace(/\.md$/i, '');
282
+ const link = `[[${note}]]`;
283
+ if (content.includes(link))
284
+ return false;
285
+ const line = `- ${link} — final gate: ${task}`;
286
+ const marker = '\nup:: [[Home]]';
287
+ const next = content.includes(marker) ? content.replace(marker, `\n${line}\n${marker}`) : `${content.trimEnd()}\n${line}\n`;
288
+ await writeFile(indexPath, next, 'utf8');
289
+ return true;
290
+ }
291
+ function slugify(value) {
292
+ const slug = value
293
+ .normalize('NFKD')
294
+ .toLowerCase()
295
+ .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
296
+ .replace(/^-+|-+$/g, '')
297
+ .slice(0, 80)
298
+ .replace(/-+$/g, '');
299
+ return slug || 'final-gate';
300
+ }
301
+ function vaultRel(brainPath, path) {
302
+ return relative(brainPath, path).split(sep).join('/');
303
+ }
304
+ function isFinalGateContent(content, fileName) {
305
+ return (/^note_type:\s*final-gate/m.test(content) ||
306
+ /^note_type:\s*final-gate-lite/m.test(content) ||
307
+ (content.includes('## Final Verdict') && content.includes('## 1. Objective / DoD Lock')) ||
308
+ /-final\.md$/i.test(fileName));
309
+ }
310
+ function isTableHeader(lines, index) {
311
+ return lines[index]?.trim().startsWith('|') && isSeparatorRow(tableCells(lines[index + 1] ?? ''));
312
+ }
313
+ function tableCells(line) {
314
+ const trimmed = line.trim();
315
+ if (!trimmed.startsWith('|'))
316
+ return [];
317
+ return trimmed
318
+ .split('|')
319
+ .slice(1, -1)
320
+ .map((cell) => cell.trim());
321
+ }
322
+ function isSeparatorRow(cells) {
323
+ return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
324
+ }
325
+ function normalizeHeader(value) {
326
+ return value.replace(/`/g, '').trim().toLowerCase();
327
+ }
328
+ function normalizeStatus(value) {
329
+ const cleaned = value.replace(/`/g, '').trim().toUpperCase();
330
+ return STATUS_TOKENS.has(cleaned) ? cleaned : undefined;
331
+ }
332
+ function isPlaceholderEvidence(value) {
333
+ const cleaned = value
334
+ .replace(/`/g, '')
335
+ .replace(/<[^>]+>/g, (item) => (isTemplatePlaceholder(item) ? '' : item))
336
+ .trim();
337
+ return !cleaned || cleaned === '-' || cleaned === '—' || /^TODO\b/i.test(cleaned) || cleaned === '|';
338
+ }
339
+ function isTemplatePlaceholder(value) {
340
+ const inner = value.slice(1, -1).trim();
341
+ if (!inner || inner.includes('e.g.'))
342
+ return false;
343
+ if (/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(inner))
344
+ return false;
345
+ if (/^mailto:\S+$/i.test(inner))
346
+ return false;
347
+ if (/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(inner))
348
+ return false;
349
+ return true;
350
+ }
351
+ async function pathExistsAsDir(path) {
352
+ try {
353
+ return (await stat(path)).isDirectory();
354
+ }
355
+ catch {
356
+ return false;
357
+ }
358
+ }
359
+ async function fileExists(path) {
360
+ try {
361
+ return (await stat(path)).isFile();
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }
367
+ async function readText(path) {
368
+ try {
369
+ return await readFile(path, 'utf8');
370
+ }
371
+ catch {
372
+ return '';
373
+ }
374
+ }
375
+ function uniqueSorted(values) {
376
+ return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
377
+ }
@@ -0,0 +1,277 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { checkSearchIndexFreshness } from './brain-doctor.js';
4
+ import { runBrainEval } from './brain-eval.js';
5
+ import { INDEX_PATH, sanitizeManifest } from './search/store.js';
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ const SKIP_DIRS = new Set(['.git', '.obsidian', 'node_modules', 'Shared/Context7-Docs']);
8
+ const ARCHIVE_EXEMPT_PREFIXES = ['Shared/Core-Facts/', 'Shared/Archive/'];
9
+ function sectionBullets(content, heading) {
10
+ const lines = content.split('\n');
11
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading.toLowerCase()}`);
12
+ if (start < 0)
13
+ return [];
14
+ const out = [];
15
+ for (const line of lines.slice(start + 1)) {
16
+ if (/^#{1,6}\s+/.test(line.trim()))
17
+ break;
18
+ const trimmed = line.trim();
19
+ if (trimmed.startsWith('- ') && !trimmed.includes('_('))
20
+ out.push(trimmed);
21
+ }
22
+ return out;
23
+ }
24
+ function parseFrontmatterField(content, field) {
25
+ const match = content.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
26
+ return match?.[1]?.trim().replace(/^["']|["']$/g, '');
27
+ }
28
+ function parseIsoDate(value) {
29
+ if (!value)
30
+ return undefined;
31
+ const ms = Date.parse(value);
32
+ return Number.isFinite(ms) ? ms : undefined;
33
+ }
34
+ function topFolder(relPath) {
35
+ const parts = relPath.split('/');
36
+ return parts.length > 1 ? parts[0] : relPath;
37
+ }
38
+ async function listMarkdown(root) {
39
+ const out = [];
40
+ async function walk(abs, rel) {
41
+ let entries;
42
+ try {
43
+ entries = await readdir(abs, { withFileTypes: true });
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ for (const entry of entries) {
49
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
50
+ if (entry.isDirectory()) {
51
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_DIRS.has(childRel))
52
+ continue;
53
+ await walk(join(abs, entry.name), childRel);
54
+ }
55
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
56
+ out.push(childRel);
57
+ }
58
+ }
59
+ }
60
+ await walk(root, '');
61
+ return out.sort();
62
+ }
63
+ async function readText(path) {
64
+ try {
65
+ return await readFile(path, 'utf8');
66
+ }
67
+ catch {
68
+ return '';
69
+ }
70
+ }
71
+ async function mtimeMs(path) {
72
+ try {
73
+ return (await stat(path)).mtimeMs;
74
+ }
75
+ catch {
76
+ return 0;
77
+ }
78
+ }
79
+ async function readManifest(indexPath) {
80
+ try {
81
+ const raw = JSON.parse(await readFile(indexPath, 'utf8'));
82
+ return sanitizeManifest(raw.manifest);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ async function collectCounts(brainPath) {
89
+ const markdown = await listMarkdown(brainPath);
90
+ const byTopFolder = {};
91
+ for (const rel of markdown) {
92
+ const folder = topFolder(rel);
93
+ byTopFolder[folder] = (byTopFolder[folder] ?? 0) + 1;
94
+ }
95
+ const inboxPath = join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md');
96
+ const inbox = await readText(inboxPath);
97
+ const inboxCandidates = sectionBullets(inbox, 'New Candidates').length;
98
+ const inboxNeedsMerge = sectionBullets(inbox, 'Needs Merge').length;
99
+ let sessionNotes = 0;
100
+ try {
101
+ sessionNotes = (await readdir(join(brainPath, 'Sessions'), { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== '_Index.md').length;
102
+ }
103
+ catch {
104
+ sessionNotes = 0;
105
+ }
106
+ let contextPacks = 0;
107
+ try {
108
+ contextPacks = (await readdir(join(brainPath, 'Shared', 'Context-Packs'), { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== '_Index.md').length;
109
+ }
110
+ catch {
111
+ contextPacks = 0;
112
+ }
113
+ const archivedNotes = markdown.filter((rel) => rel.startsWith('Shared/Archive/')).length;
114
+ return {
115
+ markdownTotal: markdown.length,
116
+ byTopFolder,
117
+ inboxCandidates,
118
+ inboxNeedsMerge,
119
+ sessionNotes,
120
+ contextPacks,
121
+ archivedNotes,
122
+ };
123
+ }
124
+ async function collectStaleNotes(brainPath, nowMs, touchGraceDays) {
125
+ const out = [];
126
+ for (const rel of await listMarkdown(brainPath)) {
127
+ if (ARCHIVE_EXEMPT_PREFIXES.some((prefix) => rel.startsWith(prefix)))
128
+ continue;
129
+ const path = join(brainPath, rel);
130
+ const content = await readText(path);
131
+ const staleAfter = parseFrontmatterField(content, 'stale_after');
132
+ const staleMs = parseIsoDate(staleAfter);
133
+ if (staleMs === undefined || staleMs > nowMs)
134
+ continue;
135
+ const touchMs = await mtimeMs(path);
136
+ const daysPastStale = Math.floor((nowMs - staleMs) / DAY_MS);
137
+ const daysSinceTouch = Math.floor((nowMs - touchMs) / DAY_MS);
138
+ if (daysSinceTouch < touchGraceDays)
139
+ continue;
140
+ out.push({ relPath: rel, staleAfter: staleAfter, daysPastStale, daysSinceTouch });
141
+ }
142
+ return out.sort((a, b) => b.daysPastStale - a.daysPastStale);
143
+ }
144
+ async function collectRetrievalCoverage(brainPath, indexPath, evalReport) {
145
+ let sessionTotal = 0;
146
+ let sessionIndexed = 0;
147
+ const sessionMissing = [];
148
+ try {
149
+ const sessions = (await readdir(join(brainPath, 'Sessions'), { withFileTypes: true }))
150
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== '_Index.md')
151
+ .map((e) => `Sessions/${e.name}`)
152
+ .sort();
153
+ sessionTotal = sessions.length;
154
+ const manifest = await readManifest(indexPath);
155
+ if (manifest) {
156
+ for (const rel of sessions) {
157
+ if (manifest[rel])
158
+ sessionIndexed++;
159
+ else
160
+ sessionMissing.push(rel);
161
+ }
162
+ }
163
+ }
164
+ catch {
165
+ sessionTotal = 0;
166
+ }
167
+ return {
168
+ sessionIndexed,
169
+ sessionTotal,
170
+ sessionMissing,
171
+ evalPercent: evalReport?.percent ?? 0,
172
+ evalOk: evalReport?.ok ?? false,
173
+ evalScore: evalReport?.score ?? 0,
174
+ evalMaxScore: evalReport?.maxScore ?? 0,
175
+ };
176
+ }
177
+ export async function collectBrainMetrics(options = {}) {
178
+ const brainPath = options.brainPath;
179
+ const emptyCounts = {
180
+ markdownTotal: 0,
181
+ byTopFolder: {},
182
+ inboxCandidates: 0,
183
+ inboxNeedsMerge: 0,
184
+ sessionNotes: 0,
185
+ contextPacks: 0,
186
+ archivedNotes: 0,
187
+ };
188
+ if (!brainPath) {
189
+ return {
190
+ ok: false,
191
+ counts: emptyCounts,
192
+ staleNotes: [],
193
+ indexFreshness: { status: 'fail', message: 'No second-brain path is configured.' },
194
+ retrieval: { sessionIndexed: 0, sessionTotal: 0, sessionMissing: [], evalPercent: 0, evalOk: false, evalScore: 0, evalMaxScore: 0 },
195
+ };
196
+ }
197
+ try {
198
+ if (!(await stat(brainPath)).isDirectory()) {
199
+ return {
200
+ ok: false,
201
+ brainPath,
202
+ counts: emptyCounts,
203
+ staleNotes: [],
204
+ indexFreshness: { status: 'fail', message: 'Configured second-brain path is not a directory.' },
205
+ retrieval: { sessionIndexed: 0, sessionTotal: 0, sessionMissing: [], evalPercent: 0, evalOk: false, evalScore: 0, evalMaxScore: 0 },
206
+ };
207
+ }
208
+ }
209
+ catch {
210
+ return {
211
+ ok: false,
212
+ brainPath,
213
+ counts: emptyCounts,
214
+ staleNotes: [],
215
+ indexFreshness: { status: 'fail', message: 'Configured second-brain path does not exist.' },
216
+ retrieval: { sessionIndexed: 0, sessionTotal: 0, sessionMissing: [], evalPercent: 0, evalOk: false, evalScore: 0, evalMaxScore: 0 },
217
+ };
218
+ }
219
+ const nowMs = options.nowMs ?? Date.now();
220
+ const indexPath = options.indexPath ?? INDEX_PATH;
221
+ const counts = await collectCounts(brainPath);
222
+ const staleNotes = await collectStaleNotes(brainPath, nowMs, options.staleTouchGraceDays ?? 14);
223
+ const indexCheck = await checkSearchIndexFreshness(brainPath, indexPath);
224
+ const evalReport = options.runRetrievalEval === false ? undefined : await runBrainEval({ brainPath, indexPath, runRetrieval: true });
225
+ const retrieval = await collectRetrievalCoverage(brainPath, indexPath, evalReport);
226
+ const indexFreshness = {
227
+ status: indexCheck.status,
228
+ message: indexCheck.message,
229
+ };
230
+ for (const detail of indexCheck.details ?? []) {
231
+ const m = detail.match(/^(\w+)_mtime_ms=(\d+)/);
232
+ if (!m)
233
+ continue;
234
+ const value = Number(m[2]);
235
+ if (m[1] === 'index')
236
+ indexFreshness.indexMtimeMs = value;
237
+ if (m[1] === 'vault_latest')
238
+ indexFreshness.vaultLatestMtimeMs = value;
239
+ }
240
+ const ok = indexCheck.status !== 'fail' &&
241
+ staleNotes.length === 0 &&
242
+ (retrieval.sessionTotal === 0 || retrieval.sessionMissing.length === 0) &&
243
+ (evalReport === undefined || evalReport.ok);
244
+ return { ok, brainPath, counts, staleNotes, indexFreshness, retrieval };
245
+ }
246
+ export function formatBrainMetricsReport(report) {
247
+ const lines = ['Sanook brain metrics', `vault: ${report.brainPath ?? '(not configured)'}`];
248
+ lines.push('counts:');
249
+ lines.push(` markdown: ${report.counts.markdownTotal}`);
250
+ lines.push(` inbox candidates: ${report.counts.inboxCandidates} · needs merge: ${report.counts.inboxNeedsMerge}`);
251
+ lines.push(` sessions: ${report.counts.sessionNotes} · context packs: ${report.counts.contextPacks} · archived: ${report.counts.archivedNotes}`);
252
+ const folders = Object.entries(report.counts.byTopFolder).sort((a, b) => b[1] - a[1]).slice(0, 8);
253
+ if (folders.length) {
254
+ lines.push(' top folders:');
255
+ for (const [folder, count] of folders)
256
+ lines.push(` ${folder}: ${count}`);
257
+ }
258
+ lines.push(`index freshness: [${report.indexFreshness.status.toUpperCase()}] ${report.indexFreshness.message}`);
259
+ lines.push(`retrieval coverage: sessions indexed ${report.retrieval.sessionIndexed}/${report.retrieval.sessionTotal}` +
260
+ (report.retrieval.evalMaxScore ? ` · eval ${report.retrieval.evalScore}/${report.retrieval.evalMaxScore} (${report.retrieval.evalPercent.toFixed(1)}%)` : ''));
261
+ if (report.retrieval.sessionMissing.length) {
262
+ lines.push(' missing from index:');
263
+ for (const rel of report.retrieval.sessionMissing.slice(0, 10))
264
+ lines.push(` - ${rel}`);
265
+ }
266
+ if (report.staleNotes.length) {
267
+ lines.push(`stale notes (${report.staleNotes.length}):`);
268
+ for (const note of report.staleNotes.slice(0, 15)) {
269
+ lines.push(` - ${note.relPath} (stale_after=${note.staleAfter}, +${note.daysPastStale}d, untouched ${note.daysSinceTouch}d)`);
270
+ }
271
+ }
272
+ else {
273
+ lines.push('stale notes: none flagged');
274
+ }
275
+ lines.push(`summary: ${report.ok ? 'healthy' : 'needs attention'}`);
276
+ return lines.join('\n');
277
+ }