@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/dist/cli-main.cjs CHANGED
@@ -1812,7 +1812,15 @@ var import_zod19 = require("zod");
1812
1812
  var import_zod18 = require("zod");
1813
1813
  var LOG_FILE = "log.jsonl";
1814
1814
  var kbLogEntrySchema = import_zod18.z.object({
1815
- at: import_zod18.z.string().min(1),
1815
+ // Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
1816
+ // below), and a value that isn't actually chronological — a Unix
1817
+ // timestamp, a human-typed date, garbage — would sort wrong without
1818
+ // ever failing to parse. `z.iso.datetime()` accepts exactly what
1819
+ // `record()` writes (`Date#toISOString()`: full precision, `Z` offset)
1820
+ // and rejects everything else, including a non-`Z` offset — so a
1821
+ // malformed `at` is reported the same way a malformed line already is,
1822
+ // rather than silently sorting into the wrong place.
1823
+ at: import_zod18.z.iso.datetime(),
1816
1824
  by: import_zod18.z.string().min(1),
1817
1825
  operation: import_zod18.z.string().min(1),
1818
1826
  conceptId: import_zod18.z.string().min(1),
@@ -1826,6 +1834,7 @@ function renderLogEntry(entry) {
1826
1834
  function parseLog(raw) {
1827
1835
  const entries = [];
1828
1836
  const malformed = [];
1837
+ const seen = /* @__PURE__ */ new Set();
1829
1838
  raw.split("\n").forEach((text, index) => {
1830
1839
  if (!text.trim()) return;
1831
1840
  let value;
@@ -1840,8 +1849,14 @@ function parseLog(raw) {
1840
1849
  malformed.push({ line: index + 1, text });
1841
1850
  return;
1842
1851
  }
1852
+ const key = JSON.stringify(parsed.data);
1853
+ if (seen.has(key)) return;
1854
+ seen.add(key);
1843
1855
  entries.push(parsed.data);
1844
1856
  });
1857
+ entries.sort(
1858
+ (left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
1859
+ );
1845
1860
  return { entries, malformed };
1846
1861
  }
1847
1862
 
@@ -2508,6 +2523,30 @@ function typeRank(record) {
2508
2523
  return index === -1 ? TYPE_PRIORITY.length : index;
2509
2524
  }
2510
2525
 
2526
+ // src/kb-gitattributes.ts
2527
+ var GITATTRIBUTES_FILE = ".gitattributes";
2528
+ var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
2529
+ function parseLine(line) {
2530
+ const trimmed = line.trim();
2531
+ if (!trimmed || trimmed.startsWith("#")) return null;
2532
+ const [pattern, ...attrs] = trimmed.split(/\s+/);
2533
+ return pattern === void 0 ? null : { pattern, attrs };
2534
+ }
2535
+ function hasMergeDeclaration(contents) {
2536
+ return contents.split("\n").some((line) => {
2537
+ const parsed = parseLine(line);
2538
+ if (!parsed || parsed.pattern !== LOG_FILE) return false;
2539
+ return parsed.attrs.some(
2540
+ (attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
2541
+ );
2542
+ });
2543
+ }
2544
+ function appendUnionMergeLine(contents) {
2545
+ const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
2546
+ return `${separator}${UNION_MERGE_LINE}
2547
+ `;
2548
+ }
2549
+
2511
2550
  // src/kb-store.ts
2512
2551
  var KB_DIR = (0, import_node_path6.join)(".strauss", "kb");
2513
2552
  var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
@@ -2933,8 +2972,87 @@ ${answer}
2933
2972
  await (0, import_promises4.unlink)(staging).catch(() => void 0);
2934
2973
  }
2935
2974
  }
2975
+ /**
2976
+ * Declares union merge for the log, so two worktrees writing the same
2977
+ * bundle interleave their `log.jsonl` lines on merge rather than one
2978
+ * side's appends silently losing to git's ordinary line-level merge.
2979
+ *
2980
+ * Called from `record` — every path that appends a log line, not just
2981
+ * `write` — so a bundle only ever mutated through `setStatus`/`verify`/
2982
+ * `supersede` still gets it. There is no cheaper reliable signal for
2983
+ * "first write" than checking the file itself, and after the first call
2984
+ * the check is a no-op `readFile`.
2985
+ *
2986
+ * A missing `.gitattributes` is created outright, with `wx` (exclusive
2987
+ * create) rather than a plain write: if another process's `write()` won a
2988
+ * race and created the file between the `readFile` below and this call,
2989
+ * `wx` fails instead of truncating what that writer just wrote, and the
2990
+ * failure is swallowed by the catch below same as any other best-effort
2991
+ * miss. A file that exists but declares no merge strategy for the log
2992
+ * gets the line appended, never a wholesale rewrite; one that already
2993
+ * declares any merge strategy — this one or a user's own — is left alone
2994
+ * entirely (see `hasMergeDeclaration`).
2995
+ *
2996
+ * `readFile` failing is `existing === null` only for `ENOENT` — genuinely
2997
+ * missing. Any other error (a permission problem, a transient `EMFILE`,
2998
+ * the path being a directory) is *not* "missing" and must not fall into
2999
+ * the create branch, which would truncate whatever is actually there with
3000
+ * just the union-merge line: that is the file-destroying bug this
3001
+ * function exists to avoid, not commit. An unreadable existing file is
3002
+ * therefore left untouched and reported as a failure like any other.
3003
+ *
3004
+ * Two processes racing the append branch — both read a file without the
3005
+ * line, both append it — is possible and left unguarded: `appendFile` is
3006
+ * `O_APPEND`, so the result is two copies of the same line rather than a
3007
+ * torn write, and `hasMergeDeclaration` sees a duplicate declaration as
3008
+ * "already declared" on the next call. A cheap-to-detect, harmless-to-
3009
+ * leave residue, not a reason to add a cross-process lock (see
3010
+ * `ARCHITECTURE.md`'s rejection of one for the same trade on records).
3011
+ *
3012
+ * Best-effort, like the log append it precedes: failing to write this
3013
+ * file must not fail the mutation it guards.
3014
+ */
3015
+ async ensureGitattributes(root) {
3016
+ const target = (0, import_node_path6.join)(root, GITATTRIBUTES_FILE);
3017
+ try {
3018
+ let existing;
3019
+ try {
3020
+ existing = await (0, import_promises4.readFile)(target, "utf8");
3021
+ } catch (error) {
3022
+ if (error.code !== "ENOENT") throw error;
3023
+ existing = null;
3024
+ }
3025
+ if (existing === null) {
3026
+ await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
3027
+ encoding: "utf8",
3028
+ flag: "wx"
3029
+ });
3030
+ this.logger.info?.({
3031
+ operation: "kb.gitattributes.ensure",
3032
+ bundlePath: root,
3033
+ outcome: "created"
3034
+ });
3035
+ return;
3036
+ }
3037
+ if (!hasMergeDeclaration(existing)) {
3038
+ await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
3039
+ this.logger.info?.({
3040
+ operation: "kb.gitattributes.ensure",
3041
+ bundlePath: root,
3042
+ outcome: "appended"
3043
+ });
3044
+ }
3045
+ } catch (error) {
3046
+ this.logger.warn?.({
3047
+ operation: "kb.gitattributes.ensure",
3048
+ outcome: "failed",
3049
+ error: error instanceof Error ? error.message : "unknown"
3050
+ });
3051
+ }
3052
+ }
2936
3053
  /** Appends one log line. Failing to log must not fail the mutation. */
2937
3054
  async record(root, entry) {
3055
+ await this.ensureGitattributes(root);
2938
3056
  const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
2939
3057
  await (0, import_promises4.appendFile)((0, import_node_path6.join)(root, LOG_FILE), line, "utf8").catch((error) => {
2940
3058
  this.logger.warn?.({
@@ -3008,7 +3126,7 @@ function digest(contents) {
3008
3126
  }
3009
3127
 
3010
3128
  // src/version.ts
3011
- var VERSION = true ? "0.1.8" : "0.0.0-dev";
3129
+ var VERSION = true ? "0.1.9" : "0.0.0-dev";
3012
3130
 
3013
3131
  // src/cli.ts
3014
3132
  async function runKbCli(argv) {