pi-weave 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eran Yonai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # pi-weave
2
+
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/EranYonai/pi-weave/main/docs/logo-high-res.jpg" alt="pi-weave โ€” an agent-native knowledge workspace" width="220"/>
5
+ </p>
6
+
7
+ **An agent-native knowledge workspace for your life and your code.**
8
+
9
+ pi-weave is a [pi](https://github.com/earendil-works/pi)
10
+ extension with two faces that are secretly one:
11
+
12
+ 1. **A smart notepad with AI skills.** A persistent vault of knowledge โ€”
13
+ decisions, ideas, people, meetings โ€” stored as plain Markdown notes with
14
+ front matter under `~/.okf/notes/`. Your agent reads and writes it *with*
15
+ you; everything is editable by hand in any editor.
16
+
17
+ 2. **A repository exploration engine.** A derived, git-aware knowledge index
18
+ of the repo you're in, living at `<repo>/.okf/` โ€” structure, languages,
19
+ packages, modules, entry points, and staleness state. Rebuildable,
20
+ disposable, never the source of truth.
21
+
22
+ And one rule across both: **everything is equally usable by humans and
23
+ agents.** No opaque databases. No lock-in formats.
24
+
25
+ ```
26
+ ๐Ÿงต vault:12 ยท my-project:ok โ† pi's status line when weave is active
27
+ ```
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pi install npm:pi-weave # from npm
33
+ pi install git:github.com/EranYonai/pi-weave # from git
34
+ pi install /path/to/pi-weave # local path
35
+ ```
36
+
37
+ Or for development: `pi -e ./src/pi/index.ts`.
38
+
39
+ ## What you get
40
+
41
+ | Surface | Name | Purpose |
42
+ |---|---|---|
43
+ | Tool | `weave_note` | list / get / add / append / search vault notes |
44
+ | Tool | `weave_repo` | status / scan / overview of the `.okf` repo index |
45
+ | Command | `/weave` | workspace dashboard (vault + repository) |
46
+ | Command | `/weave-scan` | build/refresh the repository index (light) |
47
+ | Command | `/weave-scan deep` | light index + model-summarized sidecars (opt-in, incremental) |
48
+ | Command | `/weave-view` | open the local graph viewer in your browser |
49
+ | Command | `/weave-view tui` | explore the same graph in-terminal (keyboard) |
50
+ | Skill | `weave-notepad` | how the agent should take good notes |
51
+ | Skill | `weave-explore` | how the agent should explore repositories |
52
+
53
+ **`/weave-view`** starts a loopback-only server (`127.0.0.1`, random port)
54
+ and opens an interactive graph of your knowledge space: vault notes with
55
+ trust provenance (solid = human, dashed = agent, dimmed = generated),
56
+ wiki-link edges between notes, and the repository's structure anchored to
57
+ git state. It reads disk live on every refresh โ€” never a stale cache.
58
+ Zoom/scroll, drag to pan, click nodes to expand; notes open in a rendered
59
+ markdown side panel.
60
+
61
+ **`/weave-view tui`** explores the same knowledge graph in the terminal:
62
+ an expandable containment tree (Explore), a 1-hop neighborhood (Focus), a
63
+ selected-node detail view with note/`.okf` bodies, and a staleness + link
64
+ health surface โ€” all keyboard-driven and read-only. Same data as the
65
+ browser viewer (the `GraphModel` assembled from vault + repo index); a
66
+ pure, harness-free view-model (`src/pi/viewer/tui/model.ts`) backs the
67
+ `WeaveExplorer` component. See `docs/weave-view-tui-design.md`.
68
+
69
+ On session start, pi-weave detects the repository you're in, checks whether
70
+ `.okf` exists and is fresh, and says so in the status line.
71
+
72
+ **`/weave-scan deep`** is the opt-in, incremental deep pass: it refreshes the
73
+ light index and then writes a short model summary per file to
74
+ `.okf/repository/summaries/`, skipping files whose content hash is unchanged
75
+ since their last summary. It costs tokens, so it never runs implicitly.
76
+
77
+ ## The formats (why everything is portable)
78
+
79
+ Vault note (`~/.okf/notes/auth-boundary.md`):
80
+
81
+ ```markdown
82
+ ---
83
+ title: Auth boundary decision
84
+ created: 2026-08-22T09:00:00.000Z
85
+ updated: 2026-08-22T09:30:00.000Z
86
+ tags: [auth, security]
87
+ source: human
88
+ ---
89
+
90
+ JWT validation happens at the gateway becauseโ€ฆ
91
+ ```
92
+
93
+ Repository index (`<repo>/.okf/`):
94
+
95
+ ```text
96
+ .okf/
97
+ โ”œโ”€โ”€ okf.json # format version + generator + source: generated
98
+ โ””โ”€โ”€ repository/
99
+ โ”œโ”€โ”€ identity.json # name, remotes, default branch
100
+ โ”œโ”€โ”€ git.json # HEAD sha + branch + changed-file content hashes (staleness anchor)
101
+ โ””โ”€โ”€ structure.json # languages, packages, modules, entry points
102
+ ```
103
+
104
+ The `.okf` index is **derived**: delete it, rescan, lose nothing. By default
105
+ it's excluded from git locally (`.git/info/exclude`); committing it for team
106
+ sharing is a deliberate opt-in.
107
+
108
+ ## For other agent harnesses
109
+
110
+ The on-disk artifacts and `src/core` are harness-agnostic by design โ€” Claude
111
+ Code and opencode adapters are on the roadmap (docs/design.md ยง21), and the
112
+ skills follow the [Agent Skills standard](https://agentskills.io/specification).
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ npm install
118
+ npm run check # typecheck + tests with coverage gate (โ‰ฅ95%)
119
+ ```
120
+
121
+ See [AGENTS.md](AGENTS.md) for contributor/agent rules and
122
+ [docs/design.md](docs/design.md) for the full design.
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "pi-weave",
3
+ "version": "0.1.1",
4
+ "description": "An agent-native knowledge workspace for your life and your code. Smart notepad + repository exploration, readable by humans and agents alike.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "keywords": [
11
+ "pi-package",
12
+ "knowledge-graph",
13
+ "agent",
14
+ "notepad",
15
+ "repository-index"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Eran Yonai <yonai.eran@gmail.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/EranYonai/pi-weave.git"
22
+ },
23
+ "homepage": "https://github.com/EranYonai/pi-weave#readme",
24
+ "bugs": "https://github.com/EranYonai/pi-weave/issues",
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "exports": {
29
+ "./core": "./src/core/index.ts"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "skills",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/pi/index.ts"
40
+ ],
41
+ "skills": [
42
+ "./skills"
43
+ ],
44
+ "image": "https://raw.githubusercontent.com/EranYonai/pi-weave/main/docs/logo-high-res.jpg"
45
+ },
46
+ "scripts": {
47
+ "test": "vitest run",
48
+ "coverage": "vitest run --coverage",
49
+ "typecheck": "tsc --noEmit",
50
+ "check": "npm run typecheck && npm run coverage",
51
+ "prepublishOnly": "npm run check"
52
+ },
53
+ "peerDependencies": {
54
+ "@earendil-works/pi-ai": "*",
55
+ "@earendil-works/pi-coding-agent": "*",
56
+ "@earendil-works/pi-tui": "*",
57
+ "typebox": "*"
58
+ },
59
+ "devDependencies": {
60
+ "@earendil-works/pi-ai": "^0.84.2",
61
+ "@earendil-works/pi-coding-agent": "^0.84.2",
62
+ "@earendil-works/pi-tui": "^0.84.2",
63
+ "@types/node": "^24.0.0",
64
+ "@vitest/coverage-v8": "^3.2.4",
65
+ "typebox": "1.3.7",
66
+ "typescript": "^5.8.0",
67
+ "vitest": "^3.2.4"
68
+ }
69
+ }
File without changes
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: weave-explore
3
+ description: Explore a git repository through its pi-weave knowledge index (.okf). Use when starting work in an unfamiliar repo, when asked to "explore this repository", or before broad structural questions about modules, packages, or architecture.
4
+ ---
5
+
6
+ # Weave Explore
7
+
8
+ pi-weave keeps a **derived** knowledge index of the repository at
9
+ `<repo>/.okf/`. Source code is the truth; the index is a rebuildable cache.
10
+ Deleting `.okf` loses nothing โ€” rescan to regenerate it.
11
+
12
+ ## Tools
13
+
14
+ In pi, use the `weave_repo` tool (or the `/weave-scan` command). To see
15
+ the assembled graph in the terminal, run `/weave-view tui` (Explore tree,
16
+ Focus neighborhood, Health surface); `/weave-view` opens the browser viewer.
17
+ In other harnesses, read the JSON documents under `.okf/` directly.
18
+
19
+ ## Workflow
20
+
21
+ 1. **Check for an index**: `weave_repo` action=status.
22
+ - `missing` โ†’ offer to scan (`weave_repo` action=scan), or scan directly
23
+ when the user asked to explore.
24
+ - `stale` โ†’ scan again; the repository moved on.
25
+ - `fresh` โ†’ read it: action=overview.
26
+ 2. **Start from the overview**: file counts, languages, packages, module
27
+ groupings, and likely entry points. This replaces dozens of `ls`/`find`
28
+ calls.
29
+ 3. **Descend progressively** (design ยง9): only open files in modules relevant
30
+ to the user's question. The index gives you the map; the code gives you
31
+ the terrain.
32
+ 4. **Read summaries before full files**: if the repo has been deep-scanned
33
+ (`.okf/repository/summaries/` exists), read the relevant sidecars first โ€”
34
+ they tell you what a file does and its outward surface in 1โ€“3 sentences,
35
+ so you can decide whether to open the full file at all.
36
+ 5. **Answer with structure**: name modules and packages by their indexed
37
+ paths so the user can jump straight to them.
38
+
39
+ ## Deep summaries
40
+
41
+ `/weave-scan deep` creates or refreshes `.okf/repository/summaries/` โ€” one
42
+ sidecar per file, written by the session model. It is **opt-in and
43
+ incremental**: it never runs implicitly, and it only re-summarizes files
44
+ whose content hash changed since their last summary. If summaries are
45
+ missing or stale, offer `/weave-scan deep` to create or refresh them before
46
+ diving into full files.
47
+
48
+ ## On-disk layout
49
+
50
+ ```text
51
+ .okf/
52
+ โ”œโ”€โ”€ okf.json # format version + generator
53
+ โ””โ”€โ”€ repository/
54
+ โ”œโ”€โ”€ identity.json # name, remotes, default branch
55
+ โ”œโ”€โ”€ git.json # HEAD sha + branch + changed files (staleness anchor)
56
+ โ”œโ”€โ”€ structure.json # languages, packages, modules, entry points
57
+ โ””โ”€โ”€ summaries/ # deep-scan sidecars (one per file, when present)
58
+ ```
59
+
60
+ ## Trust model
61
+
62
+ Everything in `.okf` is machine-generated (`source: generated`). If the user
63
+ corrects an interpretation, that correction belongs in the vault (see the
64
+ `weave-notepad` skill) as human knowledge, not in the derived index.
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: weave-notepad
3
+ description: Take and retrieve durable notes in the pi-weave vault. Use when the user asks to remember something or to start/add to a note (aliases: notes, ai note, note-taking, note-taker), or when answering questions about past decisions, people, or projects. Also handles interview note-taking where raw dictations are appended AND expanded.
4
+ ---
5
+
6
+ # Weave Notepad
7
+
8
+ The pi-weave vault is the user's long-term memory: plain Markdown notes with
9
+ front matter under `~/.okf/notes/`. It is shared with the human โ€” anything
10
+ you write here, they can read and edit, and vice versa.
11
+
12
+ ## Tools
13
+
14
+ In pi, use the `weave_note` tool. In other harnesses (or when the tool is not
15
+ available), operate on the files directly:
16
+
17
+ - **Notes** live at `~/.okf/notes/<slug>.md` (vault root overridable via `PI_WEAVE_VAULT`).
18
+ - Each note has YAML front matter: `title`, `created`, `updated` (ISO-8601),
19
+ `tags: [..]`, and `source: human | agent | generated`.
20
+ - `weave_note` actions: `list`, `get`, `add`, `append`, `finalize`, `search`.
21
+ `finalize` restructures the body *above* the `## Raw notes` tail and preserves
22
+ the tail verbatim.
23
+
24
+ ## Dictation mode ("I will write, you expand")
25
+
26
+ When the user is dictating (e.g. "I am in an interview", "I will write, you
27
+ expand on my draft"), behave as notepad + expander:
28
+
29
+ 1. **Every raw line the user adds MUST be captured.** Append their words
30
+ verbatim to the active note (`weave_note` action=append).
31
+ 2. **Then expand it immediately.** Add `## Raw: "<user's line>"` followed by
32
+ `**Expand:** <polished, interview-ready passage>` (STAR-style when
33
+ applicable). Never append without expanding.
34
+ 3. **Terse replies.** Acknowledge briefly and show what was written. Do NOT
35
+ dump long prose in chat โ€” the expansion lives in the note, not the reply.
36
+ 4. **Small talk / questions โ†’ ask the user.** If you want to chat, clarify,
37
+ or ask something, pose it to the user directly and briefly, and wait.
38
+
39
+ ## When to take a note
40
+
41
+ Create a note **only when the user explicitly asks** for one to exist:
42
+ "start a note on X", "add to the X note", "remember this", "jot that down".
43
+ Never promote conversation into a note on your own initiative โ€” capture is
44
+ explicit by design.
45
+
46
+ ## When NOT to take a note
47
+
48
+ - Anything derivable from the repository itself (that knowledge belongs to
49
+ the `.okf` index, not the vault).
50
+ - Session-scratch information (in-progress task state).
51
+ - Secrets, credentials, or anything the user hasn't confirmed is safe to persist.
52
+
53
+ ## How to write a good note
54
+
55
+ 1. **Search first** (`weave_note` action=search): if a note exists, `append`
56
+ to it rather than creating a duplicate.
57
+ 2. Title: short noun phrase ("Auth boundary decision", not "Notes").
58
+ 3. **Scribble in, verbatim.** When the user is dictating, append their words
59
+ to the note as rough, verbatim scribbles โ€” no silent rewording. Keep them
60
+ under a `## Raw notes` tail at the end of the note.
61
+ 4. **Finalize on request.** When the user says "finalize this" / "clean this
62
+ up", restructure the body *above* the raw tail: front-loaded summary,
63
+ sections, entities, links. Use `weave_note` action=finalize (or edit the
64
+ file directly in other harnesses). Move nothing out of `## Raw notes` โ€” it
65
+ is append-only and never rewritten.
66
+ 5. Tags: 1โ€“4 lowercase tags; reuse existing tags when possible.
67
+ 6. Provenance: notes the user scribbled stay `source: human` (finalization is
68
+ editorial, not authorship) โ€” pass `source: "human"` to `add` for
69
+ user-scribbled notes. Notes you draft from scratch are `source: agent`
70
+ (the default). Never overwrite a `source: human` note's meaning; append
71
+ with a dated "Agent addendum" section instead.
72
+
73
+ ## Retrieving knowledge
74
+
75
+ Use `weave_note` action=search with the user's key terms, then `get` the best
76
+ hits. When a note and the repository index disagree, trust the repository for
77
+ facts about code and flag the discrepancy โ€” the note may be stale intent.
@@ -0,0 +1,119 @@
1
+ import type { NoteMeta, NoteSource } from "./types";
2
+ import { NOTE_SOURCES } from "./types";
3
+
4
+ /**
5
+ * Minimal YAML-front-matter handling for vault notes.
6
+ *
7
+ * Deliberately a *subset*: notes are meant to be human-editable plain text,
8
+ * so we write only `key: value` scalars and `[a, b]` inline arrays, and we
9
+ * parse exactly that. Anything richer belongs in the Markdown body.
10
+ */
11
+
12
+ export interface ParsedNoteFile {
13
+ meta: NoteMeta;
14
+ body: string;
15
+ }
16
+
17
+ /** Generic, tolerant front-matter parse (any fields) โ€” for non-note OKF files. */
18
+ export interface ParsedFrontMatter {
19
+ fields: Map<string, string>;
20
+ body: string;
21
+ }
22
+
23
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
24
+
25
+ export function quoteField(value: string): string {
26
+ // Only quote when the value could confuse our subset parser.
27
+ if (/[:#[\]]|^\s|\s$|^$/.test(value)) {
28
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
29
+ }
30
+ return value;
31
+ }
32
+
33
+ export function unquoteField(value: string): string {
34
+ const trimmed = value.trim();
35
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
36
+ return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
37
+ }
38
+ return trimmed;
39
+ }
40
+
41
+ function parseTags(value: string): string[] {
42
+ const trimmed = value.trim();
43
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
44
+ return trimmed.length > 0 ? [unquoteField(trimmed)] : [];
45
+ }
46
+ const inner = trimmed.slice(1, -1).trim();
47
+ if (inner.length === 0) return [];
48
+ return inner
49
+ .split(",")
50
+ .map((t) => unquoteField(t))
51
+ .filter((t) => t.length > 0);
52
+ }
53
+
54
+ function parseSource(value: string): NoteSource {
55
+ const raw = value.trim();
56
+ return (NOTE_SOURCES as readonly string[]).includes(raw)
57
+ ? (raw as NoteSource)
58
+ : "human";
59
+ }
60
+
61
+ /** Serialize note metadata + body to the on-disk Markdown form. */
62
+ export function serializeNote(meta: NoteMeta, body: string): string {
63
+ const lines = [
64
+ "---",
65
+ `title: ${quoteField(meta.title)}`,
66
+ `created: ${meta.created}`,
67
+ `updated: ${meta.updated}`,
68
+ `tags: [${meta.tags.map(quoteField).join(", ")}]`,
69
+ `source: ${meta.source}`,
70
+ "---",
71
+ "",
72
+ body.replace(/\s+$/, ""),
73
+ "",
74
+ ];
75
+ return lines.join("\n");
76
+ }
77
+
78
+ /**
79
+ * Parse any OKF front-matter block generically. Returns null when there is
80
+ * no leading `---` block; field parsing is best-effort (junk lines skipped).
81
+ * The body is stripped of the serializer's blank separator and trailing
82
+ * whitespace so round-trips are stable.
83
+ */
84
+ export function parseFrontMatter(text: string): ParsedFrontMatter | null {
85
+ const match = FRONTMATTER_RE.exec(text);
86
+ if (!match || match[1] === undefined) return null;
87
+ const body = text.slice(match[0].length).replace(/^\n/, "").replace(/\s+$/, "");
88
+ const fields = new Map<string, string>();
89
+ for (const line of match[1].split("\n")) {
90
+ if (line.trim().length === 0) continue;
91
+ const idx = line.indexOf(":");
92
+ if (idx <= 0) continue; // tolerate blank/junk lines, front matter is best-effort
93
+ fields.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
94
+ }
95
+ return { fields, body };
96
+ }
97
+
98
+ /**
99
+ * Parse a note file. Throws on missing/invalid front matter so callers can
100
+ * treat the file as malformed rather than guessing.
101
+ */
102
+ export function parseNoteFile(text: string): ParsedNoteFile {
103
+ const parsed = parseFrontMatter(text);
104
+ if (!parsed) {
105
+ throw new Error("Missing front matter block (expected leading --- block)");
106
+ }
107
+ const title = parsed.fields.get("title");
108
+ if (!title) {
109
+ throw new Error("Front matter is missing required field: title");
110
+ }
111
+ const meta: NoteMeta = {
112
+ title: unquoteField(title),
113
+ created: parsed.fields.get("created") ?? "",
114
+ updated: parsed.fields.get("updated") ?? "",
115
+ tags: parseTags(parsed.fields.get("tags") ?? "[]"),
116
+ source: parseSource(parsed.fields.get("source") ?? "human"),
117
+ };
118
+ return { meta, body: parsed.body };
119
+ }
@@ -0,0 +1,194 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { promises as fs } from "node:fs";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
+ import type { GitState } from "./types";
6
+
7
+ /**
8
+ * Minimal git layer for repository knowledge.
9
+ *
10
+ * Every function degrades to `null`/empty instead of throwing: callers are
11
+ * usually answering "is there repo knowledge here?" and must survive
12
+ * detached worktrees, missing git binaries, and non-repo directories.
13
+ */
14
+
15
+ export interface GitExecOptions {
16
+ timeoutMs?: number;
17
+ }
18
+
19
+ const DEFAULT_TIMEOUT_MS = 5_000;
20
+
21
+ async function git(args: string[], cwd: string, timeoutMs: number): Promise<string | null> {
22
+ return new Promise((resolve) => {
23
+ execFile("git", args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
24
+ if (err) resolve(null);
25
+ else resolve(stdout);
26
+ });
27
+ });
28
+ }
29
+
30
+ /** Absolute path of the enclosing git worktree root, or null. */
31
+ export async function findGitRoot(cwd: string, options: GitExecOptions = {}): Promise<string | null> {
32
+ const out = await git(["rev-parse", "--show-toplevel"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
33
+ const root = out?.trim();
34
+ if (!root || root.length === 0) return null;
35
+ // Normalize symlinks (macOS /var -> /private/var) so callers always get a
36
+ // canonical, comparable path.
37
+ try {
38
+ return await fs.realpath(root);
39
+ } catch {
40
+ return root;
41
+ }
42
+ }
43
+
44
+ /** Current HEAD sha, or null (unborn branch, not a repo, ...). */
45
+ export async function headSha(cwd: string, options: GitExecOptions = {}): Promise<string | null> {
46
+ const out = await git(["rev-parse", "HEAD"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
47
+ const sha = out?.trim();
48
+ return sha && sha.length > 0 ? sha : null;
49
+ }
50
+
51
+ /** Current branch name; null when detached or not a repo. */
52
+ export async function currentBranch(cwd: string, options: GitExecOptions = {}): Promise<string | null> {
53
+ const out = await git(["symbolic-ref", "--short", "HEAD"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
54
+ const branch = out?.trim();
55
+ return branch && branch.length > 0 ? branch : null;
56
+ }
57
+
58
+ /** Paths changed in the worktree relative to HEAD (porcelain v1). */
59
+ export async function changedFiles(cwd: string, options: GitExecOptions = {}): Promise<string[]> {
60
+ const out = await git(["status", "--porcelain"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
61
+ if (out === null) return [];
62
+ return out
63
+ .split("\n")
64
+ .map((line) => line.slice(3).trim()) // drop the 2 status columns + space
65
+ .map((path) => {
66
+ // Renames appear as "old -> new"; the new path is what matters.
67
+ const arrow = path.indexOf(" -> ");
68
+ return arrow >= 0 ? path.slice(arrow + 4) : path;
69
+ })
70
+ .filter((path) => path.length > 0)
71
+ .sort();
72
+ }
73
+
74
+ /** Files git knows about: tracked plus untracked-but-not-ignored. */
75
+ export async function listFiles(cwd: string, options: GitExecOptions = {}): Promise<string[] | null> {
76
+ const out = await git(
77
+ ["ls-files", "--cached", "--others", "--exclude-standard"],
78
+ cwd,
79
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
80
+ );
81
+ if (out === null) return null;
82
+ return out
83
+ .split("\n")
84
+ .filter((f) => f.length > 0)
85
+ .sort();
86
+ }
87
+
88
+ /** Configured remotes, deduplicated URLs. */
89
+ export async function remotes(cwd: string, options: GitExecOptions = {}): Promise<string[]> {
90
+ const out = await git(["remote", "-v"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
91
+ if (out === null) return [];
92
+ const urls = new Set<string>();
93
+ for (const line of out.split("\n")) {
94
+ const parts = line.split(/\s+/);
95
+ const url = parts[1];
96
+ if (url) urls.add(url);
97
+ }
98
+ return [...urls].sort();
99
+ }
100
+
101
+ /** Remote default branch (origin/HEAD), null when unknown. */
102
+ export async function defaultBranch(cwd: string, options: GitExecOptions = {}): Promise<string | null> {
103
+ const out = await git(
104
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
105
+ cwd,
106
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
107
+ );
108
+ const ref = out?.trim();
109
+ if (!ref) return null;
110
+ const prefix = "origin/";
111
+ return ref.startsWith(prefix) ? ref.slice(prefix.length) : ref;
112
+ }
113
+
114
+ /**
115
+ * Resolve a path inside the repo's *real* git dir via `git rev-parse
116
+ * --git-path`. In linked worktrees and submodules `.git` is a file pointing
117
+ * at the real git dir, so naive `<root>/.git/...` joins fail with ENOTDIR.
118
+ * Returns null when no git metadata is available.
119
+ */
120
+ async function resolveGitPath(repoRoot: string, relPath: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string | null> {
121
+ const out = await git(["rev-parse", "--git-path", relPath], repoRoot, timeoutMs);
122
+ const resolved = out?.trim();
123
+ if (!resolved || resolved.length === 0) return null;
124
+ // Normal repos print a root-relative path (".git/info/exclude"); linked
125
+ // worktrees and submodules print the real, absolute location.
126
+ return isAbsolute(resolved) ? resolved : join(repoRoot, resolved);
127
+ }
128
+
129
+ /**
130
+ * Add `.okf/` to the repo's local exclude file (info/exclude in the real
131
+ * git dir) so the derived index stays a local cache by default (design ยง15,
132
+ * Model A) without touching tracked files. Idempotent; no-op when the repo
133
+ * has no git metadata (not a repo, no git binary).
134
+ */
135
+ export async function excludeOkfLocally(repoRoot: string): Promise<void> {
136
+ const excludePath = await resolveGitPath(repoRoot, "info/exclude");
137
+ if (!excludePath) return;
138
+ let current = "";
139
+ try {
140
+ current = await fs.readFile(excludePath, "utf8");
141
+ } catch {
142
+ // no exclude file yet โ€” we will create it
143
+ }
144
+ const already = current
145
+ .split("\n")
146
+ .map((line) => line.trim())
147
+ .some((line) => line === ".okf/" || line === ".okf");
148
+ if (already) return;
149
+ await fs.mkdir(dirname(excludePath), { recursive: true });
150
+ const prefix = current.length > 0 && !current.endsWith("\n") ? "\n" : "";
151
+ await fs.writeFile(excludePath, `${current}${prefix}.okf/\n`, "utf8");
152
+ }
153
+
154
+ /**
155
+ * sha1 the worktree content of each repo-relative path. Paths without file
156
+ * content (deletions, untracked directories, unreadable entries) map to
157
+ * null โ€” their porcelain membership still anchors them.
158
+ */
159
+ export async function hashWorktreeFiles(
160
+ repoRoot: string,
161
+ paths: string[],
162
+ ): Promise<Record<string, string | null>> {
163
+ const entries = await Promise.all(
164
+ paths.map(async (path): Promise<[string, string | null]> => {
165
+ try {
166
+ const full = join(repoRoot, path);
167
+ if (!(await fs.stat(full)).isFile()) return [path, null];
168
+ return [path, createHash("sha1").update(await fs.readFile(full)).digest("hex")];
169
+ } catch {
170
+ return [path, null];
171
+ }
172
+ }),
173
+ );
174
+ return Object.fromEntries(entries);
175
+ }
176
+
177
+ /** Snapshot the current git state. Returns null when HEAD does not exist. */
178
+ export async function snapshotGitState(cwd: string, options: GitExecOptions = {}): Promise<GitState | null> {
179
+ const sha = await headSha(cwd, options);
180
+ if (!sha) return null;
181
+ const [branch, changed, root] = await Promise.all([
182
+ currentBranch(cwd, options),
183
+ changedFiles(cwd, options),
184
+ findGitRoot(cwd, options),
185
+ ]);
186
+ return {
187
+ headSha: sha,
188
+ branch: branch ?? "(detached)",
189
+ changedFiles: changed,
190
+ // Hash against the repo root: `cwd` may be a subdirectory.
191
+ changedHashes: await hashWorktreeFiles(root ?? cwd, changed),
192
+ capturedAt: new Date().toISOString(),
193
+ };
194
+ }