neurain 0.1.0-alpha.7 → 0.1.0-alpha.9

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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  - No unreleased changes recorded.
6
6
 
7
+ ## 0.1.0-alpha.9
8
+
9
+ - Output consistency (single render source): added `src/core/render.mjs` as the one place that renders user-facing command output, so every host shows the same block. `status`, `compile`, `sync`, and `capture` now build their output via `renderX(payload)`, expose it as `payload.rendered`, and return it as `{ text }` (the existing `digest.mjs` pattern). The MCP server's user-facing view tools (`neurain_session_status`, `neurain_compile`, `neurain_digest_preview`) now return that rendered markdown block instead of `JSON.stringify(payload)`, so an MCP host and the CLI emit byte-identical bytes; diagnostic/eval MCP tools still return JSON. Output is clean GitHub-flavored markdown tables, ASCII only, with status as words (ok/warn/missing/blocked) and no status glyphs or dashes. The `--json` payload shape is unchanged (machine consumers unaffected). Added `test/render.test.mjs` (per-renderer ascii/table/determinism, CLI-text == payload.rendered, MCP returns markdown not JSON). npm test 167/167, readiness 100.
10
+
11
+
12
+ ## 0.1.0-alpha.8
13
+
14
+ - Privacy (exact recall freshness): the exact-token branch reads from the SQLite FTS index, which is a cache rebuilt only on demand, so a markdown file that was public when indexed but has since turned `sensitivity: private`, been deleted, or gained a secret could linger in `recall search` and `hybrid-search` exact results until the next rebuild. `searchRecall` now re-gates the returned markdown paths against the CURRENT files (same private/secret/exists gate the markdown branches already apply) and drops any that are no longer safe. Only the top-K returned paths are re-read, so the exact branch stays fast (~4ms warm); a fresh index drops nothing, so results are unchanged (golden 9/9 identical). Event and receipt rows keep their own collection-time gating and pass through. Added `test/perf_recall_equivalence.test.mjs` coverage that a public-then-private and a deleted file do not surface from a stale index.
15
+
16
+
7
17
  ## 0.1.0-alpha.7
8
18
 
9
19
  - Hardening (recall perf, from an adversarial review): lock the "byte-identical results" claim and tighten the fast-path contracts, with no change to ranking/scores (golden-identical).
package/README.md CHANGED
@@ -204,7 +204,7 @@ It exposes read/capture/scan/preview tools only. It does not silently compile, p
204
204
 
205
205
  ## Status
206
206
 
207
- This is `0.1.0-alpha.7`. It is not a public SaaS GA release. The alpha exists to prove installability, local-first onboarding, Codex, Claude, Gemini, and Runtime connectivity, plus safety receipts.
207
+ This is `0.1.0-alpha.9`. It is not a public SaaS GA release. The alpha exists to prove installability, local-first onboarding, Codex, Claude, Gemini, and Runtime connectivity, plus safety receipts.
208
208
 
209
209
  Alpha publish command:
210
210
 
@@ -1,9 +1,9 @@
1
1
  # Development Status
2
2
 
3
3
  Version: v0.1
4
- Last updated: 2026-06-20 KST
5
- Package: `neurain@0.1.0-alpha.7`
6
- Latest documented commit: `18bbb9f perf(recall): lock byte-identical claim in CI + harden fast-path contracts`
4
+ Last updated: 2026-06-21 KST
5
+ Package: `neurain@0.1.0-alpha.9`
6
+ Latest documented commit: `291ba2e feat(render): single engine render source; MCP relays rendered markdown`
7
7
 
8
8
  This document is the canonical product development snapshot for the public package. It tracks what is shipped, what has evidence, and what must not be claimed yet.
9
9
 
@@ -1,9 +1,9 @@
1
1
  # 개발 진행 상태
2
2
 
3
3
  Version: v0.1
4
- Last updated: 2026-06-20 KST
5
- Package: `neurain@0.1.0-alpha.7`
6
- Latest documented commit: `18bbb9f perf(recall): lock byte-identical claim in CI + harden fast-path contracts`
4
+ Last updated: 2026-06-21 KST
5
+ Package: `neurain@0.1.0-alpha.9`
6
+ Latest documented commit: `291ba2e feat(render): single engine render source; MCP relays rendered markdown`
7
7
 
8
8
  이 문서는 public package 기준의 canonical 개발 상태 스냅샷입니다. 무엇이 shipped인지, 어떤 증거가 있는지, 아직 주장하면 안 되는 것이 무엇인지 함께 기록합니다.
9
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neurain",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "Local-first Neurain Knowledge OS CLI and MCP connector.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -21,6 +21,7 @@ import { firstLineTitle, inferFlushLevelFromEnvelope, inferTargetLayerFromIntent
21
21
  import { resolveCanonicalPath } from './resolve_target.mjs';
22
22
  import { folderForSourceType, makeEnvelope, readArgInput, renderCaptureMarkdown, slugify } from './envelope.mjs';
23
23
  import { withFileLock, atomicWriteJson } from './durable.mjs';
24
+ import { renderSave } from './render.mjs';
24
25
  import { stageAndPromote } from './stage.mjs';
25
26
  import { loadSessionState, pendingCountForSession } from './vault_state.mjs';
26
27
  import { appendWikiLog } from './wiki_log.mjs';
@@ -242,27 +243,7 @@ export async function captureCommand(args) {
242
243
  }
243
244
 
244
245
  function done(args, payload) {
246
+ payload.rendered = renderSave(payload);
245
247
  if (args.json) return { json: true, payload };
246
- if (!payload.ok) {
247
- if (payload.refused) {
248
- const lines = [`⛔ BLOCKED (secret): ${payload.reason}`];
249
- for (const h of payload.hits || []) lines.push(` [${h.confidence}] ${h.type}: ${h.sample}`);
250
- return { text: lines.join('\n') };
251
- }
252
- return { text: `# Capture\n\n- ${payload.error}` };
253
- }
254
- if (payload.dry_run) {
255
- const e = payload.envelope;
256
- const lines = [`# Capture Dry Run`, '', `- Source ID: ${e.source_id}`, `- Raw path: ${e.raw_path}`, `- Areas: ${e.area_candidates.join(', ') || 'none'}`, `- Sensitivity: ${e.sensitivity}`, `- Intent: ${e.write_intent}`, `- Requires user decision: ${e.requires_user_decision ? 'yes' : 'no'}`];
257
- if (payload.duplicate_of) lines.push(`- Duplicate of: ${payload.duplicate_of.source_id} (${payload.duplicate_of.raw_path})`);
258
- if (e.extracted_text_chars != null) lines.push(`- Extracted text: ${e.extracted_text_chars} chars via ${e.extracted_method}`);
259
- if (e.overlap_candidates) lines.push(`- Overlaps existing: ${e.overlap_candidates.map((o) => o.path).join(', ')}`);
260
- if (e.numeric_conflict_candidates) lines.push(`- Numeric review (${e.numeric_conflict_candidates.length}): ${e.numeric_conflict_candidates.slice(0, 3).map((n) => `${n.label} new=${n.new_values.join('/')} vs existing=${n.existing_value}`).join('; ')}`);
261
- return { text: lines.join('\n') };
262
- }
263
- const lines = ['# Captured', '', `- Source ID: ${payload.source_id}`, `- Raw path: ${payload.raw_path}`];
264
- if (payload.asset_path) lines.push(`- Raw asset: ${payload.asset_path}`);
265
- lines.push(`- Envelope: ${payload.envelope_path}`, `- Requires user decision: ${payload.requires_user_decision ? 'yes' : 'no'}`);
266
- if (payload.session_state_delta) lines.push(`- Session-state delta (unapplied, pending_count=${payload.session_state_delta.patch.pending_count}) returned for the vault shuttle.`);
267
- return { text: lines.join('\n') };
248
+ return { text: payload.rendered };
268
249
  }
@@ -10,6 +10,7 @@ import { absPath, timestamp } from './fs.mjs';
10
10
  import { vaultConfig } from './config.mjs';
11
11
  import { resolveCanonicalPath } from './resolve_target.mjs';
12
12
  import { readJsonSafe, readJsonl } from './vault_state.mjs';
13
+ import { renderCompile } from './render.mjs';
13
14
 
14
15
  function manifestEntries(value) {
15
16
  return Object.values((value && value.files) || {});
@@ -310,32 +311,7 @@ export async function compileCommand(args) {
310
311
  : 'No safe compile target was selected. This desk shows candidates and does not read raw bodies.',
311
312
  };
312
313
 
314
+ payload.rendered = renderCompile(payload);
313
315
  if (args.json) return { json: true, payload };
314
- return { text: render(payload) };
315
- }
316
-
317
- function render(value) {
318
- const lines = ['# Neurain Compile Desk', ''];
319
- lines.push(`- Mode: ${value.mode}`);
320
- lines.push(`- Pending queue: ${value.queue.pending_count}`);
321
- lines.push(`- Safe queue: ${value.queue.safe_count}`);
322
- lines.push(`- Needs confirmation: ${value.queue.needs_confirmation_count}`);
323
- lines.push(`- Deep compile candidates: ${value.source_digest.deep_compile_candidate_count}`);
324
- lines.push(`- Raw full reads allowed: ${value.performance_policy.raw_full_reads_allowed}`);
325
- if (value.candidates && value.candidates.length) {
326
- lines.push('', `## Candidates (priority order): ${value.candidates.length}`);
327
- for (const c of value.candidates) {
328
- lines.push(` ${c.rank}. [${c.priority}] ${c.title || c.source_id || c.kind}${c.source_id ? ` (${c.source_id})` : ''}`);
329
- }
330
- }
331
- if (value.excluded && value.excluded.length) {
332
- lines.push('', `제외(확인필요/비공개) ${value.excluded.length}건: ${value.excluded.map((e) => e.title || e.source_id).join(', ')}`);
333
- }
334
- if (value.candidates && value.candidates.length) {
335
- lines.push('', '몇 건 정리할까요? (숫자 1–10; "전부"는 안전한 것 최대 10건 + 나머지 개수 공시)');
336
- } else if (!value.selected_target && value.excluded && value.excluded.length) {
337
- lines.push('', '정리 가능한 안전 후보가 없습니다. 위 제외 항목은 확인/승인 후에만 처리됩니다.');
338
- }
339
- lines.push(`- Next action: ${value.next_action}`);
340
- return lines.join('\n');
316
+ return { text: payload.rendered };
341
317
  }
@@ -7,7 +7,7 @@ import { inferSensitivityFromPath } from './safety.mjs';
7
7
  import { alternativeForm, getProvider, tokenize } from './semantic.mjs';
8
8
  import { recallConfig } from './config.mjs';
9
9
  import { createSensitivityResolver } from './labels.mjs';
10
- import { kindForPath, listRecallMarkdownFiles, recallConfigErrors, resolveAreaDir, safeToIndex, scopeForArea, scopeForPath, titleForText } from './recall_corpus.mjs';
10
+ import { filterCurrentlySafePaths, kindForPath, listRecallMarkdownFiles, recallConfigErrors, resolveAreaDir, safeToIndex, scopeForArea, scopeForPath, titleForText } from './recall_corpus.mjs';
11
11
  import { buildLexicalContext, lexicalSearchWithContext } from './recall_lexical.mjs';
12
12
  import { benchRecall, scorecardRecall } from './recall_bench.mjs';
13
13
 
@@ -158,17 +158,28 @@ export async function searchRecall(root, query, { top = 10, host = '', fallback
158
158
  ORDER BY rank ASC, d.path ASC
159
159
  LIMIT ?
160
160
  `).all(ftsQuery, String(host || ''), String(host || ''), scopeFilter, scopeFilter, limit);
161
- payload.results = rows.map((row) => ({
162
- path: row.path,
163
- kind: row.kind,
164
- host: row.host,
165
- scope: row.scope,
166
- sensitivity: row.sensitivity,
167
- title: row.title,
168
- snippet: row.snippet,
169
- source_hash: row.source_hash,
170
- score: Number((-Number(row.rank || 0)).toFixed(3)),
171
- }));
161
+ // The FTS index is a cache rebuilt only on demand, so re-gate the returned MARKDOWN
162
+ // paths against the CURRENT files: drop any row whose file has since turned private,
163
+ // been deleted, gained a secret, or left the corpus. Only the top-K paths are
164
+ // re-read, so the exact branch stays fast while never surfacing stale-private or
165
+ // deleted content. A fresh index drops nothing (results unchanged). Event/receipt
166
+ // rows use synthetic paths (a '#event' suffix or a non-.md receipt path) and carry
167
+ // their own collection-time gating, so they pass through unchanged.
168
+ const isMarkdownRow = (p) => p.endsWith('.md') && !p.includes('#');
169
+ const stillSafe = filterCurrentlySafePaths(root, recallConfig(root), rows.map((row) => row.path).filter(isMarkdownRow));
170
+ payload.results = rows
171
+ .filter((row) => !isMarkdownRow(row.path) || stillSafe.has(row.path))
172
+ .map((row) => ({
173
+ path: row.path,
174
+ kind: row.kind,
175
+ host: row.host,
176
+ scope: row.scope,
177
+ sensitivity: row.sensitivity,
178
+ title: row.title,
179
+ snippet: row.snippet,
180
+ source_hash: row.source_hash,
181
+ score: Number((-Number(row.rank || 0)).toFixed(3)),
182
+ }));
172
183
  } catch (error) {
173
184
  payload.ok = false;
174
185
  payload.error = error.message;
@@ -150,3 +150,29 @@ export function listRecallMarkdownFiles(root, recallCfg, { area = '' } = {}) {
150
150
  }
151
151
  return out;
152
152
  }
153
+
154
+ // Re-verify, against the CURRENT files, that specific paths still belong in the
155
+ // recall corpus right now (in-corpus + exists + not private + not secret/injection).
156
+ // The exact-token branch reads from a SQLite cache that is only rebuilt explicitly,
157
+ // so a file that was public when indexed but has since turned private or been
158
+ // deleted could otherwise linger in exact results until the next rebuild. Applying
159
+ // this same gate to the returned paths closes that staleness window. It re-reads
160
+ // only the handful of returned paths (top-K), not the whole corpus, so the exact
161
+ // branch stays fast. Returns a Set of the still-safe paths.
162
+ export function filterCurrentlySafePaths(root, recallCfg, rels) {
163
+ const resolver = createSensitivityResolver(root, recallCfg);
164
+ const matches = buildRecallPathMatcher(recallCfg);
165
+ const safe = new Set();
166
+ for (const rel of rels) {
167
+ if (safe.has(rel)) continue;
168
+ if (!matches(rel)) continue;
169
+ if (!isTextFile(rel)) continue;
170
+ const abs = path.join(root, rel);
171
+ if (!fs.existsSync(abs)) continue;
172
+ const text = readText(abs, '');
173
+ if (resolver.sensitivityFor(rel, text) === 'private') continue;
174
+ if (!safeToIndex(text)) continue;
175
+ safe.add(rel);
176
+ }
177
+ return safe;
178
+ }
@@ -0,0 +1,179 @@
1
+ // Single source of truth for user-facing command rendering.
2
+ //
3
+ // Every five-command output (and the read-only views) is produced HERE as one
4
+ // deterministic markdown block, then exposed by each command-function as
5
+ // `payload.rendered` AND `{ text }` (the digest.mjs pattern). The CLI, the MCP
6
+ // server, and the vault shuttles all relay that SAME string verbatim, so the
7
+ // output is byte-identical across every host (Claude Code, Codex, Gemini).
8
+ //
9
+ // Rules that keep the bytes host-identical and clean:
10
+ // - pure functions (payload -> string), no Date.now / no I/O. Timestamps and
11
+ // ids arrive already stamped in the payload.
12
+ // - GitHub-flavored markdown tables only, ASCII characters only. Status is a
13
+ // word (ok / warn / missing / blocked / none / yes / no), never a glyph.
14
+ // No status glyphs and no em/en dashes (the operating contract bans them and
15
+ // a determinism test rejects them).
16
+ // - fixed row and column order per command; an absent value renders as "-".
17
+
18
+ const BANNED = /[✓✗⚠⛔•∞—–]/; // checks/warn/stop/bullet/inf/em/en dash
19
+
20
+ export function escapeCell(v) {
21
+ return String(v ?? '').replace(/\r?\n/g, ' ').replace(/(?<!\\)\|/g, '\\|').trim();
22
+ }
23
+
24
+ export function mdTable(headers, rows) {
25
+ const head = `| ${headers.join(' | ')} |`;
26
+ const sep = `| ${headers.map(() => '---').join(' | ')} |`;
27
+ const body = rows.map((r) => `| ${r.map(escapeCell).join(' | ')} |`);
28
+ return [head, sep, ...body].join('\n');
29
+ }
30
+
31
+ // Two-column "field/value" table. pairs: [[label, value], ...]; absent -> "-".
32
+ export function fieldsTable(pairs) {
33
+ return mdTable(['항목', '값'], pairs.map(([k, v]) => [k, v === undefined || v === null || v === '' ? '-' : v]));
34
+ }
35
+
36
+ // title -> "# title"; fields -> 2-col table; sections -> "## title" + table|text;
37
+ // notes -> trailing lines. Returns a string with no trailing newline (the CLI/print
38
+ // layer appends one).
39
+ export function dashboard({ title, fields = [], sections = [], notes = [] }) {
40
+ const parts = [`# ${title}`];
41
+ if (fields.length) parts.push('', fieldsTable(fields));
42
+ for (const s of sections) {
43
+ parts.push('', `## ${s.title}`);
44
+ if (s.table) parts.push('', mdTable(s.table.headers, s.table.rows));
45
+ else if (s.text) parts.push('', s.text);
46
+ }
47
+ for (const n of notes) parts.push('', n);
48
+ return parts.join('\n');
49
+ }
50
+
51
+ // Throw if a rendered block contains a banned glyph/dash; used by renderers in a
52
+ // final guard and by the determinism test. Keeps the ASCII-table contract enforced.
53
+ export function assertClean(text) {
54
+ const m = String(text).match(BANNED);
55
+ if (m) throw new Error(`render: banned character ${JSON.stringify(m[0])} in output`);
56
+ return text;
57
+ }
58
+
59
+ const yn = (v) => (v ? 'yes' : 'no');
60
+
61
+ // ---- _compile (engine compile_desk payload) ----
62
+ export function renderCompile(p) {
63
+ const fields = [
64
+ ['mode', p.mode],
65
+ ['작업 대기', p.queue?.pending_count ?? 0],
66
+ ['안전(safe)', p.queue?.safe_count ?? 0],
67
+ ['확인 필요', p.queue?.needs_confirmation_count ?? 0],
68
+ ['deep 후보', p.source_digest?.deep_compile_candidate_count ?? 0],
69
+ ['raw 읽기 허용', yn(p.performance_policy?.raw_full_reads_allowed)],
70
+ ['다음 행동', p.next_action],
71
+ ];
72
+ const sections = [];
73
+ if (p.candidates?.length) {
74
+ sections.push({
75
+ title: `후보 (우선순위) ${p.candidates.length}건`,
76
+ table: {
77
+ headers: ['순위', '우선순위', '후보', 'source id'],
78
+ rows: p.candidates.map((c) => [c.rank, c.priority, c.title || c.kind || '-', c.source_id || '-']),
79
+ },
80
+ });
81
+ }
82
+ const notes = [];
83
+ if (p.excluded?.length) notes.push(`제외(확인필요/비공개) ${p.excluded.length}건: ${p.excluded.map((e) => e.title || e.source_id).join(', ')}`);
84
+ if (p.candidates?.length) notes.push('몇 건 정리할까요? (숫자 1-10; "전부"는 안전한 것 최대 10건)');
85
+ else if (!p.selected_target && p.excluded?.length) notes.push('정리 가능한 안전 후보가 없습니다. 위 제외 항목은 확인/승인 후에만 처리됩니다.');
86
+ return assertClean(dashboard({ title: 'Neurain Compile', fields, sections, notes }));
87
+ }
88
+
89
+ // ---- _sync (engine sync payload) ----
90
+ export function renderSync(p) {
91
+ if (!p.ok) return assertClean(`# Neurain Sync\n\n- ${p.error || p.reason}`);
92
+ const fields = [
93
+ ['세션', p.session_id],
94
+ ['mode', p.mode],
95
+ ['pulsed', yn(p.pulsed)],
96
+ ['작업 대기', p.queue?.pending_count ?? 0],
97
+ ['안전(safe)', p.queue?.safe_count ?? 0],
98
+ ['확인 필요', p.queue?.needs_review_count ?? 0],
99
+ ['충돌', p.queue?.conflict_count ?? 0],
100
+ ['durable wiki 쓰기', 'no'],
101
+ ['다음 권장', p.recommendation],
102
+ ];
103
+ return assertClean(dashboard({ title: 'Neurain Sync', fields }));
104
+ }
105
+
106
+ // ---- _save (engine capture_durable payload) ----
107
+ export function renderSave(p) {
108
+ if (!p.ok) {
109
+ if (p.refused) {
110
+ const notes = [`secret 차단(blocked): ${p.reason}`, ...(p.hits || []).map((h) => ` [${h.confidence}] ${h.type}: ${h.sample}`)];
111
+ return assertClean(dashboard({ title: 'Neurain Capture - blocked', notes }));
112
+ }
113
+ return assertClean(`# Neurain Capture\n\n- ${p.error}`);
114
+ }
115
+ if (p.dry_run) {
116
+ const e = p.envelope;
117
+ const fields = [
118
+ ['source id', e.source_id],
119
+ ['raw 경로', e.raw_path],
120
+ ['영역', e.area_candidates?.join(', ') || 'none'],
121
+ ['민감도', e.sensitivity],
122
+ ['intent', e.write_intent],
123
+ ['확인 필요', yn(e.requires_user_decision)],
124
+ ];
125
+ if (p.duplicate_of) fields.push(['중복', `${p.duplicate_of.source_id} (${p.duplicate_of.raw_path})`]);
126
+ if (e.extracted_text_chars != null) fields.push(['추출 글자수', `${e.extracted_text_chars} (${e.extracted_method})`]);
127
+ if (e.overlap_candidates) fields.push(['overlap', e.overlap_candidates.map((o) => o.path).join(', ')]);
128
+ if (e.numeric_conflict_candidates) fields.push(['numeric 검토', `${e.numeric_conflict_candidates.length}건`]);
129
+ return assertClean(dashboard({ title: 'Neurain Capture (dry run)', fields }));
130
+ }
131
+ const fields = [
132
+ ['source id', p.source_id],
133
+ ['raw 경로', p.raw_path],
134
+ ];
135
+ if (p.asset_path) fields.push(['raw asset', p.asset_path]);
136
+ fields.push(['envelope', p.envelope_path], ['확인 필요', yn(p.requires_user_decision)]);
137
+ return assertClean(dashboard({ title: 'Neurain Captured', fields }));
138
+ }
139
+
140
+ // ---- _status (normalized view; both the engine status command and the vault
141
+ // session-status tool build this view from their own payloads) ----
142
+ // view = { mode: 'boot'|'all'|'session', ...fields, sections?, notes? }
143
+ export function renderStatus(view) {
144
+ if (view.mode === 'all') {
145
+ return assertClean(dashboard({
146
+ title: 'Neurain Session Status',
147
+ fields: [['세션 수', view.session_count ?? 0]],
148
+ sections: view.sessions?.length
149
+ ? [{ title: '세션', table: { headers: ['세션', '상태', 'freshness', '작업 대기', 'handoff'], rows: view.sessions.map((s) => [s.session_id, s.status, s.freshness_status, s.pending_count, s.handoff_exists ? 'ok' : 'missing']) } }]
150
+ : [],
151
+ }));
152
+ }
153
+ if (view.mode === 'session') {
154
+ const fields = [
155
+ ['세션', view.session_id],
156
+ ['영역', view.area],
157
+ ['scope', view.scope],
158
+ ['작업 대기', view.pending_count ?? 0],
159
+ ['handoff', view.handoff_exists ? view.handoff_path : 'missing'],
160
+ ['area brief', view.area_brief_exists ? view.area_brief_path : 'missing'],
161
+ ['freshness', view.freshness_message],
162
+ ['다음 권장', view.next || '-'],
163
+ ];
164
+ return assertClean(dashboard({ title: 'Neurain Session Status', fields, notes: view.notes || [] }));
165
+ }
166
+ // boot
167
+ const fields = [
168
+ ['상태', view.state || 'ok'],
169
+ ['세션', view.session_count != null ? `${view.session_count}${view.fresh_count != null ? ` (fresh ${view.fresh_count} / stale ${view.stale_count})` : ''}` : '-'],
170
+ ['영역', view.areas?.length ? view.areas.join(', ') : '-'],
171
+ ['작업 대기', view.pending_text || (view.pending_count ? `${view.pending_count}` : '없음')],
172
+ ['지식화 후보', view.compile_candidate_count ?? 0],
173
+ ['정리 후보', view.tidy_candidate_count ?? 0],
174
+ ['구조 점검', view.structure_findings ?? 0],
175
+ ['지금 할 일', view.next || '-'],
176
+ ];
177
+ const notes = [...(view.notes || []), '자세한 점검: _status detail'];
178
+ return assertClean(dashboard({ title: 'Neurain Status', fields, notes }));
179
+ }
@@ -13,6 +13,7 @@ import { vaultConfig } from './config.mjs';
13
13
  import { areaBriefPath, extractMarkdownSection, freshnessFor, loadSessionState, pendingQueueRows, readJsonl, readTextAt } from './vault_state.mjs';
14
14
  import { compileCandidateSummary } from './source_digest.mjs';
15
15
  import { computeDigest, renderDigest } from './digest.mjs';
16
+ import { renderStatus } from './render.mjs';
16
17
  import { listLessons } from './lessons.mjs';
17
18
 
18
19
  function summarizeSession(root, vaultCfg, session, { staleDays, now }) {
@@ -108,28 +109,28 @@ export async function statusCommand(args) {
108
109
 
109
110
  if (!sessionId && !args.all) {
110
111
  const payload = bootStatus(root, vaultCfg, state, { staleDays, now });
112
+ payload.rendered = renderStatus({
113
+ mode: 'boot',
114
+ state: payload.ok ? 'ok' : '확인 필요',
115
+ session_count: payload.session_count,
116
+ areas: payload.active_areas,
117
+ pending_text: payload.total_pending_count
118
+ ? `${payload.total_pending_count}${payload.pending_requires_confirmation_count ? ` (승인 필요 ${payload.pending_requires_confirmation_count})` : ''}`
119
+ : '없음',
120
+ compile_candidate_count: payload.compile_candidate_count || 0,
121
+ next: payload.next_action,
122
+ notes: payload.stale_guidance ? [`참고: 오래된 세션 ${payload.stale_guidance.count}개는 오류가 아닙니다.`] : [],
123
+ });
111
124
  if (args.json) return { json: true, payload };
112
- return {
113
- text: [
114
- '# Neurain Status',
115
- '',
116
- `- OK: ${payload.ok ? 'yes' : 'needs attention'}`,
117
- `- Sessions: ${payload.session_count} | Areas: ${payload.active_areas.join(', ') || 'none'}`,
118
- `- Pending: ${payload.total_pending_count}${payload.pending_requires_confirmation_count ? ` (confirmation needed: ${payload.pending_requires_confirmation_count})` : ''}`,
119
- payload.compile_candidate_count ? `- Compile candidates: ${payload.compile_candidate_count}` : '',
120
- payload.stale_guidance ? `- Stale sessions: ${payload.stale_guidance.count} (not an error)` : '',
121
- `- Next: ${payload.next_action}`,
122
- ].filter(Boolean).join('\n'),
123
- };
125
+ return { text: payload.rendered };
124
126
  }
125
127
 
126
128
  if (args.all) {
127
129
  const sessions = Object.values(state.sessions || {}).map((s) => summarizeSession(root, vaultCfg, s, { staleDays, now }));
128
130
  const payload = { ok: true, command: 'status', durable_write: false, mode: 'all', updated_at: state.updated_at || '', count: sessions.length, sessions };
131
+ payload.rendered = renderStatus({ mode: 'all', session_count: payload.count, sessions });
129
132
  if (args.json) return { json: true, payload };
130
- return {
131
- text: ['# Session Status', '', `- Sessions: ${payload.count}`, ...sessions.map((s) => `- ${s.session_id}: ${s.status}, freshness ${s.freshness_status}, pending ${s.pending_count}, handoff ${s.handoff_exists ? 'ok' : 'missing'}`)].join('\n'),
132
- };
133
+ return { text: payload.rendered };
133
134
  }
134
135
 
135
136
  const session = state.sessions[String(sessionId)];
@@ -158,18 +159,20 @@ export async function statusCommand(args) {
158
159
  lesson_review: lessonReview(root),
159
160
  };
160
161
  // The engine never acks; a vault shim acks after delivering the rendered text.
162
+ const digestBlock = renderDigest(sinceLastVisit);
163
+ payload.rendered = renderStatus({
164
+ mode: 'session',
165
+ session_id: payload.session_id,
166
+ area: payload.area,
167
+ scope: payload.scope,
168
+ pending_count: payload.pending_count,
169
+ handoff_exists: payload.handoff_exists,
170
+ handoff_path: payload.handoff_path,
171
+ area_brief_exists: payload.area_brief_exists,
172
+ area_brief_path: payload.area_brief_path,
173
+ freshness_message: payload.freshness_message,
174
+ notes: digestBlock ? [digestBlock] : [],
175
+ });
161
176
  if (args.json) return { json: true, payload };
162
- return {
163
- text: [
164
- '# Session Status',
165
- '',
166
- `- Session: ${payload.session_id}`,
167
- `- Area: ${payload.area}`,
168
- `- Pending: ${payload.pending_count}`,
169
- `- Handoff: ${payload.handoff_exists ? payload.handoff_path : 'missing'}`,
170
- `- Area brief: ${payload.area_brief_exists ? payload.area_brief_path : 'missing'}`,
171
- `- Freshness: ${payload.freshness_message}`,
172
- renderDigest(sinceLastVisit) ? `\n${renderDigest(sinceLastVisit)}` : '',
173
- ].filter(Boolean).join('\n'),
174
- };
177
+ return { text: payload.rendered };
175
178
  }
package/src/core/sync.mjs CHANGED
@@ -10,6 +10,7 @@ import { vaultConfig } from './config.mjs';
10
10
  import { areaBriefPath, loadSessionState, readJsonl } from './vault_state.mjs';
11
11
  import { slugify } from './envelope.mjs';
12
12
  import { pulseSession } from './session_write.mjs';
13
+ import { renderSync } from './render.mjs';
13
14
 
14
15
  function readSummary(args, root) {
15
16
  if (typeof args.summary === 'string') return args.summary;
@@ -133,8 +134,7 @@ export async function syncCommand(args) {
133
134
  }
134
135
 
135
136
  function done(args, payload) {
137
+ payload.rendered = renderSync(payload);
136
138
  if (args.json) return { json: true, payload };
137
- if (!payload.ok) return { text: `# Neurain Sync\n\n- ${payload.error || payload.reason}` };
138
- const lines = ['# Neurain Sync', '', `- Session: ${payload.session_id}`, `- Mode: ${payload.mode}`, `- Pulsed: ${payload.pulsed ? 'yes' : 'no'}`, `- Pending: ${payload.queue.pending_count}`, `- Safe: ${payload.queue.safe_count}`, `- Needs review: ${payload.queue.needs_review_count}`, `- Conflicts: ${payload.queue.conflict_count}`, `- Durable wiki writes: no`, `- Recommendation: ${payload.recommendation}`];
139
- return { text: lines.join('\n') };
139
+ return { text: payload.rendered };
140
140
  }
@@ -739,11 +739,11 @@ export async function startMcpServer(args) {
739
739
  }
740
740
  if (name === 'neurain_session_status') {
741
741
  const out = await statusCommand({ _: [scopedRoot(input.root, root)], 'session-id': input.sessionId, all: input.all, 'stale-days': input.staleDays, json: true });
742
- return textResult(out.payload);
742
+ return renderedResult(out.payload);
743
743
  }
744
744
  if (name === 'neurain_digest_preview') {
745
745
  const out = await digestCommand({ _: [scopedRoot(input.root, root)], session: input.session, 'window-hours': input.windowHours, json: true });
746
- return textResult(out.payload);
746
+ return renderedResult(out.payload);
747
747
  }
748
748
  if (name === 'neurain_queue_view') {
749
749
  const out = await queueCommand({ _: [scopedRoot(input.root, root)], queue: input.queue, status: input.status, pending: input.pending, stats: input.stats, 'lossless-check': input.losslessCheck, json: true });
@@ -771,7 +771,7 @@ export async function startMcpServer(args) {
771
771
  }
772
772
  if (name === 'neurain_compile') {
773
773
  const out = await compileCommand({ _: [scopedRoot(input.root, root)], target: input.target, 'session-id': input.sessionId, top: input.top, manifest: input.manifest, queue: input.queue, json: true });
774
- return textResult(out.payload);
774
+ return renderedResult(out.payload);
775
775
  }
776
776
  throw new Error(`Unknown Neurain tool: ${name}`);
777
777
  });
@@ -837,6 +837,14 @@ function textResult(value) {
837
837
  };
838
838
  }
839
839
 
840
+ // User-facing command views relay the engine's single rendered block (payload.rendered)
841
+ // verbatim, so an MCP host shows the SAME markdown as the CLI. Falls back to JSON only
842
+ // if a payload has no rendered block. Diagnostic/eval tools keep textResult (JSON).
843
+ function renderedResult(payload) {
844
+ const text = payload?.rendered ?? JSON.stringify(payload, null, 2);
845
+ return { content: [{ type: 'text', text }] };
846
+ }
847
+
840
848
  function answerEvalScaledCounts(total) {
841
849
  const count = Math.max(10, Math.min(Number(total || 120), 500));
842
850
  const supportedCases = Math.max(1, Math.round(count * 0.42));