@saasontools/strauss-kb 0.1.8 → 0.1.9

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/ARCHITECTURE.md CHANGED
@@ -72,6 +72,95 @@ replacement's first log format was `·`-delimited, with a splitter to read it
72
72
  back. Both are gone: the log is JSONL and the schema is emitted from Zod, so
73
73
  `strauss-kb schema` is the contract rather than a description of one.
74
74
 
75
+ ## Cross-worktree log safety
76
+
77
+ `log.jsonl` is append-only and the one artifact nothing can rebuild, so how it
78
+ merges across worktrees matters more than how any record file does. Records
79
+ already don't need this: one concept id is one file, so two writers choosing
80
+ distinct ids never merge at all, and `link`-based publish (above) turns the
81
+ one case where they collide into a 409 rather than a merge. The log has no
82
+ such escape — every writer appends into the same file by design — so it needs
83
+ an actual merge strategy, not just atomicity per write.
84
+
85
+ The append itself already had it: `record()` uses `appendFile`, which opens
86
+ `O_APPEND` and is one `write(2)` for an entry this small, so two processes
87
+ appending locally interleave whole lines, never a torn one. What was missing
88
+ was git's merge of two worktrees' independently-appended logs — the default
89
+ line-level merge can conflict, or silently keep one side, on lines that were
90
+ never in conflict, since both sides only ever added, never edited, a line.
91
+
92
+ The fix is `.gitattributes: log.jsonl text eol=lf merge=union`, written by
93
+ `record()` — every path that appends a log line (`write`, `setStatus`,
94
+ `verify`, `supersede`), not `write()` alone — on first use
95
+ (`kb-store.ts#ensureGitattributes`). `union` is a merge driver git ships —
96
+ nothing to configure beyond the attribute — that keeps both sides' added
97
+ lines; `eol=lf` closes a second divergence path, below. The alternative
98
+ considered and dropped was a lock file coordinating writes across worktrees
99
+ the way `mutate`'s CAS coordinates a single record: it would need to span
100
+ process boundaries and survive a crashed holder, which is the same
101
+ stale-lock failure mode rejected above, for a smaller problem than the one it
102
+ solves there.
103
+
104
+ Decisions worth naming because a later reader could reopen them:
105
+
106
+ - **A `.gitattributes` that exists but declares no merge strategy for the
107
+ log gets the line appended, not left alone.** The alternative — leave a
108
+ user's own file untouched — reads as more conservative, but a bundle that
109
+ already has a `.gitattributes` in front of it is exactly the bundle most
110
+ likely to be shared across worktrees or forks, so leaving it without union
111
+ merge is the worse of the two failure modes. A file that _does_ already
112
+ declare a merge strategy for `log.jsonl` — this one or a user's own, such
113
+ as `merge=ours` — is left alone entirely: gitattributes resolves repeated
114
+ lines for one pattern by "last one wins", so appending a second `merge=`
115
+ line would silently override rather than coexist. Recognizing "already
116
+ declared" needed a real tokenizer (`hasMergeDeclaration`) rather than
117
+ exact-string matching against the line this module writes — the first cut
118
+ missed a tab or doubled space between tokens (false negative, a harmless
119
+ duplicate line) and could never recognize a user's own `merge=ours` as a
120
+ decision already made (the one case where appending anything is wrong).
121
+ - **A `.gitattributes` that fails to _read_ is never treated as "missing".**
122
+ The first cut folded every `readFile` failure — permissions, a transient
123
+ `EMFILE`, the path being a directory — into "doesn't exist yet" and took
124
+ the create branch, which is a truncating write: a real `.gitattributes`
125
+ hit by a transient read error would be silently replaced with just the
126
+ union-merge line. Only `ENOENT` means missing; anything else is reported
127
+ as a failure and the file is left exactly as it was. The create branch
128
+ also uses `wx` (exclusive create) rather than a plain write, so a
129
+ concurrent writer that created the file between the read and this write
130
+ fails loudly into the same best-effort catch instead of the second writer
131
+ truncating the first one's file.
132
+ - **Union merge does not preserve line order, so `kb_log`'s reader sorts by
133
+ `at` rather than trusting file order.** Sorting there, once, is cheaper
134
+ than trying to make every future merge order-preserving. `at` is now
135
+ validated as an actual ISO-8601 timestamp (`z.iso.datetime()`, matching
136
+ exactly what `record()` writes) rather than any non-empty string — a value
137
+ that parses as JSON and matches the schema's shape but isn't really a
138
+ timestamp would otherwise sort unpredictably instead of failing, and
139
+ `parseLog` already has a place for "well-formed but wrong" to go: reported
140
+ as malformed, same as any other schema mismatch, never silently repaired.
141
+ - **A union merge can keep the exact same line twice** — a cherry-pick or
142
+ rebase that carried one worktree's entry into the other's history before
143
+ the merge, not two independent writes agreeing by chance: `record()` mints
144
+ its own `at` per call, so two entries equal on every field including `at`
145
+ cannot be genuine. `parseLog` dedupes entries that are byte-for-byte equal
146
+ after parsing and keeps everything else, including two entries that agree
147
+ on every field except `at` — that pair is two real events. The
148
+ alternative — leave duplicates visible and call it "genuine repeat
149
+ ambiguity" — was rejected: there's no ambiguity to preserve, since the
150
+ only way to produce an exact duplicate is the merge itself.
151
+ - **A read-then-append race across processes on the append branch — two
152
+ processes both reading a `.gitattributes` without the line, both
153
+ appending it — is left unguarded, not a reason to lock.** `appendFile` is
154
+ `O_APPEND`, so the outcome is two copies of the same line, never a torn
155
+ write, and `hasMergeDeclaration` sees a duplicate declaration as "already
156
+ declared" on the very next call. Cheap residue, not corruption — the same
157
+ trade the lock-file alternative above was rejected for, at a smaller
158
+ scale.
159
+
160
+ GitHub does not run merge drivers for a PR it merges server-side — see the
161
+ README's "Cross-worktree writes" section. The driver only helps a merge a
162
+ local git client actually performs.
163
+
75
164
  ## Rejected for now: a base registry
76
165
 
77
166
  Cross-base questions are unaskable by construction — supersession, traces, and
package/README.md CHANGED
@@ -54,6 +54,7 @@ bundling can `require()` it without depending on its Node version honouring
54
54
  <type>.<slug>.md records
55
55
  INDEX.md index derived, store-owned
56
56
  log.jsonl history primary, append-only
57
+ .gitattributes merge store-owned, written on first write
57
58
  .index.sqlite search derived, gitignored
58
59
  ```
59
60
 
@@ -76,6 +77,46 @@ Repair-on-read, not coordination, is what lets both exist without a lock. The
76
77
  index is _eventually_ correct: a writer whose scan predated another's record
77
78
  publishes a briefly stale index, and the next read through the store settles it.
78
79
 
80
+ ### Cross-worktree writes
81
+
82
+ A committed base is routinely written from more than one worktree at once —
83
+ each records into the same `log.jsonl`, and a plain git merge of two branches
84
+ that both appended lines resolves that file at the line level, same as any
85
+ other text file. That is the wrong merge for an append-only log: git's default
86
+ picks a side, or conflicts, on lines that both branches only ever meant to add
87
+ to.
88
+
89
+ So the first write to a base (through `write`, or whichever call happens to
90
+ append the first log line) declares a merge driver for its log: it writes
91
+ `log.jsonl text eol=lf merge=union` into the base's `.gitattributes` if that
92
+ file does not exist yet, and appends the line if the file exists but declares
93
+ no merge strategy for `log.jsonl` yet — a `.gitattributes` a user put there
94
+ first is respected, never overwritten wholesale, and a line that already
95
+ gives `log.jsonl` _any_ merge strategy — this one or the user's own choice
96
+ such as `merge=ours` — is left alone rather than layered under a second,
97
+ possibly conflicting one. `union` is one of git's built-in merge drivers; the
98
+ attribute alone is enough; nothing else needs configuring. `eol=lf` pins line
99
+ endings to `\n` regardless of a checkout's `core.autocrlf`, so a Windows
100
+ checkout normalizing the file on checkout can't leave it with mixed endings
101
+ against the raw `\n` every append writes. With it, a merge of two branches
102
+ that both appended to `log.jsonl` keeps both sides' lines instead of picking
103
+ one.
104
+
105
+ A union merge does not preserve line order, and can occasionally keep the
106
+ same line twice (a cherry-pick or rebase that carried one side's entry into
107
+ the other's history before the merge). `kb_log`'s reader (`kb-log.ts`) sorts
108
+ entries by `at` and drops exact duplicates before returning them, so neither
109
+ is something a caller has to account for.
110
+
111
+ **This applies to a local `git merge`, not to GitHub.** GitHub computes pull
112
+ request merges (and the merge/squash/rebase buttons) through its own service,
113
+ which does not read `.gitattributes` merge-driver declarations — a PR that
114
+ merges two branches' `log.jsonl` appends on GitHub gets git's ordinary
115
+ line-level merge (or a conflict) even with the attribute in place. The union
116
+ driver only fires for a merge actually run by a local git client, which covers
117
+ worktrees pulling from and pushing to each other directly, but not a merge
118
+ GitHub itself performs.
119
+
79
120
  ## Records
80
121
 
81
122
  The filename is the identity. `fact.auth-retries.md` has concept id
@@ -4,7 +4,7 @@ import {
4
4
  KB_DIR,
5
5
  KbStore,
6
6
  VERSION
7
- } from "./chunk-YJK7KGHN.js";
7
+ } from "./chunk-OFDWRMY6.js";
8
8
 
9
9
  // src/cli.ts
10
10
  import { join } from "path";
@@ -112,4 +112,4 @@ function usage() {
112
112
  export {
113
113
  runKbCli
114
114
  };
115
- //# sourceMappingURL=chunk-MGPYUZOM.js.map
115
+ //# sourceMappingURL=chunk-KVEEISYQ.js.map
@@ -2,7 +2,7 @@ import {
2
2
  KB_COMMANDS,
3
3
  KbStore,
4
4
  VERSION
5
- } from "./chunk-YJK7KGHN.js";
5
+ } from "./chunk-OFDWRMY6.js";
6
6
 
7
7
  // src/mcp.ts
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -46,4 +46,4 @@ export {
46
46
  createKbMcpServer,
47
47
  runKbMcpServer
48
48
  };
49
- //# sourceMappingURL=chunk-YYX6CX6V.js.map
49
+ //# sourceMappingURL=chunk-MWWDD23L.js.map
@@ -1301,7 +1301,15 @@ function ageInDays(record, now) {
1301
1301
  import { z as z5 } from "zod";
1302
1302
  var LOG_FILE = "log.jsonl";
1303
1303
  var kbLogEntrySchema = z5.object({
1304
- at: z5.string().min(1),
1304
+ // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
1305
+ // below), and a value that isn't actually chronological — a Unix
1306
+ // timestamp, a human-typed date, garbage — would sort wrong without
1307
+ // ever failing to parse. `z.iso.datetime()` accepts exactly what
1308
+ // `record()` writes (`Date#toISOString()`: full precision, `Z` offset)
1309
+ // and rejects everything else, including a non-`Z` offset — so a
1310
+ // malformed `at` is reported the same way a malformed line already is,
1311
+ // rather than silently sorting into the wrong place.
1312
+ at: z5.iso.datetime(),
1305
1313
  by: z5.string().min(1),
1306
1314
  operation: z5.string().min(1),
1307
1315
  conceptId: z5.string().min(1),
@@ -1315,6 +1323,7 @@ function renderLogEntry(entry) {
1315
1323
  function parseLog(raw) {
1316
1324
  const entries = [];
1317
1325
  const malformed = [];
1326
+ const seen = /* @__PURE__ */ new Set();
1318
1327
  raw.split("\n").forEach((text, index) => {
1319
1328
  if (!text.trim()) return;
1320
1329
  let value;
@@ -1329,8 +1338,14 @@ function parseLog(raw) {
1329
1338
  malformed.push({ line: index + 1, text });
1330
1339
  return;
1331
1340
  }
1341
+ const key = JSON.stringify(parsed.data);
1342
+ if (seen.has(key)) return;
1343
+ seen.add(key);
1332
1344
  entries.push(parsed.data);
1333
1345
  });
1346
+ entries.sort(
1347
+ (left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
1348
+ );
1334
1349
  return { entries, malformed };
1335
1350
  }
1336
1351
 
@@ -2416,6 +2431,32 @@ import {
2416
2431
  writeFile as writeFile3
2417
2432
  } from "fs/promises";
2418
2433
  import { join as join4, resolve as resolve4, sep as sep2 } from "path";
2434
+
2435
+ // src/kb-gitattributes.ts
2436
+ var GITATTRIBUTES_FILE = ".gitattributes";
2437
+ var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
2438
+ function parseLine(line) {
2439
+ const trimmed = line.trim();
2440
+ if (!trimmed || trimmed.startsWith("#")) return null;
2441
+ const [pattern, ...attrs] = trimmed.split(/\s+/);
2442
+ return pattern === void 0 ? null : { pattern, attrs };
2443
+ }
2444
+ function hasMergeDeclaration(contents) {
2445
+ return contents.split("\n").some((line) => {
2446
+ const parsed = parseLine(line);
2447
+ if (!parsed || parsed.pattern !== LOG_FILE) return false;
2448
+ return parsed.attrs.some(
2449
+ (attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
2450
+ );
2451
+ });
2452
+ }
2453
+ function appendUnionMergeLine(contents) {
2454
+ const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
2455
+ return `${separator}${UNION_MERGE_LINE}
2456
+ `;
2457
+ }
2458
+
2459
+ // src/kb-store.ts
2419
2460
  var KB_DIR = join4(".strauss", "kb");
2420
2461
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
2421
2462
  var DEFAULT_LOAD_BUDGET = 25e3;
@@ -2840,8 +2881,87 @@ ${answer}
2840
2881
  await unlink(staging).catch(() => void 0);
2841
2882
  }
2842
2883
  }
2884
+ /**
2885
+ * Declares union merge for the log, so two worktrees writing the same
2886
+ * bundle interleave their `log.jsonl` lines on merge rather than one
2887
+ * side's appends silently losing to git's ordinary line-level merge.
2888
+ *
2889
+ * Called from `record` — every path that appends a log line, not just
2890
+ * `write` — so a bundle only ever mutated through `setStatus`/`verify`/
2891
+ * `supersede` still gets it. There is no cheaper reliable signal for
2892
+ * "first write" than checking the file itself, and after the first call
2893
+ * the check is a no-op `readFile`.
2894
+ *
2895
+ * A missing `.gitattributes` is created outright, with `wx` (exclusive
2896
+ * create) rather than a plain write: if another process's `write()` won a
2897
+ * race and created the file between the `readFile` below and this call,
2898
+ * `wx` fails instead of truncating what that writer just wrote, and the
2899
+ * failure is swallowed by the catch below same as any other best-effort
2900
+ * miss. A file that exists but declares no merge strategy for the log
2901
+ * gets the line appended, never a wholesale rewrite; one that already
2902
+ * declares any merge strategy — this one or a user's own — is left alone
2903
+ * entirely (see `hasMergeDeclaration`).
2904
+ *
2905
+ * `readFile` failing is `existing === null` only for `ENOENT` — genuinely
2906
+ * missing. Any other error (a permission problem, a transient `EMFILE`,
2907
+ * the path being a directory) is *not* "missing" and must not fall into
2908
+ * the create branch, which would truncate whatever is actually there with
2909
+ * just the union-merge line: that is the file-destroying bug this
2910
+ * function exists to avoid, not commit. An unreadable existing file is
2911
+ * therefore left untouched and reported as a failure like any other.
2912
+ *
2913
+ * Two processes racing the append branch — both read a file without the
2914
+ * line, both append it — is possible and left unguarded: `appendFile` is
2915
+ * `O_APPEND`, so the result is two copies of the same line rather than a
2916
+ * torn write, and `hasMergeDeclaration` sees a duplicate declaration as
2917
+ * "already declared" on the next call. A cheap-to-detect, harmless-to-
2918
+ * leave residue, not a reason to add a cross-process lock (see
2919
+ * `ARCHITECTURE.md`'s rejection of one for the same trade on records).
2920
+ *
2921
+ * Best-effort, like the log append it precedes: failing to write this
2922
+ * file must not fail the mutation it guards.
2923
+ */
2924
+ async ensureGitattributes(root) {
2925
+ const target = join4(root, GITATTRIBUTES_FILE);
2926
+ try {
2927
+ let existing;
2928
+ try {
2929
+ existing = await readFile3(target, "utf8");
2930
+ } catch (error) {
2931
+ if (error.code !== "ENOENT") throw error;
2932
+ existing = null;
2933
+ }
2934
+ if (existing === null) {
2935
+ await writeFile3(target, appendUnionMergeLine(""), {
2936
+ encoding: "utf8",
2937
+ flag: "wx"
2938
+ });
2939
+ this.logger.info?.({
2940
+ operation: "kb.gitattributes.ensure",
2941
+ bundlePath: root,
2942
+ outcome: "created"
2943
+ });
2944
+ return;
2945
+ }
2946
+ if (!hasMergeDeclaration(existing)) {
2947
+ await appendFile(target, appendUnionMergeLine(existing), "utf8");
2948
+ this.logger.info?.({
2949
+ operation: "kb.gitattributes.ensure",
2950
+ bundlePath: root,
2951
+ outcome: "appended"
2952
+ });
2953
+ }
2954
+ } catch (error) {
2955
+ this.logger.warn?.({
2956
+ operation: "kb.gitattributes.ensure",
2957
+ outcome: "failed",
2958
+ error: error instanceof Error ? error.message : "unknown"
2959
+ });
2960
+ }
2961
+ }
2843
2962
  /** Appends one log line. Failing to log must not fail the mutation. */
2844
2963
  async record(root, entry) {
2964
+ await this.ensureGitattributes(root);
2845
2965
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
2846
2966
  await appendFile(join4(root, LOG_FILE), line, "utf8").catch((error) => {
2847
2967
  this.logger.warn?.({
@@ -2999,7 +3119,7 @@ function typeRank(record) {
2999
3119
  }
3000
3120
 
3001
3121
  // src/version.ts
3002
- var VERSION = true ? "0.1.8" : "0.0.0-dev";
3122
+ var VERSION = true ? "0.1.9" : "0.0.0-dev";
3003
3123
 
3004
3124
  export {
3005
3125
  kbSourceSchema,
@@ -3093,4 +3213,4 @@ export {
3093
3213
  KbStore,
3094
3214
  VERSION
3095
3215
  };
3096
- //# sourceMappingURL=chunk-YJK7KGHN.js.map
3216
+ //# sourceMappingURL=chunk-OFDWRMY6.js.map