@aprimediet/codewalker 1.0.0 → 1.2.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 (46) hide show
  1. package/README.md +44 -50
  2. package/index.ts +6 -42
  3. package/package.json +20 -39
  4. package/prompts/codewalker.md +7 -0
  5. package/skills/codewalker/SKILL.md +43 -0
  6. package/src/cards.test.ts +88 -0
  7. package/src/cards.ts +87 -0
  8. package/src/db.test.ts +343 -0
  9. package/src/db.ts +363 -0
  10. package/src/extract/ctags-parse.test.ts +108 -0
  11. package/src/extract/ctags-parse.ts +112 -0
  12. package/src/extract/ctags.ts +51 -0
  13. package/src/extract/docs.test.ts +81 -0
  14. package/src/extract/docs.ts +169 -0
  15. package/src/extract/regex.test.ts +202 -0
  16. package/src/extract/regex.ts +192 -0
  17. package/src/format.test.ts +123 -0
  18. package/src/format.ts +69 -0
  19. package/src/git.test.ts +75 -0
  20. package/src/git.ts +62 -0
  21. package/src/index.contract.test.ts +145 -0
  22. package/src/index.ts +173 -0
  23. package/src/indexer.test.ts +138 -0
  24. package/src/indexer.ts +352 -0
  25. package/src/libs/cards.test.ts +86 -0
  26. package/src/libs/cards.ts +53 -0
  27. package/src/libs/dts.test.ts +269 -0
  28. package/src/libs/dts.ts +213 -0
  29. package/src/libs/indexer.test.ts +236 -0
  30. package/src/libs/indexer.ts +291 -0
  31. package/src/libs/resolve.test.ts +218 -0
  32. package/src/libs/resolve.ts +120 -0
  33. package/src/project.test.ts +115 -0
  34. package/src/project.ts +206 -0
  35. package/src/query.test.ts +169 -0
  36. package/src/query.ts +89 -0
  37. package/src/sync.test.ts +116 -0
  38. package/src/types.ts +117 -0
  39. package/vitest.config.ts +28 -0
  40. package/LICENSE +0 -21
  41. package/agents.ts +0 -126
  42. package/compat.ts +0 -217
  43. package/detect.ts +0 -188
  44. package/docs/PRD.md +0 -78
  45. package/prd.ts +0 -106
  46. package/skills/learn-this/SKILL.md +0 -325
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Tests for libs/resolve.ts — library dependency discovery.
3
+ *
4
+ * Covers:
5
+ * - PURE parseDependencies (deps / deps+devDeps)
6
+ * - PURE resolveTypesEntry (types → typings → index.d.ts → main)
7
+ * - Integration locateLibrary over a fixture node_modules
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+ import { parseDependencies, resolveTypesEntry, locateLibrary } from "./resolve.ts";
15
+
16
+ // ── PURE: parseDependencies ────────────────────────────────────
17
+ describe("parseDependencies", () => {
18
+ it("returns names from `dependencies`", () => {
19
+ const pkg = { dependencies: { express: "^4.0.0", lodash: "^4.17.0" } };
20
+ const result = parseDependencies(pkg);
21
+ expect(result).toEqual(["express", "lodash"]);
22
+ });
23
+
24
+ it("returns empty array when no dependencies", () => {
25
+ expect(parseDependencies({})).toEqual([]);
26
+ });
27
+
28
+ it("ignores devDependencies by default", () => {
29
+ const pkg = {
30
+ dependencies: { express: "^4.0.0" },
31
+ devDependencies: { vitest: "^1.0.0" },
32
+ };
33
+ expect(parseDependencies(pkg)).toEqual(["express"]);
34
+ });
35
+
36
+ it("includes devDependencies when includeDev=true", () => {
37
+ const pkg = {
38
+ dependencies: { express: "^4.0.0" },
39
+ devDependencies: { vitest: "^1.0.0", typescript: "^5.0.0" },
40
+ };
41
+ const result = parseDependencies(pkg, true);
42
+ expect(result).toContain("express");
43
+ expect(result).toContain("vitest");
44
+ expect(result).toContain("typescript");
45
+ expect(result).toHaveLength(3);
46
+ });
47
+
48
+ it("ignores peerDependencies and optionalDependencies", () => {
49
+ const pkg = {
50
+ dependencies: { express: "^4.0.0" },
51
+ peerDependencies: { react: "^18.0.0" },
52
+ optionalDependencies: { fsevents: "^2.0.0" },
53
+ };
54
+ expect(parseDependencies(pkg)).toEqual(["express"]);
55
+ });
56
+
57
+ it("returns empty array for null/undefined input", () => {
58
+ expect(parseDependencies(null as any)).toEqual([]);
59
+ expect(parseDependencies(undefined as any)).toEqual([]);
60
+ });
61
+ });
62
+
63
+ // ── PURE: resolveTypesEntry ────────────────────────────────────
64
+ describe("resolveTypesEntry", () => {
65
+ it("prefers `types` field", () => {
66
+ const pkg = { types: "dist/index.d.ts", typings: "lib/index.d.ts" };
67
+ expect(resolveTypesEntry(pkg)).toBe("dist/index.d.ts");
68
+ });
69
+
70
+ it("falls back to `typings` field", () => {
71
+ const pkg = { typings: "lib/index.d.ts" };
72
+ expect(resolveTypesEntry(pkg)).toBe("lib/index.d.ts");
73
+ });
74
+
75
+ it("falls back to `index.d.ts`", () => {
76
+ const pkg = {};
77
+ expect(resolveTypesEntry(pkg)).toBe("index.d.ts");
78
+ });
79
+
80
+ it("derives from `main` by swapping .js for .d.ts", () => {
81
+ const pkg = { main: "dist/main.js" };
82
+ expect(resolveTypesEntry(pkg)).toBe("dist/main.d.ts");
83
+ });
84
+
85
+ it("handles main with no extension by appending .d.ts", () => {
86
+ const pkg = { main: "dist/main" };
87
+ expect(resolveTypesEntry(pkg)).toBe("dist/main.d.ts");
88
+ });
89
+
90
+ it("returns index.d.ts when main is not present", () => {
91
+ const pkg = {};
92
+ expect(resolveTypesEntry(pkg)).toBe("index.d.ts");
93
+ });
94
+ });
95
+
96
+ // ── Integration: locateLibrary ─────────────────────────────────
97
+ describe("locateLibrary", () => {
98
+ let tmpDir: string;
99
+ let projectRoot: string;
100
+ let nodeModulesDir: string;
101
+
102
+ beforeEach(() => {
103
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-resolve-"));
104
+ projectRoot = path.join(tmpDir, "my-project");
105
+ nodeModulesDir = path.join(projectRoot, "node_modules");
106
+ fs.mkdirSync(nodeModulesDir, { recursive: true });
107
+ });
108
+
109
+ afterEach(() => {
110
+ fs.rmSync(tmpDir, { recursive: true, force: true });
111
+ });
112
+
113
+ it("returns version, dtsPath, and readmePath for a typed package", () => {
114
+ // Create a typed package
115
+ const pkgDir = path.join(nodeModulesDir, "typed-pkg");
116
+ fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
117
+
118
+ fs.writeFileSync(
119
+ path.join(pkgDir, "package.json"),
120
+ JSON.stringify({
121
+ name: "typed-pkg",
122
+ version: "2.1.0",
123
+ types: "dist/index.d.ts",
124
+ main: "dist/index.js",
125
+ }),
126
+ );
127
+
128
+ fs.writeFileSync(
129
+ path.join(pkgDir, "dist", "index.d.ts"),
130
+ "export declare function hello(): void;\n",
131
+ );
132
+
133
+ fs.writeFileSync(
134
+ path.join(pkgDir, "README.md"),
135
+ "# typed-pkg\nA typed package.\n",
136
+ );
137
+
138
+ const result = locateLibrary(projectRoot, "typed-pkg");
139
+ expect(result).not.toBeNull();
140
+ expect(result!.version).toBe("2.1.0");
141
+ expect(result!.dtsPath).toBe(path.join(pkgDir, "dist", "index.d.ts"));
142
+ expect(result!.readmePath).toBe(path.join(pkgDir, "README.md"));
143
+ });
144
+
145
+ it("returns null for a non-existent package", () => {
146
+ const result = locateLibrary(projectRoot, "non-existent-pkg");
147
+ expect(result).toBeNull();
148
+ });
149
+
150
+ it("returns null when node_modules does not exist", () => {
151
+ const noNodeModules = path.join(tmpDir, "empty-project");
152
+ fs.mkdirSync(noNodeModules);
153
+ const result = locateLibrary(noNodeModules, "anything");
154
+ expect(result).toBeNull();
155
+ });
156
+
157
+ it("returns dtsPath=null for a package with no .d.ts file", () => {
158
+ // JS-only package, no types
159
+ const pkgDir = path.join(nodeModulesDir, "js-only");
160
+ fs.mkdirSync(pkgDir);
161
+
162
+ fs.writeFileSync(
163
+ path.join(pkgDir, "package.json"),
164
+ JSON.stringify({
165
+ name: "js-only",
166
+ version: "0.5.0",
167
+ main: "index.js",
168
+ }),
169
+ );
170
+
171
+ // Create the JS file but no .d.ts
172
+ fs.writeFileSync(path.join(pkgDir, "index.js"), "module.exports = {};\n");
173
+
174
+ // Also no README
175
+ const result = locateLibrary(projectRoot, "js-only");
176
+ expect(result).not.toBeNull();
177
+ expect(result!.version).toBe("0.5.0");
178
+ expect(result!.dtsPath).toBeNull();
179
+ expect(result!.readmePath).toBeNull();
180
+ });
181
+
182
+ it("finds README.md case-insensitively (README.md or readme.md)", () => {
183
+ const pkgDir = path.join(nodeModulesDir, "readme-case");
184
+ fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
185
+
186
+ fs.writeFileSync(
187
+ path.join(pkgDir, "package.json"),
188
+ JSON.stringify({ name: "readme-case", version: "1.0.0" }),
189
+ );
190
+
191
+ // Only readme.md (lowercase)
192
+ fs.writeFileSync(path.join(pkgDir, "readme.md"), "# Lowercase readme\n");
193
+
194
+ // Need index.d.ts so it doesn't return null
195
+ fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export const foo: number;\n");
196
+
197
+ const result = locateLibrary(projectRoot, "readme-case");
198
+ expect(result).not.toBeNull();
199
+ expect(result!.readmePath).toBe(path.join(pkgDir, "readme.md"));
200
+ });
201
+
202
+ it("resolves package.json even without types field (uses index.d.ts fallback)", () => {
203
+ const pkgDir = path.join(nodeModulesDir, "no-types-field");
204
+ fs.mkdirSync(pkgDir);
205
+
206
+ fs.writeFileSync(
207
+ path.join(pkgDir, "package.json"),
208
+ JSON.stringify({ name: "no-types-field", version: "3.0.0" }),
209
+ );
210
+
211
+ fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export const bar: boolean;\n");
212
+
213
+ const result = locateLibrary(projectRoot, "no-types-field");
214
+ expect(result).not.toBeNull();
215
+ expect(result!.version).toBe("3.0.0");
216
+ expect(result!.dtsPath).toBe(path.join(pkgDir, "index.d.ts"));
217
+ });
218
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Library dependency discovery for codewalker v1.2.
3
+ *
4
+ * - `parseDependencies(pkgJson, includeDev?)`: PURE — extract dep names from package.json.
5
+ * - `resolveTypesEntry(pkgJson)`: PURE — find the .d.ts entry point.
6
+ * - `locateLibrary(projectRoot, name)`: integration — read the installed package info.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+
12
+ export interface LocateResult {
13
+ version: string;
14
+ dtsPath: string | null;
15
+ readmePath: string | null;
16
+ }
17
+
18
+ /**
19
+ * Extract dependency names from a package.json object.
20
+ * By default returns only `dependencies`; set `includeDev=true` to add `devDependencies`.
21
+ * Ignores peerDependencies and optionalDependencies.
22
+ * PURE — no I/O.
23
+ */
24
+ export function parseDependencies(
25
+ pkgJson: Record<string, any> | null | undefined,
26
+ includeDev = false,
27
+ ): string[] {
28
+ if (!pkgJson) return [];
29
+
30
+ const deps: string[] = [];
31
+
32
+ if (pkgJson.dependencies) {
33
+ deps.push(...Object.keys(pkgJson.dependencies));
34
+ }
35
+
36
+ if (includeDev && pkgJson.devDependencies) {
37
+ deps.push(...Object.keys(pkgJson.devDependencies));
38
+ }
39
+
40
+ return deps;
41
+ }
42
+
43
+ /**
44
+ * Resolve the `.d.ts` entry point for a package.
45
+ * Priority: `types` → `typings` → `index.d.ts` → derive from `main` (swap .js for .d.ts).
46
+ * Returns a relative path string.
47
+ * PURE — no I/O.
48
+ */
49
+ export function resolveTypesEntry(
50
+ pkgJson: Record<string, any> | null | undefined,
51
+ ): string {
52
+ if (!pkgJson) return "index.d.ts";
53
+
54
+ if (pkgJson.types) return pkgJson.types;
55
+ if (pkgJson.typings) return pkgJson.typings;
56
+
57
+ // Derive from `main` if present
58
+ if (pkgJson.main) {
59
+ const main = pkgJson.main as string;
60
+ // Swap .js|.mjs|.cjs endings for .d.ts; otherwise append .d.ts
61
+ if (/\.(js|mjs|cjs)$/.test(main)) {
62
+ return main.replace(/\.(js|mjs|cjs)$/, ".d.ts");
63
+ }
64
+ return main + ".d.ts";
65
+ }
66
+
67
+ return "index.d.ts";
68
+ }
69
+
70
+ /**
71
+ * Locate an installed library in `node_modules/<name>`.
72
+ * Returns null if the package or its directory does not exist.
73
+ * Integration — reads the filesystem.
74
+ */
75
+ export function locateLibrary(
76
+ projectRoot: string,
77
+ name: string,
78
+ ): LocateResult | null {
79
+ const nmDir = path.join(projectRoot, "node_modules");
80
+ if (!fs.existsSync(nmDir)) return null;
81
+
82
+ const pkgDir = path.join(nmDir, name);
83
+ const pkgJsonPath = path.join(pkgDir, "package.json");
84
+
85
+ if (!fs.existsSync(pkgJsonPath)) return null;
86
+
87
+ let pkgJson: Record<string, any>;
88
+ try {
89
+ pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
90
+ } catch {
91
+ return null;
92
+ }
93
+
94
+ const version = pkgJson.version ?? "unknown";
95
+
96
+ // Resolve .d.ts path
97
+ const typesRel = resolveTypesEntry(pkgJson);
98
+ let dtsPath: string | null = path.join(pkgDir, typesRel);
99
+ if (!fs.existsSync(dtsPath)) {
100
+ // Try common alternative locations
101
+ const altDts = path.join(pkgDir, "index.d.ts");
102
+ if (fs.existsSync(altDts)) {
103
+ dtsPath = altDts;
104
+ } else {
105
+ dtsPath = null;
106
+ }
107
+ }
108
+
109
+ // Find README (case-insensitive)
110
+ let readmePath: string | null = null;
111
+ for (const name of ["README.md", "readme.md", "Readme.md"]) {
112
+ const candidate = path.join(pkgDir, name);
113
+ if (fs.existsSync(candidate)) {
114
+ readmePath = candidate;
115
+ break;
116
+ }
117
+ }
118
+
119
+ return { version, dtsPath, readmePath };
120
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import crypto from 'node:crypto';
6
+
7
+ // We'll test project.ts after writing the test
8
+
9
+ describe('project.ts', () => {
10
+ let tmpDir: string;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-project-'));
14
+ });
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(tmpDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe('slug + id algorithm', () => {
21
+ it('generates a deterministic id from project root: slug(basename)-sha1(absRoot)[:8]', async () => {
22
+ const mod = await import('./project.ts');
23
+ const p = mod.resolveProject(tmpDir);
24
+ // slug = basename lowercased, non-alphanumeric → '-'
25
+ const basename = path.basename(tmpDir).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'project';
26
+ const hash = crypto.createHash('sha1').update(tmpDir).digest('hex').slice(0, 8);
27
+ const expectedId = `${basename}-${hash}`;
28
+ expect(p.id).toBe(expectedId);
29
+ });
30
+
31
+ it('reuses an existing .pi/<id>.md marker id (marker-wins)', async () => {
32
+ const configDir = path.join(tmpDir, '.pi');
33
+ fs.mkdirSync(configDir, { recursive: true });
34
+ const markerId = 'my-project-a1b2c3d4';
35
+ const markerPath = path.join(configDir, `${markerId}.md`);
36
+ fs.writeFileSync(markerPath, `---\npi-project: true\nid: ${markerId}\n---\n# marker\n`, 'utf-8');
37
+
38
+ const mod = await import('./project.ts');
39
+ const p = mod.resolveProject(tmpDir);
40
+ expect(p.id).toBe(markerId);
41
+ expect(p.markerPath).toBe(markerPath);
42
+ });
43
+ });
44
+
45
+ describe('findProjectRoot', () => {
46
+ it('walks up from cwd to find .git', async () => {
47
+ const gitDir = path.join(tmpDir, 'sub', 'dir');
48
+ fs.mkdirSync(gitDir, { recursive: true });
49
+ fs.writeFileSync(path.join(tmpDir, '.git'), '');
50
+ const mod = await import('./project.ts');
51
+ // We need to call resolveProject from within sub/dir
52
+ const p = mod.resolveProject(gitDir);
53
+ expect(p.root).toBe(tmpDir);
54
+ });
55
+
56
+ it('walks up from cwd to find .pi config dir', async () => {
57
+ const piDir = path.join(tmpDir, 'deep', 'nested');
58
+ fs.mkdirSync(piDir, { recursive: true });
59
+ fs.mkdirSync(path.join(tmpDir, '.pi'), { recursive: true });
60
+ const mod = await import('./project.ts');
61
+ const p = mod.resolveProject(piDir);
62
+ expect(p.root).toBe(tmpDir);
63
+ });
64
+
65
+ it('returns cwd when no marker or .git is found', async () => {
66
+ const isolated = path.join(tmpDir, 'isolated');
67
+ fs.mkdirSync(isolated, { recursive: true });
68
+ const mod = await import('./project.ts');
69
+ const p = mod.resolveProject(isolated);
70
+ expect(p.root).toBe(isolated);
71
+ });
72
+ });
73
+
74
+ describe('ProjectPaths', () => {
75
+ it('resolves codewalker paths under ~/.pi/projects/<id>/codewalker/', async () => {
76
+ const mod = await import('./project.ts');
77
+ const p = mod.resolveProject(tmpDir);
78
+ expect(p.codewalkerDir).toContain(path.join('projects', p.id, 'codewalker'));
79
+ expect(p.dbPath).toBe(path.join(p.codewalkerDir, 'index.db'));
80
+ expect(p.metaFile).toBe(path.join(p.codewalkerDir, 'meta.json'));
81
+ expect(p.entriesDir).toBe(path.join(p.codewalkerDir, 'entries'));
82
+ expect(p.symbolsDir).toBe(path.join(p.codewalkerDir, 'entries', 'symbols'));
83
+ });
84
+ });
85
+
86
+ describe('ensureProject', () => {
87
+ it('creates the codewalker directory structure and returns paths', async () => {
88
+ const mod = await import('./project.ts');
89
+ const p = await mod.ensureProject(tmpDir);
90
+ // marker file exists
91
+ expect(fs.existsSync(p.markerPath)).toBe(true);
92
+ // codewalker dir is created
93
+ expect(fs.existsSync(p.codewalkerDir)).toBe(true);
94
+ expect(fs.existsSync(p.entriesDir)).toBe(true);
95
+ expect(fs.existsSync(p.symbolsDir)).toBe(true);
96
+ // meta.json written
97
+ expect(fs.existsSync(p.metaFile)).toBe(true);
98
+ // meta.json has correct shape
99
+ const meta = JSON.parse(fs.readFileSync(p.metaFile, 'utf-8'));
100
+ expect(meta.id).toBe(p.id);
101
+ expect(meta.name).toBe(path.basename(tmpDir));
102
+ expect(Array.isArray(meta.paths)).toBe(true);
103
+ expect(meta.paths).toContain(tmpDir);
104
+ });
105
+
106
+ it('is idempotent — calling ensureProject twice does not error', async () => {
107
+ const mod = await import('./project.ts');
108
+ await mod.ensureProject(tmpDir);
109
+ await mod.ensureProject(tmpDir);
110
+ // No error means idempotent
111
+ const p = mod.resolveProject(tmpDir);
112
+ expect(fs.existsSync(p.codewalkerDir)).toBe(true);
113
+ });
114
+ });
115
+ });
package/src/project.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Project identity + path layout for codewalker.
3
+ *
4
+ * The working tree stays clean: the only artifact written into <cwd>/.pi is a single identifier
5
+ * file `<project-id>.md`. Everything else (codewalker index, cards, meta) lives globally
6
+ * under ~/.pi/projects/<project-id>/codewalker/.
7
+ *
8
+ * The project id is deterministic from the project root path (`<slug>-<hash>`) so it is stable
9
+ * across runs; if the marker already records an id (e.g. the directory was moved), that id wins,
10
+ * so codewalker follows the project rather than the path.
11
+ *
12
+ * This module is adapted from @aprimediet/memory's project.ts — same id algorithm, same marker,
13
+ * different per-extension subdirectory.
14
+ */
15
+
16
+ import * as crypto from "node:crypto";
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+
20
+ const CONFIG_DIR_NAME = ".pi";
21
+
22
+ export interface ProjectPaths {
23
+ id: string;
24
+ root: string;
25
+ configDir: string;
26
+ markerPath: string;
27
+ globalDir: string;
28
+ codewalkerDir: string;
29
+ dbPath: string;
30
+ metaFile: string;
31
+ entriesDir: string;
32
+ symbolsDir: string;
33
+ libsDir: string;
34
+ }
35
+
36
+ function piHome(): string {
37
+ // Try common pi home locations; fallback to ~/.pi
38
+ const homePi = path.join(osHomedir(), ".pi");
39
+ const projects = path.join(homePi, "projects");
40
+ // If ~/.pi exists and has a projects/ dir, use it
41
+ if (fs.existsSync(projects)) return homePi;
42
+ // Otherwise create it
43
+ fs.mkdirSync(projects, { recursive: true });
44
+ return homePi;
45
+ }
46
+
47
+ function osHomedir(): string {
48
+ return process.env.HOME || process.env.USERPROFILE || "/root";
49
+ }
50
+
51
+ export function projectsRoot(): string {
52
+ return path.join(piHome(), "projects");
53
+ }
54
+
55
+ function slug(name: string): string {
56
+ const s = name
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9]+/g, "-")
59
+ .replace(/^-+|-+$/g, "")
60
+ .slice(0, 40);
61
+ return s || "project";
62
+ }
63
+
64
+ function pathHash(abs: string): string {
65
+ return crypto.createHash("sha1").update(abs).digest("hex").slice(0, 8);
66
+ }
67
+
68
+ function findProjectRoot(cwd: string): string {
69
+ let dir = path.resolve(cwd);
70
+ for (;;) {
71
+ if (fs.existsSync(path.join(dir, CONFIG_DIR_NAME)) || fs.existsSync(path.join(dir, ".git"))) return dir;
72
+ const parent = path.dirname(dir);
73
+ if (parent === dir) return cwd;
74
+ dir = parent;
75
+ }
76
+ }
77
+
78
+ /** Read an existing marker (a .pi/*.md file with `pi-project: true`); return its id + file. */
79
+ function readMarker(configDir: string): { id: string; file: string } | null {
80
+ if (!fs.existsSync(configDir)) return null;
81
+ let names: string[];
82
+ try {
83
+ names = fs.readdirSync(configDir).filter((n) => n.endsWith(".md"));
84
+ } catch {
85
+ return null;
86
+ }
87
+ for (const name of names) {
88
+ const file = path.join(configDir, name);
89
+ try {
90
+ const content = fs.readFileSync(file, "utf-8");
91
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
92
+ if (!match) continue;
93
+ const fm = match[1] as string;
94
+ const lines = fm.split("\n");
95
+ const fmObj: Record<string, string> = {};
96
+ for (const line of lines) {
97
+ const sep = line.indexOf(":");
98
+ if (sep > 0) {
99
+ const key = line.slice(0, sep).trim();
100
+ const val = line.slice(sep + 1).trim();
101
+ fmObj[key] = val;
102
+ }
103
+ }
104
+ if (fmObj["pi-project"] === "true" && fmObj["id"]) {
105
+ return { id: fmObj["id"], file };
106
+ }
107
+ } catch {
108
+ /* not a marker */
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function pathsForId(id: string, root: string, configDir: string, markerPath: string): ProjectPaths {
115
+ const globalDir = path.join(projectsRoot(), id);
116
+ return {
117
+ id,
118
+ root,
119
+ configDir,
120
+ markerPath,
121
+ globalDir,
122
+ codewalkerDir: path.join(globalDir, "codewalker"),
123
+ dbPath: path.join(globalDir, "codewalker", "index.db"),
124
+ metaFile: path.join(globalDir, "codewalker", "meta.json"),
125
+ entriesDir: path.join(globalDir, "codewalker", "entries"),
126
+ symbolsDir: path.join(globalDir, "codewalker", "entries", "symbols"),
127
+ libsDir: path.join(globalDir, "codewalker", "entries", "libs"),
128
+ };
129
+ }
130
+
131
+ /** Resolve project identity for a cwd (read-only — does not create anything). */
132
+ export function resolveProject(cwd: string): ProjectPaths {
133
+ const root = findProjectRoot(cwd);
134
+ const configDir = path.join(root, CONFIG_DIR_NAME);
135
+ const existing = readMarker(configDir);
136
+ const id = existing?.id ?? `${slug(path.basename(root))}-${pathHash(root)}`;
137
+ const markerPath = existing?.file ?? path.join(configDir, `${id}.md`);
138
+ return pathsForId(id, root, configDir, markerPath);
139
+ }
140
+
141
+ function markerBody(id: string, createdISO: string): string {
142
+ return [
143
+ "---",
144
+ "pi-project: true",
145
+ `id: ${id}`,
146
+ `created: ${createdISO}`,
147
+ "---",
148
+ "# pi codewalker project",
149
+ "",
150
+ "This file marks this directory as a pi codewalker project. To keep your working tree clean,",
151
+ "all codewalker artifacts are stored globally — NOT here — under:",
152
+ "",
153
+ ` ~/.pi/projects/${id}/codewalker/`,
154
+ "",
155
+ "- `index.db` disposable SQLite+FTS5 index",
156
+ "- `meta.json` last-indexed commit and schema version",
157
+ "- `entries/` markdown cards (source of truth)",
158
+ "",
159
+ "Managed by @aprimediet/codewalker. Safe to commit (stable id) and safe to delete (recreated).",
160
+ "",
161
+ ].join("\n");
162
+ }
163
+
164
+ /** Create the global directory structure + the cwd marker (idempotent). Returns the paths. */
165
+ export async function ensureProject(cwd: string): Promise<ProjectPaths> {
166
+ const p = resolveProject(cwd);
167
+ const nowISO = new Date().toISOString();
168
+
169
+ for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir, p.libsDir]) {
170
+ fs.mkdirSync(dir, { recursive: true });
171
+ }
172
+
173
+ // marker in cwd (the only thing we write into the working tree)
174
+ if (!fs.existsSync(p.markerPath)) {
175
+ fs.mkdirSync(p.configDir, { recursive: true });
176
+ const tmp = `${p.markerPath}.tmp`;
177
+ fs.writeFileSync(tmp, markerBody(p.id, nowISO), { encoding: "utf-8", mode: 0o644 });
178
+ fs.renameSync(tmp, p.markerPath);
179
+ }
180
+
181
+ // meta.json — track every path this project has been seen at
182
+ interface ProjectMeta {
183
+ id: string;
184
+ name: string;
185
+ paths: string[];
186
+ created: string;
187
+ lastSeen: string;
188
+ }
189
+ let meta: ProjectMeta = { id: p.id, name: path.basename(p.root), paths: [], created: nowISO, lastSeen: nowISO };
190
+ try {
191
+ meta = { ...meta, ...(JSON.parse(fs.readFileSync(p.metaFile, "utf-8")) as ProjectMeta) };
192
+ } catch {
193
+ /* first run */
194
+ }
195
+ if (!meta.paths.includes(p.root)) meta.paths.push(p.root);
196
+ meta.lastSeen = nowISO;
197
+ try {
198
+ const tmp = `${p.metaFile}.tmp`;
199
+ fs.writeFileSync(tmp, JSON.stringify(meta, null, 2), { encoding: "utf-8", mode: 0o600 });
200
+ fs.renameSync(tmp, p.metaFile);
201
+ } catch {
202
+ /* non-fatal */
203
+ }
204
+
205
+ return p;
206
+ }