neurain 0.1.0-alpha.2 → 0.1.0-alpha.3
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 +10 -0
- package/README.md +17 -1
- package/docs/development-status.en.md +16 -5
- package/docs/development-status.kr.md +16 -5
- package/package.json +1 -1
- package/src/cli.mjs +13 -0
- package/src/core/adopt.mjs +6 -2
- package/src/core/capture_durable.mjs +3 -2
- package/src/core/compile_desk.mjs +27 -10
- package/src/core/file_loose.mjs +204 -0
- package/src/core/lint.mjs +4 -0
- package/src/core/onboard.mjs +3 -3
- package/src/core/organize.mjs +297 -0
- package/src/core/resolve_target.mjs +92 -0
- package/src/core/structure_audit.mjs +107 -0
- package/src/core/tidy.mjs +167 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
- No unreleased changes recorded.
|
|
6
6
|
|
|
7
|
+
## 0.1.0-alpha.3
|
|
8
|
+
|
|
9
|
+
- Destination resolver (`resolve_target.mjs`): `capture` and `compile` now derive one canonical target path from area, write intent, and sensitivity instead of leaving placement to the caller. Private, multi-area, and decision-required items still gate on the `<N>건 저장 진행` confirmation and are never auto-filed.
|
|
10
|
+
|
|
11
|
+
- Janitor (`neurain tidy`): read-only detection of provably-junk residue only (empty Obsidian `Untitled*.canvas/.base`, zero-byte daily notes, stray empty top-level directories); quarantine is recoverable (30-day trash). Registered areas, archives, and structural directories are never touched.
|
|
12
|
+
- Auto-filer (`neurain file`): finds loose durable markdown outside any canonical home and, gated by confirmation, relocates it recoverably and secret-gated; also surfaces read-only `raw/_inbox` routing proposals. Registered area roots and root-contract files are excluded.
|
|
13
|
+
- Structure audit (`neurain structure-audit`): advisory flags for area-adapter gaps, the `output/` vs `outputs/` split, and `sources_map` drift; composed into `lint`. Never writes and never fails a build.
|
|
14
|
+
- New-user organize (`neurain organize`): turns an unknown folder into a searchable area. Reuses the adopt scan, decides a per-file disposition, and on a gated apply scaffolds the area in copy mode (originals are never moved or deleted) and reindexes; hash-keyed receipt with `--rollback`. Secrets and already-structured KBs are protected.
|
|
15
|
+
- Organize and adopt preserve non-ASCII (for example Korean) folder names as area slugs.
|
|
16
|
+
|
|
7
17
|
## 0.1.0-alpha.2
|
|
8
18
|
|
|
9
19
|
- Documentation currency gate: README and development-status version strings are asserted against package.json in readiness; volatile metric snapshots de-hardcoded (verified green in CI, not pinned in docs).
|
package/README.md
CHANGED
|
@@ -176,6 +176,22 @@ Neurain scans first and recommends one of three modes:
|
|
|
176
176
|
|
|
177
177
|
Writes require an explicit confirmation phrase shown by the scan.
|
|
178
178
|
|
|
179
|
+
## Keeping the Vault Tidy
|
|
180
|
+
|
|
181
|
+
Neurain keeps a working vault organized through read-only detection plus gated, recoverable writes. Nothing here moves or deletes a file without an explicit confirmation phrase, and registered areas, archives, and root-contract files are never touched.
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npx neurain tidy ~/NeurainDemo # report provably-junk residue (read-only)
|
|
185
|
+
npx neurain structure-audit ~/NeurainDemo # advisory structure flags (read-only)
|
|
186
|
+
npx neurain file ~/NeurainDemo # propose homes for loose docs (read-only)
|
|
187
|
+
npx neurain organize ~/SomeUnknownFolder --dry-run # turn an unknown folder into a searchable area
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- `tidy` detects only provably-junk residue (empty Obsidian `Untitled*` canvas/base files, zero-byte daily notes, stray empty top-level directories) and quarantines recoverably.
|
|
191
|
+
- `structure-audit` is advisory only: it flags area-adapter gaps, the `output/` vs `outputs/` split, and `sources_map` drift, and never fails a build.
|
|
192
|
+
- `file` relocates loose durable markdown into its canonical home behind a confirmation gate, and surfaces `raw/_inbox` routing proposals.
|
|
193
|
+
- `organize` turns an unknown folder into a searchable area in copy mode (originals are never moved or deleted), with a hash-keyed receipt and `--rollback`. Secrets and already-structured knowledge bases are protected.
|
|
194
|
+
|
|
179
195
|
## MCP
|
|
180
196
|
|
|
181
197
|
The alpha MCP server is stdio-only:
|
|
@@ -188,7 +204,7 @@ It exposes read/capture/scan/preview tools only. It does not silently compile, p
|
|
|
188
204
|
|
|
189
205
|
## Status
|
|
190
206
|
|
|
191
|
-
This is `0.1.0-alpha.
|
|
207
|
+
This is `0.1.0-alpha.3`. 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.
|
|
192
208
|
|
|
193
209
|
Alpha publish command:
|
|
194
210
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Development Status
|
|
2
2
|
|
|
3
3
|
Version: v0.1
|
|
4
|
-
Last updated: 2026-06-
|
|
5
|
-
Package: `neurain@0.1.0-alpha.
|
|
6
|
-
Latest documented commit: `
|
|
4
|
+
Last updated: 2026-06-19 KST
|
|
5
|
+
Package: `neurain@0.1.0-alpha.3`
|
|
6
|
+
Latest documented commit: `f3d3849 docs: document auto-organization wave (E27) for alpha.3`
|
|
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
|
|
|
@@ -91,7 +91,7 @@ Status: shipped as publish-ready alpha.
|
|
|
91
91
|
- README, quickstart, safety, privacy, support, pricing, troubleshooting, and release checklist exist.
|
|
92
92
|
- CI workflow, issue templates, PR template, license, and security doc exist.
|
|
93
93
|
- Full readiness verifies npm audit, pack dry-run, and temporary tarball install smoke.
|
|
94
|
-
-
|
|
94
|
+
- Status: npm publish is done. `neurain@0.1.0-alpha.3` is published under the `alpha` tag and the package name is secured.
|
|
95
95
|
|
|
96
96
|
### E26: Thin MCP Connector
|
|
97
97
|
|
|
@@ -104,6 +104,18 @@ Status: shipped.
|
|
|
104
104
|
- MCP server exposes bounded alpha tools for status, search, scan, recall, eval, live-case scaffold, lesson preview, scheduler preview, lifecycle report/eval, and wrap preview.
|
|
105
105
|
- MCP does not expose silent durable wiki writes, lifecycle emit, daemon run/stop, curator write, recall rebuild write, or lesson promotion.
|
|
106
106
|
|
|
107
|
+
### E27: Auto-Organization System
|
|
108
|
+
|
|
109
|
+
Status: shipped.
|
|
110
|
+
|
|
111
|
+
- CLI: `neurain tidy`, `neurain file`, `neurain structure-audit`, `neurain organize`.
|
|
112
|
+
- Destination resolver: `capture` and `compile` derive one canonical target path from area, write intent, and sensitivity. Private, multi-area, and decision-required items still gate on the `<N>건 저장 진행` confirmation and are never auto-filed.
|
|
113
|
+
- Janitor (`tidy`) detects provably-junk residue only (empty Obsidian canvas/base files, zero-byte daily notes, stray empty top-level directories). Read-only; quarantine is recoverable. Registered areas, archives, and structural directories are never touched.
|
|
114
|
+
- Auto-filer (`file`) relocates loose durable markdown into its canonical home behind a confirmation gate, recoverably and secret-gated, and surfaces read-only `raw/_inbox` routing proposals.
|
|
115
|
+
- Structure audit (`structure-audit`) is advisory: it flags area-adapter gaps, the `output/` vs `outputs/` split, and `sources_map` drift, and composes into `lint` without ever writing or failing a build.
|
|
116
|
+
- New-user organize (`organize`) turns an unknown folder into a searchable area in copy mode (originals are never moved or deleted), with a hash-keyed receipt and `--rollback`. Secrets and already-structured KBs are protected. Non-ASCII (for example Korean) folder names are preserved as area slugs.
|
|
117
|
+
- Honest scope: these are vault-hygiene helpers. Detection is read-only and every relocation is gated and recoverable; Neurain does not autonomously reorganize a user's folder.
|
|
118
|
+
|
|
107
119
|
## Current Connector Truth Table
|
|
108
120
|
|
|
109
121
|
| Host | MCP connection | Lifecycle hook preview | Notes |
|
|
@@ -119,7 +131,6 @@ Status: shipped.
|
|
|
119
131
|
- Non-developer GUI.
|
|
120
132
|
- Hosted control plane.
|
|
121
133
|
- Public Trust Center and status page.
|
|
122
|
-
- npm publish or package name reservation.
|
|
123
134
|
- Public GitHub publication if the repo is intentionally kept private during beta.
|
|
124
135
|
|
|
125
136
|
## Documentation Maintenance Rule
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# 개발 진행 상태
|
|
2
2
|
|
|
3
3
|
Version: v0.1
|
|
4
|
-
Last updated: 2026-06-
|
|
5
|
-
Package: `neurain@0.1.0-alpha.
|
|
6
|
-
Latest documented commit: `
|
|
4
|
+
Last updated: 2026-06-19 KST
|
|
5
|
+
Package: `neurain@0.1.0-alpha.3`
|
|
6
|
+
Latest documented commit: `f3d3849 docs: document auto-organization wave (E27) for alpha.3`
|
|
7
7
|
|
|
8
8
|
이 문서는 public package 기준의 canonical 개발 상태 스냅샷입니다. 무엇이 shipped인지, 어떤 증거가 있는지, 아직 주장하면 안 되는 것이 무엇인지 함께 기록합니다.
|
|
9
9
|
|
|
@@ -91,7 +91,7 @@ Status: publish-ready alpha로 shipped.
|
|
|
91
91
|
- README, quickstart, safety, privacy, support, pricing, troubleshooting, release checklist가 존재합니다.
|
|
92
92
|
- CI workflow, issue template, PR template, license, security doc이 존재합니다.
|
|
93
93
|
- full readiness가 npm audit, pack dry-run, temporary tarball install smoke를 검증합니다.
|
|
94
|
-
-
|
|
94
|
+
- 상태: npm publish 완료. `neurain@0.1.0-alpha.3`가 `alpha` 태그로 발행됐고 패키지 이름도 선점됨.
|
|
95
95
|
|
|
96
96
|
### E26: Thin MCP Connector
|
|
97
97
|
|
|
@@ -104,6 +104,18 @@ Status: shipped.
|
|
|
104
104
|
- MCP server는 status, search, scan, recall, eval, live-case scaffold, lesson preview, scheduler preview, lifecycle report/eval, wrap preview용 bounded alpha tool을 노출합니다.
|
|
105
105
|
- MCP는 silent durable wiki write, lifecycle emit, daemon run/stop, curator write, recall rebuild write, lesson promotion을 노출하지 않습니다.
|
|
106
106
|
|
|
107
|
+
### E27: 자동 정리 시스템
|
|
108
|
+
|
|
109
|
+
Status: shipped.
|
|
110
|
+
|
|
111
|
+
- CLI: `neurain tidy`, `neurain file`, `neurain structure-audit`, `neurain organize`.
|
|
112
|
+
- 목적지 리졸버: `capture`·`compile`이 영역·쓰기의도·민감도로부터 단일 canonical 목적지 경로를 도출합니다. private·다중영역·결정필요 항목은 여전히 `<N>건 저장 진행` 확인을 거치며 절대 자동 파일링되지 않습니다.
|
|
113
|
+
- Janitor(`tidy`)는 증명 가능한 잔여물만 탐지합니다(빈 Obsidian canvas/base 파일, 0바이트 데일리노트, 빈 최상위 디렉터리). 읽기전용이고 격리는 복구 가능하며, 등록된 영역·아카이브·구조 디렉터리는 건드리지 않습니다.
|
|
114
|
+
- Auto-filer(`file`)는 떠도는 durable 마크다운을 확인 게이트 뒤에서 복구 가능·시크릿 게이트로 canonical 위치로 옮기고, `raw/_inbox` 라우팅 제안을 읽기전용으로 보여줍니다.
|
|
115
|
+
- 구조 점검(`structure-audit`)은 권고용입니다: 영역 어댑터 결손, `output/` 대 `outputs/` 분리, `sources_map` 드리프트를 표시하고 `lint`에 합성되며, 쓰기나 빌드 실패를 일으키지 않습니다.
|
|
116
|
+
- 신규 사용자 organize(`organize`)는 미지의 폴더를 검색 가능한 영역으로 COPY 모드로 전환합니다(원본은 절대 이동·삭제 안 함). 해시 키 receipt + `--rollback`. 시크릿과 이미 구조화된 KB는 보호됩니다. 비ASCII(예: 한글) 폴더명은 영역 슬러그로 보존됩니다.
|
|
117
|
+
- 정직한 범위: 이들은 vault 위생 도구입니다. 탐지는 읽기전용이고 모든 재배치는 게이트·복구 가능하며, Neurain이 사용자 폴더를 자율적으로 재편하지 않습니다.
|
|
118
|
+
|
|
107
119
|
## 현재 connector truth table
|
|
108
120
|
|
|
109
121
|
| Host | MCP connection | Lifecycle hook preview | Notes |
|
|
@@ -119,7 +131,6 @@ Status: shipped.
|
|
|
119
131
|
- non-developer GUI.
|
|
120
132
|
- hosted control plane.
|
|
121
133
|
- public Trust Center 및 status page.
|
|
122
|
-
- npm publish 또는 package name reservation.
|
|
123
134
|
- beta 동안 repo를 private으로 유지한다면 public GitHub publication.
|
|
124
135
|
|
|
125
136
|
## 문서 유지관리 규칙
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -38,6 +38,10 @@ import { hubsCommand } from './core/hubs.mjs';
|
|
|
38
38
|
import { areaIndexCommand } from './core/area_index.mjs';
|
|
39
39
|
import { reindexCommand } from './core/reindex.mjs';
|
|
40
40
|
import { lintCommand } from './core/lint.mjs';
|
|
41
|
+
import { tidyCommand } from './core/tidy.mjs';
|
|
42
|
+
import { structureAuditCommand } from './core/structure_audit.mjs';
|
|
43
|
+
import { fileCommand } from './core/file_loose.mjs';
|
|
44
|
+
import { organizeCommand } from './core/organize.mjs';
|
|
41
45
|
import { sessionPulseCommand } from './core/session_pulse.mjs';
|
|
42
46
|
import { syncCommand } from './core/sync.mjs';
|
|
43
47
|
import { healthCommand } from './core/health.mjs';
|
|
@@ -105,6 +109,10 @@ export async function runCli(argv) {
|
|
|
105
109
|
if (command === 'hubs') return render(await hubsCommand(args));
|
|
106
110
|
if (command === 'area-index') return render(await areaIndexCommand(args));
|
|
107
111
|
if (command === 'lint') return render(await lintCommand(args));
|
|
112
|
+
if (command === 'tidy') return render(await tidyCommand(args));
|
|
113
|
+
if (command === 'structure-audit') return render(await structureAuditCommand(args));
|
|
114
|
+
if (command === 'file') return render(await fileCommand(args));
|
|
115
|
+
if (command === 'organize') return render(await organizeCommand(args));
|
|
108
116
|
if (command === 'session-pulse') return render(await sessionPulseCommand(args));
|
|
109
117
|
if (command === 'sync') return render(await syncCommand(args));
|
|
110
118
|
if (command === 'health') return render(await healthCommand(args));
|
|
@@ -184,6 +192,8 @@ Usage:
|
|
|
184
192
|
neurain onboard <folder> [--lang ko|en] [--host codex|claude|gemini|runtime] [--json]
|
|
185
193
|
neurain adopt <folder> [--dry-run] [--apply --confirm "<N>건 저장 진행"] [--no-reindex]
|
|
186
194
|
neurain adopt --rollback <receipt> [--root <folder>]
|
|
195
|
+
neurain organize <folder> [--apply --confirm "<N>건 저장 진행"] [--allow-secret] [--no-reindex] [--json]
|
|
196
|
+
neurain organize --rollback <receipt> --root <folder> [--json]
|
|
187
197
|
neurain reindex <folder> [--dry-run] [--json]
|
|
188
198
|
neurain doctor <folder> [--json]
|
|
189
199
|
neurain search <folder> <query> [--top 10] [--json]
|
|
@@ -240,6 +250,9 @@ Usage:
|
|
|
240
250
|
neurain hubs <folder> [--check | --apply] [--area name] [--json]
|
|
241
251
|
neurain area-index <folder> [--build area | --refresh area | --register-curated area [--sensitivity private] | --detect [--fix] [--restamp-curated]] [--force] [--dry-run] [--json]
|
|
242
252
|
neurain lint <folder> [--json]
|
|
253
|
+
neurain tidy <folder> [--json]
|
|
254
|
+
neurain structure-audit <folder> [--json]
|
|
255
|
+
neurain file <folder> [--apply --confirm "<N>건 저장 진행"] [--allow-secret] [--json]
|
|
243
256
|
neurain session-pulse <folder> --session-id id --summary "..." [--focus t] [--next t] [--max-notes 8] [--no-area-brief] [--queue ...] [--now ts] [--dry-run] [--allow-secret] [--json]
|
|
244
257
|
neurain sync <folder> --session-id id [--summary "..."] [--level light|standard|full] [--no-pulse] [--no-area-brief] [--queue ...] [--now ts] [--dry-run] [--json]
|
|
245
258
|
neurain health <folder> [--fix] [--now ts] [--json]
|
package/src/core/adopt.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { absPath, compactStamp, ensureDir, exists, generatedPath, isTextFile, relPath, safeResolve, sha256,
|
|
4
|
+
import { absPath, compactStamp, ensureDir, exists, generatedPath, isTextFile, relPath, safeResolve, sha256, timestamp, writeFileNoOverwrite } from './fs.mjs';
|
|
5
5
|
import { inferSensitivityFromPath, secretLike } from './safety.mjs';
|
|
6
6
|
import { reindexCommand } from './reindex.mjs';
|
|
7
|
+
import { slugify } from './envelope.mjs';
|
|
7
8
|
|
|
8
9
|
const maxFileBytes = 20 * 1024 * 1024;
|
|
9
10
|
const maxFiles = 5000;
|
|
@@ -119,7 +120,10 @@ export function scanAdoption(root, { dryRun = true } = {}) {
|
|
|
119
120
|
: generatedRatio > 0.35
|
|
120
121
|
? 'hybrid'
|
|
121
122
|
: 'in-place';
|
|
122
|
-
|
|
123
|
+
// Hangul-preserving slug so a Korean folder name (e.g. 내폴더) becomes the area
|
|
124
|
+
// name `_내폴더` instead of being stripped to the generic `_adopted-area`. Area
|
|
125
|
+
// ids flow through `[^/]+` matchers and dir-listing discovery, so non-ASCII is safe.
|
|
126
|
+
const areaSlug = slugify(path.basename(root), 'adopted-area');
|
|
123
127
|
const fingerprint = sha256(Buffer.from(files.map((file) => `${file.rel}:${file.sha256}`).sort().join('\n')));
|
|
124
128
|
const required = [
|
|
125
129
|
`10_areas/_${areaSlug}/_area.md`,
|
|
@@ -18,6 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { absPath, ensureDir, timestamp } from './fs.mjs';
|
|
19
19
|
import { vaultConfig } from './config.mjs';
|
|
20
20
|
import { firstLineTitle, inferFlushLevelFromEnvelope, inferTargetLayerFromIntent } from './classify.mjs';
|
|
21
|
+
import { resolveCanonicalPath } from './resolve_target.mjs';
|
|
21
22
|
import { folderForSourceType, makeEnvelope, readArgInput, renderCaptureMarkdown, slugify } from './envelope.mjs';
|
|
22
23
|
import { withFileLock, atomicWriteJson } from './durable.mjs';
|
|
23
24
|
import { stageAndPromote } from './stage.mjs';
|
|
@@ -151,7 +152,7 @@ export async function captureCommand(args) {
|
|
|
151
152
|
const { envelope, assetPath, numericConflicts } = buildFullEnvelope(`raw-${timestamp().slice(0, 10).replace(/-/g, '')}-DRYRUN`, 'planned');
|
|
152
153
|
const flushLevel = args['flush-level'] || inferFlushLevelFromEnvelope(envelope);
|
|
153
154
|
const targetLayer = args['target-layer'] || inferTargetLayerFromIntent(envelope.write_intent);
|
|
154
|
-
const queueItem = buildQueueItem(envelope, { sourceId: envelope.source_id, rawPath: envelope.raw_path, assetPath, title, numericConflictCount: numericConflicts.length, session, sessionId, flushLevel, targetLayer, targetPath: args['target-path'] ||
|
|
155
|
+
const queueItem = buildQueueItem(envelope, { sourceId: envelope.source_id, rawPath: envelope.raw_path, assetPath, title, numericConflictCount: numericConflicts.length, session, sessionId, flushLevel, targetLayer, targetPath: args['target-path'] || resolveCanonicalPath(root, vaultCfg, { area_candidates: envelope.area_candidates, write_intent: envelope.write_intent, sensitivity: envelope.sensitivity, title: envelope.title, target_layer: targetLayer, requires_user_decision: envelope.requires_user_decision }).target_path, conflictPolicy: args['conflict-policy'] || 'queue_first', handoffPath: handoffPathFor(vaultCfg, sessionId, session) });
|
|
155
156
|
return done(args, {
|
|
156
157
|
ok: true, command: 'capture', durable_write: false, dry_run: true,
|
|
157
158
|
envelope, queue_item: queueItem, duplicate_of: duplicateOf || undefined,
|
|
@@ -202,7 +203,7 @@ export async function captureCommand(args) {
|
|
|
202
203
|
|
|
203
204
|
const flushLevel = args['flush-level'] || inferFlushLevelFromEnvelope(envelope);
|
|
204
205
|
const targetLayer = args['target-layer'] || inferTargetLayerFromIntent(envelope.write_intent);
|
|
205
|
-
const queueItem = buildQueueItem(envelope, { sourceId, rawPath, assetPath, title, numericConflictCount: numericConflicts.length, session, sessionId, flushLevel, targetLayer, targetPath: args['target-path'] ||
|
|
206
|
+
const queueItem = buildQueueItem(envelope, { sourceId, rawPath, assetPath, title, numericConflictCount: numericConflicts.length, session, sessionId, flushLevel, targetLayer, targetPath: args['target-path'] || resolveCanonicalPath(root, vaultCfg, { area_candidates: envelope.area_candidates, write_intent: envelope.write_intent, sensitivity: envelope.sensitivity, title: envelope.title, target_layer: targetLayer, requires_user_decision: envelope.requires_user_decision }).target_path, conflictPolicy: args['conflict-policy'] || 'queue_first', handoffPath: handoffPathFor(vaultCfg, sessionId, session) });
|
|
206
207
|
fs.appendFileSync(queueAbs, `${JSON.stringify(queueItem)}\n`); // direct: already holding the queue lock
|
|
207
208
|
const pendingCount = pendingCountForSession(root, vaultCfg, sessionId); // after append
|
|
208
209
|
return { refused: false, sourceId, envelope, rawPath, assetPath, pendingCount };
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { absPath, timestamp } from './fs.mjs';
|
|
10
10
|
import { vaultConfig } from './config.mjs';
|
|
11
|
+
import { resolveCanonicalPath } from './resolve_target.mjs';
|
|
11
12
|
import { readJsonSafe, readJsonl } from './vault_state.mjs';
|
|
12
13
|
|
|
13
14
|
function manifestEntries(value) {
|
|
@@ -51,13 +52,29 @@ function reviewReasons(item) {
|
|
|
51
52
|
return [...new Set(reasons)];
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function summarizeQueueItem(item, reasons = []) {
|
|
55
|
+
function summarizeQueueItem(item, reasons = [], ctx = null) {
|
|
56
|
+
// Fill the canonical destination the pipeline historically left blank: when the
|
|
57
|
+
// queue row carries no explicit target_path, derive it deterministically from
|
|
58
|
+
// area + write_intent + sensitivity so every candidate shows a real save target
|
|
59
|
+
// instead of an empty string (the wiki/area/output drift fix). ctx carries the
|
|
60
|
+
// root + vaultCfg needed to build the path; without it we keep the old behavior.
|
|
61
|
+
let targetPath = item.target_path || '';
|
|
62
|
+
if (!targetPath && ctx && ctx.root && ctx.vaultCfg) {
|
|
63
|
+
targetPath = resolveCanonicalPath(ctx.root, ctx.vaultCfg, {
|
|
64
|
+
area_candidates: item.area_candidates,
|
|
65
|
+
write_intent: item.write_intent,
|
|
66
|
+
sensitivity: item.sensitivity,
|
|
67
|
+
title: item.title,
|
|
68
|
+
target_layer: item.target_layer,
|
|
69
|
+
requires_user_decision: item.requires_user_decision,
|
|
70
|
+
}).target_path;
|
|
71
|
+
}
|
|
55
72
|
return {
|
|
56
73
|
source_id: item.source_id || '',
|
|
57
74
|
title: item.title || '',
|
|
58
75
|
raw_path: item.raw_path || '',
|
|
59
76
|
target_layer: item.target_layer || inferTargetLayer(item),
|
|
60
|
-
target_path:
|
|
77
|
+
target_path: targetPath,
|
|
61
78
|
sensitivity: item.sensitivity || 'internal',
|
|
62
79
|
flush_level: item.flush_level || inferFlushLevel(item),
|
|
63
80
|
reasons,
|
|
@@ -127,13 +144,13 @@ function summarizeManifestTarget(entry, selectedBy) {
|
|
|
127
144
|
};
|
|
128
145
|
}
|
|
129
146
|
|
|
130
|
-
function selectTarget(query, value, queueItems, rawDir) {
|
|
147
|
+
function selectTarget(query, value, queueItems, rawDir, ctx = null) {
|
|
131
148
|
const lower = String(query || '').toLowerCase();
|
|
132
149
|
const sourceIdMatch = String(query || '').match(/\b(?:raw-\d{8}-\d{3}|clipper-\d{8}-\d{6})\b/);
|
|
133
150
|
if (sourceIdMatch) {
|
|
134
151
|
const sourceId = sourceIdMatch[0];
|
|
135
152
|
const queueMatch = queueItems.find((item) => item.source_id === sourceId);
|
|
136
|
-
if (queueMatch) return { kind: 'queue_item', ...summarizeQueueItem(queueMatch, reviewReasons(queueMatch)) };
|
|
153
|
+
if (queueMatch) return { kind: 'queue_item', ...summarizeQueueItem(queueMatch, reviewReasons(queueMatch), ctx) };
|
|
137
154
|
const manifestMatch = manifestEntries(value).find((entry) => entry.source_id === sourceId);
|
|
138
155
|
if (manifestMatch) return summarizeManifestTarget(manifestMatch, 'source_id');
|
|
139
156
|
}
|
|
@@ -156,10 +173,10 @@ function selectTarget(query, value, queueItems, rawDir) {
|
|
|
156
173
|
return null;
|
|
157
174
|
}
|
|
158
175
|
|
|
159
|
-
function selectAutoTarget({ safe: safeItems, needsConfirmation: confirmationItems, manifest: value, sessionId: targetSessionId }) {
|
|
176
|
+
function selectAutoTarget({ safe: safeItems, needsConfirmation: confirmationItems, manifest: value, sessionId: targetSessionId, ctx = null }) {
|
|
160
177
|
if (safeItems.length > 0) {
|
|
161
178
|
const item = safeItems[0];
|
|
162
|
-
return { auto_selected: true, selected_by: 'safe_queue', kind: 'queue_item', ...summarizeQueueItem(item, []) };
|
|
179
|
+
return { auto_selected: true, selected_by: 'safe_queue', kind: 'queue_item', ...summarizeQueueItem(item, [], ctx) };
|
|
163
180
|
}
|
|
164
181
|
if (confirmationItems.length > 0) return null;
|
|
165
182
|
|
|
@@ -217,7 +234,7 @@ export async function compileCommand(args) {
|
|
|
217
234
|
const needsConfirmation = pending
|
|
218
235
|
.filter((item) => reviewReasons(item).length > 0)
|
|
219
236
|
.map((item) => {
|
|
220
|
-
const s = summarizeQueueItem(item, reviewReasons(item));
|
|
237
|
+
const s = summarizeQueueItem(item, reviewReasons(item), { root, vaultCfg });
|
|
221
238
|
// Never expose a private item's file paths in output (R3-#1).
|
|
222
239
|
if (s.sensitivity === 'private') { s.raw_path = '[redacted:private]'; s.target_path = ''; }
|
|
223
240
|
return s;
|
|
@@ -236,10 +253,10 @@ export async function compileCommand(args) {
|
|
|
236
253
|
const wantAuto = Boolean(args.auto);
|
|
237
254
|
const deskOnly = Boolean(args.desk || args['no-auto-select']) || wantList || (!detail && !wantAuto);
|
|
238
255
|
const selected = detail
|
|
239
|
-
? selectTarget(detail, manifest, pending, rawDir)
|
|
256
|
+
? selectTarget(detail, manifest, pending, rawDir, { root, vaultCfg })
|
|
240
257
|
: deskOnly
|
|
241
258
|
? null
|
|
242
|
-
: selectAutoTarget({ safe, needsConfirmation, manifest, sessionId });
|
|
259
|
+
: selectAutoTarget({ safe, needsConfirmation, manifest, sessionId, ctx: { root, vaultCfg } });
|
|
243
260
|
const mode = selected
|
|
244
261
|
? (selected.auto_selected ? 'auto_selected_compile_plan' : 'selected_compile_plan')
|
|
245
262
|
: deskOnly
|
|
@@ -267,7 +284,7 @@ export async function compileCommand(args) {
|
|
|
267
284
|
pending_count: pending.length,
|
|
268
285
|
safe_count: safe.length,
|
|
269
286
|
needs_confirmation_count: needsConfirmation.length,
|
|
270
|
-
safe: safe.slice(0, top).map((item) => summarizeQueueItem(item, [])),
|
|
287
|
+
safe: safe.slice(0, top).map((item) => summarizeQueueItem(item, [], { root, vaultCfg })),
|
|
271
288
|
needs_confirmation: needsConfirmation.slice(0, top),
|
|
272
289
|
},
|
|
273
290
|
source_digest: {
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// `file` command (auto-filer, W-B/W-C). Closes the loop the resolver opened: it
|
|
2
|
+
// finds LOOSE durable markdown sitting outside any canonical home and, on a gated
|
|
3
|
+
// --apply, relocates each to the canonical path the resolver computes (recoverable
|
|
4
|
+
// move: secret-gated write of the new copy, then the old path into _trash). It also
|
|
5
|
+
// surfaces a read-only routing PROPOSAL for the raw/_inbox backlog (those are raw
|
|
6
|
+
// evidence whose durable write is a compile/agent step, never an auto-move here).
|
|
7
|
+
//
|
|
8
|
+
// Safety rails: registered area roots and every structural dir (raw/output/wiki/
|
|
9
|
+
// 00_system/90_archive/_trash/_archive) are EXCLUDED from the loose sweep, so a
|
|
10
|
+
// mature KB (e.g. a registered area) is never swept; the root contract files (index/log/AGENTS/
|
|
11
|
+
// CLAUDE/README) are never moved; private/multi-area/requires-decision items are
|
|
12
|
+
// proposal-only and never auto-filed. All moves are recoverable via neurain-trash.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { absPath, ensureDir, exists, readText, relPath, sha256, timestamp } from './fs.mjs';
|
|
16
|
+
import { vaultConfig } from './config.mjs';
|
|
17
|
+
import { classifyText, firstLineTitle } from './classify.mjs';
|
|
18
|
+
import { resolveCanonicalPath } from './resolve_target.mjs';
|
|
19
|
+
import { stageAndPromote } from './stage.mjs';
|
|
20
|
+
import { rebuildRecall } from './recall.mjs';
|
|
21
|
+
|
|
22
|
+
const CONTRACT_FILES = new Set(['index.md', 'log.md', 'AGENTS.md', 'CLAUDE.md', 'README.md']);
|
|
23
|
+
const PRUNE_DIR_NAMES = new Set(['.git', 'node_modules', '.next', 'out', '.vercel', '.cache', '.neurain-staging', '.DS_Store', '.obsidian', '.claude', '.agents', '_trash', '_archive']);
|
|
24
|
+
|
|
25
|
+
// Top-level dirs whose contents are already in a canonical home and must never be
|
|
26
|
+
// treated as "loose": areas (user-organized / registered), raw capture, output,
|
|
27
|
+
// wiki (compiled layer), system, archive, hubs.
|
|
28
|
+
function structuralTopDirs(vaultCfg) {
|
|
29
|
+
return new Set([
|
|
30
|
+
vaultCfg.areas_dir, vaultCfg.raw_dir, vaultCfg.output_dir, vaultCfg.wiki_dir,
|
|
31
|
+
vaultCfg.system_dir, vaultCfg.archive_dir, vaultCfg.hubs_dir, 'outputs', '_trash',
|
|
32
|
+
].filter(Boolean));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Loose = a .md knowledge file sitting outside every canonical home (effectively
|
|
36
|
+
// the vault root or a stray non-structural top-level dir), not a root-contract file.
|
|
37
|
+
export function findLooseFiles(root, vaultCfg = vaultConfig(root), { maxFiles = 5000 } = {}) {
|
|
38
|
+
const structural = structuralTopDirs(vaultCfg);
|
|
39
|
+
const out = [];
|
|
40
|
+
const walk = (dir, depth) => {
|
|
41
|
+
if (out.length >= maxFiles) return;
|
|
42
|
+
let entries = [];
|
|
43
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
if (out.length >= maxFiles) return;
|
|
46
|
+
const abs = path.join(dir, e.name);
|
|
47
|
+
const rel = relPath(root, abs);
|
|
48
|
+
if (e.isSymbolicLink()) continue;
|
|
49
|
+
if (e.isDirectory()) {
|
|
50
|
+
if (e.name.startsWith('.') || PRUNE_DIR_NAMES.has(e.name)) continue;
|
|
51
|
+
if (depth === 0 && structural.has(e.name)) continue; // skip canonical top dirs
|
|
52
|
+
walk(abs, depth + 1);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
56
|
+
if (depth === 0 && CONTRACT_FILES.has(e.name)) continue; // root contract files stay
|
|
57
|
+
out.push(rel);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(root, 0);
|
|
61
|
+
|
|
62
|
+
return out.map((rel) => {
|
|
63
|
+
const head = readText(path.join(root, rel), '').slice(0, 4000);
|
|
64
|
+
const route = classifyText(root, head, {}, { vaultCfg });
|
|
65
|
+
const title = firstLineTitle(head, path.basename(rel, '.md'));
|
|
66
|
+
const resolved = resolveCanonicalPath(root, vaultCfg, {
|
|
67
|
+
area_candidates: route.area_candidates,
|
|
68
|
+
write_intent: route.write_intent,
|
|
69
|
+
sensitivity: route.sensitivity,
|
|
70
|
+
title,
|
|
71
|
+
requires_user_decision: route.requires_user_decision,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
rel,
|
|
75
|
+
title,
|
|
76
|
+
area_candidates: route.area_candidates,
|
|
77
|
+
target_path: resolved.target_path,
|
|
78
|
+
requires_confirmation: resolved.requires_confirmation,
|
|
79
|
+
reasons: resolved.reasons,
|
|
80
|
+
auto_fileable: Boolean(resolved.target_path) && !resolved.requires_confirmation && resolved.target_path !== rel,
|
|
81
|
+
};
|
|
82
|
+
}).filter((p) => p.target_path && p.target_path !== p.rel);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Read-only routing proposals for the raw/_inbox backlog. These are NOT auto-filed
|
|
86
|
+
// (raw -> durable is a compile/agent step); the list just shows where each pending
|
|
87
|
+
// capture would route, so the inbox stops being an opaque tomb.
|
|
88
|
+
export function inboxProposals(root, vaultCfg = vaultConfig(root), { limit = 20 } = {}) {
|
|
89
|
+
const inboxDir = path.join(root, vaultCfg.raw_inbox_dir || 'raw/_inbox');
|
|
90
|
+
let files = [];
|
|
91
|
+
try { files = fs.readdirSync(inboxDir).filter((f) => f.endsWith('.json')); } catch { return { pending: 0, examples: [] }; }
|
|
92
|
+
const pending = [];
|
|
93
|
+
for (const f of files) {
|
|
94
|
+
let env;
|
|
95
|
+
try { env = JSON.parse(fs.readFileSync(path.join(inboxDir, f), 'utf8')); } catch { continue; }
|
|
96
|
+
if (env.status && env.status !== 'pending' && env.status !== 'planned') continue;
|
|
97
|
+
const resolved = resolveCanonicalPath(root, vaultCfg, {
|
|
98
|
+
area_candidates: env.area_candidates,
|
|
99
|
+
write_intent: env.write_intent,
|
|
100
|
+
sensitivity: env.sensitivity,
|
|
101
|
+
title: env.title,
|
|
102
|
+
requires_user_decision: env.requires_user_decision,
|
|
103
|
+
});
|
|
104
|
+
pending.push({
|
|
105
|
+
source_id: env.source_id || f.replace(/\.json$/, ''),
|
|
106
|
+
title: env.title || '',
|
|
107
|
+
proposed_target: resolved.target_path,
|
|
108
|
+
requires_confirmation: resolved.requires_confirmation,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return { pending: pending.length, examples: pending.slice(0, limit) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Recoverable move into _trash, byte-faithful to neurain-trash's manifest format so
|
|
115
|
+
// `neurain-trash --restore <from>` round-trips an engine-filed relocation.
|
|
116
|
+
function recoverableTrash(root, rel) {
|
|
117
|
+
const date = timestamp().slice(0, 10);
|
|
118
|
+
let destRel = path.join('_trash', date, rel).split(path.sep).join('/');
|
|
119
|
+
let destAbs = path.join(root, destRel);
|
|
120
|
+
for (let i = 1; exists(destAbs) && i < 100; i += 1) {
|
|
121
|
+
destRel = path.join('_trash', date, `${rel}-${i}`).split(path.sep).join('/');
|
|
122
|
+
destAbs = path.join(root, destRel);
|
|
123
|
+
}
|
|
124
|
+
const srcAbs = path.join(root, rel);
|
|
125
|
+
const content = readText(srcAbs, '');
|
|
126
|
+
const entry = { ts: timestamp(), type: 'file', from: rel, to: destRel, size: Buffer.byteLength(content, 'utf8'), sha256: sha256(content) };
|
|
127
|
+
ensureDir(path.dirname(destAbs));
|
|
128
|
+
fs.renameSync(srcAbs, destAbs);
|
|
129
|
+
// The move already succeeded; the file is physically recoverable from _trash even
|
|
130
|
+
// if the manifest row fails to write, so the append is best-effort (--restore is
|
|
131
|
+
// the only thing that needs the row).
|
|
132
|
+
try {
|
|
133
|
+
const manifestAbs = path.join(root, '_trash', 'manifest.jsonl');
|
|
134
|
+
ensureDir(path.dirname(manifestAbs));
|
|
135
|
+
fs.appendFileSync(manifestAbs, `${JSON.stringify(entry)}\n`);
|
|
136
|
+
} catch { /* recoverable from _trash without the row */ }
|
|
137
|
+
return entry;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Relocate ONE loose file to its canonical path: secret-gated write of the new copy
|
|
141
|
+
// (stage), then recoverable-trash the old path. Fully reversible.
|
|
142
|
+
export function fileOneLoose(root, vaultCfg, { rel, target_path, allowSecret = false }) {
|
|
143
|
+
const srcAbs = path.join(root, rel);
|
|
144
|
+
if (!exists(srcAbs)) return { ok: false, rel, error: 'source missing' };
|
|
145
|
+
if (exists(path.join(root, target_path))) return { ok: false, rel, target_path, error: 'target exists' };
|
|
146
|
+
const content = readText(srcAbs, null);
|
|
147
|
+
if (content == null) return { ok: false, rel, error: 'unreadable' };
|
|
148
|
+
const promo = stageAndPromote(root, target_path, content, { allowSecret });
|
|
149
|
+
if (!promo.promoted) return { ok: false, rel, target_path, blocked: promo.blocked || 'secret' };
|
|
150
|
+
const trashed = recoverableTrash(root, rel);
|
|
151
|
+
return { ok: true, rel, target_path, trashed_to: trashed.to };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function confirmOk(args, n) {
|
|
155
|
+
if (n === 0) return true; // nothing to file -> no confirmation needed
|
|
156
|
+
return String(args.confirm || '').trim() === `${n}건 저장 진행`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function fileCommand(args) {
|
|
160
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
161
|
+
const vaultCfg = vaultConfig(root);
|
|
162
|
+
const apply = Boolean(args.apply);
|
|
163
|
+
const allowSecret = Boolean(args['allow-secret']);
|
|
164
|
+
|
|
165
|
+
const loose = findLooseFiles(root, vaultCfg);
|
|
166
|
+
const autoFileable = loose.filter((p) => p.auto_fileable);
|
|
167
|
+
const inbox = inboxProposals(root, vaultCfg);
|
|
168
|
+
|
|
169
|
+
if (!apply) {
|
|
170
|
+
const payload = {
|
|
171
|
+
ok: true, command: 'file', durable_write: false, mode: 'plan',
|
|
172
|
+
loose_total: loose.length, auto_fileable: autoFileable.length,
|
|
173
|
+
needs_confirmation: loose.filter((p) => p.requires_confirmation).map((p) => ({ rel: p.rel, target_path: p.target_path, reasons: p.reasons })),
|
|
174
|
+
proposals: autoFileable, inbox,
|
|
175
|
+
};
|
|
176
|
+
if (args.json) return { json: true, payload };
|
|
177
|
+
const lines = ['# Neurain File (자동 파일링, 미리보기)'];
|
|
178
|
+
if (!autoFileable.length) lines.push('- 자동 재배치할 떠도는 문서 없음.');
|
|
179
|
+
else { lines.push(`- 떠도는 문서 ${autoFileable.length}건 → 제안 경로:`); for (const p of autoFileable) lines.push(` - ${p.rel} → ${p.target_path}`); }
|
|
180
|
+
lines.push(`- inbox 대기 ${inbox.pending}건 (라우팅 제안만; 실제 정리는 !compile)`);
|
|
181
|
+
return { text: lines.join('\n') };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!confirmOk(args, autoFileable.length)) {
|
|
185
|
+
return { json: Boolean(args.json), ...(args.json
|
|
186
|
+
? { payload: { ok: false, command: 'file', durable_write: false, error: 'confirmation required', need: `${autoFileable.length}건 저장 진행` } }
|
|
187
|
+
: {}), text: args.json ? undefined : `확인 문구가 필요합니다: "${autoFileable.length}건 저장 진행"` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const results = [];
|
|
191
|
+
for (const p of autoFileable) results.push(fileOneLoose(root, vaultCfg, { rel: p.rel, target_path: p.target_path, allowSecret }));
|
|
192
|
+
const moved = results.filter((r) => r.ok);
|
|
193
|
+
if (moved.length) { try { await rebuildRecall(root, {}); } catch { /* reindex best-effort */ } }
|
|
194
|
+
|
|
195
|
+
const payload = {
|
|
196
|
+
ok: true, command: 'file', durable_write: moved.length > 0, mode: 'apply',
|
|
197
|
+
moved: moved.length, results, inbox,
|
|
198
|
+
};
|
|
199
|
+
if (args.json) return { json: true, payload };
|
|
200
|
+
const lines = [`# Neurain File - 적용 (${moved.length}건 재배치)`];
|
|
201
|
+
for (const r of results) lines.push(r.ok ? ` - ${r.rel} → ${r.target_path} (이전 위치는 휴지통)` : ` - 건너뜀 ${r.rel}: ${r.error || r.blocked}`);
|
|
202
|
+
lines.push('복구: node 00_system/tools/neurain-trash.mjs --restore <경로>');
|
|
203
|
+
return { text: lines.join('\n') };
|
|
204
|
+
}
|
package/src/core/lint.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { absPath } from './fs.mjs';
|
|
|
9
9
|
import { linkCheckCommand } from './link_check.mjs';
|
|
10
10
|
import { sessionLintCommand } from './session_lint.mjs';
|
|
11
11
|
import { orphansCommand } from './orphans.mjs';
|
|
12
|
+
import { auditStructure } from './structure_audit.mjs';
|
|
12
13
|
|
|
13
14
|
export async function lintCommand(args) {
|
|
14
15
|
const root = absPath(args._[0] || args.root || process.cwd());
|
|
@@ -16,6 +17,7 @@ export async function lintCommand(args) {
|
|
|
16
17
|
const lc = (await linkCheckCommand({ _: [root], json: true })).payload;
|
|
17
18
|
const sl = (await sessionLintCommand({ _: [root], json: true, now: args.now })).payload;
|
|
18
19
|
const orph = (await orphansCommand({ _: [root], json: true })).payload;
|
|
20
|
+
const sa = auditStructure(root);
|
|
19
21
|
|
|
20
22
|
const checks = [
|
|
21
23
|
{ name: 'links', ok: lc.ok, broken_markdown_links: lc.broken_markdown_links_count, unresolved_wikilinks: lc.unresolved_wiki_links_count },
|
|
@@ -23,6 +25,8 @@ export async function lintCommand(args) {
|
|
|
23
25
|
// orphans are advisory (graph hygiene), never an error that fails the lint;
|
|
24
26
|
// queue rows are validated by the session check (queue-reference integrity).
|
|
25
27
|
{ name: 'orphans', ok: true, advisory: true, orphans: orph.grand.orphans, actionable_has_entities: orph.grand.has_entities },
|
|
28
|
+
// structure is advisory too: adapter gaps, output/outputs split, sources_map drift.
|
|
29
|
+
{ name: 'structure', ok: true, advisory: true, findings: sa.findings, adapter_gaps: sa.adapter_gaps.length, deliverable_split: sa.deliverable_split ? 1 : 0, sources_map_drift: sa.sources_map_drift.length },
|
|
26
30
|
];
|
|
27
31
|
const ok = checks.filter((c) => !c.advisory).every((c) => c.ok);
|
|
28
32
|
|
package/src/core/onboard.mjs
CHANGED
|
@@ -77,9 +77,9 @@ function nextSteps({ root, lang, host, folderExists, isDirectory, initialized, a
|
|
|
77
77
|
const mode = adoption?.recommended_mode || 'copy';
|
|
78
78
|
return [
|
|
79
79
|
{
|
|
80
|
-
title: text(lang, '
|
|
81
|
-
why: text(lang, `
|
|
82
|
-
command: `npx neurain
|
|
80
|
+
title: text(lang, 'Preview how this folder would be organized', '이 폴더가 어떻게 정리될지 미리보기'),
|
|
81
|
+
why: text(lang, `Read-only: scans the folder, detects structure, and shows which files become docs, stay native, or are quarantined (${mode} mode). Nothing is written. (Lower-level "adopt --dry-run" scaffolds adapters only.)`, `읽기 전용: 폴더를 스캔해 구조를 감지하고 어떤 파일이 문서로 정리/원본유지/격리될지 보여줍니다(${mode} 모드). 아무것도 쓰지 않습니다. (하위 명령 "adopt --dry-run"은 어댑터만 만듭니다.)`),
|
|
82
|
+
command: `npx neurain organize "${root}" --dry-run`,
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
title: text(lang, 'Create a clean starter vault if this is a new Neurain folder', '새 Neurain 폴더라면 starter vault 만들기'),
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// `organize` command (new-user first-organize). Points at an UNKNOWN folder and
|
|
2
|
+
// turns it into an organized, searchable Neurain area: it reuses the adopt scan
|
|
3
|
+
// (inventory + secret detection + mode), decides a per-file disposition (durable
|
|
4
|
+
// doc / keep native / quarantine), and on a gated --apply scaffolds the adapters,
|
|
5
|
+
// copies durable notes into docs/, and reindexes so the area earns recall boosts.
|
|
6
|
+
//
|
|
7
|
+
// Safety: COPY mode only in this slice (originals are never moved or deleted, so
|
|
8
|
+
// the source folder is untouched and the whole thing is reversible). Private/secret
|
|
9
|
+
// files are quarantined logical-only and never copied into the indexed tree. A
|
|
10
|
+
// detected mature/native KB (Obsidian vault, git/code project, deep docs tree) is
|
|
11
|
+
// kept native and merely mapped, never restructured. Every write is recorded in a
|
|
12
|
+
// hash-keyed receipt with a one-command rollback.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import {
|
|
16
|
+
absPath, compactStamp, ensureDir, exists, readText, relPath, safeResolve, sha256, timestamp,
|
|
17
|
+
} from './fs.mjs';
|
|
18
|
+
import { scanAdoption, applyAdoption } from './adopt.mjs';
|
|
19
|
+
import { reindexCommand } from './reindex.mjs';
|
|
20
|
+
import { slugify } from './envelope.mjs';
|
|
21
|
+
import { secretLike } from './safety.mjs';
|
|
22
|
+
|
|
23
|
+
const SCRATCH_NAME = /(^|\/)(untitled|temp|tmp|~\$)[^/]*$/i;
|
|
24
|
+
const NATIVE_PATH = /(^|\/)(web|scripts|src|dist|build|\.obsidian|\.github)(\/|$)/i;
|
|
25
|
+
const BUILD_FILE = /(^|\/)(package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|tsconfig[^/]*\.json|\.env[^/]*|Cargo\.(toml|lock)|go\.(mod|sum)|requirements\.txt|Gemfile(\.lock)?)$/i;
|
|
26
|
+
|
|
27
|
+
// Read-only structure detector: is this an already-structured KB (keep native) or
|
|
28
|
+
// a loose pile (impose docs/)? Scores the inventory scanAdoption already produced.
|
|
29
|
+
export function detectStructure(scan) {
|
|
30
|
+
const rels = scan.files.map((f) => f.rel);
|
|
31
|
+
const excludedRels = (scan.excluded || []).map((e) => e.rel);
|
|
32
|
+
const signals = [];
|
|
33
|
+
let structured = 0;
|
|
34
|
+
if (rels.some((r) => r.startsWith('.obsidian/'))) { structured += 2; signals.push('Obsidian vault'); }
|
|
35
|
+
if (excludedRels.some((r) => r === '.git' || r.startsWith('.git/'))) { structured += 1; signals.push('git repo'); }
|
|
36
|
+
const codeRatio = scan.files.filter((f) => f.kind === 'code').length / Math.max(1, scan.files.length);
|
|
37
|
+
if (codeRatio > 0.2) { structured += 1; signals.push('code project'); }
|
|
38
|
+
if (rels.some((r) => /(^|\/)docs\//.test(r))) { structured += 1; signals.push('docs tree'); }
|
|
39
|
+
const dirDepth = rels.length ? Math.max(...rels.map((r) => r.split('/').length - 1)) : 0;
|
|
40
|
+
if (dirDepth >= 2) structured += 1;
|
|
41
|
+
const rootNotes = rels.filter((r) => !r.includes('/') && /\.(md|txt)$/i.test(r)).length;
|
|
42
|
+
const looseLean = rootNotes >= 3 && dirDepth < 2 && structured < 2;
|
|
43
|
+
|
|
44
|
+
let shape = structured >= 2 ? 'structured' : looseLean ? 'loose' : 'mixed';
|
|
45
|
+
// Safety bias: a large/dense folder looks like a real KB -> never aggressively
|
|
46
|
+
// restructure; downgrade a "loose" verdict to "mixed" (keep-native dominant).
|
|
47
|
+
if (scan.summary.scanned_files > 300 && shape === 'loose') shape = 'mixed';
|
|
48
|
+
return { shape, structured_score: structured, signals, dir_depth: dirDepth };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Per-file disposition (first match wins). Biased toward keep_native: the only files
|
|
52
|
+
// that ever get COPIED are durable notes; nothing is moved or deleted in copy mode.
|
|
53
|
+
export function decideDisposition(file, structure) {
|
|
54
|
+
const rel = file.rel;
|
|
55
|
+
// Secret detection covers BOTH the body (scan set file.sensitivity) AND the
|
|
56
|
+
// filename itself (a credential-shaped name would otherwise slip through
|
|
57
|
+
// with an innocent body and get copied into the indexed tree).
|
|
58
|
+
if (file.sensitivity === 'private' || secretLike(rel)) return { disposition: 'quarantine', reason: 'private/secret', risk: 'high' };
|
|
59
|
+
if (file.kind === 'code' || NATIVE_PATH.test(rel) || BUILD_FILE.test(rel)) return { disposition: 'keep_native', reason: 'code/project file' };
|
|
60
|
+
if (file.bytes === 0 || SCRATCH_NAME.test(rel)) return { disposition: 'quarantine', reason: 'empty/scratch' };
|
|
61
|
+
// structured KB -> preserve everything native. mixed -> keep NESTED content native
|
|
62
|
+
// (it likely carries its own structure); only root-level loose notes become docs.
|
|
63
|
+
// loose -> notes become docs.
|
|
64
|
+
if (structure.shape === 'structured') return { disposition: 'keep_native', reason: 'structured KB preserved' };
|
|
65
|
+
if (structure.shape === 'mixed' && rel.includes('/')) return { disposition: 'keep_native', reason: 'nested content kept native' };
|
|
66
|
+
if ((file.kind === 'source_or_note' || file.kind === 'text') && file.bytes >= 40) return { disposition: 'durable_doc', reason: 'note -> docs' };
|
|
67
|
+
if (file.kind === 'config_or_data') return { disposition: 'keep_native', reason: 'reference data' };
|
|
68
|
+
if (file.kind === 'binary_or_asset') return { disposition: 'keep_native', reason: 'asset' };
|
|
69
|
+
if (/\.(md|txt)$/i.test(rel)) return { disposition: 'durable_doc', reason: 'short note -> docs' };
|
|
70
|
+
return { disposition: 'keep_native', reason: 'unsorted (kept)' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildDispositions(scan, structure) {
|
|
74
|
+
const used = new Set();
|
|
75
|
+
return scan.files.map((f) => {
|
|
76
|
+
const d = decideDisposition(f, structure);
|
|
77
|
+
let targetPath = '';
|
|
78
|
+
if (d.disposition === 'durable_doc') {
|
|
79
|
+
let base = slugify(path.basename(f.rel).replace(/\.[^.]+$/, '')) || 'note';
|
|
80
|
+
let candidate = `10_areas/${scan.proposed_area}/docs/${base}.md`;
|
|
81
|
+
for (let i = 1; used.has(candidate); i += 1) candidate = `10_areas/${scan.proposed_area}/docs/${base}-${i}.md`;
|
|
82
|
+
used.add(candidate);
|
|
83
|
+
targetPath = candidate;
|
|
84
|
+
}
|
|
85
|
+
return { rel: f.rel, kind: f.kind, bytes: f.bytes, sensitivity: f.sensitivity, ...d, target_path: targetPath };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tally(dispositions) {
|
|
90
|
+
const counts = { durable_doc: 0, keep_native: 0, quarantine: 0 };
|
|
91
|
+
for (const x of dispositions) counts[x.disposition] += 1;
|
|
92
|
+
return counts;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// adapters written by apply: 4 required + 1 area brief (we suppress the empty
|
|
96
|
+
// entities/domain stubs so reindex can derive real ones). Plus N durable copies.
|
|
97
|
+
function confirmationFor(durableCount) {
|
|
98
|
+
return `${5 + durableCount}건 저장 진행`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function planOrganize(target) {
|
|
102
|
+
const scan = scanAdoption(target, { dryRun: true });
|
|
103
|
+
const structure = detectStructure(scan);
|
|
104
|
+
const dispositions = buildDispositions(scan, structure);
|
|
105
|
+
const counts = tally(dispositions);
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
command: 'organize',
|
|
109
|
+
durable_write: false,
|
|
110
|
+
mode: 'plan',
|
|
111
|
+
target: scan.target,
|
|
112
|
+
proposed_area: scan.proposed_area,
|
|
113
|
+
shape: structure.shape,
|
|
114
|
+
structure_signals: structure.signals,
|
|
115
|
+
recommended_mode: scan.summary.recommended_mode,
|
|
116
|
+
scanned_files: scan.summary.scanned_files,
|
|
117
|
+
dispositioned: dispositions.length,
|
|
118
|
+
truncated: scan.summary.scanned_files > dispositions.length,
|
|
119
|
+
private_or_secret: scan.summary.private_or_secret_files,
|
|
120
|
+
counts,
|
|
121
|
+
dispositions: dispositions.slice(0, 200),
|
|
122
|
+
confirmation_required: confirmationFor(counts.durable_doc),
|
|
123
|
+
rollback_hint: 'neurain organize --rollback <receipt> --root "<folder>"',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function applyOrganize(target, { confirm = '', allowSecret = false } = {}) {
|
|
128
|
+
const scan = scanAdoption(target, { dryRun: false });
|
|
129
|
+
const structure = detectStructure(scan);
|
|
130
|
+
const dispositions = buildDispositions(scan, structure);
|
|
131
|
+
const counts = tally(dispositions);
|
|
132
|
+
const expected = confirmationFor(counts.durable_doc);
|
|
133
|
+
if (String(confirm) !== expected) {
|
|
134
|
+
return { ok: false, command: 'organize', durable_write: false, error: 'confirmation required', need: expected };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Refuse to write if the scan flagged a high-severity symlink / external / path-
|
|
138
|
+
// traversal risk: a symlinked area dir could let an adapter/doc write escape the
|
|
139
|
+
// target root. The user resolves the symlink first, then re-runs.
|
|
140
|
+
const symlinkRisk = (scan.risks || []).some((r) => r.severity === 'high' && /symlink|external|traversal/i.test(r.reason || ''));
|
|
141
|
+
if (symlinkRisk) {
|
|
142
|
+
return { ok: false, command: 'organize', durable_write: false, error: 'high-severity symlink/external risk; refusing to write. Resolve the symlink(s) first.' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 1. Scaffold adapters, but suppress the empty entities.json/domain-routing.json
|
|
146
|
+
// stubs so reindex -> buildArea can derive real ones from the copied docs.
|
|
147
|
+
const trimmedScan = {
|
|
148
|
+
...scan,
|
|
149
|
+
write_plan_preview: {
|
|
150
|
+
...scan.write_plan_preview,
|
|
151
|
+
optional_writes: scan.write_plan_preview.optional_writes.filter((r) => !/entities\.json$|domain-routing\.json$/.test(r)),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const adopt = applyAdoption(trimmedScan, { root: target });
|
|
155
|
+
if (!adopt.ok) return { ok: false, command: 'organize', durable_write: false, error: 'adapter scaffold failed', detail: adopt };
|
|
156
|
+
|
|
157
|
+
// 2. Copy durable notes into docs/ (COPY mode: originals untouched). Defense in
|
|
158
|
+
// depth: re-scan each copied body for secrets and skip if it trips the gate.
|
|
159
|
+
const copied = [];
|
|
160
|
+
const skipped = [];
|
|
161
|
+
for (const d of dispositions.filter((x) => x.disposition === 'durable_doc')) {
|
|
162
|
+
// Each copy is independent: a single failing copy must not abort the loop and
|
|
163
|
+
// orphan the already-copied files without a receipt. Record and continue.
|
|
164
|
+
try {
|
|
165
|
+
const srcAbs = safeResolve(target, d.rel);
|
|
166
|
+
const dstAbs = safeResolve(target, d.target_path);
|
|
167
|
+
if (exists(dstAbs)) { skipped.push({ rel: d.rel, reason: 'target exists' }); continue; }
|
|
168
|
+
const content = readText(srcAbs, null);
|
|
169
|
+
if (content == null) { skipped.push({ rel: d.rel, reason: 'unreadable' }); continue; }
|
|
170
|
+
if (!allowSecret && secretLike(content)) { skipped.push({ rel: d.rel, reason: 'secret-like (kept native)' }); continue; }
|
|
171
|
+
ensureDir(path.dirname(dstAbs));
|
|
172
|
+
fs.writeFileSync(dstAbs, content);
|
|
173
|
+
copied.push({ from: d.rel, to: d.target_path, sha256: sha256(Buffer.from(content)) });
|
|
174
|
+
} catch (e) {
|
|
175
|
+
skipped.push({ rel: d.rel, reason: `copy failed: ${(e && e.message) || e}` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 3. Organize receipt (adapters + copies) with hash-keyed rollback.
|
|
180
|
+
const stamp = compactStamp();
|
|
181
|
+
const areaSlug = scan.proposed_area.replace(/^_+/, '');
|
|
182
|
+
const receiptRel = `output/receipts/organize/${stamp}-${areaSlug}.json`;
|
|
183
|
+
const receipt = {
|
|
184
|
+
ok: true,
|
|
185
|
+
tool: 'neurain-organize',
|
|
186
|
+
generated_at: timestamp(),
|
|
187
|
+
receipt_version: 1,
|
|
188
|
+
mode: 'copy',
|
|
189
|
+
target: scan.target,
|
|
190
|
+
proposed_area: scan.proposed_area,
|
|
191
|
+
shape: structure.shape,
|
|
192
|
+
adapter_files: adopt.created_files || [],
|
|
193
|
+
adapter_file_hashes: adopt.created_file_hashes || {},
|
|
194
|
+
copied_files: copied,
|
|
195
|
+
skipped_files: skipped,
|
|
196
|
+
counts,
|
|
197
|
+
rollback_command: `neurain organize --rollback ${receiptRel} --root "${target}"`,
|
|
198
|
+
};
|
|
199
|
+
ensureDir(safeResolve(target, 'output/receipts/organize'));
|
|
200
|
+
fs.writeFileSync(safeResolve(target, receiptRel), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
201
|
+
|
|
202
|
+
return { ...receipt, receipt_path: receiptRel, adopt_receipt: adopt.receipt_path };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function rollbackOrganize(target, receiptRel) {
|
|
206
|
+
const receiptAbs = safeResolve(target, receiptRel);
|
|
207
|
+
if (!exists(receiptAbs)) throw new Error(`Receipt does not exist: ${receiptRel}`);
|
|
208
|
+
const receipt = JSON.parse(fs.readFileSync(receiptAbs, 'utf8'));
|
|
209
|
+
if (receipt.tool !== 'neurain-organize') throw new Error(`Not an organize receipt: ${receiptRel}`);
|
|
210
|
+
// The receipt records the folder it was applied to. Refuse to remove files from a
|
|
211
|
+
// different folder than that recorded target, so a stray --rollback in the wrong
|
|
212
|
+
// cwd can never delete from an unintended directory.
|
|
213
|
+
try {
|
|
214
|
+
if (exists(receipt.target) && fs.realpathSync(receipt.target) !== fs.realpathSync(target)) {
|
|
215
|
+
throw new Error(`receipt target does not match --root: ${receipt.target} != ${target}`);
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (/receipt target does not match/.test(e.message || '')) throw e;
|
|
219
|
+
// realpath failure on a missing path is non-fatal: the receipt was still loaded
|
|
220
|
+
// from inside `target` via safeResolve, so it belongs to this folder.
|
|
221
|
+
}
|
|
222
|
+
const removed = [];
|
|
223
|
+
const kept = [];
|
|
224
|
+
// Remove copies (originals are untouched in copy mode) then adapters, hash-guarded.
|
|
225
|
+
for (const c of receipt.copied_files || []) {
|
|
226
|
+
const abs = safeResolve(target, c.to);
|
|
227
|
+
if (exists(abs) && sha256(fs.readFileSync(abs)) === c.sha256) { fs.rmSync(abs); removed.push(c.to); }
|
|
228
|
+
else kept.push(c.to);
|
|
229
|
+
}
|
|
230
|
+
for (const rel of receipt.adapter_files || []) {
|
|
231
|
+
const abs = safeResolve(target, rel);
|
|
232
|
+
const expected = receipt.adapter_file_hashes?.[rel];
|
|
233
|
+
if (exists(abs) && expected && sha256(fs.readFileSync(abs)) === expected) { fs.rmSync(abs); removed.push(rel); }
|
|
234
|
+
else if (exists(abs)) kept.push(rel);
|
|
235
|
+
}
|
|
236
|
+
return { ok: kept.length === 0, tool: 'neurain-organize-rollback', removed_files: removed, kept_files: kept };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function organizeCommand(args) {
|
|
240
|
+
// --rollback passes the folder via --root (no positional), the same convention as
|
|
241
|
+
// adopt rollback; preview/apply pass it positionally. Accept either.
|
|
242
|
+
const target = absPath(args._[0] || args.folder || args.root || process.cwd());
|
|
243
|
+
|
|
244
|
+
if (args.rollback) {
|
|
245
|
+
// Rollback deletes files, so the folder must be named explicitly (never the cwd):
|
|
246
|
+
// require --root or a positional, so a stray rollback can't target the wrong dir.
|
|
247
|
+
if (!args.root && !args._[0] && !args.folder) {
|
|
248
|
+
const msg = 'organize --rollback requires --root "<folder>" (the folder the receipt was applied to).';
|
|
249
|
+
if (args.json) return { json: true, payload: { ok: false, command: 'organize', error: msg } };
|
|
250
|
+
return { text: `# Neurain organize rollback\n\n- ${msg}` };
|
|
251
|
+
}
|
|
252
|
+
const out = rollbackOrganize(target, String(args.rollback));
|
|
253
|
+
if (args.json) return { json: true, payload: out };
|
|
254
|
+
return { text: `# Neurain organize rollback\n\n- OK: ${out.ok ? 'yes' : 'no'}\n- Removed: ${out.removed_files.length}\n- Kept (changed/missing): ${out.kept_files.length}` };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (args.apply) {
|
|
258
|
+
const out = applyOrganize(target, { confirm: args.confirm, allowSecret: Boolean(args['allow-secret']) });
|
|
259
|
+
let reindex = null;
|
|
260
|
+
if (out.ok && !args['no-reindex']) {
|
|
261
|
+
try { reindex = (await reindexCommand({ _: [target], json: true })).payload || null; } catch (e) { reindex = { ok: false, error: String((e && e.message) || e) }; }
|
|
262
|
+
}
|
|
263
|
+
const payload = { ...out, reindex };
|
|
264
|
+
if (args.json) return { json: true, payload };
|
|
265
|
+
if (!out.ok) return { text: `# Neurain organize\n\n- 진행 불가: ${out.error}${out.need ? ` (확인 문구: "${out.need}")` : ''}` };
|
|
266
|
+
return {
|
|
267
|
+
text: [
|
|
268
|
+
'# Neurain organize - 적용 (copy 모드)',
|
|
269
|
+
'',
|
|
270
|
+
`- 영역 생성: ${out.proposed_area}`,
|
|
271
|
+
`- 문서로 정리: ${out.copied_files.length}건 (원본은 그대로)`,
|
|
272
|
+
`- 격리/원본유지: 건드리지 않음`,
|
|
273
|
+
reindex ? `- 색인: ${reindex.ok ? '등록 + 검색 준비됨' : '건너뜀 (' + (reindex.error || 'n/a') + ')'}` : '',
|
|
274
|
+
`- 되돌리기: ${out.rollback_command}`,
|
|
275
|
+
].filter(Boolean).join('\n'),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// default: read-only preview
|
|
280
|
+
const plan = planOrganize(target);
|
|
281
|
+
if (args.json) return { json: true, payload: plan };
|
|
282
|
+
return {
|
|
283
|
+
text: [
|
|
284
|
+
'# Neurain organize - 미리보기 (쓰기 없음)',
|
|
285
|
+
'',
|
|
286
|
+
`- 대상 폴더: ${plan.target}`,
|
|
287
|
+
`- 찾은 파일: ${plan.scanned_files}개${plan.truncated ? ` (분석은 ${plan.dispositioned}개까지)` : ''}`,
|
|
288
|
+
`- 구조 판정: ${plan.shape === 'structured' ? '이미 구조화됨 (원본 유지)' : plan.shape === 'loose' ? '느슨한 더미 (정리 권장)' : '혼합'}${plan.structure_signals.length ? ` [${plan.structure_signals.join(', ')}]` : ''}`,
|
|
289
|
+
`- 문서로 정리: ${plan.counts.durable_doc}개 · 원본 유지+매핑: ${plan.counts.keep_native}개 · 격리(비밀/빈파일): ${plan.counts.quarantine}개`,
|
|
290
|
+
`- 비밀/민감 파일: ${plan.private_or_secret}개 → copy 모드, 색인 제외`,
|
|
291
|
+
`- 새 영역: ${plan.proposed_area}`,
|
|
292
|
+
'',
|
|
293
|
+
`진행하려면: neurain organize "${plan.target}" --apply --confirm "${plan.confirmation_required}"`,
|
|
294
|
+
`모두 되돌릴 수 있습니다 (${plan.rollback_hint}).`,
|
|
295
|
+
].join('\n'),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Destination resolver (W-B keystone). Turns a classify/route result (area +
|
|
2
|
+
// write_intent + sensitivity) into ONE concrete canonical target path, closing the
|
|
3
|
+
// long-standing gap where the queue/compile pipeline inferred a target LAYER but
|
|
4
|
+
// left `target_path` an empty string for the agent to fill by hand (the source of
|
|
5
|
+
// wiki/ vs 10_areas/<area> vs output/ drift). Pure: builds path strings only, no
|
|
6
|
+
// writes and no fs reads beyond the timestamp.
|
|
7
|
+
//
|
|
8
|
+
// Layer/path contract (deterministic):
|
|
9
|
+
// update_current -> 10_areas/<area>/current/<slug>.md (no area -> wiki/current/)
|
|
10
|
+
// create_task -> 10_areas/<area>/memory_layer/02_task-memory/<slug>.md
|
|
11
|
+
// output_request -> output/<YYYY>/<slug>.md (ONE canonical deliverable sink)
|
|
12
|
+
// remember (durable concept) -> 10_areas/<area>/docs/<slug>.md (no area -> wiki/concepts/)
|
|
13
|
+
// evidence_only -> '' (capture already files the raw source; nothing to relocate)
|
|
14
|
+
//
|
|
15
|
+
// `output/` is the engine-canonical deliverable dir (config.mjs output_dir); a
|
|
16
|
+
// second `outputs/` sink is drift the resolver never writes to. requires_confirmation
|
|
17
|
+
// mirrors compile_desk.reviewReasons exactly so the existing save-gate semantics
|
|
18
|
+
// carry through unchanged (private / requires-user-decision / official-memory-layer
|
|
19
|
+
// / multi-area never auto-file).
|
|
20
|
+
import { slugify } from './envelope.mjs';
|
|
21
|
+
import { timestamp } from './fs.mjs';
|
|
22
|
+
|
|
23
|
+
export function inferTargetLayer(intent) {
|
|
24
|
+
if (intent === 'update_current') return 'current';
|
|
25
|
+
if (intent === 'create_task') return 'task_memory';
|
|
26
|
+
if (intent === 'output_request') return 'output';
|
|
27
|
+
if (intent === 'evidence_only') return 'raw';
|
|
28
|
+
return 'wiki';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function singleArea(area, areaCandidates) {
|
|
32
|
+
if (area) return String(area);
|
|
33
|
+
if (Array.isArray(areaCandidates) && areaCandidates.length === 1) return String(areaCandidates[0]);
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveCanonicalPath(root, vaultCfg, input = {}) {
|
|
38
|
+
const {
|
|
39
|
+
area = '',
|
|
40
|
+
area_candidates = [],
|
|
41
|
+
write_intent = 'evidence_only',
|
|
42
|
+
sensitivity = 'internal',
|
|
43
|
+
title = '',
|
|
44
|
+
slug: slugIn = '',
|
|
45
|
+
target_layer: explicitLayer = '',
|
|
46
|
+
requires_user_decision = false,
|
|
47
|
+
} = input;
|
|
48
|
+
|
|
49
|
+
const areasDir = vaultCfg.areas_dir || '10_areas';
|
|
50
|
+
const wikiDir = vaultCfg.wiki_dir || 'wiki';
|
|
51
|
+
const outputDir = vaultCfg.output_dir || 'output';
|
|
52
|
+
|
|
53
|
+
const intent = String(write_intent || 'evidence_only');
|
|
54
|
+
const layer = explicitLayer || inferTargetLayer(intent);
|
|
55
|
+
// Always run through slugify (idempotent) so an explicit slug can never carry a
|
|
56
|
+
// path-traversal payload (e.g. '../') into the canonical path.
|
|
57
|
+
const slug = slugify(slugIn || title || 'untitled');
|
|
58
|
+
const year = timestamp().slice(0, 4);
|
|
59
|
+
|
|
60
|
+
const multiArea = Array.isArray(area_candidates) && area_candidates.length > 1;
|
|
61
|
+
const resolvedArea = singleArea(area, area_candidates);
|
|
62
|
+
const areaResolved = Boolean(resolvedArea) && !multiArea;
|
|
63
|
+
|
|
64
|
+
let targetPath = '';
|
|
65
|
+
if (layer === 'raw' || intent === 'evidence_only') {
|
|
66
|
+
targetPath = ''; // capture_durable already wrote raw/<type>/<YYYY>/<MM>/...
|
|
67
|
+
} else if (layer === 'current') {
|
|
68
|
+
targetPath = areaResolved ? `${areasDir}/${resolvedArea}/current/${slug}.md` : `${wikiDir}/current/${slug}.md`;
|
|
69
|
+
} else if (layer === 'task_memory') {
|
|
70
|
+
targetPath = areaResolved ? `${areasDir}/${resolvedArea}/memory_layer/02_task-memory/${slug}.md` : `${wikiDir}/current/${slug}.md`;
|
|
71
|
+
} else if (layer === 'output') {
|
|
72
|
+
targetPath = `${outputDir}/${year}/${slug}.md`;
|
|
73
|
+
} else {
|
|
74
|
+
// durable concept (remember / default wiki)
|
|
75
|
+
targetPath = areaResolved ? `${areasDir}/${resolvedArea}/docs/${slug}.md` : `${wikiDir}/concepts/${slug}.md`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Mirror compile_desk.reviewReasons so the save-gate fires on exactly the same conditions.
|
|
79
|
+
const reasons = [];
|
|
80
|
+
if (requires_user_decision) reasons.push('requires user decision');
|
|
81
|
+
if (String(sensitivity) === 'private') reasons.push('private source');
|
|
82
|
+
if (['current', 'fact_ledger', 'task_memory'].includes(layer)) reasons.push('official memory layer');
|
|
83
|
+
if (multiArea) reasons.push('multiple area candidates');
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
target_path: targetPath,
|
|
87
|
+
target_layer: layer,
|
|
88
|
+
area_resolved: areaResolved,
|
|
89
|
+
requires_confirmation: reasons.length > 0,
|
|
90
|
+
reasons,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `structure-audit` (W-C, read-only ADVISORY). Flags the structural drift that
|
|
2
|
+
// lets a knowledge vault decay over time: area adapter gaps (a missing required
|
|
3
|
+
// _area.md/index.md/log.md/sources_map.md), the output/ vs outputs/ deliverable
|
|
4
|
+
// split (a second sink the resolver never writes to), and sources_map drift
|
|
5
|
+
// (native/project subdirs of an area not mentioned in its sources_map.md). Every
|
|
6
|
+
// finding is advisory with an offered one-liner fix — like orphans, it never fails
|
|
7
|
+
// a build and never writes. Composed into `lint` and surfaced in status/wrap.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { absPath, exists, readText } from './fs.mjs';
|
|
11
|
+
import { vaultConfig } from './config.mjs';
|
|
12
|
+
import { discoverAreas } from './label_intel.mjs';
|
|
13
|
+
|
|
14
|
+
const REQUIRED_ADAPTERS = ['_area.md', 'index.md', 'log.md', 'sources_map.md'];
|
|
15
|
+
|
|
16
|
+
// A subdir counts as "mapped" only when its name appears as a delimited path token
|
|
17
|
+
// in sources_map.md (backticked, quoted, or as a path segment), not as an incidental
|
|
18
|
+
// substring of prose — so a common name like `src` in a sentence is not a false match.
|
|
19
|
+
function mappedInSourcesMap(sm, dir) {
|
|
20
|
+
const e = dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
return new RegExp('(^|[\\s`"\'(\\[/])' + e + '([\\s`"\').,:\\]]|/|$)', 'm').test(sm);
|
|
22
|
+
}
|
|
23
|
+
// Structural layer dirs are implicitly understood by the area standard; only
|
|
24
|
+
// NATIVE/project subdirs that aren't one of these need an explicit sources_map row.
|
|
25
|
+
const KNOWN_LAYER_DIRS = new Set([
|
|
26
|
+
'current', 'docs', 'memory_layer', 'search-index', 'decisions', 'qa', 'reviews', '_entities',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export function auditStructure(root, vaultCfg = vaultConfig(root)) {
|
|
30
|
+
const areasDir = vaultCfg.areas_dir || '10_areas';
|
|
31
|
+
const areas = discoverAreas(root);
|
|
32
|
+
|
|
33
|
+
// 1) Adapter completeness: each area should carry the four required adapter files.
|
|
34
|
+
const adapterGaps = [];
|
|
35
|
+
for (const area of areas) {
|
|
36
|
+
const areaAbs = path.join(root, areasDir, area);
|
|
37
|
+
const missing = REQUIRED_ADAPTERS.filter((f) => !exists(path.join(areaAbs, f)));
|
|
38
|
+
if (missing.length) adapterGaps.push({ area, missing });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2) Deliverable split: the engine-canonical output dir alongside a second sink.
|
|
42
|
+
const outputDir = vaultCfg.output_dir || 'output';
|
|
43
|
+
const altOutput = outputDir === 'output' ? 'outputs' : 'output';
|
|
44
|
+
const deliverableSplit = exists(path.join(root, outputDir)) && exists(path.join(root, altOutput))
|
|
45
|
+
? { canonical: outputDir, drift: altOutput }
|
|
46
|
+
: null;
|
|
47
|
+
|
|
48
|
+
// 3) sources_map drift: native/project subdirs not referenced in sources_map.md.
|
|
49
|
+
const sourcesMapDrift = [];
|
|
50
|
+
for (const area of areas) {
|
|
51
|
+
const areaAbs = path.join(root, areasDir, area);
|
|
52
|
+
const smPath = path.join(areaAbs, 'sources_map.md');
|
|
53
|
+
if (!exists(smPath)) continue; // a missing sources_map is already an adapter gap
|
|
54
|
+
const sm = readText(smPath, '');
|
|
55
|
+
let subdirs = [];
|
|
56
|
+
try {
|
|
57
|
+
subdirs = fs.readdirSync(areaAbs, { withFileTypes: true })
|
|
58
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.') && !KNOWN_LAYER_DIRS.has(d.name))
|
|
59
|
+
.map((d) => d.name);
|
|
60
|
+
} catch { subdirs = []; }
|
|
61
|
+
const unmapped = subdirs.filter((d) => !mappedInSourcesMap(sm, d));
|
|
62
|
+
if (unmapped.length) sourcesMapDrift.push({ area, unmapped });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
areas: areas.length,
|
|
67
|
+
adapter_gaps: adapterGaps,
|
|
68
|
+
deliverable_split: deliverableSplit,
|
|
69
|
+
sources_map_drift: sourcesMapDrift,
|
|
70
|
+
findings:
|
|
71
|
+
adapterGaps.length + (deliverableSplit ? 1 : 0) + sourcesMapDrift.length,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One-line offered fixes (never auto-applied — advisory).
|
|
76
|
+
function fixHints(audit) {
|
|
77
|
+
const hints = [];
|
|
78
|
+
for (const g of audit.adapter_gaps) hints.push(`${g.area}: 어댑터 ${g.missing.join(', ')} 누락 → neurain adopt --area ${g.area} 로 보강`);
|
|
79
|
+
if (audit.deliverable_split) hints.push(`산출물 폴더 분리: ${audit.deliverable_split.drift}/ 가 ${audit.deliverable_split.canonical}/ 와 공존 → 참조 확인 후 ${audit.deliverable_split.canonical}/ 로 통합`);
|
|
80
|
+
for (const d of audit.sources_map_drift) hints.push(`${d.area}: sources_map 미기재 폴더 ${d.unmapped.join(', ')} → sources_map.md 에 매핑 추가`);
|
|
81
|
+
return hints;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function structureAuditCommand(args) {
|
|
85
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
86
|
+
const audit = auditStructure(root);
|
|
87
|
+
const hints = fixHints(audit);
|
|
88
|
+
|
|
89
|
+
const payload = {
|
|
90
|
+
ok: true,
|
|
91
|
+
command: 'structure-audit',
|
|
92
|
+
durable_write: false,
|
|
93
|
+
advisory: true,
|
|
94
|
+
...audit,
|
|
95
|
+
fix_hints: hints,
|
|
96
|
+
};
|
|
97
|
+
if (args.json) return { json: true, payload };
|
|
98
|
+
|
|
99
|
+
const lines = ['# Neurain Structure Audit (구조 점검, advisory)'];
|
|
100
|
+
if (!audit.findings) {
|
|
101
|
+
lines.push('- 구조 이슈 없음. 모든 영역이 어댑터를 갖췄고 산출물 싱크/매핑이 정합합니다.');
|
|
102
|
+
return { text: lines.join('\n') };
|
|
103
|
+
}
|
|
104
|
+
lines.push(`- 점검 영역 ${audit.areas}개 · 발견 ${audit.findings}건 (모두 권고, 자동 수정 안 함)`);
|
|
105
|
+
for (const h of hints) lines.push(` - ${h}`);
|
|
106
|
+
return { text: lines.join('\n') };
|
|
107
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// `tidy` command (Janitor, read-only). Detects PROVABLY-junk vault residue that
|
|
2
|
+
// no other tool cleans: empty Obsidian scratch files (Untitled*.canvas/.base/.md),
|
|
3
|
+
// zero-byte dated daily notes, and stray empty top-level directories (e.g. a
|
|
4
|
+
// leftover `08_20260423_memory_layer/` fragment or an empty `node_modules/`).
|
|
5
|
+
//
|
|
6
|
+
// It WRITES NOTHING. It returns a quarantine plan (a list of vault-relative paths
|
|
7
|
+
// with reason codes) that the vault `neurain-tidy` shuttle feeds to the existing
|
|
8
|
+
// recoverable-delete tool (`neurain-trash.mjs`, 30-day recoverable). Detection is
|
|
9
|
+
// byte-exact, not heuristic: a candidate is only ever an empty/template-only file
|
|
10
|
+
// or a recursively-empty directory, so a real canvas the user drew on, a non-empty
|
|
11
|
+
// note, or a structural scaffolding folder is never a candidate.
|
|
12
|
+
//
|
|
13
|
+
// Safety rails: GENERATED/dotfile dirs and `_trash` are pruned; empty-directory
|
|
14
|
+
// detection runs only at the vault TOP LEVEL and never flags a known structural
|
|
15
|
+
// dir (00_system, 10_areas, raw, output, wiki, ...), so registered areas and
|
|
16
|
+
// valid-empty layer folders are untouched.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { absPath, GENERATED_NAMES, generatedPath, relPath } from './fs.mjs';
|
|
20
|
+
import { vaultConfig } from './config.mjs';
|
|
21
|
+
|
|
22
|
+
const SCRATCH_CANVAS_RE = /^Untitled.*\.canvas$/i;
|
|
23
|
+
const SCRATCH_BASE_RE = /^Untitled.*\.base$/i;
|
|
24
|
+
const SCRATCH_MD_RE = /^Untitled.*\.md$/i;
|
|
25
|
+
const DAILY_NOTE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
|
|
26
|
+
const NUMBERED_FRAGMENT_RE = /^\d{2}_\d{8}_/;
|
|
27
|
+
|
|
28
|
+
// Directories we never descend into for scratch detection: config/runtime (not
|
|
29
|
+
// knowledge), the trash itself, and `_archive` (deliberate frozen snapshots must
|
|
30
|
+
// stay byte-for-byte, even if they contain empty files — preserving an archive is
|
|
31
|
+
// the whole point of it).
|
|
32
|
+
const PRUNE_DIR_NAMES = new Set([...GENERATED_NAMES, '.obsidian', '.claude', '.agents', '_trash', '_archive']);
|
|
33
|
+
|
|
34
|
+
// Structural top-level dirs that are ALLOWED to be empty (valid scaffolding) and
|
|
35
|
+
// must never be flagged as a stray fragment. Derived from the vault config so a
|
|
36
|
+
// re-pathed layout works identically.
|
|
37
|
+
function knownTopDirs(vaultCfg) {
|
|
38
|
+
return new Set([
|
|
39
|
+
vaultCfg.system_dir, vaultCfg.areas_dir, vaultCfg.hubs_dir, vaultCfg.archive_dir,
|
|
40
|
+
vaultCfg.output_dir, vaultCfg.raw_dir, vaultCfg.wiki_dir,
|
|
41
|
+
'outputs', '_trash', '_archive', '.obsidian', '.claude', '.git', '.agents', '.neurain-staging',
|
|
42
|
+
].filter(Boolean));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function classifyScratchFile(absFile, name) {
|
|
46
|
+
let size = 0;
|
|
47
|
+
try { size = fs.statSync(absFile).size; } catch { return null; }
|
|
48
|
+
if (SCRATCH_CANVAS_RE.test(name)) {
|
|
49
|
+
if (size <= 5) return { reason: 'empty_scratch_canvas', kind: 'file', bytes: size };
|
|
50
|
+
let body = '';
|
|
51
|
+
try { body = fs.readFileSync(absFile, 'utf8').trim(); } catch { return null; }
|
|
52
|
+
if (body === '' || body === '{}' || body === '[]') return { reason: 'empty_scratch_canvas', kind: 'file', bytes: size };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (SCRATCH_BASE_RE.test(name)) {
|
|
56
|
+
// Only an EMPTY body or the DEFAULT Obsidian base stub (a single unconfigured
|
|
57
|
+
// table view, no user filters/formulas/properties) counts as accidental junk.
|
|
58
|
+
// A byte threshold alone could flag a real small base, so match the content.
|
|
59
|
+
if (size === 0) return { reason: 'empty_scratch_base', kind: 'file', bytes: 0 };
|
|
60
|
+
if (size > 256) return null;
|
|
61
|
+
let body = '';
|
|
62
|
+
try { body = fs.readFileSync(absFile, 'utf8').trim(); } catch { return null; }
|
|
63
|
+
if (body === '' || /^views:\s*\n\s*-\s*type:\s*table\s*\n\s*name:\s*table\s*$/i.test(body)) {
|
|
64
|
+
return { reason: 'empty_scratch_base', kind: 'file', bytes: size };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (SCRATCH_MD_RE.test(name) && size === 0) return { reason: 'empty_scratch_md', kind: 'file', bytes: 0 };
|
|
69
|
+
if (DAILY_NOTE_RE.test(name) && size === 0) return { reason: 'empty_daily_note', kind: 'file', bytes: 0 };
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// True if the directory subtree contains at least one regular file or symlink
|
|
74
|
+
// (a `.DS_Store` is ignored — its own removal is handled elsewhere). An empty
|
|
75
|
+
// subdirectory chain (dirs but no files) counts as empty.
|
|
76
|
+
function subtreeHasContent(absDir) {
|
|
77
|
+
let entries = [];
|
|
78
|
+
try { entries = fs.readdirSync(absDir, { withFileTypes: true }); } catch { return true; }
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
if (e.name === '.DS_Store') continue;
|
|
81
|
+
if (e.isSymbolicLink()) return true;
|
|
82
|
+
if (e.isFile()) return true;
|
|
83
|
+
if (e.isDirectory() && subtreeHasContent(path.join(absDir, e.name))) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function detectJunk(root, vaultCfg = vaultConfig(root), { maxFiles = 20000 } = {}) {
|
|
89
|
+
const candidates = [];
|
|
90
|
+
|
|
91
|
+
// 1) Scratch files anywhere (byte-exact empty/template-only).
|
|
92
|
+
const walk = (dir) => {
|
|
93
|
+
if (candidates.length >= maxFiles) return;
|
|
94
|
+
let entries = [];
|
|
95
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
if (candidates.length >= maxFiles) return;
|
|
98
|
+
const abs = path.join(dir, e.name);
|
|
99
|
+
const rel = relPath(root, abs);
|
|
100
|
+
if (e.isSymbolicLink()) continue;
|
|
101
|
+
if (e.isDirectory()) {
|
|
102
|
+
if (PRUNE_DIR_NAMES.has(e.name) || generatedPath(rel)) continue;
|
|
103
|
+
walk(abs);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!e.isFile()) continue;
|
|
107
|
+
const hit = classifyScratchFile(abs, e.name);
|
|
108
|
+
if (hit) candidates.push({ rel, ...hit });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(root);
|
|
112
|
+
|
|
113
|
+
// 2) Stray empty directories at the vault TOP LEVEL only (never inside areas).
|
|
114
|
+
const known = knownTopDirs(vaultCfg);
|
|
115
|
+
let topEntries = [];
|
|
116
|
+
try { topEntries = fs.readdirSync(root, { withFileTypes: true }); } catch { topEntries = []; }
|
|
117
|
+
for (const e of topEntries) {
|
|
118
|
+
if (!e.isDirectory() || e.isSymbolicLink()) continue;
|
|
119
|
+
if (known.has(e.name) || e.name.startsWith('.')) continue;
|
|
120
|
+
const abs = path.join(root, e.name);
|
|
121
|
+
if (subtreeHasContent(abs)) continue;
|
|
122
|
+
const reason = NUMBERED_FRAGMENT_RE.test(e.name) ? 'stray_root_fragment' : 'orphan_empty_dir';
|
|
123
|
+
candidates.push({ rel: e.name, reason, kind: 'dir', bytes: 0 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return candidates.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const REASON_LABEL = {
|
|
130
|
+
empty_scratch_canvas: '빈 Obsidian 캔버스',
|
|
131
|
+
empty_scratch_base: '빈 Obsidian base',
|
|
132
|
+
empty_scratch_md: '빈 Untitled 노트',
|
|
133
|
+
empty_daily_note: '빈 일일노트',
|
|
134
|
+
orphan_empty_dir: '빈 폴더',
|
|
135
|
+
stray_root_fragment: '빈 잔여 폴더',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export async function tidyCommand(args) {
|
|
139
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
140
|
+
const vaultCfg = vaultConfig(root);
|
|
141
|
+
const candidates = detectJunk(root, vaultCfg);
|
|
142
|
+
|
|
143
|
+
const byReason = {};
|
|
144
|
+
for (const c of candidates) byReason[c.reason] = (byReason[c.reason] || 0) + 1;
|
|
145
|
+
|
|
146
|
+
const payload = {
|
|
147
|
+
ok: true,
|
|
148
|
+
command: 'tidy',
|
|
149
|
+
durable_write: false,
|
|
150
|
+
total: candidates.length,
|
|
151
|
+
by_reason: byReason,
|
|
152
|
+
files: candidates.filter((c) => c.kind === 'file').map((c) => c.rel),
|
|
153
|
+
dirs: candidates.filter((c) => c.kind === 'dir').map((c) => c.rel),
|
|
154
|
+
candidates,
|
|
155
|
+
};
|
|
156
|
+
if (args.json) return { json: true, payload };
|
|
157
|
+
|
|
158
|
+
const lines = ['# neurain tidy (정리 후보 = 복구가능 휴지통 이동 대상)'];
|
|
159
|
+
if (!candidates.length) {
|
|
160
|
+
lines.push('- 정리할 잡파일이 없습니다. 깨끗합니다.');
|
|
161
|
+
return { text: lines.join('\n') };
|
|
162
|
+
}
|
|
163
|
+
lines.push(`- 총 ${candidates.length}건 (${Object.entries(byReason).map(([k, v]) => `${REASON_LABEL[k] || k} ${v}`).join(', ')})`);
|
|
164
|
+
lines.push('- 전부 휴지통으로 안전 이동(30일 복구 가능). 내용 있는 파일은 후보에서 자동 제외됩니다.');
|
|
165
|
+
for (const c of candidates) lines.push(` - ${c.rel} (${REASON_LABEL[c.reason] || c.reason})`);
|
|
166
|
+
return { text: lines.join('\n') };
|
|
167
|
+
}
|