substrata-cli 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.
@@ -0,0 +1,1589 @@
1
+ // ../core/dist/index.js
2
+ import path from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
5
+ import { readFile } from "fs/promises";
6
+ import path2 from "path";
7
+ import { parse as parseYaml2, stringify as stringifyYaml2 } from "yaml";
8
+ import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
9
+ import path3 from "path";
10
+ import { readdir as readdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
11
+ import path4 from "path";
12
+ import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
13
+ import { parse as parseYaml3, stringify as stringifyYaml3 } from "yaml";
14
+ import { mkdir as mkdir2, writeFile as writeFile4 } from "fs/promises";
15
+ import path5 from "path";
16
+ import { readFileSync, writeFileSync } from "fs";
17
+ import path6 from "path";
18
+ import { lstatSync } from "fs";
19
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
20
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
21
+ import path7 from "path";
22
+ import { chmodSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
23
+ import path8 from "path";
24
+ var SubstrataError = class extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = "SubstrataError";
28
+ }
29
+ };
30
+ var ConfigError = class extends SubstrataError {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "ConfigError";
34
+ }
35
+ };
36
+ var ParseError = class extends SubstrataError {
37
+ filePath;
38
+ constructor(message, filePath) {
39
+ super(filePath ? `${message} (${filePath})` : message);
40
+ this.name = "ParseError";
41
+ this.filePath = filePath;
42
+ }
43
+ };
44
+ var SecretDetectedError = class extends SubstrataError {
45
+ findings;
46
+ constructor(findings) {
47
+ const detail = findings.map((f) => `${f.name} at line ${f.line}`).join(", ");
48
+ super(`Refusing to write: ${findings.length} potential secret(s) detected: ${detail}`);
49
+ this.name = "SecretDetectedError";
50
+ this.findings = findings;
51
+ }
52
+ };
53
+ var NotFoundError = class extends SubstrataError {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = "NotFoundError";
57
+ }
58
+ };
59
+ var SUBSTRATA_DIRNAME = ".substrata";
60
+ function toPosix(p) {
61
+ return p.split(path.sep).join("/");
62
+ }
63
+ function substrataDir(cwd) {
64
+ return path.join(cwd, SUBSTRATA_DIRNAME);
65
+ }
66
+ function configPath(cwd) {
67
+ return path.join(substrataDir(cwd), "config.yml");
68
+ }
69
+ function footprintsDir(cwd) {
70
+ return path.join(substrataDir(cwd), "footprints");
71
+ }
72
+ function memoryDir(cwd) {
73
+ return path.join(substrataDir(cwd), "memory");
74
+ }
75
+ function templatesDir(cwd) {
76
+ return path.join(substrataDir(cwd), "templates");
77
+ }
78
+ function indexPath(cwd) {
79
+ return path.join(substrataDir(cwd), "index", "footprint.sqlite");
80
+ }
81
+ function relativeToCwd(cwd, absolutePath) {
82
+ return toPosix(path.relative(cwd, absolutePath));
83
+ }
84
+ var SUFFIX_ALPHABET = "abcdefghjkmnpqrstvwxyz0123456789";
85
+ var SUFFIX_LENGTH = 6;
86
+ function slugify(title) {
87
+ const slug = title.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
88
+ return slug || "untitled";
89
+ }
90
+ function randomSuffix(length = SUFFIX_LENGTH) {
91
+ const bytes = randomBytes(length);
92
+ let out = "";
93
+ for (let i = 0; i < length; i++) {
94
+ out += SUFFIX_ALPHABET[bytes[i] % SUFFIX_ALPHABET.length];
95
+ }
96
+ return out;
97
+ }
98
+ function generateFootprintId(date, slug, suffix) {
99
+ const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
100
+ const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
101
+ const dd = String(date.getUTCDate()).padStart(2, "0");
102
+ const underscored = slug.replace(/-/g, "_");
103
+ const sfx = suffix ?? randomSuffix();
104
+ return `fp_${yyyy}${mm}${dd}_${underscored}_${sfx}`;
105
+ }
106
+ function buildFootprintFilename(date, slug, suffix) {
107
+ const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
108
+ const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
109
+ const dd = String(date.getUTCDate()).padStart(2, "0");
110
+ return `${yyyy}/${mm}/${yyyy}-${mm}-${dd}-${slug}-${suffix}.md`;
111
+ }
112
+ var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
113
+ function parseFrontmatter(raw) {
114
+ const match = FRONTMATTER_RE.exec(raw);
115
+ if (!match) {
116
+ return { frontmatter: {}, body: raw };
117
+ }
118
+ const parsed = parseYaml(match[1]);
119
+ const frontmatter = parsed && typeof parsed === "object" ? parsed : {};
120
+ return { frontmatter, body: match[2] ?? "" };
121
+ }
122
+ function serializeFrontmatter(frontmatter, body) {
123
+ const yaml = stringifyYaml(frontmatter, { lineWidth: 0 }).trimEnd();
124
+ const trimmedBody = body.replace(/^\r?\n+/, "");
125
+ return `---
126
+ ${yaml}
127
+ ---
128
+
129
+ ${trimmedBody.length > 0 ? `${trimmedBody.replace(/\s+$/, "")}
130
+ ` : ""}`;
131
+ }
132
+ function extractTitle(body) {
133
+ const match = /^#\s+(.+?)\s*$/m.exec(body);
134
+ return match ? match[1].trim() : "";
135
+ }
136
+ function renderListSection(heading, items) {
137
+ if (!items || items.length === 0) return null;
138
+ const lines = items.map((item) => `- ${item}`);
139
+ return `## ${heading}
140
+
141
+ ${lines.join("\n")}`;
142
+ }
143
+ function renderProseSection(heading, text) {
144
+ if (!text || text.trim().length === 0) return null;
145
+ return `## ${heading}
146
+
147
+ ${text.trim()}`;
148
+ }
149
+ function renderRejectedOptions(options) {
150
+ if (!options || options.length === 0) return null;
151
+ const blocks = options.map((o) => `### ${o.option}
152
+
153
+ ${o.reason.trim()}`);
154
+ return `## Rejected options
155
+
156
+ ${blocks.join("\n\n")}`;
157
+ }
158
+ function renderCommands(commands) {
159
+ if (!commands || commands.length === 0) return null;
160
+ return `## Commands run
161
+
162
+ \`\`\`bash
163
+ ${commands.join("\n")}
164
+ \`\`\``;
165
+ }
166
+ function renderFootprintBody(title, sections) {
167
+ const parts = [
168
+ `# ${title}`,
169
+ renderProseSection("Purpose", sections.purpose),
170
+ renderListSection("Decisions", sections.decisions),
171
+ renderRejectedOptions(sections.rejectedOptions),
172
+ renderProseSection("Implementation notes", sections.implementationNotes),
173
+ renderCommands(sections.commandsRun),
174
+ renderListSection("Memory learned", sections.memoryLearned),
175
+ renderProseSection("Future agent guidance", sections.futureAgentGuidance)
176
+ ];
177
+ return `${parts.filter((p) => p !== null).join("\n\n")}
178
+ `;
179
+ }
180
+ function splitSections(body) {
181
+ const lines = body.split(/\r?\n/);
182
+ const sections = [];
183
+ let current = null;
184
+ let inFence = false;
185
+ for (const line of lines) {
186
+ if (/^```/.test(line.trim())) {
187
+ inFence = !inFence;
188
+ if (current) current.content += `${line}
189
+ `;
190
+ continue;
191
+ }
192
+ const headingMatch = !inFence ? /^##\s+(.+?)\s*$/.exec(line) : null;
193
+ if (headingMatch) {
194
+ if (current) sections.push(current);
195
+ current = { heading: headingMatch[1].trim(), content: "" };
196
+ continue;
197
+ }
198
+ if (current) current.content += `${line}
199
+ `;
200
+ }
201
+ if (current) sections.push(current);
202
+ return sections;
203
+ }
204
+ function parseListItems(content) {
205
+ return content.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim()).filter((l) => l.length > 0);
206
+ }
207
+ function parseProse(content) {
208
+ return content.trim();
209
+ }
210
+ function parseCommands(content) {
211
+ const fenceMatch = /```(?:bash|sh)?\r?\n([\s\S]*?)```/.exec(content);
212
+ const inner = fenceMatch ? fenceMatch[1] : content;
213
+ return inner.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
214
+ }
215
+ function parseRejectedOptions(content) {
216
+ const options = [];
217
+ const parts = content.split(/^###\s+/m);
218
+ for (const part of parts) {
219
+ const trimmed = part.trim();
220
+ if (!trimmed) continue;
221
+ const newlineIdx = trimmed.indexOf("\n");
222
+ if (newlineIdx === -1) {
223
+ options.push({ option: trimmed, reason: "" });
224
+ continue;
225
+ }
226
+ const option = trimmed.slice(0, newlineIdx).trim();
227
+ const reason = trimmed.slice(newlineIdx + 1).trim();
228
+ options.push({ option, reason });
229
+ }
230
+ return options;
231
+ }
232
+ function parseFootprintBody(body) {
233
+ const sections = {};
234
+ for (const raw of splitSections(body)) {
235
+ const key = raw.heading.toLowerCase();
236
+ switch (key) {
237
+ case "purpose":
238
+ sections.purpose = parseProse(raw.content);
239
+ break;
240
+ case "decisions":
241
+ sections.decisions = parseListItems(raw.content);
242
+ break;
243
+ case "rejected options":
244
+ sections.rejectedOptions = parseRejectedOptions(raw.content);
245
+ break;
246
+ case "implementation notes":
247
+ sections.implementationNotes = parseProse(raw.content);
248
+ break;
249
+ case "commands run":
250
+ sections.commandsRun = parseCommands(raw.content);
251
+ break;
252
+ case "memory learned":
253
+ sections.memoryLearned = parseListItems(raw.content);
254
+ break;
255
+ case "future agent guidance":
256
+ sections.futureAgentGuidance = parseProse(raw.content);
257
+ break;
258
+ default:
259
+ break;
260
+ }
261
+ }
262
+ return sections;
263
+ }
264
+ var defaultConfig = {
265
+ schema_version: 1,
266
+ project: {
267
+ name: "substrata-demo"
268
+ },
269
+ storage: {
270
+ footprints_dir: ".substrata/footprints",
271
+ memory_dir: ".substrata/memory",
272
+ index_path: ".substrata/index/footprint.sqlite"
273
+ },
274
+ search: {
275
+ default_limit: 8,
276
+ max_context_tokens: 1600
277
+ },
278
+ security: {
279
+ redact: true,
280
+ scan_content: true,
281
+ entropy_scan: false,
282
+ entropy_min_length: 32,
283
+ block_on_secret: true,
284
+ redaction_keys: [
285
+ "token",
286
+ "apiKey",
287
+ "api_key",
288
+ "authorization",
289
+ "password",
290
+ "secret",
291
+ "cookie",
292
+ "privateKey",
293
+ "accessToken",
294
+ "refreshToken"
295
+ ]
296
+ },
297
+ agent: {
298
+ default_actor: "unknown-agent",
299
+ require_footprint_after_non_trivial_work: true
300
+ }
301
+ };
302
+ function isObject(value) {
303
+ return typeof value === "object" && value !== null && !Array.isArray(value);
304
+ }
305
+ function deepMerge(base, override) {
306
+ if (!isObject(base) || !isObject(override)) {
307
+ return override === void 0 ? base : override;
308
+ }
309
+ const result = { ...base };
310
+ for (const [key, value] of Object.entries(override)) {
311
+ if (value === void 0) continue;
312
+ const baseValue = base[key];
313
+ if (isObject(baseValue) && isObject(value)) {
314
+ result[key] = deepMerge(baseValue, value);
315
+ } else {
316
+ result[key] = value;
317
+ }
318
+ }
319
+ return result;
320
+ }
321
+ async function loadConfig(cwd) {
322
+ const file = configPath(cwd);
323
+ let raw;
324
+ try {
325
+ raw = await readFile(file, "utf8");
326
+ } catch {
327
+ throw new ConfigError(`Config not found at ${file}. Run \`substrata init\`.`);
328
+ }
329
+ let parsed;
330
+ try {
331
+ parsed = parseYaml2(raw);
332
+ } catch (err) {
333
+ throw new ConfigError(`Failed to parse ${file}: ${err.message}`);
334
+ }
335
+ if (!isObject(parsed)) {
336
+ throw new ConfigError(`Config at ${file} must be a YAML mapping.`);
337
+ }
338
+ if (parsed.schema_version !== 1) {
339
+ throw new ConfigError(
340
+ `Unsupported config schema_version: ${String(parsed.schema_version)} (expected 1).`
341
+ );
342
+ }
343
+ const base = {
344
+ ...defaultConfig,
345
+ project: { name: path2.basename(cwd) }
346
+ };
347
+ return deepMerge(base, parsed);
348
+ }
349
+ function renderConfig(projectName) {
350
+ const config = {
351
+ ...defaultConfig,
352
+ project: { name: projectName }
353
+ };
354
+ return stringifyYaml2(config, { lineWidth: 0 });
355
+ }
356
+ var DEFAULT_REDACTION_KEYS2 = [
357
+ "token",
358
+ "apiKey",
359
+ "api_key",
360
+ "authorization",
361
+ "password",
362
+ "secret",
363
+ "cookie",
364
+ "set-cookie",
365
+ "privateKey",
366
+ "accessToken",
367
+ "refreshToken"
368
+ ];
369
+ var REDACTED = "[REDACTED]";
370
+ function normalizeKey(key) {
371
+ return key.toLowerCase().replace(/[-_]/g, "");
372
+ }
373
+ function redactDeep(value, options = {}) {
374
+ const keys = options.keys ?? DEFAULT_REDACTION_KEYS2;
375
+ const replacement = options.replacement ?? REDACTED;
376
+ const normalizedKeys = new Set(keys.map(normalizeKey));
377
+ const walk = (node) => {
378
+ if (Array.isArray(node)) {
379
+ return node.map(walk);
380
+ }
381
+ if (node && typeof node === "object") {
382
+ const out = {};
383
+ for (const [k, v] of Object.entries(node)) {
384
+ if (normalizedKeys.has(normalizeKey(k))) {
385
+ out[k] = replacement;
386
+ } else {
387
+ out[k] = walk(v);
388
+ }
389
+ }
390
+ return out;
391
+ }
392
+ return node;
393
+ };
394
+ return walk(value);
395
+ }
396
+ var SECRET_PATTERNS = [
397
+ { name: "aws_access_key_id", re: /\bAKIA[0-9A-Z]{16}\b/ },
398
+ { name: "github_pat", re: /\bghp_[A-Za-z0-9]{36}\b/ },
399
+ { name: "github_fine_grained", re: /\bgithub_pat_[A-Za-z0-9_]{60,}\b/ },
400
+ { name: "gitlab_pat", re: /\bglpat-[A-Za-z0-9_-]{20}\b/ },
401
+ { name: "slack_token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
402
+ { name: "google_api_key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
403
+ { name: "openai_key", re: /\bsk-[A-Za-z0-9]{20,}\b/ },
404
+ { name: "anthropic_key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
405
+ {
406
+ name: "jwt",
407
+ re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/
408
+ },
409
+ {
410
+ name: "private_key_block",
411
+ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/
412
+ },
413
+ { name: "bearer_header", re: /\bBearer\s+[A-Za-z0-9._-]{20,}\b/ },
414
+ { name: "url_basic_auth", re: /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s:@]+@/i }
415
+ ];
416
+ function scanForSecrets(text) {
417
+ const findings = [];
418
+ const lines = text.split(/\r?\n/);
419
+ for (let i = 0; i < lines.length; i++) {
420
+ const line = lines[i];
421
+ for (const { name, re } of SECRET_PATTERNS) {
422
+ if (re.test(line)) {
423
+ findings.push({ name, line: i + 1 });
424
+ }
425
+ }
426
+ }
427
+ return findings;
428
+ }
429
+ var REQUIRED_KEYS = [
430
+ "schema_version",
431
+ "id",
432
+ "created_at",
433
+ "actor",
434
+ "work_type",
435
+ "status"
436
+ ];
437
+ var VALID_WORK_TYPES = /* @__PURE__ */ new Set([
438
+ "implementation",
439
+ "implementation_decision",
440
+ "bug_fix",
441
+ "refactor",
442
+ "investigation",
443
+ "architecture_decision",
444
+ "test_update",
445
+ "documentation"
446
+ ]);
447
+ var VALID_STATUSES = /* @__PURE__ */ new Set([
448
+ "draft",
449
+ "completed",
450
+ "superseded",
451
+ "deprecated"
452
+ ]);
453
+ function validateFrontmatter(fm, filePath) {
454
+ for (const key of REQUIRED_KEYS) {
455
+ if (fm[key] === void 0 || fm[key] === null) {
456
+ throw new ParseError(`Footprint missing required frontmatter field "${key}"`, filePath);
457
+ }
458
+ }
459
+ if (fm.schema_version !== 1) {
460
+ throw new ParseError(
461
+ `Footprint has unsupported schema_version: ${String(fm.schema_version)} (expected 1)`,
462
+ filePath
463
+ );
464
+ }
465
+ if (typeof fm.id !== "string" || fm.id.length === 0) {
466
+ throw new ParseError('Footprint "id" must be a non-empty string', filePath);
467
+ }
468
+ if (typeof fm.actor !== "string" || fm.actor.length === 0) {
469
+ throw new ParseError('Footprint "actor" must be a non-empty string', filePath);
470
+ }
471
+ if (typeof fm.created_at !== "string" || fm.created_at.length === 0) {
472
+ throw new ParseError('Footprint "created_at" must be a non-empty string', filePath);
473
+ }
474
+ if (typeof fm.work_type !== "string" || !VALID_WORK_TYPES.has(fm.work_type)) {
475
+ throw new ParseError(`Footprint has invalid work_type: ${String(fm.work_type)}`, filePath);
476
+ }
477
+ if (typeof fm.status !== "string" || !VALID_STATUSES.has(fm.status)) {
478
+ throw new ParseError(`Footprint has invalid status: ${String(fm.status)}`, filePath);
479
+ }
480
+ return fm;
481
+ }
482
+ function parseFootprint(raw, filePath) {
483
+ const { frontmatter, body } = parseFrontmatter(raw);
484
+ const fm = validateFrontmatter(frontmatter, filePath);
485
+ const title = extractTitle(body);
486
+ const sections = parseFootprintBody(body);
487
+ return { frontmatter: fm, title, body, sections, filePath, raw };
488
+ }
489
+ async function parseFootprintFile(filePath) {
490
+ let raw;
491
+ try {
492
+ raw = await readFile2(filePath, "utf8");
493
+ } catch {
494
+ throw new ParseError("Footprint file could not be read", filePath);
495
+ }
496
+ return parseFootprint(raw, filePath);
497
+ }
498
+ function mergeSupersedes(related, supersedes) {
499
+ if (!related && (!supersedes || supersedes.length === 0)) return void 0;
500
+ const merged = { ...related ?? {} };
501
+ if (supersedes && supersedes.length > 0) {
502
+ const existing = merged.supersedes ?? [];
503
+ merged.supersedes = Array.from(/* @__PURE__ */ new Set([...existing, ...supersedes]));
504
+ }
505
+ return merged;
506
+ }
507
+ function cleanFrontmatter(fm) {
508
+ const out = {
509
+ schema_version: fm.schema_version,
510
+ id: fm.id,
511
+ created_at: fm.created_at
512
+ };
513
+ if (fm.updated_at) out.updated_at = fm.updated_at;
514
+ out.actor = fm.actor;
515
+ if (fm.requester) out.requester = fm.requester;
516
+ if (fm.agent_model) out.agent_model = fm.agent_model;
517
+ out.work_type = fm.work_type;
518
+ out.status = fm.status;
519
+ if (fm.repo && (fm.repo.name || fm.repo.branch)) out.repo = fm.repo;
520
+ if (fm.related && Object.keys(fm.related).length > 0) out.related = fm.related;
521
+ if (fm.files_touched && fm.files_touched.length > 0) out.files_touched = fm.files_touched;
522
+ if (fm.tags && fm.tags.length > 0) out.tags = fm.tags;
523
+ return out;
524
+ }
525
+ function validateFilesTouched(files) {
526
+ if (!files || files.length === 0) return void 0;
527
+ return files.map((entry) => {
528
+ const f = entry.trim().replace(/\\/g, "/");
529
+ if (f === "") {
530
+ throw new ParseError("files_touched entries must be non-empty repo-relative paths");
531
+ }
532
+ if (f.startsWith("/") || /^[A-Za-z]:\//.test(f)) {
533
+ throw new ParseError(`files_touched must be repo-relative, got absolute path: ${f}`);
534
+ }
535
+ if (f.split("/").includes("..")) {
536
+ throw new ParseError(`files_touched must not contain '..' segments: ${f}`);
537
+ }
538
+ return f;
539
+ });
540
+ }
541
+ function validateRelatedUrls(related) {
542
+ for (const u of related?.urls ?? []) {
543
+ let parsed;
544
+ try {
545
+ parsed = new URL(u);
546
+ } catch {
547
+ throw new ParseError(`related.urls entry is not a valid URL: ${u}`);
548
+ }
549
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
550
+ throw new ParseError(`related.urls only allows http(s) URLs, got: ${u}`);
551
+ }
552
+ }
553
+ }
554
+ async function writeFootprint(input) {
555
+ const { cwd } = input;
556
+ const config = await loadConfig(cwd);
557
+ const createdAt = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
558
+ const date = new Date(createdAt);
559
+ const slug = slugify(input.title);
560
+ const suffix = randomSuffix();
561
+ const id = generateFootprintId(date, slug, suffix);
562
+ const relPath = buildFootprintFilename(date, slug, suffix);
563
+ const absPath = path3.join(footprintsDir(cwd), ...relPath.split("/"));
564
+ const workType = input.workType ?? "implementation";
565
+ const status = input.status ?? "completed";
566
+ const filesTouched = validateFilesTouched(input.filesTouched);
567
+ const related = mergeSupersedes(input.related, input.supersedes);
568
+ validateRelatedUrls(related);
569
+ const frontmatter = {
570
+ schema_version: 1,
571
+ id,
572
+ created_at: createdAt,
573
+ actor: input.actor,
574
+ requester: input.requester,
575
+ agent_model: input.agentModel,
576
+ work_type: workType,
577
+ status,
578
+ repo: input.repo ? redactDeep(input.repo, { keys: config.security.redaction_keys }) : void 0,
579
+ related: related ? redactDeep(related, { keys: config.security.redaction_keys }) : void 0,
580
+ files_touched: filesTouched,
581
+ tags: input.tags
582
+ };
583
+ const body = renderFootprintBody(input.title, {
584
+ purpose: input.purpose,
585
+ decisions: input.decisions,
586
+ rejectedOptions: input.rejectedOptions,
587
+ implementationNotes: input.implementationNotes,
588
+ commandsRun: input.commandsRun,
589
+ memoryLearned: input.memoryLearned,
590
+ futureAgentGuidance: input.futureAgentGuidance
591
+ });
592
+ if (config.security.scan_content) {
593
+ const findings = scanForSecrets(body);
594
+ if (findings.length > 0 && config.security.block_on_secret && !input.allowSecret) {
595
+ throw new SecretDetectedError(findings);
596
+ }
597
+ }
598
+ const raw = serializeFrontmatter(cleanFrontmatter(frontmatter), body);
599
+ await mkdir(path3.dirname(absPath), { recursive: true });
600
+ await writeFile(absPath, raw, "utf8");
601
+ return parseFootprint(raw, absPath);
602
+ }
603
+ async function walkMarkdown(dir) {
604
+ let entries;
605
+ try {
606
+ entries = await readdir(dir, { withFileTypes: true });
607
+ } catch {
608
+ return [];
609
+ }
610
+ const files = [];
611
+ for (const entry of entries) {
612
+ const full = path3.join(dir, entry.name);
613
+ if (entry.isDirectory()) {
614
+ files.push(...await walkMarkdown(full));
615
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
616
+ files.push(full);
617
+ }
618
+ }
619
+ return files;
620
+ }
621
+ async function listFootprints(cwd) {
622
+ const dir = footprintsDir(cwd);
623
+ const files = await walkMarkdown(dir);
624
+ const footprints = await Promise.all(files.map((f) => parseFootprintFile(f)));
625
+ footprints.sort((a, b) => {
626
+ const at = a.frontmatter.created_at;
627
+ const bt = b.frontmatter.created_at;
628
+ return at < bt ? 1 : at > bt ? -1 : 0;
629
+ });
630
+ return footprints;
631
+ }
632
+ async function findFootprintById(cwd, id) {
633
+ const files = await walkMarkdown(footprintsDir(cwd));
634
+ for (const file of files) {
635
+ const fp = await parseFootprintFile(file);
636
+ if (fp.frontmatter.id === id) return fp;
637
+ }
638
+ return null;
639
+ }
640
+ var ENTRIES_START = "<!-- substrata:entries:start -->";
641
+ var ENTRIES_END = "<!-- substrata:entries:end -->";
642
+ function entryOpen(id) {
643
+ return `<!-- substrata:entry id=${id} -->`;
644
+ }
645
+ var ENTRY_CLOSE = "<!-- /substrata:entry -->";
646
+ function validateMemoryFrontmatter(fm, filePath) {
647
+ if (fm.schema_version !== 1) {
648
+ throw new ParseError(
649
+ `Memory file has unsupported schema_version: ${String(fm.schema_version)} (expected 1)`,
650
+ filePath
651
+ );
652
+ }
653
+ if (typeof fm.id !== "string" || fm.id.length === 0) {
654
+ throw new ParseError('Memory "id" must be a non-empty string', filePath);
655
+ }
656
+ return fm;
657
+ }
658
+ function parseMemory(raw, filePath) {
659
+ const { frontmatter, body } = parseFrontmatter(raw);
660
+ const fm = validateMemoryFrontmatter(frontmatter, filePath);
661
+ return { frontmatter: fm, title: extractTitle(body), body, filePath, raw };
662
+ }
663
+ async function parseMemoryFile(filePath) {
664
+ let raw;
665
+ try {
666
+ raw = await readFile3(filePath, "utf8");
667
+ } catch {
668
+ throw new ParseError("Memory file could not be read", filePath);
669
+ }
670
+ return parseMemory(raw, filePath);
671
+ }
672
+ async function walkMarkdown2(dir) {
673
+ let entries;
674
+ try {
675
+ entries = await readdir2(dir, { withFileTypes: true });
676
+ } catch {
677
+ return [];
678
+ }
679
+ const files = [];
680
+ for (const entry of entries) {
681
+ const full = path4.join(dir, entry.name);
682
+ if (entry.isDirectory()) {
683
+ files.push(...await walkMarkdown2(full));
684
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
685
+ files.push(full);
686
+ }
687
+ }
688
+ return files;
689
+ }
690
+ async function listMemoryDocuments(cwd) {
691
+ const files = await walkMarkdown2(memoryDir(cwd));
692
+ files.sort();
693
+ return Promise.all(files.map((f) => parseMemoryFile(f)));
694
+ }
695
+ function existingEntryIds(content) {
696
+ const ids = /* @__PURE__ */ new Set();
697
+ const re = /<!--\s*substrata:entry\s+id=([^\s]+)\s*-->/g;
698
+ let match;
699
+ while ((match = re.exec(content)) !== null) {
700
+ ids.add(match[1]);
701
+ }
702
+ return ids;
703
+ }
704
+ function renderEntry(entry) {
705
+ return [entryOpen(entry.sourceId), ...entry.lines, ENTRY_CLOSE].join("\n");
706
+ }
707
+ async function appendMemoryEntries(filePath, entries) {
708
+ const original = await readFile3(filePath, "utf8");
709
+ const existing = existingEntryIds(original);
710
+ const toAdd = entries.filter((e) => !existing.has(e.sourceId));
711
+ if (toAdd.length === 0) return false;
712
+ const rendered = toAdd.map(renderEntry).join("\n");
713
+ let next;
714
+ const endIdx = original.indexOf(ENTRIES_END);
715
+ if (endIdx !== -1) {
716
+ const before = original.slice(0, endIdx);
717
+ const after = original.slice(endIdx);
718
+ const sep = before.endsWith("\n") ? "" : "\n";
719
+ next = `${before}${sep}${rendered}
720
+ ${after}`;
721
+ } else {
722
+ const base = original.endsWith("\n") ? original : `${original}
723
+ `;
724
+ next = `${base}
725
+ ${ENTRIES_START}
726
+ ${rendered}
727
+ ${ENTRIES_END}
728
+ `;
729
+ }
730
+ await writeFile2(filePath, next, "utf8");
731
+ return true;
732
+ }
733
+ var FRONTMATTER_RE2 = /^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)([\s\S]*)$/;
734
+ function addUnique(list, value) {
735
+ const arr = list ? [...list] : [];
736
+ if (!arr.includes(value)) arr.push(value);
737
+ return arr;
738
+ }
739
+ async function editFrontmatter(filePath, mutate) {
740
+ const raw = await readFile4(filePath, "utf8");
741
+ const match = FRONTMATTER_RE2.exec(raw);
742
+ if (!match) {
743
+ throw new NotFoundError(`Footprint at ${filePath} has no parseable frontmatter`);
744
+ }
745
+ const [, open, yamlText, close, body] = match;
746
+ const parsed = parseYaml3(yamlText);
747
+ mutate(parsed);
748
+ const newYaml = stringifyYaml3(parsed, { lineWidth: 0 }).replace(/\n$/, "");
749
+ const next = `${open}${newYaml}${close}${body}`;
750
+ await writeFile3(filePath, next, "utf8");
751
+ }
752
+ async function supersedeFootprint(cwd, oldId, newId) {
753
+ const oldFp = await findFootprintById(cwd, oldId);
754
+ if (!oldFp) throw new NotFoundError(`Footprint not found: ${oldId}`);
755
+ const newFp = await findFootprintById(cwd, newId);
756
+ if (!newFp) throw new NotFoundError(`Footprint not found: ${newId}`);
757
+ await editFrontmatter(oldFp.filePath, (fm) => {
758
+ fm.status = "superseded";
759
+ const related = fm.related ?? {};
760
+ related.superseded_by = addUnique(related.superseded_by, newId);
761
+ fm.related = related;
762
+ });
763
+ await editFrontmatter(newFp.filePath, (fm) => {
764
+ const related = fm.related ?? {};
765
+ related.supersedes = addUnique(related.supersedes, oldId);
766
+ fm.related = related;
767
+ });
768
+ }
769
+ var README_CONTENTS = `# Substrata
770
+
771
+ This directory holds shared agent memory for this repository.
772
+
773
+ - \`footprints/\` \u2014 committed source-of-truth records of agent-assisted work.
774
+ - \`memory/\` \u2014 curated, durable repo knowledge agents should read often.
775
+ - \`templates/\` \u2014 templates used when creating footprints and memory files.
776
+ - \`config.yml\` \u2014 Substrata configuration (see the docs).
777
+ - \`index/\` and \`cache/\` \u2014 generated, gitignored; safe to delete and rebuild.
778
+
779
+ Footprints and memory files are committed. Do not store secrets, credentials,
780
+ private keys, tokens, or sensitive user data here.
781
+ `;
782
+ var FOOTPRINT_TEMPLATE = `---
783
+ schema_version: 1
784
+ id: fp_YYYYMMDD_slug_suffix
785
+ created_at: 2026-01-01T00:00:00Z
786
+ actor: unknown-agent
787
+ work_type: implementation
788
+ status: completed
789
+ ---
790
+
791
+ # Title
792
+
793
+ ## Purpose
794
+
795
+ Why this work was done.
796
+
797
+ ## Decisions
798
+
799
+ - Decision one.
800
+
801
+ ## Rejected options
802
+
803
+ ### Option name
804
+
805
+ Why it was rejected.
806
+
807
+ ## Implementation notes
808
+
809
+ What was changed.
810
+
811
+ ## Commands run
812
+
813
+ \`\`\`bash
814
+ pnpm test
815
+ \`\`\`
816
+
817
+ ## Memory learned
818
+
819
+ - Something durable about this repo.
820
+
821
+ ## Future agent guidance
822
+
823
+ What a future agent should check before changing this area.
824
+ `;
825
+ var MEMORY_TEMPLATE = `---
826
+ schema_version: 1
827
+ id: mem_example
828
+ type: repo_conventions
829
+ tags:
830
+ - conventions
831
+ ---
832
+
833
+ # Memory title
834
+
835
+ ## Section
836
+
837
+ - Durable note one.
838
+
839
+ <!-- substrata:entries:start -->
840
+ <!-- substrata:entries:end -->
841
+ `;
842
+ async function exists(p) {
843
+ const { stat: stat3 } = await import("fs/promises");
844
+ try {
845
+ await stat3(p);
846
+ return true;
847
+ } catch {
848
+ return false;
849
+ }
850
+ }
851
+ async function initProject(cwd, options = {}) {
852
+ const projectName = options.projectName ?? path5.basename(path5.resolve(cwd));
853
+ const dirs = [
854
+ substrataDir(cwd),
855
+ footprintsDir(cwd),
856
+ memoryDir(cwd),
857
+ templatesDir(cwd)
858
+ ];
859
+ const files = [
860
+ { absPath: configPath(cwd), contents: renderConfig(projectName) },
861
+ { absPath: path5.join(substrataDir(cwd), "README.md"), contents: README_CONTENTS },
862
+ { absPath: path5.join(templatesDir(cwd), "footprint.md"), contents: FOOTPRINT_TEMPLATE },
863
+ { absPath: path5.join(templatesDir(cwd), "memory.md"), contents: MEMORY_TEMPLATE }
864
+ ];
865
+ const changes = [];
866
+ for (const dir of dirs) {
867
+ await mkdir2(dir, { recursive: true });
868
+ }
869
+ for (const file of files) {
870
+ if (await exists(file.absPath)) {
871
+ changes.push({
872
+ path: file.absPath,
873
+ action: "skip",
874
+ description: "already exists"
875
+ });
876
+ continue;
877
+ }
878
+ await writeFile4(file.absPath, file.contents, "utf8");
879
+ changes.push({
880
+ path: file.absPath,
881
+ action: "create",
882
+ description: "created",
883
+ contents: file.contents
884
+ });
885
+ }
886
+ return changes;
887
+ }
888
+ function isSymlink(filePath) {
889
+ try {
890
+ return lstatSync(filePath).isSymbolicLink();
891
+ } catch {
892
+ return false;
893
+ }
894
+ }
895
+ var GITIGNORE_LINES = [".substrata/index/", ".substrata/cache/", ".substrata/tmp/"];
896
+ function readIfExists(filePath) {
897
+ try {
898
+ return readFileSync(filePath, "utf8");
899
+ } catch {
900
+ return null;
901
+ }
902
+ }
903
+ function ensureGitignore(cwd, dry = false) {
904
+ const filePath = path6.join(cwd, ".gitignore");
905
+ if (isSymlink(filePath)) {
906
+ return { path: filePath, action: "skip", description: "refused: .gitignore is a symlink" };
907
+ }
908
+ const existing = readIfExists(filePath);
909
+ const present = existing ?? "";
910
+ const presentLines = new Set(present.split(/\r?\n/).map((l) => l.trim()));
911
+ const missing = GITIGNORE_LINES.filter((line) => !presentLines.has(line));
912
+ if (missing.length === 0) {
913
+ return {
914
+ path: filePath,
915
+ action: "skip",
916
+ description: existing === null ? "no gitignore needed" : "gitignore already covers Substrata"
917
+ };
918
+ }
919
+ const header = "# Substrata generated files";
920
+ const block = presentLines.has(header) ? missing.join("\n") : `${header}
921
+ ${missing.join("\n")}`;
922
+ let next;
923
+ if (existing === null) {
924
+ next = `${block}
925
+ `;
926
+ } else {
927
+ const sep = existing.endsWith("\n") ? "" : "\n";
928
+ next = `${existing}${sep}${block}
929
+ `;
930
+ }
931
+ if (!dry) writeFileSync(filePath, next, "utf8");
932
+ return {
933
+ path: filePath,
934
+ action: existing === null ? "create" : "update",
935
+ description: `add ${missing.length} gitignore line(s)`,
936
+ contents: next
937
+ };
938
+ }
939
+ var BEGIN = "# >>> substrata >>>";
940
+ var END = "# <<< substrata <<<";
941
+ function readIfExists2(filePath) {
942
+ try {
943
+ return readFileSync2(filePath, "utf8");
944
+ } catch {
945
+ return null;
946
+ }
947
+ }
948
+ function renderBlock(vars) {
949
+ const lines = [BEGIN, "# Substrata agent attribution (managed; safe to edit values)"];
950
+ if (vars.actor) lines.push(`export SUBSTRATA_ACTOR="${vars.actor}"`);
951
+ if (vars.model) lines.push(`export SUBSTRATA_MODEL="${vars.model}"`);
952
+ if (vars.requester) lines.push(`export SUBSTRATA_REQUESTER="${vars.requester}"`);
953
+ lines.push(END);
954
+ return lines.join("\n");
955
+ }
956
+ function writeShellEnv(rcPath, vars, dry = false) {
957
+ const block = renderBlock(vars);
958
+ const existing = readIfExists2(rcPath);
959
+ const markerRe = new RegExp(`${escapeRegExp(BEGIN)}[\\s\\S]*?${escapeRegExp(END)}`, "m");
960
+ let next;
961
+ let action;
962
+ if (existing === null) {
963
+ next = `${block}
964
+ `;
965
+ action = "create";
966
+ } else if (markerRe.test(existing)) {
967
+ const replaced = existing.replace(markerRe, block);
968
+ if (replaced === existing) {
969
+ return { path: rcPath, action: "skip", description: "env block already current" };
970
+ }
971
+ next = replaced;
972
+ action = "update";
973
+ } else {
974
+ const sep = existing.endsWith("\n") ? "" : "\n";
975
+ next = `${existing}${sep}
976
+ ${block}
977
+ `;
978
+ action = "update";
979
+ }
980
+ if (!dry) writeFileSync2(rcPath, next, "utf8");
981
+ return {
982
+ path: rcPath,
983
+ action,
984
+ description: "write Substrata env block",
985
+ contents: next
986
+ };
987
+ }
988
+ function escapeRegExp(s) {
989
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
990
+ }
991
+ var BEGIN2 = "<!-- substrata:start -->";
992
+ var END2 = "<!-- substrata:end -->";
993
+ var AGENTS_MD_SECTION = `${BEGIN2}
994
+ ## Substrata Rules
995
+
996
+ This repository uses Substrata for shared agent memory.
997
+
998
+ Set these once per agent session so footprints are attributed correctly:
999
+ - \`SUBSTRATA_ACTOR\` (e.g. "claude-code")
1000
+ - \`SUBSTRATA_MODEL\` (e.g. "claude-opus-4")
1001
+ - \`SUBSTRATA_REQUESTER\` (falls back to git user.email)
1002
+
1003
+ Before making non-trivial changes:
1004
+
1005
+ 1. Run \`substrata context "<task description>"\`.
1006
+ 2. Search for relevant files using \`substrata search\` or the MCP tool \`substrata_context\`.
1007
+ 3. Respect prior architectural decisions unless the user explicitly asks to override them.
1008
+
1009
+ After making non-trivial changes:
1010
+
1011
+ 1. Add a footprint with \`substrata add\` or MCP tool \`substrata_add\`.
1012
+ 2. Include: purpose, requester, actor, files changed, decisions made, rejected
1013
+ alternatives, implementation notes, commands run, memory learned, future agent guidance.
1014
+ 3. If the work changes durable repo conventions, update \`.substrata/memory/\`.
1015
+ 4. If the work reverses a prior decision, use \`substrata supersede\`.
1016
+
1017
+ Do not store secrets, credentials, private keys, tokens, or sensitive user data in Substrata files.
1018
+ ${END2}`;
1019
+ function readIfExists3(filePath) {
1020
+ try {
1021
+ return readFileSync3(filePath, "utf8");
1022
+ } catch {
1023
+ return null;
1024
+ }
1025
+ }
1026
+ function escapeRegExp2(s) {
1027
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1028
+ }
1029
+ function upsertAgentsMd(cwd, dry = false) {
1030
+ const filePath = path7.join(cwd, "AGENTS.md");
1031
+ if (isSymlink(filePath)) {
1032
+ return { path: filePath, action: "skip", description: "refused: AGENTS.md is a symlink" };
1033
+ }
1034
+ const existing = readIfExists3(filePath);
1035
+ const markerRe = new RegExp(`${escapeRegExp2(BEGIN2)}[\\s\\S]*?${escapeRegExp2(END2)}`, "m");
1036
+ let next;
1037
+ let action;
1038
+ if (existing === null) {
1039
+ next = `${AGENTS_MD_SECTION}
1040
+ `;
1041
+ action = "create";
1042
+ } else if (markerRe.test(existing)) {
1043
+ const replaced = existing.replace(markerRe, AGENTS_MD_SECTION);
1044
+ if (replaced === existing) {
1045
+ return { path: filePath, action: "skip", description: "AGENTS.md section already current" };
1046
+ }
1047
+ next = replaced;
1048
+ action = "update";
1049
+ } else {
1050
+ const sep = existing.endsWith("\n") ? "" : "\n";
1051
+ next = `${existing}${sep}
1052
+ ${AGENTS_MD_SECTION}
1053
+ `;
1054
+ action = "update";
1055
+ }
1056
+ if (!dry) writeFileSync3(filePath, next, "utf8");
1057
+ return {
1058
+ path: filePath,
1059
+ action,
1060
+ description: "write Substrata AGENTS.md section",
1061
+ contents: next
1062
+ };
1063
+ }
1064
+ var GUARD_BEGIN = "# >>> substrata pre-commit >>>";
1065
+ var GUARD_END = "# <<< substrata pre-commit <<<";
1066
+ var GUARDED_BLOCK = `${GUARD_BEGIN}
1067
+ # Substrata secret scan over staged .substrata files (second line of defense).
1068
+ npx --no-install substrata internal-scan-staged || exit 1
1069
+ ${GUARD_END}`;
1070
+ var FULL_HOOK = `#!/bin/sh
1071
+ ${GUARDED_BLOCK}
1072
+ `;
1073
+ function readIfExists4(filePath) {
1074
+ try {
1075
+ return readFileSync4(filePath, "utf8");
1076
+ } catch {
1077
+ return null;
1078
+ }
1079
+ }
1080
+ function installSecretHook(cwd, dry = false) {
1081
+ const filePath = path8.join(cwd, ".git", "hooks", "pre-commit");
1082
+ if (isSymlink(filePath)) {
1083
+ return { path: filePath, action: "skip", description: "refused: pre-commit hook is a symlink" };
1084
+ }
1085
+ const existing = readIfExists4(filePath);
1086
+ let next;
1087
+ let action;
1088
+ if (existing === null) {
1089
+ next = FULL_HOOK;
1090
+ action = "create";
1091
+ } else if (existing.includes(GUARD_BEGIN)) {
1092
+ return { path: filePath, action: "skip", description: "pre-commit hook already installed" };
1093
+ } else {
1094
+ const sep = existing.endsWith("\n") ? "" : "\n";
1095
+ next = `${existing}${sep}
1096
+ ${GUARDED_BLOCK}
1097
+ `;
1098
+ action = "update";
1099
+ }
1100
+ if (!dry) {
1101
+ writeFileSync4(filePath, next, "utf8");
1102
+ chmodSync(filePath, 493);
1103
+ }
1104
+ return {
1105
+ path: filePath,
1106
+ action,
1107
+ description: "install Substrata pre-commit secret hook",
1108
+ contents: next
1109
+ };
1110
+ }
1111
+
1112
+ // ../search/dist/index.js
1113
+ import { stat } from "fs/promises";
1114
+ import { existsSync, mkdirSync } from "fs";
1115
+ import path9 from "path";
1116
+ import Database from "better-sqlite3";
1117
+ import { readdir as readdir3, stat as stat2 } from "fs/promises";
1118
+ import path22 from "path";
1119
+ import path32 from "path";
1120
+ var SCHEMA_VERSION = 1;
1121
+ var CREATE_DOCUMENTS = `
1122
+ CREATE TABLE IF NOT EXISTS documents (
1123
+ id TEXT PRIMARY KEY,
1124
+ type TEXT NOT NULL,
1125
+ title TEXT NOT NULL,
1126
+ file_path TEXT NOT NULL,
1127
+ status TEXT,
1128
+ created_at TEXT,
1129
+ updated_at TEXT,
1130
+ tags_json TEXT NOT NULL,
1131
+ files_json TEXT NOT NULL,
1132
+ raw_text TEXT NOT NULL,
1133
+ work_type TEXT
1134
+ );
1135
+ `;
1136
+ var CREATE_DOCUMENTS_FTS = `
1137
+ CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
1138
+ id UNINDEXED,
1139
+ title,
1140
+ tags,
1141
+ files,
1142
+ content,
1143
+ tokenize = 'porter unicode61'
1144
+ );
1145
+ `;
1146
+ var CREATE_INDEX_META = `
1147
+ CREATE TABLE IF NOT EXISTS index_meta (
1148
+ key TEXT PRIMARY KEY,
1149
+ value TEXT NOT NULL
1150
+ );
1151
+ `;
1152
+ function applySchema(db) {
1153
+ db.exec(CREATE_DOCUMENTS);
1154
+ db.exec(CREATE_DOCUMENTS_FTS);
1155
+ db.exec(CREATE_INDEX_META);
1156
+ }
1157
+ function dropSchema(db) {
1158
+ db.exec("DROP TABLE IF EXISTS documents;");
1159
+ db.exec("DROP TABLE IF EXISTS documents_fts;");
1160
+ db.exec("DROP TABLE IF EXISTS index_meta;");
1161
+ }
1162
+ function openIndexDb(cwd, options = {}) {
1163
+ const dbPath = indexPath(cwd);
1164
+ if (!options.readonly) {
1165
+ mkdirSync(path9.dirname(dbPath), { recursive: true });
1166
+ }
1167
+ const db = new Database(dbPath, { readonly: options.readonly ?? false });
1168
+ if (!options.readonly) {
1169
+ db.pragma("journal_mode = DELETE");
1170
+ applySchema(db);
1171
+ }
1172
+ return db;
1173
+ }
1174
+ function indexDbExists(cwd) {
1175
+ return existsSync(indexPath(cwd));
1176
+ }
1177
+ function closeDb(db) {
1178
+ try {
1179
+ db.close();
1180
+ } catch {
1181
+ }
1182
+ }
1183
+ function joinNonEmpty(parts) {
1184
+ return parts.map((p) => (p ?? "").trim()).filter((p) => p.length > 0).join("\n\n");
1185
+ }
1186
+ function footprintToRow(cwd, fp) {
1187
+ const fm = fp.frontmatter;
1188
+ const s = fp.sections;
1189
+ const rejected = (s.rejectedOptions ?? []).map((r) => `${r.option}: ${r.reason}`).join("\n");
1190
+ const content = joinNonEmpty([
1191
+ fp.title,
1192
+ s.purpose,
1193
+ (s.decisions ?? []).join("\n"),
1194
+ rejected,
1195
+ s.implementationNotes,
1196
+ (s.memoryLearned ?? []).join("\n"),
1197
+ s.futureAgentGuidance,
1198
+ (fm.tags ?? []).join(" "),
1199
+ (fm.files_touched ?? []).join(" ")
1200
+ ]);
1201
+ return {
1202
+ id: fm.id,
1203
+ type: "footprint",
1204
+ title: fp.title,
1205
+ filePath: relativeToCwd(cwd, fp.filePath),
1206
+ status: fm.status,
1207
+ createdAt: fm.created_at,
1208
+ updatedAt: fm.updated_at ?? null,
1209
+ tags: fm.tags ?? [],
1210
+ files: fm.files_touched ?? [],
1211
+ content,
1212
+ workType: fm.work_type
1213
+ };
1214
+ }
1215
+ function memoryToRow(cwd, doc) {
1216
+ const fm = doc.frontmatter;
1217
+ const tags = Array.isArray(fm.tags) ? fm.tags : [];
1218
+ const content = joinNonEmpty([doc.title, doc.body, tags.join(" ")]);
1219
+ return {
1220
+ id: fm.id,
1221
+ type: "memory",
1222
+ title: doc.title,
1223
+ filePath: relativeToCwd(cwd, doc.filePath),
1224
+ status: null,
1225
+ createdAt: null,
1226
+ updatedAt: typeof fm.updated_at === "string" ? fm.updated_at : null,
1227
+ tags,
1228
+ files: [],
1229
+ content,
1230
+ workType: null
1231
+ };
1232
+ }
1233
+ async function maxMtimeMs(filePaths) {
1234
+ let max = 0;
1235
+ for (const filePath of filePaths) {
1236
+ try {
1237
+ const st = await stat(filePath);
1238
+ if (st.mtimeMs > max) max = st.mtimeMs;
1239
+ } catch {
1240
+ }
1241
+ }
1242
+ return max;
1243
+ }
1244
+ function writeRows(db, rows) {
1245
+ const insertDoc = db.prepare(
1246
+ `INSERT INTO documents
1247
+ (id, type, title, file_path, status, created_at, updated_at, tags_json, files_json, raw_text, work_type)
1248
+ VALUES
1249
+ (@id, @type, @title, @filePath, @status, @createdAt, @updatedAt, @tagsJson, @filesJson, @content, @workType)`
1250
+ );
1251
+ const insertFts = db.prepare(
1252
+ `INSERT INTO documents_fts (id, title, tags, files, content)
1253
+ VALUES (@id, @title, @tags, @files, @content)`
1254
+ );
1255
+ for (const row of rows) {
1256
+ insertDoc.run({
1257
+ id: row.id,
1258
+ type: row.type,
1259
+ title: row.title,
1260
+ filePath: row.filePath,
1261
+ status: row.status,
1262
+ createdAt: row.createdAt,
1263
+ updatedAt: row.updatedAt,
1264
+ tagsJson: JSON.stringify(row.tags),
1265
+ filesJson: JSON.stringify(row.files),
1266
+ content: row.content,
1267
+ workType: row.workType
1268
+ });
1269
+ insertFts.run({
1270
+ id: row.id,
1271
+ title: row.title,
1272
+ tags: row.tags.join(" "),
1273
+ files: row.files.join(" "),
1274
+ content: row.content
1275
+ });
1276
+ }
1277
+ }
1278
+ function writeMeta(db, meta) {
1279
+ const upsert = db.prepare(
1280
+ `INSERT INTO index_meta (key, value) VALUES (@key, @value)
1281
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
1282
+ );
1283
+ upsert.run({ key: "schema_version", value: String(SCHEMA_VERSION) });
1284
+ upsert.run({ key: "built_at", value: (/* @__PURE__ */ new Date()).toISOString() });
1285
+ upsert.run({ key: "source_max_mtime", value: String(meta.sourceMaxMtime) });
1286
+ upsert.run({ key: "source_file_count", value: String(meta.sourceFileCount) });
1287
+ }
1288
+ async function buildIndex(cwd, _options = {}) {
1289
+ const [footprints, memory] = await Promise.all([listFootprints(cwd), listMemoryDocuments(cwd)]);
1290
+ const rows = [
1291
+ ...footprints.map((fp) => footprintToRow(cwd, fp)),
1292
+ ...memory.map((doc) => memoryToRow(cwd, doc))
1293
+ ];
1294
+ const sourceFiles = [
1295
+ ...footprints.map((fp) => fp.filePath),
1296
+ ...memory.map((doc) => doc.filePath)
1297
+ ];
1298
+ const sourceMaxMtime = await maxMtimeMs(sourceFiles);
1299
+ const db = openIndexDb(cwd);
1300
+ try {
1301
+ const rebuild = db.transaction(() => {
1302
+ dropSchema(db);
1303
+ applySchema(db);
1304
+ writeRows(db, rows);
1305
+ writeMeta(db, { sourceMaxMtime, sourceFileCount: sourceFiles.length });
1306
+ });
1307
+ rebuild();
1308
+ } finally {
1309
+ closeDb(db);
1310
+ }
1311
+ }
1312
+ async function walkStats(dir) {
1313
+ let entries;
1314
+ try {
1315
+ entries = await readdir3(dir, { withFileTypes: true });
1316
+ } catch {
1317
+ return { maxMtime: 0, count: 0 };
1318
+ }
1319
+ let maxMtime = 0;
1320
+ let count = 0;
1321
+ for (const entry of entries) {
1322
+ const full = path22.join(dir, entry.name);
1323
+ if (entry.isDirectory()) {
1324
+ const sub = await walkStats(full);
1325
+ if (sub.maxMtime > maxMtime) maxMtime = sub.maxMtime;
1326
+ count += sub.count;
1327
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
1328
+ count += 1;
1329
+ try {
1330
+ const st = await stat2(full);
1331
+ if (st.mtimeMs > maxMtime) maxMtime = st.mtimeMs;
1332
+ } catch {
1333
+ }
1334
+ }
1335
+ }
1336
+ return { maxMtime, count };
1337
+ }
1338
+ function readMeta(cwd) {
1339
+ const db = openIndexDb(cwd, { readonly: true });
1340
+ try {
1341
+ const rows = db.prepare("SELECT key, value FROM index_meta").all();
1342
+ return new Map(rows.map((r) => [r.key, r.value]));
1343
+ } finally {
1344
+ closeDb(db);
1345
+ }
1346
+ }
1347
+ async function getIndexStatus(cwd) {
1348
+ if (!indexDbExists(cwd)) {
1349
+ return { state: "missing" };
1350
+ }
1351
+ let meta;
1352
+ try {
1353
+ meta = readMeta(cwd);
1354
+ } catch {
1355
+ return { state: "missing" };
1356
+ }
1357
+ const recordedSchema = Number(meta.get("schema_version"));
1358
+ if (!Number.isFinite(recordedSchema) || recordedSchema !== SCHEMA_VERSION) {
1359
+ return { state: "stale", reason: "schema" };
1360
+ }
1361
+ const [fp, mem] = await Promise.all([walkStats(footprintsDir(cwd)), walkStats(memoryDir(cwd))]);
1362
+ const sourceCount = fp.count + mem.count;
1363
+ const sourceMaxMtime = Math.max(fp.maxMtime, mem.maxMtime);
1364
+ const recordedCount = Number(meta.get("source_file_count"));
1365
+ if (!Number.isFinite(recordedCount) || recordedCount !== sourceCount) {
1366
+ return { state: "stale", reason: "count" };
1367
+ }
1368
+ const recordedMtime = Number(meta.get("source_max_mtime"));
1369
+ if (!Number.isFinite(recordedMtime) || sourceMaxMtime > recordedMtime + 1) {
1370
+ return { state: "stale", reason: "mtime" };
1371
+ }
1372
+ return { state: "fresh" };
1373
+ }
1374
+ var RECENCY_DECAY_DAYS = 180;
1375
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
1376
+ var RECENCY_BOOST_WEIGHT = 0.15;
1377
+ var FILES_OVERLAP_BOOST = 1.5;
1378
+ var STATUS_PENALTIES = {
1379
+ draft: 0.5,
1380
+ completed: 1,
1381
+ superseded: 0.15,
1382
+ deprecated: 0.1
1383
+ };
1384
+ function normalizeBm25(bm25) {
1385
+ return -bm25;
1386
+ }
1387
+ function recencyDecay(timestamp, now = Date.now()) {
1388
+ if (!timestamp) return 0;
1389
+ const t = Date.parse(timestamp);
1390
+ if (Number.isNaN(t)) return 0;
1391
+ const ageDays = (now - t) / MS_PER_DAY;
1392
+ if (ageDays <= 0) return 1;
1393
+ if (ageDays >= RECENCY_DECAY_DAYS) return 0;
1394
+ return 1 - ageDays / RECENCY_DECAY_DAYS;
1395
+ }
1396
+ function recencyBoost(timestamp, workType, now = Date.now()) {
1397
+ let contribution = RECENCY_BOOST_WEIGHT * recencyDecay(timestamp, now);
1398
+ if (workType === "architecture_decision") {
1399
+ contribution /= 2;
1400
+ }
1401
+ return 1 + contribution;
1402
+ }
1403
+ function filesOverlap(docFiles, queryFiles) {
1404
+ if (queryFiles.length === 0 || docFiles.length === 0) return false;
1405
+ const want = new Set(queryFiles.map((f) => f.toLowerCase()));
1406
+ return docFiles.some((f) => want.has(f.toLowerCase()));
1407
+ }
1408
+ function statusPenalty(status) {
1409
+ if (!status) return 1;
1410
+ return STATUS_PENALTIES[status] ?? 1;
1411
+ }
1412
+ function score(input, now = Date.now()) {
1413
+ let s = normalizeBm25(input.bm25);
1414
+ if (filesOverlap(input.docFiles, input.queryFiles)) {
1415
+ s *= FILES_OVERLAP_BOOST;
1416
+ }
1417
+ const recencyTimestamp = input.updatedAt ?? input.createdAt ?? void 0;
1418
+ s *= recencyBoost(recencyTimestamp, input.workType ?? null, now);
1419
+ s *= statusPenalty(input.status ?? null);
1420
+ return s;
1421
+ }
1422
+ var DEFAULT_LIMIT = 8;
1423
+ var FILE_HIT_BM25 = -10;
1424
+ function parseJsonArray(value) {
1425
+ try {
1426
+ const parsed = JSON.parse(value);
1427
+ return Array.isArray(parsed) ? parsed : [];
1428
+ } catch {
1429
+ return [];
1430
+ }
1431
+ }
1432
+ function buildMatchQuery(query) {
1433
+ const tokens = query.split(/[^A-Za-z0-9_]+/u).map((t) => t.trim()).filter((t) => t.length > 0);
1434
+ if (tokens.length === 0) return null;
1435
+ return tokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
1436
+ }
1437
+ function rowToResult(row, queryFiles) {
1438
+ const tags = parseJsonArray(row.tags_json);
1439
+ const files = parseJsonArray(row.files_json);
1440
+ const status = row.status ?? "completed";
1441
+ const ranked = score(
1442
+ {
1443
+ bm25: row.bm25,
1444
+ status: row.status,
1445
+ workType: row.work_type,
1446
+ updatedAt: row.updated_at,
1447
+ createdAt: row.created_at,
1448
+ docFiles: files,
1449
+ queryFiles
1450
+ },
1451
+ Date.now()
1452
+ );
1453
+ return {
1454
+ id: row.id,
1455
+ title: row.title,
1456
+ filePath: row.file_path,
1457
+ score: ranked,
1458
+ snippet: row.snippet,
1459
+ tags,
1460
+ createdAt: row.created_at ?? void 0,
1461
+ filesTouched: files,
1462
+ status
1463
+ };
1464
+ }
1465
+ function executeMatch(db, match, queryFiles, options) {
1466
+ const where = [];
1467
+ const params = { match };
1468
+ if (options.excludeSuperseded) {
1469
+ where.push("(d.status IS NULL OR d.status NOT IN ('superseded', 'deprecated'))");
1470
+ }
1471
+ if (options.tags && options.tags.length > 0) {
1472
+ const clauses = options.tags.map((tag, i) => {
1473
+ params[`tag${i}`] = tag;
1474
+ return `EXISTS (SELECT 1 FROM json_each(d.tags_json) WHERE json_each.value = @tag${i})`;
1475
+ });
1476
+ where.push(`(${clauses.join(" OR ")})`);
1477
+ }
1478
+ if (options.files && options.files.length > 0) {
1479
+ const clauses = options.files.map((file, i) => {
1480
+ params[`file${i}`] = toPosix(file);
1481
+ return `EXISTS (SELECT 1 FROM json_each(d.files_json) WHERE json_each.value = @file${i})`;
1482
+ });
1483
+ where.push(`(${clauses.join(" OR ")})`);
1484
+ }
1485
+ const whereSql = where.length > 0 ? ` AND ${where.join(" AND ")}` : "";
1486
+ const sql = `
1487
+ SELECT
1488
+ d.id, d.type, d.title, d.file_path, d.status,
1489
+ d.created_at, d.updated_at, d.tags_json, d.files_json, d.work_type,
1490
+ bm25(documents_fts) AS bm25,
1491
+ snippet(documents_fts, 4, '[', ']', ' \u2026 ', 12) AS snippet
1492
+ FROM documents_fts
1493
+ JOIN documents d ON d.id = documents_fts.id
1494
+ WHERE documents_fts MATCH @match${whereSql}
1495
+ `;
1496
+ const rows = db.prepare(sql).all(params);
1497
+ const results = rows.map((row) => rowToResult(row, queryFiles));
1498
+ results.sort((a, b) => b.score - a.score);
1499
+ return results.slice(0, options.limit);
1500
+ }
1501
+ async function search(query, options) {
1502
+ const limit = options.limit ?? DEFAULT_LIMIT;
1503
+ const match = buildMatchQuery(query);
1504
+ if (!match) return [];
1505
+ const queryFiles = (options.files ?? []).map((f) => toPosix(f));
1506
+ const db = openIndexDb(options.cwd, { readonly: true });
1507
+ try {
1508
+ return executeMatch(db, match, queryFiles, {
1509
+ excludeSuperseded: options.excludeSuperseded,
1510
+ files: options.files,
1511
+ tags: options.tags,
1512
+ limit
1513
+ });
1514
+ } finally {
1515
+ closeDb(db);
1516
+ }
1517
+ }
1518
+ async function getRelatedToFile(filePath, options) {
1519
+ const limit = options.limit ?? DEFAULT_LIMIT;
1520
+ const posixPath = toPosix(filePath);
1521
+ const stem = path32.basename(posixPath).replace(/\.[^.]+$/, "");
1522
+ const db = openIndexDb(options.cwd, { readonly: true });
1523
+ try {
1524
+ const fileRows = db.prepare(
1525
+ `SELECT
1526
+ d.id, d.type, d.title, d.file_path, d.status,
1527
+ d.created_at, d.updated_at, d.tags_json, d.files_json, d.work_type
1528
+ FROM documents d
1529
+ WHERE EXISTS (
1530
+ SELECT 1 FROM json_each(d.files_json) WHERE json_each.value = @path
1531
+ )`
1532
+ ).all({ path: posixPath });
1533
+ const byId = /* @__PURE__ */ new Map();
1534
+ for (const row of fileRows) {
1535
+ const joined = { ...row, bm25: FILE_HIT_BM25, snippet: "" };
1536
+ const result = rowToResult(joined, [posixPath]);
1537
+ byId.set(result.id, result);
1538
+ }
1539
+ const match = buildMatchQuery(stem);
1540
+ if (match) {
1541
+ const ftsResults = executeMatch(db, match, [posixPath], {
1542
+ excludeSuperseded: options.excludeSuperseded,
1543
+ tags: options.tags,
1544
+ limit: limit * 4
1545
+ });
1546
+ for (const r of ftsResults) {
1547
+ if (!byId.has(r.id)) byId.set(r.id, r);
1548
+ }
1549
+ }
1550
+ let merged = Array.from(byId.values());
1551
+ if (options.excludeSuperseded) {
1552
+ merged = merged.filter((r) => r.status !== "superseded" && r.status !== "deprecated");
1553
+ }
1554
+ merged.sort((a, b) => b.score - a.score);
1555
+ return merged.slice(0, limit);
1556
+ } finally {
1557
+ closeDb(db);
1558
+ }
1559
+ }
1560
+
1561
+ export {
1562
+ SecretDetectedError,
1563
+ NotFoundError,
1564
+ substrataDir,
1565
+ configPath,
1566
+ memoryDir,
1567
+ relativeToCwd,
1568
+ loadConfig,
1569
+ renderConfig,
1570
+ scanForSecrets,
1571
+ writeFootprint,
1572
+ listFootprints,
1573
+ findFootprintById,
1574
+ listMemoryDocuments,
1575
+ existingEntryIds,
1576
+ appendMemoryEntries,
1577
+ supersedeFootprint,
1578
+ initProject,
1579
+ GITIGNORE_LINES,
1580
+ ensureGitignore,
1581
+ writeShellEnv,
1582
+ upsertAgentsMd,
1583
+ installSecretHook,
1584
+ buildIndex,
1585
+ getIndexStatus,
1586
+ search,
1587
+ getRelatedToFile
1588
+ };
1589
+ //# sourceMappingURL=chunk-5V44C5UO.js.map