obsidian-second-brain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/config/projects.example.json +13 -0
  4. package/dist/artifact-frontmatter.js +46 -0
  5. package/dist/changeset.js +18 -0
  6. package/dist/classify.js +50 -0
  7. package/dist/cli-claude-backend.js +40 -0
  8. package/dist/cli.js +337 -0
  9. package/dist/config.js +208 -0
  10. package/dist/consolidation-backend.js +86 -0
  11. package/dist/consolidation.js +321 -0
  12. package/dist/episode-file.js +61 -0
  13. package/dist/episode-patch.js +28 -0
  14. package/dist/episode-prompt.js +43 -0
  15. package/dist/filesystem.js +86 -0
  16. package/dist/git.js +61 -0
  17. package/dist/ingest-writer.js +86 -0
  18. package/dist/ingest.js +217 -0
  19. package/dist/init.js +343 -0
  20. package/dist/install-manifest.js +56 -0
  21. package/dist/lock.js +73 -0
  22. package/dist/logger.js +19 -0
  23. package/dist/managed-section.js +30 -0
  24. package/dist/manifest.js +64 -0
  25. package/dist/ollama-backend.js +49 -0
  26. package/dist/plist.js +23 -0
  27. package/dist/render-claude-jsonl.js +179 -0
  28. package/dist/render-jsonl-markdown.js +116 -0
  29. package/dist/report.js +244 -0
  30. package/dist/shell.js +84 -0
  31. package/dist/slug.js +16 -0
  32. package/dist/sync.js +103 -0
  33. package/dist/synthesis.js +14 -0
  34. package/dist/uninstall.js +80 -0
  35. package/package.json +44 -0
  36. package/templates/claude-md-section.md +12 -0
  37. package/templates/launchd-weekly.plist.template +35 -0
  38. package/templates/launchd.plist.template +28 -0
  39. package/templates/vault-agents.md +124 -0
  40. package/templates/vault-claude-md.md +1 -0
  41. package/templates/vault-gitignore +12 -0
  42. package/templates/wiki-index.md +7 -0
  43. package/templates/wiki-log.md +1 -0
  44. package/templates/wrap-command.md +99 -0
package/dist/sync.js ADDED
@@ -0,0 +1,103 @@
1
+ import { copyFile, readFile, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { hashRenderedBody } from "./changeset.js";
4
+ import { cleanupEmptyDirectories, collectFiles, ensureDirectory, ensureWritableFilePath, removePathEntry } from "./filesystem.js";
5
+ import { renderClaudeJsonlToMarkdown } from "./render-claude-jsonl.js";
6
+ import { renderJsonlToMarkdown } from "./render-jsonl-markdown.js";
7
+ export async function syncProviderTree(input) {
8
+ await ensureDirectory(input.destinationRoot);
9
+ const sourceFiles = await collectFiles(input.sourceRoot);
10
+ const expectedDestinationFiles = collectExpectedDestinationFiles(sourceFiles);
11
+ const renderedFiles = [];
12
+ let copiedFiles = 0;
13
+ let renderedMarkdownFiles = 0;
14
+ for (const sourceFile of sourceFiles) {
15
+ const destinationRelativePath = mapSourceToDestinationPath(sourceFile.relativePath);
16
+ const destinationPath = join(input.destinationRoot, destinationRelativePath);
17
+ await ensureDirectory(dirname(destinationPath));
18
+ await ensureWritableFilePath(destinationPath);
19
+ if (sourceFile.relativePath.endsWith(".jsonl")) {
20
+ const sourceContent = await readFile(sourceFile.absolutePath, "utf8");
21
+ const rendered = input.providerName === "claude"
22
+ ? renderClaudeJsonlToMarkdown({
23
+ generatedAt: input.generatedAt,
24
+ relativePath: sourceFile.relativePath,
25
+ sourceContent
26
+ })
27
+ : renderJsonlToMarkdown({
28
+ generatedAt: input.generatedAt,
29
+ providerName: input.providerName,
30
+ relativePath: sourceFile.relativePath,
31
+ sourceContent
32
+ });
33
+ renderedFiles.push(toRenderedFile({
34
+ destinationPath,
35
+ markdown: rendered.markdown,
36
+ metadata: rendered.metadata,
37
+ providerName: input.providerName,
38
+ relativePath: sourceFile.relativePath,
39
+ sourcePath: sourceFile.absolutePath
40
+ }));
41
+ await writeFile(destinationPath, rendered.markdown);
42
+ renderedMarkdownFiles += 1;
43
+ continue;
44
+ }
45
+ await copyFile(sourceFile.absolutePath, destinationPath);
46
+ copiedFiles += 1;
47
+ }
48
+ const destinationFiles = await collectFiles(input.destinationRoot);
49
+ let deletedFiles = 0;
50
+ for (const destinationFile of destinationFiles) {
51
+ if (expectedDestinationFiles.has(destinationFile.relativePath)) {
52
+ continue;
53
+ }
54
+ await removePathEntry(destinationFile.absolutePath);
55
+ deletedFiles += 1;
56
+ }
57
+ await cleanupEmptyDirectories(input.destinationRoot);
58
+ return {
59
+ copiedFiles,
60
+ deletedFiles,
61
+ renderedFiles,
62
+ renderedMarkdownFiles
63
+ };
64
+ }
65
+ function toRenderedFile(input) {
66
+ // The relative path is the only per-file-unique identity: subagent
67
+ // transcripts carry the PARENT sessionId in their events (611 of 686 real
68
+ // files) and workflow journals all share the basename journal.jsonl, so
69
+ // neither field converges as a key. Transcripts are track-only (no
70
+ // synthesis), so a rename merely re-tracks the file at zero cost.
71
+ const sessionId = input.relativePath.replace(/\.jsonl$/u, "");
72
+ const segments = input.relativePath.split(/[\\/]/u);
73
+ const firstSegment = segments.length > 1 ? segments[0] : undefined;
74
+ const project = input.metadata.cwd
75
+ ? basename(input.metadata.cwd)
76
+ : firstSegment ?? "unknown";
77
+ return {
78
+ contentHash: hashRenderedBody(input.markdown),
79
+ destinationPath: input.destinationPath,
80
+ project,
81
+ providerName: input.providerName,
82
+ sessionId,
83
+ sessionStartedAt: input.metadata.sessionStartedAt,
84
+ sourcePath: input.sourcePath
85
+ };
86
+ }
87
+ function mapSourceToDestinationPath(relativePath) {
88
+ if (relativePath.endsWith(".jsonl")) {
89
+ return relativePath.replace(/\.jsonl$/u, ".md");
90
+ }
91
+ return relativePath;
92
+ }
93
+ function collectExpectedDestinationFiles(sourceFiles) {
94
+ const expectedDestinationFiles = new Set();
95
+ for (const sourceFile of sourceFiles) {
96
+ const destinationRelativePath = mapSourceToDestinationPath(sourceFile.relativePath);
97
+ if (expectedDestinationFiles.has(destinationRelativePath)) {
98
+ throw new Error(`Destination path collision: ${destinationRelativePath}`);
99
+ }
100
+ expectedDestinationFiles.add(destinationRelativePath);
101
+ }
102
+ return expectedDestinationFiles;
103
+ }
@@ -0,0 +1,14 @@
1
+ export const noopBackend = {
2
+ name: "noop",
3
+ synthesize: async () => ({ episodes: [] })
4
+ };
5
+ export function resolveBackend(name, backends = {}) {
6
+ if (name === "noop") {
7
+ return noopBackend;
8
+ }
9
+ const backend = backends[name];
10
+ if (backend !== undefined) {
11
+ return backend;
12
+ }
13
+ throw new Error(`Unknown synthesis backend: ${name} (available: noop, cli-claude, ollama)`);
14
+ }
@@ -0,0 +1,80 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { writeFileAtomic } from "./filesystem.js";
4
+ import { HOURLY_LABEL, installManifestPath, WEEKLY_LABEL } from "./init.js";
5
+ import { isUnchangedInstalledFile, loadInstallManifest } from "./install-manifest.js";
6
+ import { hasManagedSection, removeManagedSection } from "./managed-section.js";
7
+ import { removeLaunchdJob } from "./plist.js";
8
+ export async function runUninstall(options, dependencies) {
9
+ const manifest = await loadInstallManifest(installManifestPath(dependencies.appDir));
10
+ const removed = [];
11
+ const warnings = [];
12
+ // 1. launchd jobs: bootout always; the plist files go through the same
13
+ // manifest-unchanged rule as every other generated file.
14
+ for (const label of [HOURLY_LABEL, WEEKLY_LABEL]) {
15
+ await removeLaunchdJob({
16
+ exec: dependencies.exec,
17
+ label,
18
+ launchAgentsDir: dependencies.launchAgentsDir,
19
+ removeFile: async (path) => {
20
+ await removeIfUnchanged(path, manifest, removed, warnings);
21
+ },
22
+ uid: dependencies.uid
23
+ });
24
+ }
25
+ // 2. Managed CLAUDE.md section (by markers — the rest of the file is the
26
+ // user's).
27
+ await removeSectionFromClaudeMd(join(options.claudeDir, "CLAUDE.md"), removed, warnings);
28
+ // 3. Every other manifest-listed generated file (wrap command, config).
29
+ for (const filePath of Object.keys(manifest.files)) {
30
+ if (removed.includes(filePath)) {
31
+ continue;
32
+ }
33
+ await removeIfUnchanged(filePath, manifest, removed, warnings);
34
+ }
35
+ // 4. The install manifest itself. Vault content (raw/, wiki/, .git) is
36
+ // deliberately retained — it is the user's data and audit trail.
37
+ await rm(installManifestPath(dependencies.appDir), { force: true });
38
+ for (const warning of warnings) {
39
+ dependencies.log(`warning: ${warning}`);
40
+ }
41
+ return { removed, warnings };
42
+ }
43
+ async function removeIfUnchanged(filePath, manifest, removed, warnings) {
44
+ if (manifest.files[filePath] === undefined) {
45
+ return;
46
+ }
47
+ if (await isUnchangedInstalledFile(manifest, filePath)) {
48
+ await rm(filePath, { force: true });
49
+ removed.push(filePath);
50
+ return;
51
+ }
52
+ try {
53
+ await readFile(filePath, "utf8");
54
+ warnings.push(`kept ${filePath} — modified since install`);
55
+ }
56
+ catch {
57
+ // Already gone; nothing to do.
58
+ }
59
+ }
60
+ async function removeSectionFromClaudeMd(claudeMdPath, removed, warnings) {
61
+ let existing;
62
+ try {
63
+ existing = await readFile(claudeMdPath, "utf8");
64
+ }
65
+ catch {
66
+ return;
67
+ }
68
+ if (!hasManagedSection(existing)) {
69
+ return;
70
+ }
71
+ const next = removeManagedSection(existing);
72
+ if (next.length === 0) {
73
+ await rm(claudeMdPath, { force: true });
74
+ removed.push(claudeMdPath);
75
+ return;
76
+ }
77
+ await writeFileAtomic(claudeMdPath, next);
78
+ removed.push(`${claudeMdPath} (managed section)`);
79
+ warnings.push(`${claudeMdPath} kept — only the managed section was removed`);
80
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "obsidian-second-brain",
3
+ "version": "0.1.0",
4
+ "description": "Turn an Obsidian vault into a self-maintaining second brain for AI coding sessions",
5
+ "license": "MIT",
6
+ "files": [
7
+ "dist",
8
+ "templates",
9
+ "config/projects.example.json"
10
+ ],
11
+ "author": "Minh Nhat Hoang <hoangminhnhat08@gmail.com>",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/minhnhat08/second-brain.git"
15
+ },
16
+ "homepage": "https://github.com/minhnhat08/second-brain#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/minhnhat08/second-brain/issues"
19
+ },
20
+ "type": "module",
21
+ "bin": {
22
+ "second-brain": "dist/cli.js"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "prepare": "npm run build",
27
+ "typecheck": "tsc -p tsconfig.json",
28
+ "sync": "tsx src/cli.ts",
29
+ "test": "tsx --test tests/**/*.test.ts",
30
+ "coverage": "tsx --test --experimental-test-coverage tests/**/*.test.ts",
31
+ "e2e": "npm run build && tsx --test tests/e2e/cli.e2e.ts tests/e2e/lifecycle.e2e.ts tests/e2e/claude-smoke.e2e.ts"
32
+ },
33
+ "dependencies": {
34
+ "zod": "^4.1.12"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.6.0",
38
+ "tsx": "^4.20.6",
39
+ "typescript": "^5.9.3"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ }
44
+ }
@@ -0,0 +1,12 @@
1
+ <!-- BEGIN second-brain (managed by second-brain init — do not edit inside) -->
2
+ At the end of any significant task, offer /wrap. /wrap writes
3
+ {{VAULT}}/raw/artifacts/<project>/YYYY-MM-DD-<task>.<session_id>.{plan,output}.md
4
+ with frontmatter (project, task, type, session_id, date).
5
+
6
+ Second-brain retrieval: when the repository alone cannot answer — prior
7
+ decisions and their rationale, how projects relate, "did a past session
8
+ already solve this" — read {{VAULT}}/wiki/index.md and follow only the
9
+ relevant links (entity pages in wiki/, recent activity in wiki/episodes/
10
+ and wiki/reports/). The wiki records history; the repository is the source
11
+ of truth for current implementation — when they disagree, trust the code.
12
+ <!-- END second-brain -->
@@ -0,0 +1,35 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.second-brain.weekly</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>{{NODE_PATH}}</string>
10
+ <string>{{BIN_PATH}}</string>
11
+ <string>sync</string>
12
+ <string>--all</string>
13
+ <string>--consolidate</string>
14
+ <string>--run</string>
15
+ <string>weekly</string>
16
+ </array>
17
+ <key>WorkingDirectory</key>
18
+ <string>{{APP_DIR}}</string>
19
+ <key>StartCalendarInterval</key>
20
+ <dict>
21
+ <key>Weekday</key>
22
+ <integer>0</integer>
23
+ <key>Hour</key>
24
+ <integer>9</integer>
25
+ <key>Minute</key>
26
+ <integer>0</integer>
27
+ </dict>
28
+ <key>RunAtLoad</key>
29
+ <false/>
30
+ <key>StandardOutPath</key>
31
+ <string>/tmp/second-brain-weekly.stdout.log</string>
32
+ <key>StandardErrorPath</key>
33
+ <string>/tmp/second-brain-weekly.stderr.log</string>
34
+ </dict>
35
+ </plist>
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.second-brain.hourly</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>{{NODE_PATH}}</string>
10
+ <string>{{BIN_PATH}}</string>
11
+ <string>sync</string>
12
+ <string>--all</string>
13
+ <string>--ingest</string>
14
+ <string>--run</string>
15
+ <string>hourly</string>
16
+ </array>
17
+ <key>WorkingDirectory</key>
18
+ <string>{{APP_DIR}}</string>
19
+ <key>StartInterval</key>
20
+ <integer>3600</integer>
21
+ <key>RunAtLoad</key>
22
+ <true/>
23
+ <key>StandardOutPath</key>
24
+ <string>/tmp/second-brain.stdout.log</string>
25
+ <key>StandardErrorPath</key>
26
+ <string>/tmp/second-brain.stderr.log</string>
27
+ </dict>
28
+ </plist>
@@ -0,0 +1,124 @@
1
+ # LLM Wiki Schema
2
+
3
+ This is a personal knowledge base maintained by an LLM. The human curates sources and directs analysis. The LLM handles all writing, cross-referencing, and maintenance.
4
+
5
+ ## Directory Structure
6
+
7
+ ```
8
+ raw/ # Immutable source documents
9
+ raw/projects/sessions/ # Mirrored AI session transcripts — machine-managed archive
10
+ raw/artifacts/ # Curated task artifacts written by /wrap (plan + output per task)
11
+ wiki/ # LLM-generated and maintained markdown pages
12
+ wiki/index.md # Content catalog — entity/concept pages listed with summaries
13
+ wiki/log.md # Chronological record of all operations
14
+ wiki/episodes/ # Quarantined episodic notes from the automated hourly ingest
15
+ wiki/.ingest-state.json # Ingest manifest (idempotency watermark) — pipeline-owned, never hand-edit
16
+ ```
17
+
18
+ ## Conventions
19
+
20
+ - All wiki pages use `[[wikilinks]]` for cross-references (Obsidian style)
21
+ - Page filenames: kebab-case, descriptive
22
+ - Every wiki page has YAML frontmatter:
23
+
24
+ ```yaml
25
+ ---
26
+ title: Page Title
27
+ type: entity | concept | source-summary | synthesis | comparison | analysis
28
+ tags: [relevant, tags]
29
+ sources: [source-filename.md]
30
+ created: YYYY-MM-DD
31
+ updated: YYYY-MM-DD
32
+ ---
33
+ ```
34
+
35
+ ### Page Types
36
+
37
+ - **source-summary**: Summary of a single raw source. One per source file.
38
+ - **entity**: A person, organization, tool, place, or other named thing.
39
+ - **concept**: An idea, theory, methodology, or abstract topic.
40
+ - **synthesis**: A page that draws together multiple sources/concepts into an argument or narrative.
41
+ - **comparison**: Side-by-side analysis of two or more entities/concepts.
42
+ - **analysis**: An answer to a specific question, filed for future reference.
43
+
44
+ ## Automated Ingest Rules
45
+
46
+ These rules bind every ingest run — automated (hourly/weekly pipeline) or manual. They exist to prevent silent corruption of trusted pages.
47
+
48
+ ### Canonical-name resolution
49
+
50
+ Before creating any entity or concept page, resolve the canonical name first:
51
+
52
+ 1. Search `wiki/index.md` and existing page frontmatter for the same real-world thing under alternative spellings, abbreviations, and aliases.
53
+ 2. If a page already exists under another name, update that page — never create a duplicate.
54
+ 3. New page names: kebab-case, the most specific commonly-used name. Record known aliases in frontmatter (`aliases: [...]`).
55
+
56
+ ### Additive edits only
57
+
58
+ - Update pages by adding dated sections or appending to existing sections — never rewrite a page wholesale.
59
+ - Never delete prior content. When new information supersedes old, add the correction, mark the old claim explicitly, and keep both.
60
+ - Episodic notes from the hourly pass land only in `wiki/episodes/` — they never touch trusted entity/concept pages directly. Only the weekly consolidation pass may edit stable pages.
61
+
62
+ ### Index scope
63
+
64
+ `wiki/index.md` indexes entity, concept, synthesis, comparison, and analysis pages only. Never index individual session transcripts — they are archive material, reachable from project pages via source links.
65
+
66
+ ### Write order (transactional)
67
+
68
+ Every ingest run writes in this exact order:
69
+
70
+ 1. Wiki pages (entity/concept/episode pages)
71
+ 2. `wiki/index.md`
72
+ 3. `wiki/log.md`
73
+ 4. `wiki/.ingest-state.json` (manifest — last; advancing it commits the run)
74
+
75
+ A crash before step 4 leaves the run fully replayable. Never hand-edit `.ingest-state.json`.
76
+
77
+ ## Workflows
78
+
79
+ ### Query
80
+
81
+ When the user asks a question:
82
+
83
+ 1. Read `wiki/index.md` to identify relevant pages.
84
+ 2. Read the relevant pages.
85
+ 3. If needed, read raw sources for additional detail.
86
+ 4. Synthesize an answer with `[[wikilinks]]` to referenced pages.
87
+ 5. If the answer is substantial and reusable, offer to file it as an `analysis` or `synthesis` page.
88
+ 6. If filed, update `wiki/index.md` and append to `wiki/log.md`.
89
+
90
+ ### Lint
91
+
92
+ When the user asks to health-check the wiki:
93
+
94
+ 1. Check for contradictions between pages.
95
+ 2. Find stale claims superseded by newer sources.
96
+ 3. Identify orphan pages (no inbound links).
97
+ 4. Flag concepts mentioned but lacking their own page.
98
+ 5. Suggest missing cross-references.
99
+ 6. Report findings and fix issues with user approval.
100
+ 7. Append a lint entry to `wiki/log.md`.
101
+
102
+ ## Index Format (wiki/index.md)
103
+
104
+ Organized by page type. Each entry: `- [[page-name]] — one-line summary (N sources)`
105
+
106
+ ## Log Format (wiki/log.md)
107
+
108
+ Each entry:
109
+
110
+ ```
111
+ ## [YYYY-MM-DD] operation | Subject
112
+ Brief description of what was done.
113
+ Pages created: [[page1]], [[page2]]
114
+ Pages updated: [[page3]]
115
+ ```
116
+
117
+ ## Guidelines
118
+
119
+ - Never modify files in `raw/`. They are immutable source material.
120
+ - Always maintain bidirectional links — if A links to B, B should link back to A.
121
+ - When new information contradicts existing wiki content, note the contradiction explicitly and cite both sources.
122
+ - Prefer updating existing pages over creating new ones when the topic overlaps.
123
+ - Keep summaries concise. Link to source for full detail.
124
+ - Use Obsidian-compatible markdown (callouts, wikilinks, tags, footnotes).
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,12 @@
1
+ # OS noise
2
+ .DS_Store
3
+
4
+ # Obsidian volatile state
5
+ .obsidian/workspace*
6
+
7
+ # Obsidian trash
8
+ .trash/
9
+
10
+ # Mirrored session transcripts: machine-managed, regenerable by re-running
11
+ # sync, and rewritten with a fresh generated_at every hourly run.
12
+ raw/projects/
@@ -0,0 +1,7 @@
1
+ # Wiki Index
2
+
3
+ ## Source Summaries
4
+
5
+ ## Entities
6
+
7
+ ## Concepts
@@ -0,0 +1 @@
1
+ # Ingest Log
@@ -0,0 +1,99 @@
1
+ ---
2
+ description: Capture the just-finished task into the second-brain vault as curated plan + output artifacts
3
+ argument-hint: [short-task-slug]
4
+ allowed-tools: Read, Write, Bash(git rev-parse:*), Bash(date:*), Bash(ls:*), Bash(test:*), Bash(mkdir:*), Bash(mv:*)
5
+ ---
6
+
7
+ # /wrap — capture task artifacts into the vault
8
+
9
+ You just finished a task in this session and still hold its full context. Distill it
10
+ into two small, high-signal artifact files written directly into the Obsidian vault.
11
+ These artifacts are the primary input for the vault's automated wiki ingest — write
12
+ clean, intentional summaries, never a transcript dump.
13
+
14
+ ## Fixed install paths
15
+
16
+ - Artifacts root: `{{VAULT}}/raw/artifacts`
17
+
18
+ ## Pre-computed context
19
+
20
+ - Project root (git toplevel, falls back to cwd): !`git rev-parse --show-toplevel 2>/dev/null || pwd`
21
+ - Today: !`date +%Y-%m-%d`
22
+ - Session ID: ${CLAUDE_SESSION_ID}
23
+
24
+ ## Metadata rules
25
+
26
+ 1. `project` = basename of the project root above. If the session is not inside any
27
+ real project directory (home dir, /tmp, filesystem root), use the reserved
28
+ constant `_inbox` — a fixed literal, never derived from repo content. It bypasses
29
+ slugification and is the only segment allowed to start with `_`.
30
+ 2. `task` = $ARGUMENTS if non-empty, else a short slug derived from the task just
31
+ completed.
32
+ 3. `session_id` = the Session ID above. If it is empty, use the basename (minus
33
+ `.jsonl`) of the most recently modified transcript in
34
+ `~/.claude/projects/<slugified-cwd>/`.
35
+ 4. `date` = today (above).
36
+
37
+ ## Validation — mandatory before any write
38
+
39
+ Slugify `project` and `task`: lowercase, replace whitespace and `_` with `-`, drop
40
+ every character outside `[a-z0-9-]`, collapse repeated `-`, trim leading/trailing `-`.
41
+ After slugification both MUST match `^[a-z0-9][a-z0-9-]*$`; if either is empty,
42
+ abort and tell the user what failed. Never accept `.`, `..`, `/`, or `\` inside a
43
+ segment — these rules guard against prompt-injected path escapes from repo content.
44
+ Sole exception: the reserved fallback `_inbox` is used verbatim and skips the slug
45
+ pipeline (it is a fixed constant, not agent-derived input).
46
+
47
+ ## Path containment — run immediately before each write
48
+
49
+ 1. `mkdir -p` the project directory `<artifacts-root>/<project>/` first.
50
+ 2. Canonicalize and compare: `root="$(realpath "<artifacts-root>")"` and
51
+ `dir="$(realpath "<artifacts-root>/<project>")"`. Require exactly
52
+ `dir == root + "/" + project`. If `realpath` fails or the prefix differs, abort.
53
+ 3. `test -L` every path component from the vault root down to the project directory
54
+ (vault, `raw`, `artifacts`, `<project>`) — refuse if ANY component is a symlink.
55
+ 4. Immediately before each Write: the exact `.tmp-` path and the final path must
56
+ both be absent and must not be symlinks (`test -e` / `test -L`). Re-run the
57
+ `test -L` check on the final path right before the `mv`.
58
+ 5. These prose checks cannot fully close TOCTOU races; the ingest pipeline
59
+ independently re-validates every path it reads — that is the backstop. On ANY
60
+ anomaly, refuse and report instead of working around it.
61
+
62
+ ## Files to write
63
+
64
+ ```
65
+ <artifacts-root>/<project>/<date>-<task>.<session_id>.plan.md
66
+ <artifacts-root>/<project>/<date>-<task>.<session_id>.output.md
67
+ ```
68
+
69
+ Both with this frontmatter (English, no emojis):
70
+
71
+ ```yaml
72
+ ---
73
+ project: <project>
74
+ task: <one-line human-readable task description>
75
+ type: plan # or: output
76
+ session_id: <session_id>
77
+ date: <date>
78
+ ---
79
+ ```
80
+
81
+ - `plan.md` — the intent: what was asked, the approach chosen, decisions made and
82
+ why, alternatives rejected, constraints discovered.
83
+ - `output.md` — the result: what actually changed (files, commits, configs), how it
84
+ was verified, gotchas hit, follow-ups left open.
85
+
86
+ Keep each file under ~120 lines. Write for a future reader with zero session
87
+ context. Use `[[wikilinks]]` only for project-specific entities likely to have wiki
88
+ pages; plain `[text](url)` links for well-known technologies.
89
+
90
+ ## Write procedure
91
+
92
+ 1. `mkdir -p` the project directory under the artifacts root.
93
+ 2. Write each file atomically: write to `<dir>/.tmp-<final-name>` with the Write
94
+ tool, then `mv` it to the final name.
95
+ 3. Never overwrite an existing artifact: if a final path already exists, append a
96
+ `-2` (then `-3`, ...) suffix to the task slug and retry.
97
+ 4. Touch nothing else in the vault — no `wiki/` edits, no other files. The ingest
98
+ pipeline owns the wiki.
99
+ 5. Report the two final paths to the user.