sanook-cli 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +161 -3
- package/CHANGELOG.md +57 -8
- package/README.md +240 -23
- package/README.th.md +87 -6
- package/dist/approval.js +6 -0
- package/dist/bin.js +3026 -196
- package/dist/brain-context.js +223 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +371 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +12 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +152 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/commands.js +172 -13
- package/dist/compaction.js +96 -11
- package/dist/config.js +118 -28
- package/dist/context-compression.js +191 -0
- package/dist/cost.js +49 -15
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +37 -8
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +357 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/email.js +472 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +18 -0
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +343 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/insights-args.js +35 -0
- package/dist/insights.js +86 -0
- package/dist/loop.js +123 -24
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-registry.js +350 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +44 -6
- package/dist/memory.js +100 -33
- package/dist/orchestrate.js +49 -19
- package/dist/personality.js +58 -0
- package/dist/providers/codex.js +70 -36
- package/dist/providers/keys.js +1 -1
- package/dist/providers/models.js +1 -1
- package/dist/providers/registry.js +14 -47
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +75 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session.js +93 -7
- package/dist/skill-install.js +29 -12
- package/dist/support-dump.js +175 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +923 -9
- package/dist/tools/read.js +16 -4
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +217 -13
- package/dist/tools/task.js +18 -7
- package/dist/tools/timeout.js +21 -3
- package/dist/trust.js +11 -1
- package/dist/ui/app.js +48 -8
- package/dist/ui/history.js +37 -5
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/setup.js +17 -4
- package/dist/update.js +24 -11
- package/dist/worktree.js +175 -4
- package/package.json +4 -4
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +3 -1
- package/second-brain/Projects/sanook-cli/_Index.md +26 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/_Index.md +6 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +22 -3
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FOLDERS } from './brain.js';
|
|
4
|
+
import { validateFinalGateContent } from './brain-final.js';
|
|
5
|
+
import { checkBrainFolders, checkSearchIndexFreshness, checkVaultStructureMap } from './brain-doctor.js';
|
|
6
|
+
import { inspectBrainContext } from './brain-context.js';
|
|
7
|
+
import { search } from './search/engine.js';
|
|
8
|
+
import { INDEX_PATH } from './search/store.js';
|
|
9
|
+
const STATIC_CASES = ['SB-01', 'SB-02', 'SB-03', 'SB-04', 'SB-05', 'SB-06', 'SB-07', 'SB-08', 'SB-09', 'SB-10'];
|
|
10
|
+
const RETRIEVAL_CASES = [
|
|
11
|
+
{ id: 'RET-01', query: 'memory write protocol merge dont append', expectedPath: 'Shared/Rules/memory-write-protocol.md' },
|
|
12
|
+
{ id: 'RET-02', query: 'context assembly policy small context', expectedPath: 'Shared/Rules/context-assembly-policy.md' },
|
|
13
|
+
{ id: 'RET-03', query: 'quality ledger retrieval hit grounded', expectedPath: 'Evals/quality-ledger.md' },
|
|
14
|
+
];
|
|
15
|
+
async function fileExists(path) {
|
|
16
|
+
try {
|
|
17
|
+
return (await stat(path)).isFile();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function readText(path) {
|
|
24
|
+
try {
|
|
25
|
+
return await readFile(path, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function caseResult(id, title, passed, evidence = [], details = [], partial = false) {
|
|
32
|
+
const status = passed ? 'pass' : partial ? 'partial' : 'fail';
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
title,
|
|
36
|
+
status,
|
|
37
|
+
score: status === 'pass' ? 1 : status === 'partial' ? 0.5 : 0,
|
|
38
|
+
maxScore: 1,
|
|
39
|
+
evidence,
|
|
40
|
+
details,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function summarize(cases, brainPath) {
|
|
44
|
+
const score = cases.reduce((sum, item) => sum + item.score, 0);
|
|
45
|
+
const maxScore = cases.reduce((sum, item) => sum + item.maxScore, 0);
|
|
46
|
+
const percent = maxScore ? (score / maxScore) * 100 : 0;
|
|
47
|
+
return { ok: cases.every((item) => item.status === 'pass'), brainPath, score, maxScore, percent, cases };
|
|
48
|
+
}
|
|
49
|
+
async function benchmarkFileCase(brainPath) {
|
|
50
|
+
const path = join(brainPath, 'Evals', 'second-brain-benchmarks.md');
|
|
51
|
+
const content = await readText(path);
|
|
52
|
+
if (!content)
|
|
53
|
+
return caseResult('SB-00', 'Benchmark file exists', false, [path]);
|
|
54
|
+
const missing = STATIC_CASES.filter((id) => !content.includes(id));
|
|
55
|
+
return caseResult('SB-00', 'Benchmark file exists and names static cases', missing.length === 0, [path], missing.length ? [`missing case ids: ${missing.join(', ')}`] : [], missing.length > 0 && missing.length < STATIC_CASES.length);
|
|
56
|
+
}
|
|
57
|
+
async function routingCase(brainPath) {
|
|
58
|
+
const folderCheck = await checkBrainFolders(brainPath);
|
|
59
|
+
const mapCheck = await checkVaultStructureMap(brainPath);
|
|
60
|
+
const missingIndexes = [];
|
|
61
|
+
for (const folder of FOLDERS) {
|
|
62
|
+
if (!(await fileExists(join(brainPath, folder.dir, '_Index.md'))))
|
|
63
|
+
missingIndexes.push(`${folder.dir}/_Index.md`);
|
|
64
|
+
}
|
|
65
|
+
const pass = folderCheck.status === 'pass' && mapCheck.status === 'pass' && missingIndexes.length === 0;
|
|
66
|
+
return caseResult('SB-02', 'Routing map and destination indexes are complete', pass, [join(brainPath, 'Vault Structure Map.md')], [...(folderCheck.details ?? []), ...(mapCheck.details ?? []), ...missingIndexes], folderCheck.status === 'pass' || mapCheck.status === 'pass');
|
|
67
|
+
}
|
|
68
|
+
async function requiredFilesCase(brainPath, id, title, relPaths, tokens = []) {
|
|
69
|
+
const missing = [];
|
|
70
|
+
const evidence = [];
|
|
71
|
+
const tokenMisses = [];
|
|
72
|
+
for (const rel of relPaths) {
|
|
73
|
+
const path = join(brainPath, rel);
|
|
74
|
+
const content = await readText(path);
|
|
75
|
+
if (!content) {
|
|
76
|
+
missing.push(rel);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
evidence.push(path);
|
|
80
|
+
for (const token of tokens) {
|
|
81
|
+
if (!content.toLowerCase().includes(token.toLowerCase()))
|
|
82
|
+
tokenMisses.push(`${rel}: missing "${token}"`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const details = [...missing.map((m) => `missing ${m}`), ...tokenMisses];
|
|
86
|
+
return caseResult(id, title, details.length === 0, evidence, details, evidence.length > 0 && missing.length < relPaths.length);
|
|
87
|
+
}
|
|
88
|
+
async function finalGateCase(brainPath) {
|
|
89
|
+
const relPaths = ['Templates/final.md', 'Templates/final-lite.md', 'Shared/Tech-Standards/verification-standard.md'];
|
|
90
|
+
const evidence = [];
|
|
91
|
+
const details = [];
|
|
92
|
+
for (const rel of relPaths) {
|
|
93
|
+
const path = join(brainPath, rel);
|
|
94
|
+
const content = await readText(path);
|
|
95
|
+
if (!content) {
|
|
96
|
+
details.push(`missing ${rel}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
evidence.push(path);
|
|
100
|
+
if (!content.includes('If a row has no evidence'))
|
|
101
|
+
details.push(`${rel}: missing evidence rule`);
|
|
102
|
+
}
|
|
103
|
+
for (const rel of ['Templates/final.md', 'Templates/final-lite.md']) {
|
|
104
|
+
const content = await readText(join(brainPath, rel));
|
|
105
|
+
if (!content)
|
|
106
|
+
continue;
|
|
107
|
+
const validation = validateFinalGateContent(content.replace('note_type: template', 'note_type: final-gate'));
|
|
108
|
+
const structuralWarnings = validation.warnings.filter((warning) => warning.startsWith('Missing'));
|
|
109
|
+
details.push(...structuralWarnings.map((warning) => `${rel}: ${warning}`));
|
|
110
|
+
}
|
|
111
|
+
return caseResult('SB-FINAL', 'Final gate templates and validator contract exist', details.length === 0, evidence, details, evidence.length > 0);
|
|
112
|
+
}
|
|
113
|
+
async function contextCase(brainPath, indexPath) {
|
|
114
|
+
const report = await inspectBrainContext({ brainPath, indexPath });
|
|
115
|
+
const structuralWarnings = report.warnings.filter((warning) => !warning.includes('Search index is missing') && !warning.includes('Search index is older'));
|
|
116
|
+
return caseResult('SB-09', 'Hot context assembles without missing or oversized sources', report.ok && report.contextChars > 0 && structuralWarnings.length === 0, report.sources.map((source) => source.path), structuralWarnings, report.contextChars > 0);
|
|
117
|
+
}
|
|
118
|
+
async function indexCase(brainPath, indexPath) {
|
|
119
|
+
const check = await checkSearchIndexFreshness(brainPath, indexPath);
|
|
120
|
+
return caseResult('SB-IDX', 'Search index exists and is fresh enough', check.status === 'pass', [indexPath], check.status === 'pass' ? [] : [check.message, ...(check.details ?? [])], check.status === 'warn');
|
|
121
|
+
}
|
|
122
|
+
async function retrievalCase(brainPath, item, searchImpl) {
|
|
123
|
+
const res = await searchImpl(item.query, { mode: 'fts', limit: 5, sources: ['vault'] });
|
|
124
|
+
const hit = res.hits.find((candidate) => candidate.path === item.expectedPath);
|
|
125
|
+
return caseResult(item.id, `Retrieval finds ${item.expectedPath}`, !!hit, hit ? [join(brainPath, hit.path ?? item.expectedPath)] : [], hit ? [] : [`query="${item.query}" did not return ${item.expectedPath}`], res.hits.length > 0);
|
|
126
|
+
}
|
|
127
|
+
export async function runBrainEval(options = {}) {
|
|
128
|
+
const brainPath = options.brainPath;
|
|
129
|
+
if (!brainPath) {
|
|
130
|
+
return summarize([
|
|
131
|
+
caseResult('SB-CONFIG', 'Second-brain path is configured', false, [], ['Run `sanook brain init [path]` first.']),
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
if (!(await stat(brainPath)).isDirectory()) {
|
|
136
|
+
return summarize([
|
|
137
|
+
caseResult('SB-CONFIG', 'Second-brain path is a directory', false, [brainPath], ['Configured brainPath is not a directory.']),
|
|
138
|
+
], brainPath);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return summarize([
|
|
143
|
+
caseResult('SB-CONFIG', 'Second-brain path exists', false, [brainPath], ['Configured brainPath does not exist.']),
|
|
144
|
+
], brainPath);
|
|
145
|
+
}
|
|
146
|
+
const indexPath = options.indexPath ?? INDEX_PATH;
|
|
147
|
+
const cases = [
|
|
148
|
+
await benchmarkFileCase(brainPath),
|
|
149
|
+
await requiredFilesCase(brainPath, 'SB-01', 'AI context entrypoint exists', ['Shared/AI-Context-Index.md'], ['Vault Structure Map']),
|
|
150
|
+
await routingCase(brainPath),
|
|
151
|
+
await requiredFilesCase(brainPath, 'SB-03', 'Memory write protocol is documented', ['Shared/Rules/memory-write-protocol.md'], ['ADD', 'UPDATE', 'DELETE', 'NOOP']),
|
|
152
|
+
await requiredFilesCase(brainPath, 'SB-04', 'External ingest quarantine/provenance path exists', ['Runbooks/ingest-quarantine.md', 'Shared/Provenance/ingest-log.md']),
|
|
153
|
+
await requiredFilesCase(brainPath, 'SB-05', 'Coding verification standard exists', ['Shared/Tech-Standards/verification-standard.md']),
|
|
154
|
+
await requiredFilesCase(brainPath, 'SB-06', 'Owner-facing response examples exist', ['Shared/User-Memory/response-examples.md']),
|
|
155
|
+
await requiredFilesCase(brainPath, 'SB-07', 'Framework improvement evidence paths exist', ['Evals/quality-ledger.md', 'Research/_Index.md', 'Sessions/_Index.md']),
|
|
156
|
+
await requiredFilesCase(brainPath, 'SB-08', 'Multi-agent coordination paths exist', ['Shared/Coordination/task-board.md', 'Shared/Coordination/task-board/_Index.md']),
|
|
157
|
+
await contextCase(brainPath, indexPath),
|
|
158
|
+
await requiredFilesCase(brainPath, 'SB-10', 'Learning loop ledger and session index exist', ['Evals/quality-ledger.md', 'Sessions/_Index.md']),
|
|
159
|
+
await finalGateCase(brainPath),
|
|
160
|
+
await indexCase(brainPath, indexPath),
|
|
161
|
+
];
|
|
162
|
+
if (options.runRetrieval !== false) {
|
|
163
|
+
const searchImpl = options.searchImpl ?? search;
|
|
164
|
+
for (const item of RETRIEVAL_CASES)
|
|
165
|
+
cases.push(await retrievalCase(brainPath, item, searchImpl));
|
|
166
|
+
}
|
|
167
|
+
return summarize(cases, brainPath);
|
|
168
|
+
}
|
|
169
|
+
function statusLabel(status) {
|
|
170
|
+
return status.toUpperCase().padEnd(7);
|
|
171
|
+
}
|
|
172
|
+
export function formatBrainEvalReport(report) {
|
|
173
|
+
const lines = [
|
|
174
|
+
'Sanook brain eval',
|
|
175
|
+
`vault: ${report.brainPath ?? '(not configured)'}`,
|
|
176
|
+
`score: ${report.score.toFixed(1)}/${report.maxScore} (${report.percent.toFixed(1)}%)`,
|
|
177
|
+
];
|
|
178
|
+
for (const item of report.cases) {
|
|
179
|
+
lines.push(`[${statusLabel(item.status)}] ${item.id} — ${item.title}`);
|
|
180
|
+
for (const evidence of item.evidence ?? [])
|
|
181
|
+
lines.push(` ${evidence}`);
|
|
182
|
+
for (const detail of item.details ?? [])
|
|
183
|
+
lines.push(` - ${detail}`);
|
|
184
|
+
}
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
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
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const TEMPLATE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'second-brain', 'Templates');
|
|
8
|
+
const FINAL_HEADINGS = [
|
|
9
|
+
'## 1. Objective / DoD Lock',
|
|
10
|
+
'## 2. Evidence-Backed Checklist',
|
|
11
|
+
'## 3. Status Matrix',
|
|
12
|
+
'## 4. Evidence Matrix',
|
|
13
|
+
'## 5. Residual Risk',
|
|
14
|
+
'## 6. Change Summary Audit',
|
|
15
|
+
'## 7. Final Answer Draft',
|
|
16
|
+
'## 8. Second-Brain Routing / Memory Closeout',
|
|
17
|
+
];
|
|
18
|
+
const STATUS_TOKENS = new Set(['PASS', 'PARTIAL', 'FAIL', 'N/A', 'BLOCKED', 'TODO']);
|
|
19
|
+
const PASS_STATUS = 'PASS';
|
|
20
|
+
export function parseBrainFinalArgs(args) {
|
|
21
|
+
const positional = [];
|
|
22
|
+
const parsed = { fromDiff: false, lite: false, force: false };
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const arg = args[i];
|
|
25
|
+
if (arg === '--from-diff') {
|
|
26
|
+
parsed.fromDiff = true;
|
|
27
|
+
}
|
|
28
|
+
else if (arg === '--lite') {
|
|
29
|
+
parsed.lite = true;
|
|
30
|
+
}
|
|
31
|
+
else if (arg === '--force') {
|
|
32
|
+
parsed.force = true;
|
|
33
|
+
}
|
|
34
|
+
else if (arg === '--task') {
|
|
35
|
+
const value = args[++i];
|
|
36
|
+
if (!value?.trim())
|
|
37
|
+
return { ok: false, message: 'ต้องระบุค่าให้ --task' };
|
|
38
|
+
parsed.task = value.trim();
|
|
39
|
+
}
|
|
40
|
+
else if (arg.startsWith('--task=')) {
|
|
41
|
+
const value = arg.slice('--task='.length).trim();
|
|
42
|
+
if (!value)
|
|
43
|
+
return { ok: false, message: 'ต้องระบุค่าให้ --task' };
|
|
44
|
+
parsed.task = value;
|
|
45
|
+
}
|
|
46
|
+
else if (arg === '--output') {
|
|
47
|
+
const value = args[++i];
|
|
48
|
+
if (!value?.trim())
|
|
49
|
+
return { ok: false, message: 'ต้องระบุค่าให้ --output' };
|
|
50
|
+
parsed.output = value.trim();
|
|
51
|
+
}
|
|
52
|
+
else if (arg.startsWith('--output=')) {
|
|
53
|
+
const value = arg.slice('--output='.length).trim();
|
|
54
|
+
if (!value)
|
|
55
|
+
return { ok: false, message: 'ต้องระบุค่าให้ --output' };
|
|
56
|
+
parsed.output = value;
|
|
57
|
+
}
|
|
58
|
+
else if (arg === '--') {
|
|
59
|
+
positional.push(...args.slice(i + 1));
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
else if (arg.startsWith('-')) {
|
|
63
|
+
return { ok: false, message: `ไม่รู้จัก option: ${arg}` };
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
positional.push(arg);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const positionalTask = positional.join(' ').trim();
|
|
70
|
+
if (parsed.task && positionalTask)
|
|
71
|
+
return { ok: false, message: 'ระบุ task ได้ครั้งเดียว: ใช้ positional หรือ --task อย่างใดอย่างหนึ่ง' };
|
|
72
|
+
if (positionalTask)
|
|
73
|
+
parsed.task = positionalTask;
|
|
74
|
+
return { ok: true, value: parsed };
|
|
75
|
+
}
|
|
76
|
+
export async function createBrainFinal(options) {
|
|
77
|
+
const task = (options.task ?? 'current task').trim() || 'current task';
|
|
78
|
+
const template = options.lite ? 'final-lite.md' : 'final.md';
|
|
79
|
+
const warnings = [];
|
|
80
|
+
if (!options.brainPath) {
|
|
81
|
+
return { ok: false, task, template, fromDiff: !!options.fromDiff, diffFiles: [], indexed: false, warnings: ['No second-brain path is configured.'] };
|
|
82
|
+
}
|
|
83
|
+
const brainPath = resolve(options.brainPath);
|
|
84
|
+
if (!(await pathExistsAsDir(brainPath))) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
brainPath,
|
|
88
|
+
task,
|
|
89
|
+
template,
|
|
90
|
+
fromDiff: !!options.fromDiff,
|
|
91
|
+
diffFiles: [],
|
|
92
|
+
indexed: false,
|
|
93
|
+
warnings: ['Configured second-brain path does not exist or is not a directory.'],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const today = options.today ?? new Date().toISOString().slice(0, 10);
|
|
97
|
+
const slug = slugify(task || 'final-gate');
|
|
98
|
+
const output = outputPath(brainPath, options.output ?? join('Sessions', `${today}-${slug}-final.md`));
|
|
99
|
+
if (!output.ok) {
|
|
100
|
+
return { ok: false, brainPath, task, template, fromDiff: !!options.fromDiff, diffFiles: [], indexed: false, warnings: [output.message] };
|
|
101
|
+
}
|
|
102
|
+
if ((await fileExists(output.path)) && !options.force) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
brainPath,
|
|
106
|
+
path: output.path,
|
|
107
|
+
relPath: vaultRel(brainPath, output.path),
|
|
108
|
+
task,
|
|
109
|
+
template,
|
|
110
|
+
fromDiff: !!options.fromDiff,
|
|
111
|
+
diffFiles: [],
|
|
112
|
+
indexed: false,
|
|
113
|
+
warnings: ['Final gate file already exists. Re-run with --force or choose --output.'],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const diffFiles = options.fromDiff
|
|
117
|
+
? uniqueSorted(options.diffFiles ?? (await (options.diffProvider ?? defaultDiffFiles)()))
|
|
118
|
+
: [];
|
|
119
|
+
const raw = await readTemplate(brainPath, template);
|
|
120
|
+
let content = instantiateTemplate(raw, { today, task, template, fromDiff: !!options.fromDiff, diffFiles });
|
|
121
|
+
if (template === 'final-lite.md')
|
|
122
|
+
content = content.replace('note_type: template', 'note_type: final-gate-lite');
|
|
123
|
+
else
|
|
124
|
+
content = content.replace('note_type: template', 'note_type: final-gate');
|
|
125
|
+
await mkdir(dirname(output.path), { recursive: true });
|
|
126
|
+
await writeFile(output.path, content, 'utf8');
|
|
127
|
+
const relPath = vaultRel(brainPath, output.path);
|
|
128
|
+
const indexed = await maybeAppendSessionIndex(brainPath, relPath, task);
|
|
129
|
+
if (options.fromDiff && diffFiles.length === 0)
|
|
130
|
+
warnings.push('No git worktree changes were detected for --from-diff.');
|
|
131
|
+
return { ok: true, brainPath, path: output.path, relPath, task, template, fromDiff: !!options.fromDiff, diffFiles, indexed, warnings };
|
|
132
|
+
}
|
|
133
|
+
export function formatBrainFinalReport(report) {
|
|
134
|
+
const lines = ['Sanook brain final'];
|
|
135
|
+
lines.push(`vault: ${report.brainPath ?? '(not configured)'}`);
|
|
136
|
+
lines.push(`task: ${report.task}`);
|
|
137
|
+
lines.push(`template: ${report.template}`);
|
|
138
|
+
if (report.path)
|
|
139
|
+
lines.push(`created: ${report.path}`);
|
|
140
|
+
if (report.relPath)
|
|
141
|
+
lines.push(`link: [[${report.relPath.replace(/\.md$/i, '')}]]`);
|
|
142
|
+
if (report.fromDiff)
|
|
143
|
+
lines.push(`from-diff: ${report.diffFiles.length} file(s)`);
|
|
144
|
+
if (report.indexed)
|
|
145
|
+
lines.push('sessions-index: updated');
|
|
146
|
+
for (const warning of report.warnings)
|
|
147
|
+
lines.push(`warning: ${warning}`);
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
export async function listFinalGateFiles(brainPath) {
|
|
151
|
+
const sessionsDir = join(brainPath, 'Sessions');
|
|
152
|
+
const entries = await readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === '_Index.md')
|
|
156
|
+
continue;
|
|
157
|
+
const relPath = `Sessions/${entry.name}`;
|
|
158
|
+
const path = join(brainPath, relPath);
|
|
159
|
+
const content = await readText(path);
|
|
160
|
+
if (isFinalGateContent(content, entry.name))
|
|
161
|
+
out.push({ relPath, path, content });
|
|
162
|
+
}
|
|
163
|
+
return out.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
164
|
+
}
|
|
165
|
+
export function validateFinalGateContent(content) {
|
|
166
|
+
const warnings = [];
|
|
167
|
+
for (const heading of FINAL_HEADINGS) {
|
|
168
|
+
if (!content.includes(heading))
|
|
169
|
+
warnings.push(`Missing final gate section: ${heading}`);
|
|
170
|
+
}
|
|
171
|
+
if (!content.includes('## Final Verdict'))
|
|
172
|
+
warnings.push('Missing final verdict section.');
|
|
173
|
+
if (!content.includes('If a row has no evidence'))
|
|
174
|
+
warnings.push('Missing explicit evidence rule: "If a row has no evidence".');
|
|
175
|
+
const lines = content.split('\n');
|
|
176
|
+
for (let i = 0; i < lines.length; i++) {
|
|
177
|
+
if (!isTableHeader(lines, i))
|
|
178
|
+
continue;
|
|
179
|
+
const headers = tableCells(lines[i]).map((cell) => normalizeHeader(cell));
|
|
180
|
+
const statusIndex = headers.findIndex((header) => header === 'status' || header === 'verdict');
|
|
181
|
+
const evidenceIndex = headers.findIndex((header) => header === 'evidence' || header === 'important output' || header === 'scope proven');
|
|
182
|
+
if (statusIndex < 0)
|
|
183
|
+
continue;
|
|
184
|
+
for (let j = i + 2; j < lines.length && lines[j].trim().startsWith('|'); j++) {
|
|
185
|
+
const cells = tableCells(lines[j]);
|
|
186
|
+
if (!cells.length || isSeparatorRow(cells))
|
|
187
|
+
continue;
|
|
188
|
+
const status = normalizeStatus(cells[statusIndex]);
|
|
189
|
+
if (!status)
|
|
190
|
+
continue;
|
|
191
|
+
const rowName = cells[0]?.replace(/`/g, '').trim() || `row ${j + 1}`;
|
|
192
|
+
if (status === 'TODO')
|
|
193
|
+
warnings.push(`TODO status remains in final gate row: ${rowName}`);
|
|
194
|
+
if (status === PASS_STATUS && evidenceIndex >= 0 && isPlaceholderEvidence(cells[evidenceIndex] ?? '')) {
|
|
195
|
+
warnings.push(`PASS row has no evidence: ${rowName}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const placeholders = content.match(/<[^>\n]+>/g) ?? [];
|
|
200
|
+
const meaningfulPlaceholders = placeholders.filter(isTemplatePlaceholder);
|
|
201
|
+
if (meaningfulPlaceholders.length)
|
|
202
|
+
warnings.push(`Unfilled placeholder(s) remain: ${uniqueSorted(meaningfulPlaceholders).slice(0, 5).join(', ')}`);
|
|
203
|
+
return { ok: warnings.length === 0, warnings };
|
|
204
|
+
}
|
|
205
|
+
function instantiateTemplate(raw, options) {
|
|
206
|
+
const titleTask = options.task;
|
|
207
|
+
let content = raw
|
|
208
|
+
.replaceAll('YYYY-MM-DD', options.today)
|
|
209
|
+
.replaceAll('<task/topic>', titleTask)
|
|
210
|
+
.replace('tags: [template, final-gate, verification, dod]', 'tags: [final-gate, verification, dod]')
|
|
211
|
+
.replace('tags: [template, final-gate, verification, lite]', 'tags: [final-gate, verification, lite]')
|
|
212
|
+
.replace('parent: "[[Templates/_Index]]"', 'parent: "[[Sessions/_Index]]"')
|
|
213
|
+
.replace('up:: [[Templates/_Index]]', 'up:: [[Sessions/_Index]]')
|
|
214
|
+
.replace('<paste owner request or goal text here>', options.task);
|
|
215
|
+
if (options.fromDiff)
|
|
216
|
+
content = injectDiffEvidence(content, options.diffFiles);
|
|
217
|
+
return content;
|
|
218
|
+
}
|
|
219
|
+
function injectDiffEvidence(content, diffFiles) {
|
|
220
|
+
const fileRows = diffFiles.length
|
|
221
|
+
? diffFiles.map((file) => `| \`${file}\` | TODO: summarize change | \`git status --short\` / \`git diff -- ${file}\` |`).join('\n')
|
|
222
|
+
: '| `(no git worktree changes detected)` | N/A | `git status --short` |';
|
|
223
|
+
const commandRows = diffFiles.length
|
|
224
|
+
? '| `git status --short` | TODO | Populated by `--from-diff`; review before marking PASS. | Current worktree changed paths. |'
|
|
225
|
+
: '| `git status --short` | N/A | No changed paths detected by `--from-diff`. | Worktree scan only. |';
|
|
226
|
+
return replaceFilesChangedRows(content.replace('| `<command>` | TODO | | |', commandRows), fileRows);
|
|
227
|
+
}
|
|
228
|
+
function replaceFilesChangedRows(content, fileRows) {
|
|
229
|
+
const fullPattern = /(Files changed:\n\n\| File\/path \| Change summary \| Evidence \|\n\|---\|---\|---\|\n)\| `(?:<path>|<file>)` \| \| \|/;
|
|
230
|
+
const litePattern = /(Changed files:\n\n\| File \| Change summary \| Evidence \|\n\|---\|---\|---\|\n)\| `(?:<path>|<file>)` \| \| \|/;
|
|
231
|
+
return content.replace(fullPattern, `$1${fileRows}`).replace(litePattern, `$1${fileRows}`);
|
|
232
|
+
}
|
|
233
|
+
async function readTemplate(brainPath, template) {
|
|
234
|
+
const vaultTemplate = join(brainPath, 'Templates', template);
|
|
235
|
+
const fromVault = await readText(vaultTemplate);
|
|
236
|
+
if (fromVault)
|
|
237
|
+
return fromVault;
|
|
238
|
+
return readFile(join(TEMPLATE_ROOT, template), 'utf8');
|
|
239
|
+
}
|
|
240
|
+
async function defaultDiffFiles() {
|
|
241
|
+
try {
|
|
242
|
+
const { stdout } = await execFileAsync('git', ['status', '--porcelain=v1'], { cwd: process.cwd(), encoding: 'utf8' });
|
|
243
|
+
return stdout
|
|
244
|
+
.split('\n')
|
|
245
|
+
.map((line) => line.trimEnd())
|
|
246
|
+
.filter(Boolean)
|
|
247
|
+
.map(parsePorcelainPath)
|
|
248
|
+
.filter((path) => !!path);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function parsePorcelainPath(line) {
|
|
255
|
+
const raw = line.slice(3).trim();
|
|
256
|
+
if (!raw)
|
|
257
|
+
return undefined;
|
|
258
|
+
const renameIndex = raw.lastIndexOf(' -> ');
|
|
259
|
+
return renameIndex >= 0 ? raw.slice(renameIndex + 4) : raw;
|
|
260
|
+
}
|
|
261
|
+
function outputPath(brainPath, output) {
|
|
262
|
+
const path = resolve(isAbsolute(output) ? output : join(brainPath, output));
|
|
263
|
+
const rel = relative(brainPath, path);
|
|
264
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
265
|
+
return { ok: false, message: '--output must stay inside the configured second-brain vault.' };
|
|
266
|
+
return { ok: true, path };
|
|
267
|
+
}
|
|
268
|
+
async function maybeAppendSessionIndex(brainPath, relPath, task) {
|
|
269
|
+
if (!relPath.startsWith('Sessions/') || relPath.endsWith('/_Index.md'))
|
|
270
|
+
return false;
|
|
271
|
+
const indexPath = join(brainPath, 'Sessions', '_Index.md');
|
|
272
|
+
const content = await readText(indexPath);
|
|
273
|
+
if (!content)
|
|
274
|
+
return false;
|
|
275
|
+
const note = relPath.replace(/\.md$/i, '');
|
|
276
|
+
const link = `[[${note}]]`;
|
|
277
|
+
if (content.includes(link))
|
|
278
|
+
return false;
|
|
279
|
+
const line = `- ${link} — final gate: ${task}`;
|
|
280
|
+
const marker = '\nup:: [[Home]]';
|
|
281
|
+
const next = content.includes(marker) ? content.replace(marker, `\n${line}\n${marker}`) : `${content.trimEnd()}\n${line}\n`;
|
|
282
|
+
await writeFile(indexPath, next, 'utf8');
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
function slugify(value) {
|
|
286
|
+
const slug = value
|
|
287
|
+
.normalize('NFKD')
|
|
288
|
+
.toLowerCase()
|
|
289
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
|
|
290
|
+
.replace(/^-+|-+$/g, '')
|
|
291
|
+
.slice(0, 80)
|
|
292
|
+
.replace(/-+$/g, '');
|
|
293
|
+
return slug || 'final-gate';
|
|
294
|
+
}
|
|
295
|
+
function vaultRel(brainPath, path) {
|
|
296
|
+
return relative(brainPath, path).split(sep).join('/');
|
|
297
|
+
}
|
|
298
|
+
function isFinalGateContent(content, fileName) {
|
|
299
|
+
return (/^note_type:\s*final-gate/m.test(content) ||
|
|
300
|
+
/^note_type:\s*final-gate-lite/m.test(content) ||
|
|
301
|
+
(content.includes('## Final Verdict') && content.includes('## 1. Objective / DoD Lock')) ||
|
|
302
|
+
/-final\.md$/i.test(fileName));
|
|
303
|
+
}
|
|
304
|
+
function isTableHeader(lines, index) {
|
|
305
|
+
return lines[index]?.trim().startsWith('|') && isSeparatorRow(tableCells(lines[index + 1] ?? ''));
|
|
306
|
+
}
|
|
307
|
+
function tableCells(line) {
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
if (!trimmed.startsWith('|'))
|
|
310
|
+
return [];
|
|
311
|
+
return trimmed
|
|
312
|
+
.split('|')
|
|
313
|
+
.slice(1, -1)
|
|
314
|
+
.map((cell) => cell.trim());
|
|
315
|
+
}
|
|
316
|
+
function isSeparatorRow(cells) {
|
|
317
|
+
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
|
318
|
+
}
|
|
319
|
+
function normalizeHeader(value) {
|
|
320
|
+
return value.replace(/`/g, '').trim().toLowerCase();
|
|
321
|
+
}
|
|
322
|
+
function normalizeStatus(value) {
|
|
323
|
+
const cleaned = value.replace(/`/g, '').trim().toUpperCase();
|
|
324
|
+
return STATUS_TOKENS.has(cleaned) ? cleaned : undefined;
|
|
325
|
+
}
|
|
326
|
+
function isPlaceholderEvidence(value) {
|
|
327
|
+
const cleaned = value
|
|
328
|
+
.replace(/`/g, '')
|
|
329
|
+
.replace(/<[^>]+>/g, (item) => (isTemplatePlaceholder(item) ? '' : item))
|
|
330
|
+
.trim();
|
|
331
|
+
return !cleaned || cleaned === '-' || cleaned === '—' || /^TODO\b/i.test(cleaned) || cleaned === '|';
|
|
332
|
+
}
|
|
333
|
+
function isTemplatePlaceholder(value) {
|
|
334
|
+
const inner = value.slice(1, -1).trim();
|
|
335
|
+
if (!inner || inner.includes('e.g.'))
|
|
336
|
+
return false;
|
|
337
|
+
if (/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(inner))
|
|
338
|
+
return false;
|
|
339
|
+
if (/^mailto:\S+$/i.test(inner))
|
|
340
|
+
return false;
|
|
341
|
+
if (/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(inner))
|
|
342
|
+
return false;
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
async function pathExistsAsDir(path) {
|
|
346
|
+
try {
|
|
347
|
+
return (await stat(path)).isDirectory();
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async function fileExists(path) {
|
|
354
|
+
try {
|
|
355
|
+
return (await stat(path)).isFile();
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function readText(path) {
|
|
362
|
+
try {
|
|
363
|
+
return await readFile(path, 'utf8');
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return '';
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function uniqueSorted(values) {
|
|
370
|
+
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
371
|
+
}
|