@workscribe/cli 3.0.0 → 3.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.
package/dist/index.js CHANGED
@@ -378,7 +378,7 @@ var init_logger = __esm({
378
378
  success: (msg) => console.log(import_chalk.default.green("\u2713"), msg),
379
379
  warn: (msg) => console.log(import_chalk.default.yellow("\u26A0"), msg),
380
380
  error: (msg) => console.error(import_chalk.default.red("\u2717"), msg),
381
- /** A warning on stderr — keeps stdout clean for JSON output (summary.format=json) and pipes. */
381
+ /** A warning on stderr — keeps stdout clean for JSON and plain text output, and pipes. */
382
382
  notice: (msg) => process.stderr.write(`${import_chalk.default.yellow("\u26A0")} ${msg}
383
383
  `),
384
384
  plain: (msg) => console.log(msg)
@@ -2635,6 +2635,29 @@ var init_db = __esm({
2635
2635
  });
2636
2636
 
2637
2637
  // src/capture/redact.ts
2638
+ function sameCommand(maxParts) {
2639
+ const quoted = String.raw`"[^"]{0,${MAX_QUOTED_LENGTH}}"|'[^']{0,${MAX_QUOTED_LENGTH}}'`;
2640
+ return String.raw`(?:(?:${quoted}|\\\n|[^|;&\n"']){0,${maxParts}}?|[^|;&\n]{0,${maxParts}}?)`;
2641
+ }
2642
+ function valueAfter(prefix, flags = "g") {
2643
+ return new RegExp(`(?<=${prefix})${VALUE}`, flags);
2644
+ }
2645
+ function toolFlagValue(tools, flag) {
2646
+ return valueAfter(String.raw`${TOOL_WORD_START}(?:${tools})${TOOL_WORD_END}${SAME_COMMAND}\s${flag}`);
2647
+ }
2648
+ function quotedValuesAfterName(prefix, name, flags = "g") {
2649
+ return [
2650
+ new RegExp(String.raw`(?<=${prefix}"${name})[^"\s][^"\n]*`, flags),
2651
+ new RegExp(String.raw`(?<=${prefix}'${name})[^'\s][^'\n]*`, flags)
2652
+ ];
2653
+ }
2654
+ function valuesAfterName(prefix, name, flags = "g") {
2655
+ return [
2656
+ ...quotedValuesAfterName(prefix, name, flags),
2657
+ new RegExp(String.raw`(?<=${prefix}${name})(?:${QUOTED})`, flags),
2658
+ new RegExp(String.raw`(?<=${prefix}${name})[^\s"'|;&]+`, flags)
2659
+ ];
2660
+ }
2638
2661
  function redact(command, extraPatterns = []) {
2639
2662
  let result = command;
2640
2663
  const allPatterns = [...BUILTIN_PATTERNS];
@@ -2646,22 +2669,73 @@ function redact(command, extraPatterns = []) {
2646
2669
  }
2647
2670
  for (const pattern of allPatterns) {
2648
2671
  pattern.lastIndex = 0;
2649
- result = result.replace(pattern, "[REDACTED]");
2672
+ result = result.replace(pattern, REDACTED_TEXT);
2650
2673
  }
2651
2674
  return result;
2652
2675
  }
2653
- var BUILTIN_PATTERNS;
2676
+ var QUOTED, WORD_REST, VALUE, VALUE_OR_DASHED, MAX_COMMAND_PARTS, MAX_QUOTED_LENGTH, MAX_PARTS_BEFORE_LOGIN, MAX_PARTS_AFTER_LOGIN, SAME_COMMAND, NOT_PART_OF_A_WORD, NOT_A_NAME, NOT_IN_A_MESSAGE, TOOL_WORD_START, TOOL_WORD_END, NOT_A_PORT_MAPPING, LONG_SECRET_FLAGS, LONG_FLAG_VALUE_START, MAX_SORT_FIELD_DIGITS, SORT_FIELD, NOT_A_KEY, MAX_SLUG_WORD_LENGTH, MAX_SLUG_WORDS_AFTER_FIRST, SLUG_WORD, UUID_IN_A_KEY, SLUG_AFTER_SK, CONTAINER_LOGIN_TOOLS, EVERYDAY_HEADERS, DISTINCTIVE_HEADERS, HEADER_FLAG, SENSITIVE_VARIABLE_WORDS, QUERY_SECRET_NAME, JSON_SECRET_NAME, BUILTIN_PATTERNS, REDACTED_TEXT;
2654
2677
  var init_redact = __esm({
2655
2678
  "src/capture/redact.ts"() {
2656
2679
  "use strict";
2680
+ QUOTED = String.raw`"[^"]*"|'[^']*'`;
2681
+ WORD_REST = String.raw`(?:[^\s"');&|]|["');&|](?=[^\s;&|)]))*`;
2682
+ VALUE = String.raw`(?:${QUOTED}|[^\s"'\-]${WORD_REST})`;
2683
+ VALUE_OR_DASHED = String.raw`(?:${QUOTED}|[^\s"']${WORD_REST})`;
2684
+ MAX_COMMAND_PARTS = 1e3;
2685
+ MAX_QUOTED_LENGTH = 64e3;
2686
+ MAX_PARTS_BEFORE_LOGIN = 200;
2687
+ MAX_PARTS_AFTER_LOGIN = 300;
2688
+ SAME_COMMAND = sameCommand(MAX_COMMAND_PARTS);
2689
+ NOT_PART_OF_A_WORD = String.raw`(?<![\w.:@-])`;
2690
+ NOT_A_NAME = String.raw`(?<!--name(?:\s+|=)|\bssh\s+)`;
2691
+ NOT_IN_A_MESSAGE = String.raw`(?<!\s(?:-[as]*m|--message|--title|--body|--subject)[\s=]*["'][^"'\n]{0,${MAX_COMMAND_PARTS}})`;
2692
+ TOOL_WORD_START = `${NOT_IN_A_MESSAGE}${NOT_A_NAME}${NOT_PART_OF_A_WORD}`;
2693
+ TOOL_WORD_END = String.raw`(?:\.exe)?["'\x60)]?(?=\s)`;
2694
+ NOT_A_PORT_MAPPING = String.raw`(?!(?:[\d.]+:)?\d+:\d+(?:\/\w+)?(?=\s|$))`;
2695
+ LONG_SECRET_FLAGS = [
2696
+ "password",
2697
+ "(?!no-)[\\w-]*-password",
2698
+ "passwd",
2699
+ "passphrase",
2700
+ "pass",
2701
+ "pwd",
2702
+ "secret(?:-key)?",
2703
+ "client[-_]secret",
2704
+ "token",
2705
+ "access-token",
2706
+ "refresh-token",
2707
+ "auth[-_]?token",
2708
+ "private-key",
2709
+ "api[-_]?key"
2710
+ ].join("|");
2711
+ LONG_FLAG_VALUE_START = String.raw`(?:=|\s+(?!--))`;
2712
+ MAX_SORT_FIELD_DIGITS = 2;
2713
+ SORT_FIELD = String.raw`\d{1,${MAX_SORT_FIELD_DIGITS}}`;
2714
+ NOT_A_KEY = String.raw`(?:${SORT_FIELD}(?:[.,]${SORT_FIELD})*|[\w./~-]*\.(?:key|pem|crt|p12|pfx|der)|[\w./~-]*\/id_\w+)(?=\s|$)`;
2715
+ MAX_SLUG_WORD_LENGTH = 15;
2716
+ MAX_SLUG_WORDS_AFTER_FIRST = 8;
2717
+ SLUG_WORD = `[a-z0-9]{1,${MAX_SLUG_WORD_LENGTH}}`;
2718
+ UUID_IN_A_KEY = String.raw`[a-z-]{0,20}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`;
2719
+ SLUG_AFTER_SK = String.raw`(?!${UUID_IN_A_KEY})${SLUG_WORD}(?:-${SLUG_WORD}){0,${MAX_SLUG_WORDS_AFTER_FIRST}}(?![\w-])`;
2720
+ CONTAINER_LOGIN_TOOLS = "docker|podman|podman-remote|nerdctl|helm";
2721
+ EVERYDAY_HEADERS = "api-key|cookie|set-cookie";
2722
+ DISTINCTIVE_HEADERS = "x-api-key|x-auth-token|x-access-token";
2723
+ HEADER_FLAG = String.raw`(?<![\w-])(?:-\w*H|--(?:proxy-)?header)[\s=]*`;
2724
+ SENSITIVE_VARIABLE_WORDS = "TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL|_PWD";
2725
+ QUERY_SECRET_NAME = String.raw`key|[\w-]*(?:token|secret|password|passwd|pwd|api[-_]?key)`;
2726
+ JSON_SECRET_NAME = String.raw`[\w-]*(?:token|secret|password|passwd|pwd|api[-_]?key)`;
2657
2727
  BUILTIN_PATTERNS = [
2658
2728
  // AWS access keys
2659
2729
  /AKIA[0-9A-Z]{16}/g,
2660
2730
  // AWS secret access keys (40 char base64-ish after common env vars)
2661
- /(?<=AWS_SECRET_ACCESS_KEY[=\s]+)\S+/gi,
2731
+ new RegExp(String.raw`(?<=AWS_SECRET_ACCESS_KEY[=\s]+)${VALUE_OR_DASHED}`, "gi"),
2662
2732
  // GitHub tokens
2663
- /ghp_[a-zA-Z0-9]{36}/g,
2733
+ /\bgh[pousr]_[a-zA-Z0-9]{36}/g,
2664
2734
  /github_pat_[a-zA-Z0-9_]{82}/g,
2735
+ // AI provider, Google and npm keys
2736
+ new RegExp(String.raw`\bsk-(?!${SLUG_AFTER_SK})[\w-]{20,}`, "g"),
2737
+ /\bAIza[a-zA-Z0-9_-]{30,}/g,
2738
+ /\bnpm_[a-zA-Z0-9]{30,}/g,
2665
2739
  // GitLab tokens
2666
2740
  /glpat-[a-zA-Z0-9\-]{20}/g,
2667
2741
  // Generic JWTs (three base64url segments)
@@ -2671,17 +2745,85 @@ var init_redact = __esm({
2671
2745
  /https:\/\/hooks\.slack\.com\/services\/[A-Z0-9]+\/[A-Z0-9]+\/[a-zA-Z0-9]+/g,
2672
2746
  // Stripe keys
2673
2747
  /sk_(live|test)_[a-zA-Z0-9]{24,}/g,
2674
- // Values after long-form secret flags (single-char flags like -t/-p/-k are too ambiguous)
2675
- /(?<=--(password|passwd|secret|token|key|api[-_]?key|auth[-_]?token)\s+)\S+/gi,
2748
+ // Values after long-form secret flags, `--flag value` or `--flag=value`. `--password-stdin` isn't one: no match on its
2749
+ // `-stdin`. A bare short flag means nothing without knowing the tool: see below
2750
+ new RegExp(String.raw`(?<=(?<![\w-])--(?:${LONG_SECRET_FLAGS})${LONG_FLAG_VALUE_START})${VALUE_OR_DASHED}`, "gi"),
2751
+ new RegExp(String.raw`(?<=(?<![\w-])--key${LONG_FLAG_VALUE_START})(?!${NOT_A_KEY})${VALUE_OR_DASHED}`, "gi"),
2752
+ // A password attached to the flag: `mysql -pSECRET`. `mysql -p db` prompts, so there's no value to remove, and
2753
+ // `psql -p 5432` is a port, so psql isn't listed
2754
+ toolFlagValue(
2755
+ "mysql|mysqldump|mysqladmin|mysqlimport|mysqlshow|mysqlcheck|mariadb|mariadb-dump|mariadb-admin|mariadb-import|mariadb-show|mariadb-check",
2756
+ "-p"
2757
+ ),
2758
+ // Short flags that take a secret, for the tools where they do
2759
+ toolFlagValue("sshpass", String.raw`-p\s*`),
2760
+ toolFlagValue("mongosh|mongo|mongodump|mongorestore|mongoexport|mongoimport", String.raw`-p\s*${NOT_A_PORT_MAPPING}`),
2761
+ toolFlagValue("redis-cli", String.raw`-a\s*`),
2762
+ toolFlagValue("ssh-keygen", String.raw`-[NP]\s*`),
2763
+ toolFlagValue("zip|unzip", String.raw`-P\s*`),
2764
+ valueAfter(
2765
+ String.raw`${TOOL_WORD_START}(?:${CONTAINER_LOGIN_TOOLS})${TOOL_WORD_END}[^|;&\n]{0,${MAX_PARTS_BEFORE_LOGIN}}?\slogin` + String.raw`${TOOL_WORD_END}${sameCommand(MAX_PARTS_AFTER_LOGIN)}\s-p\s*${NOT_A_PORT_MAPPING}`
2766
+ ),
2767
+ // openssl's `-pass pass:SECRET`, also -passin and -passout
2768
+ /(?<=\s-pass(?:in|out|word)?\s+pass:)\S+/g,
2769
+ // curl's cookies, which also covers a cookie file's name: `curl -b s=SECRET`
2770
+ valueAfter(String.raw`\bcurl\b["']?${SAME_COMMAND}\s(?:--cookie|-\w*b)[\s=]+`),
2771
+ // The password half of `curl -u user:password`
2772
+ ...valuesAfterName(String.raw`\bcurl\b["']?${SAME_COMMAND}\s(?:-\w*u[\s=]*|--user[\s=]+)`, String.raw`[^:\s"']*:`),
2773
+ // Tokens handed over on stdin: `echo SECRET | docker login --password-stdin`, `gh auth login --with-token <<< SECRET`
2774
+ new RegExp(
2775
+ String.raw`(?<=\b(?:echo(?:\s+-[neE]+)*|printf(?:\s+["']?%s(?:\\n)?["']?)?)\s+)${VALUE}` + String.raw`(?=\s*\|[^;&\n]{0,${MAX_COMMAND_PARTS}}?--(?:password-stdin|with-token))`,
2776
+ "g"
2777
+ ),
2778
+ valueAfter(String.raw`--(?:password-stdin|with-token)\s*<<<\s*`),
2779
+ // Headers. A scheme or a header flag is required for Authorization, so prose like "Authorization: added middleware"
2780
+ // stays. An everyday word needs to look like a header, so `git commit -m "cookie: rotate on login"` stays: a header
2781
+ // flag, `Cookie:value` with no space, or a quoted `"Cookie: value"` outside a message
2782
+ /(?<=\bauthorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi,
2783
+ /(?<=\bbearer\s+)(?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{8,}/gi,
2784
+ ...valuesAfterName(HEADER_FLAG, String.raw`(?:${EVERYDAY_HEADERS}):\s*`, "gi"),
2785
+ ...valuesAfterName(HEADER_FLAG, String.raw`authorization:\s*(?!(?:bearer|basic|token)\s)`, "gi"),
2786
+ ...quotedValuesAfterName("", String.raw`${NOT_IN_A_MESSAGE}(?:${EVERYDAY_HEADERS}):\s*`, "gi"),
2787
+ ...valuesAfterName("", String.raw`(?<![\w-])(?:${EVERYDAY_HEADERS}):`, "gi"),
2788
+ ...valuesAfterName("", String.raw`\b(?:${DISTINCTIVE_HEADERS}):\s*`, "gi"),
2789
+ // Private keys, whole blocks, as heredocs and `echo` can carry them
2790
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
2791
+ // password=... inside a string or query: `psql "host=x password=SECRET"`, `--set db.password=SECRET`
2792
+ /(?<=(?:^|[\s"'?&=.])(?:password|passwd|pwd)=)[^\s"';&]+/gi,
2793
+ // A secret in a query string or in JSON: `curl "x.io?token=SECRET"`, `curl -d '{"password":"SECRET"}'`
2794
+ new RegExp(String.raw`(?<=[?&](?:${QUERY_SECRET_NAME})=)[^\s"'&#]+`, "gi"),
2795
+ new RegExp(String.raw`(?<=["'](?:${JSON_SECRET_NAME})\\?["']\s*:\s*\\?")[^"\\\n]+`, "gi"),
2796
+ // npm's auth settings, with or without `=`: `npm config set //registry/:_password SECRET`
2797
+ /(?<=:_(?:password|auth|authToken)[=\s]+)\S+/g,
2676
2798
  // export VAR=VALUE where VAR looks sensitive
2677
- /(?<=export\s+(?:[A-Z_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z_]*)=)\S+/gi,
2799
+ new RegExp(String.raw`(?<=export\s+(?:[A-Z_]*(?:${SENSITIVE_VARIABLE_WORDS})[A-Z_]*)=)${VALUE_OR_DASHED}`, "gi"),
2678
2800
  // Inline assignment: VAR_WITH_SENSITIVE_KEYWORD=value cmd (e.g. API_KEY=secret npm start)
2679
2801
  // Lookbehind matches only the variable-name= prefix, so only the value is replaced
2680
- /(?<=[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z0-9_]*=)\S+/gi,
2802
+ new RegExp(String.raw`(?<=[A-Z_][A-Z0-9_]*(?:${SENSITIVE_VARIABLE_WORDS})[A-Z0-9_]*=)${VALUE_OR_DASHED}`, "gi"),
2803
+ // The same with nothing before the keyword: `TOKEN=SECRET npm start`. Capitals only, so `--author=me` and `key=value` stay
2804
+ new RegExp(
2805
+ String.raw`(?<=(?:^|[\s;&|(])(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIALS?)=)${VALUE_OR_DASHED}`,
2806
+ "g"
2807
+ ),
2681
2808
  // Credentials embedded in connection string URLs (e.g. DATABASE_URL=postgres://user:pass@host/db)
2682
2809
  // Matches the password segment between :// user : password @ — preserves scheme and host
2683
- /(?<=[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^:@\s/]+:)[^@\s]+(?=@)/g
2810
+ /(?<=[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^:@\s/]*:)[^@\s]+(?=@)/g,
2811
+ // A token as the whole userinfo of an https URL: `git clone https://TOKEN@github.com/org/repo`
2812
+ /(?<=https?:\/\/)[^\s:@/?#]+(?=@)/g
2684
2813
  ];
2814
+ REDACTED_TEXT = "[REDACTED]";
2815
+ }
2816
+ });
2817
+
2818
+ // src/capture/hint.ts
2819
+ function commandHint(safeCommand) {
2820
+ return safeCommand.trim().slice(0, HINT_LENGTH);
2821
+ }
2822
+ var HINT_LENGTH;
2823
+ var init_hint = __esm({
2824
+ "src/capture/hint.ts"() {
2825
+ "use strict";
2826
+ HINT_LENGTH = 120;
2685
2827
  }
2686
2828
  });
2687
2829
 
@@ -2853,13 +2995,13 @@ var init_git_snapshot = __esm({
2853
2995
  });
2854
2996
 
2855
2997
  // src/capture/aider.ts
2856
- function readLastAiderPrompt(cwd) {
2998
+ function readLastAiderPrompt(cwd, extraRedactions = []) {
2857
2999
  try {
2858
3000
  const histPath = import_path8.default.join(cwd, HISTORY_FILE);
2859
3001
  const content = import_fs10.default.readFileSync(histPath, "utf-8");
2860
3002
  const matches = [...content.matchAll(/^#### (.+)$/gm)];
2861
3003
  if (!matches.length) return null;
2862
- return matches[matches.length - 1][1].trim().slice(0, PROMPT_MAX_LENGTH);
3004
+ return redact(matches[matches.length - 1][1].trim(), extraRedactions).slice(0, PROMPT_MAX_LENGTH);
2863
3005
  } catch {
2864
3006
  return null;
2865
3007
  }
@@ -2870,6 +3012,7 @@ var init_aider = __esm({
2870
3012
  "use strict";
2871
3013
  import_fs10 = __toESM(require("fs"));
2872
3014
  import_path8 = __toESM(require("path"));
3015
+ init_redact();
2873
3016
  HISTORY_FILE = ".aider.chat.history.md";
2874
3017
  PROMPT_MAX_LENGTH = 200;
2875
3018
  }
@@ -3115,7 +3258,7 @@ function captureCommand(opts) {
3115
3258
  if (Date.now() - lastTime < TASK_RUN_DEDUP_WINDOW_MS) return;
3116
3259
  }
3117
3260
  }
3118
- const command2 = opts.command ?? opts.file ?? opts.type;
3261
+ const command2 = opts.command === void 0 ? opts.file ?? opts.type : redact(opts.command, config.redact.extra);
3119
3262
  const context2 = getRepoContext(cwd, config);
3120
3263
  const { repoName: repoName2, branch: branch2 } = context2;
3121
3264
  const ideEvent = {
@@ -3149,17 +3292,17 @@ function captureCommand(opts) {
3149
3292
  if (category === "other") {
3150
3293
  const firstToken = safeCommand.trim().split(/\s+/)[0];
3151
3294
  if (firstToken) metadata.tool = firstToken;
3152
- metadata.hint = safeCommand.trim().slice(0, 120);
3295
+ metadata.hint = commandHint(safeCommand);
3153
3296
  }
3154
3297
  if (category === "ai_session") {
3155
3298
  if (opts.gitBefore) {
3156
3299
  const commits = getCommitsSince(opts.gitBefore, workDirectory);
3157
- if (commits.length > 0) metadata.commits = commits;
3300
+ if (commits.length > 0) metadata.commits = commits.map((subject) => redact(subject, config.redact.extra));
3158
3301
  }
3159
3302
  if (config.capture.includeAiderPrompt !== false) {
3160
3303
  const toolEntry = loadAIToolRegistry().find((entry) => entry.tool === metadata.tool);
3161
3304
  if (toolEntry?.contextSource === "history-file") {
3162
- const prompt = readLastAiderPrompt(cwd);
3305
+ const prompt = readLastAiderPrompt(cwd, config.redact.extra);
3163
3306
  if (prompt) metadata.prompt = prompt;
3164
3307
  }
3165
3308
  }
@@ -3192,6 +3335,7 @@ var init_capture = __esm({
3192
3335
  import_fs14 = __toESM(require("fs"));
3193
3336
  init_db();
3194
3337
  init_redact();
3338
+ init_hint();
3195
3339
  init_git();
3196
3340
  init_git_snapshot();
3197
3341
  init_aider();
@@ -3531,16 +3675,58 @@ var init_completions_block = __esm({
3531
3675
  });
3532
3676
 
3533
3677
  // src/capture/shell-hook.ts
3678
+ function detectShell() {
3679
+ const shell = process.env.SHELL ?? "";
3680
+ if (shell.includes("zsh")) return { name: "zsh", rcFile: import_path14.default.join(import_os3.default.homedir(), ".zshrc") };
3681
+ if (shell.includes("bash")) return { name: "bash", rcFile: import_path14.default.join(import_os3.default.homedir(), ".bashrc") };
3682
+ if (shell.includes("fish")) {
3683
+ return { name: "fish", rcFile: import_path14.default.join(import_os3.default.homedir(), ".config", "fish", "conf.d", "workscribe.fish") };
3684
+ }
3685
+ return null;
3686
+ }
3687
+ function blockRegion(content, markers) {
3688
+ const blockStart = content.indexOf(markers.start);
3689
+ const blockEnd = content.indexOf(markers.end, blockStart);
3690
+ if (blockStart === -1 || blockEnd === -1) return null;
3691
+ const lines = content.slice(0, blockStart).split("\n");
3692
+ lines.pop();
3693
+ let headerLines = 0;
3694
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
3695
+ const line = lines[index];
3696
+ if (!markers.preamble.test(line) && line.trim() !== "") break;
3697
+ headerLines = lines.length - index;
3698
+ }
3699
+ while (headerLines > 0 && lines[lines.length - headerLines].trim() === "") headerLines -= 1;
3700
+ const header = lines.slice(lines.length - headerLines);
3701
+ const headerLength = header.reduce((length, line) => length + line.length + 1, 0);
3702
+ return { start: blockStart - headerLength, end: blockEnd + markers.end.length };
3703
+ }
3704
+ function hookRegion(content) {
3705
+ return blockRegion(content, { start: HOOK_START, end: HOOK_END, preamble: PREAMBLE_LINE });
3706
+ }
3707
+ function findChronicleHook(content) {
3708
+ return blockRegion(content, { start: CHRONICLE_START, end: CHRONICLE_END, preamble: CHRONICLE_PREAMBLE_LINE });
3709
+ }
3710
+ function hookBlockOf(hookContent) {
3711
+ const startIndex = hookContent.indexOf(HOOK_START);
3712
+ const endIndex = hookContent.indexOf(HOOK_END);
3713
+ if (startIndex === -1 || endIndex === -1) return hookContent.trim();
3714
+ return hookContent.slice(startIndex, endIndex + HOOK_END.length);
3715
+ }
3716
+ function installedHookVersion(block) {
3717
+ const match = HOOK_VERSION_LINE.exec(block);
3718
+ return match ? Number(match[1]) : 1;
3719
+ }
3534
3720
  function backUp(rcFile) {
3535
3721
  log.info(`Backed up ${rcFile} \u2192 ${import_path14.default.basename(backUpFile(rcFile))}`);
3536
3722
  }
3537
3723
  function getInstalledHookBlock(rcFile) {
3538
- if (!import_fs17.default.existsSync(rcFile)) return null;
3539
- const content = import_fs17.default.readFileSync(rcFile, "utf-8");
3540
- const startIdx = content.indexOf(HOOK_START);
3541
- const endIdx = content.indexOf(HOOK_END);
3542
- if (startIdx === -1 || endIdx === -1) return null;
3543
- return content.slice(startIdx, endIdx + HOOK_END.length);
3724
+ const content = import_fs17.default.existsSync(rcFile) ? readRcFile(rcFile) : null;
3725
+ if (content === null) return null;
3726
+ const startIndex = content.indexOf(HOOK_START);
3727
+ const endIndex = content.indexOf(HOOK_END);
3728
+ if (startIndex === -1 || endIndex === -1) return null;
3729
+ return content.slice(startIndex, endIndex + HOOK_END.length);
3544
3730
  }
3545
3731
  function isHookInstalled(rcFile) {
3546
3732
  return getInstalledHookBlock(rcFile) !== null;
@@ -3548,7 +3734,7 @@ function isHookInstalled(rcFile) {
3548
3734
  function isHookOutdated(rcFile, hookContent) {
3549
3735
  const installed = getInstalledHookBlock(rcFile);
3550
3736
  if (!installed) return false;
3551
- return installed.trim() !== hookContent.trim();
3737
+ return installed.trim() !== hookBlockOf(hookContent).trim();
3552
3738
  }
3553
3739
  function installHook(rcFile, hookContent) {
3554
3740
  backUp(rcFile);
@@ -3559,20 +3745,41 @@ ${hookContent}
3559
3745
  function updateHook(rcFile, hookContent) {
3560
3746
  backUp(rcFile);
3561
3747
  const content = import_fs17.default.readFileSync(rcFile, "utf-8");
3562
- const startIdx = content.indexOf(HOOK_START);
3563
- const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
3564
- import_fs17.default.writeFileSync(rcFile, content.slice(0, startIdx) + hookContent + content.slice(endIdx));
3748
+ const region = hookRegion(content);
3749
+ if (!region) return;
3750
+ import_fs17.default.writeFileSync(rcFile, content.slice(0, region.start) + hookContent.trimEnd() + content.slice(region.end));
3751
+ }
3752
+ function withoutRegion(content, region) {
3753
+ const before = content.slice(0, region.start).replace(/\n+$/, "\n");
3754
+ const after = content.slice(region.end).replace(/^\n/, "");
3755
+ return before + after;
3565
3756
  }
3566
3757
  function removeHook(rcFile) {
3567
3758
  backUp(rcFile);
3568
3759
  const content = import_fs17.default.readFileSync(rcFile, "utf-8");
3569
- const startIdx = content.indexOf(HOOK_START);
3570
- const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
3571
- const preambleIdx = content.lastIndexOf(HOOK_PREAMBLE, startIdx);
3572
- const blockStart = preambleIdx !== -1 ? preambleIdx : startIdx;
3573
- const before = content.slice(0, blockStart).replace(/\n+$/, "\n");
3574
- const after = content.slice(endIdx).replace(/^\n/, "");
3575
- import_fs17.default.writeFileSync(rcFile, before + after);
3760
+ const region = hookRegion(content);
3761
+ if (!region) return;
3762
+ import_fs17.default.writeFileSync(rcFile, withoutRegion(content, region));
3763
+ }
3764
+ function hasChronicleHook(rcFile) {
3765
+ const content = readRcFile(rcFile);
3766
+ return content !== null && findChronicleHook(content) !== null;
3767
+ }
3768
+ function removeChronicleHook(rcFile) {
3769
+ const content = readRcFile(rcFile);
3770
+ const region = content === null ? null : findChronicleHook(content);
3771
+ if (content === null || !region) return false;
3772
+ backUp(rcFile);
3773
+ import_fs17.default.writeFileSync(rcFile, withoutRegion(content, region));
3774
+ return true;
3775
+ }
3776
+ function shellHookHealth(shell) {
3777
+ const installed = getInstalledHookBlock(shell.rcFile);
3778
+ const currentVersion = SHELL_HOOK_VERSIONS[shell.name] ?? 1;
3779
+ return {
3780
+ isOutdated: installed !== null && installedHookVersion(installed) < currentVersion,
3781
+ hasChronicleHook: hasChronicleHook(shell.rcFile)
3782
+ };
3576
3783
  }
3577
3784
  function resolveWorkscribeBinary() {
3578
3785
  try {
@@ -3651,7 +3858,7 @@ function removeFishCompletions() {
3651
3858
  import_fs17.default.rmSync(FISH_COMPLETIONS_FILE, { force: true });
3652
3859
  return true;
3653
3860
  }
3654
- var import_fs17, import_path14, import_os3, import_child_process3, FISH_COMPLETIONS_DIR, HOOK_START, HOOK_END, HOOK_PREAMBLE, FISH_COMPLETIONS_FILE;
3861
+ var import_fs17, import_path14, import_os3, import_child_process3, FISH_COMPLETIONS_DIR, HOOK_START, HOOK_END, PREAMBLE_LINE, HOOK_VERSION_LINE, SHELL_HOOK_VERSIONS, CHRONICLE_START, CHRONICLE_END, CHRONICLE_PREAMBLE_LINE, FISH_COMPLETIONS_FILE;
3655
3862
  var init_shell_hook = __esm({
3656
3863
  "src/capture/shell-hook.ts"() {
3657
3864
  "use strict";
@@ -3668,7 +3875,12 @@ var init_shell_hook = __esm({
3668
3875
  FISH_COMPLETIONS_DIR = import_path14.default.join(import_os3.default.homedir(), ".config", "fish", "completions");
3669
3876
  HOOK_START = "# workscribe:hook:start";
3670
3877
  HOOK_END = "# workscribe:hook:end";
3671
- HOOK_PREAMBLE = "# Workscribe \u2014 developer work journal";
3878
+ PREAMBLE_LINE = /^# (?:Workscribe — developer work journal|Do not edit this (?:block|file) manually\. Managed by `workscribe init`\.)$/;
3879
+ HOOK_VERSION_LINE = /^# workscribe:hook-version (\d+)$/m;
3880
+ SHELL_HOOK_VERSIONS = Object.freeze({ zsh: 2, bash: 2, fish: 1 });
3881
+ CHRONICLE_START = "# chronicle:hook:start";
3882
+ CHRONICLE_END = "# chronicle:hook:end";
3883
+ CHRONICLE_PREAMBLE_LINE = /^# (?:Chronicle — developer work journal|Do not edit this block manually\. Managed by `chronicle init`\.)$/;
3672
3884
  FISH_COMPLETIONS_FILE = import_path14.default.join(FISH_COMPLETIONS_DIR, "workscribe.fish");
3673
3885
  }
3674
3886
  });
@@ -4305,10 +4517,10 @@ function installIntoFolder(options) {
4305
4517
  const outcome = installSkill({
4306
4518
  sourceDir: options.sourceDir,
4307
4519
  skillsFolder: target.skillsFolder,
4308
- version: "3.0.0",
4520
+ version: "3.1.0",
4309
4521
  force: options.force
4310
4522
  });
4311
- return describeInstallOutcome({ ...target, outcome, version: "3.0.0" });
4523
+ return describeInstallOutcome({ ...target, outcome, version: "3.1.0" });
4312
4524
  } catch (error) {
4313
4525
  return describeInstallFailure({ ...target, error });
4314
4526
  }
@@ -4327,8 +4539,8 @@ function removeSkills(agents, options) {
4327
4539
  }
4328
4540
  function formatAgentSkillLine(agent, homeDir = import_os6.default.homedir()) {
4329
4541
  const location = displayPath(import_path19.default.join(homeDir, agent.skillsFolder, SKILL_NAME), homeDir);
4330
- const installState = readInstallState(import_path19.default.join(homeDir, agent.skillsFolder), "3.0.0");
4331
- const status = describeSkillStatus(installState, "3.0.0");
4542
+ const installState = readInstallState(import_path19.default.join(homeDir, agent.skillsFolder), "3.1.0");
4543
+ const status = describeSkillStatus(installState, "3.1.0");
4332
4544
  return `${agent.name.padEnd(AGENT_NAME_COLUMN_WIDTH)} ${STATUS_COLOURS[status.level](status.text)} ${import_chalk2.default.dim(location)}`;
4333
4545
  }
4334
4546
  function formatUndetectedAgentLine(agent) {
@@ -4424,16 +4636,6 @@ var init_legacy_ai_cleanup = __esm({
4424
4636
  });
4425
4637
 
4426
4638
  // src/cli/init.ts
4427
- function detectShell() {
4428
- const shell = process.env.SHELL ?? "";
4429
- if (shell.includes("zsh")) return { name: "zsh", rcFile: import_path20.default.join(import_os7.default.homedir(), ".zshrc") };
4430
- if (shell.includes("bash")) return { name: "bash", rcFile: import_path20.default.join(import_os7.default.homedir(), ".bashrc") };
4431
- if (shell.includes("fish")) {
4432
- const confDir = import_path20.default.join(import_os7.default.homedir(), ".config", "fish", "conf.d");
4433
- return { name: "fish", rcFile: import_path20.default.join(confDir, "workscribe.fish") };
4434
- }
4435
- return null;
4436
- }
4437
4639
  function step(current, total, label) {
4438
4640
  log.plain(import_chalk3.default.bold(`
4439
4641
  [${current}/${total}] ${label}`));
@@ -4479,6 +4681,21 @@ async function runTelemetryStep(config) {
4479
4681
  config.telemetry.enabled = telemetryEnabled;
4480
4682
  config.telemetry.installId = ensureInstallId(config);
4481
4683
  }
4684
+ async function offerChronicleCleanup(shell) {
4685
+ if (!hasChronicleHook(shell.rcFile)) return;
4686
+ const { removeChronicle } = await (0, import_prompts2.default)({
4687
+ type: "confirm",
4688
+ name: "removeChronicle",
4689
+ message: `An old Chronicle hook in ${shell.rcFile} errors at every prompt. Remove it?`,
4690
+ initial: true
4691
+ });
4692
+ if (!removeChronicle) {
4693
+ log.info("Chronicle hook left in place");
4694
+ return;
4695
+ }
4696
+ removeChronicleHook(shell.rcFile);
4697
+ log.success(`Removed the old Chronicle hook from ${shell.rcFile}`);
4698
+ }
4482
4699
  async function runShellHookStep(program) {
4483
4700
  step(3, TOTAL_STEPS, "Installing shell hook");
4484
4701
  const shell = detectShell();
@@ -4527,6 +4744,7 @@ async function runShellHookStep(program) {
4527
4744
  } else {
4528
4745
  log.info(`Shell hook up to date in ${shell.rcFile}`);
4529
4746
  }
4747
+ await offerChronicleCleanup(shell);
4530
4748
  if (isCompletionsInstalled(shell)) {
4531
4749
  await refreshCompletions(shell, program);
4532
4750
  } else {
@@ -4614,10 +4832,10 @@ async function runAIToolsStep() {
4614
4832
  }
4615
4833
  async function offerSkillInstall(group, sourceDir) {
4616
4834
  const target = describeTarget(group);
4617
- const state = readInstallState(target.skillsFolder, "3.0.0");
4835
+ const state = readInstallState(target.skillsFolder, "3.1.0");
4618
4836
  const knownOutcome = outcomeWithoutPrompt(state);
4619
4837
  if (knownOutcome) {
4620
- printMessage(describeInstallOutcome({ ...target, outcome: knownOutcome, version: "3.0.0" }));
4838
+ printMessage(describeInstallOutcome({ ...target, outcome: knownOutcome, version: "3.1.0" }));
4621
4839
  return;
4622
4840
  }
4623
4841
  const { installSkill: shouldInstall } = await (0, import_prompts2.default)({
@@ -4720,13 +4938,12 @@ function registerInit(program) {
4720
4938
  printNextSteps(shell);
4721
4939
  });
4722
4940
  }
4723
- var import_fs22, import_path20, import_os7, import_prompts2, import_chalk3, TOTAL_STEPS;
4941
+ var import_fs22, import_path20, import_prompts2, import_chalk3, TOTAL_STEPS;
4724
4942
  var init_init = __esm({
4725
4943
  "src/cli/init.ts"() {
4726
4944
  "use strict";
4727
4945
  import_fs22 = __toESM(require("fs"));
4728
4946
  import_path20 = __toESM(require("path"));
4729
- import_os7 = __toESM(require("os"));
4730
4947
  import_prompts2 = __toESM(require("prompts"));
4731
4948
  import_chalk3 = __toESM(require("chalk"));
4732
4949
  init_paths();
@@ -4823,14 +5040,14 @@ var init_format = __esm({
4823
5040
 
4824
5041
  // src/cli/notices.ts
4825
5042
  function printLegacyAiNotice(config) {
4826
- const notice = describeLegacyAiSettings(config, displayPath(CONFIG_PATH, import_os8.default.homedir()));
5043
+ const notice = describeLegacyAiSettings(config, displayPath(CONFIG_PATH, import_os7.default.homedir()));
4827
5044
  if (notice) log.notice(notice);
4828
5045
  }
4829
- var import_os8;
5046
+ var import_os7;
4830
5047
  var init_notices = __esm({
4831
5048
  "src/cli/notices.ts"() {
4832
5049
  "use strict";
4833
- import_os8 = __toESM(require("os"));
5050
+ import_os7 = __toESM(require("os"));
4834
5051
  init_logger();
4835
5052
  init_paths();
4836
5053
  init_legacy_ai();
@@ -5595,6 +5812,117 @@ var init_ticket_refs = __esm({
5595
5812
  }
5596
5813
  });
5597
5814
 
5815
+ // src/db/jobs/scrub-secrets.ts
5816
+ function scrubValue(value, command) {
5817
+ if (command.after.includes(value)) return value;
5818
+ const redacted = redact(value);
5819
+ if (redacted !== value) return redacted;
5820
+ return command.before.includes(value) ? REDACTED_TEXT : value;
5821
+ }
5822
+ function scrubStrings(value, command) {
5823
+ if (typeof value === "string") return scrubValue(value, command);
5824
+ if (Array.isArray(value)) return value.map((item) => scrubStrings(item, command));
5825
+ if (value === null || typeof value !== "object") return value;
5826
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrubStrings(item, command)]));
5827
+ }
5828
+ function hasHint(value) {
5829
+ return typeof value?.hint === "string";
5830
+ }
5831
+ function scrubMetadata(metadata, command) {
5832
+ if (metadata === null) return null;
5833
+ let parsed;
5834
+ try {
5835
+ parsed = JSON.parse(metadata);
5836
+ } catch {
5837
+ return redact(metadata);
5838
+ }
5839
+ const scrubbed = scrubStrings(parsed, command);
5840
+ if (command.after !== command.before && hasHint(scrubbed)) scrubbed.hint = commandHint(command.after);
5841
+ const rewritten = JSON.stringify(scrubbed);
5842
+ return rewritten === JSON.stringify(parsed) ? metadata : rewritten;
5843
+ }
5844
+ function scrubEvent({ command, metadata }) {
5845
+ const scrubbedCommand = redact(command);
5846
+ const scrubbedMetadata = scrubMetadata(metadata, { before: command, after: scrubbedCommand });
5847
+ if (scrubbedCommand === command && scrubbedMetadata === metadata) return null;
5848
+ return { command: scrubbedCommand, metadata: scrubbedMetadata };
5849
+ }
5850
+ function readEvents(db, afterId, limit) {
5851
+ return db.prepare(
5852
+ `SELECT id, command, metadata FROM events
5853
+ WHERE id > ? AND command IS NOT file_path
5854
+ ORDER BY id LIMIT ?`
5855
+ ).all(afterId, limit);
5856
+ }
5857
+ function runBatch5(db, afterId, limit) {
5858
+ const events = readEvents(db, afterId, limit);
5859
+ if (events.length === 0) return { lastId: null, issues: [] };
5860
+ const update = db.prepare("UPDATE events SET command = ?, metadata = ? WHERE id = ?");
5861
+ for (const event of events) {
5862
+ const scrubbed = scrubEvent(event);
5863
+ if (scrubbed) update.run(scrubbed.command, scrubbed.metadata, event.id);
5864
+ }
5865
+ return { lastId: events[events.length - 1].id, issues: [] };
5866
+ }
5867
+ function verify4(db) {
5868
+ let checked = 0;
5869
+ let unresolved = 0;
5870
+ let afterId = 0;
5871
+ for (; ; ) {
5872
+ const events = readEvents(db, afterId, VERIFY_PAGE_SIZE);
5873
+ if (events.length === 0) break;
5874
+ checked += events.length;
5875
+ unresolved += events.filter((event) => scrubEvent(event) !== null).length;
5876
+ afterId = events[events.length - 1].id;
5877
+ }
5878
+ const notes = unresolved > 0 ? [`events that would still change: ${unresolved}`] : [];
5879
+ return { checked, resolved: checked - unresolved, unresolved, hasPassed: true, notes };
5880
+ }
5881
+ function cleanUpFile(db) {
5882
+ db.exec(`PRAGMA busy_timeout = ${CLEANUP_LOCK_WAIT_MS}`);
5883
+ try {
5884
+ if (getMetaValue(db, CLEANUP_KEY) === CLEANUP_NEEDS_VACUUM) {
5885
+ db.exec("VACUUM");
5886
+ setMetaValue(db, CLEANUP_KEY, CLEANUP_NEEDS_CHECKPOINT);
5887
+ }
5888
+ const result = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
5889
+ if (result?.busy) {
5890
+ debugLog(`data job ${JOB_NAME}: file cleanup`, "left for later, a reader is busy");
5891
+ return;
5892
+ }
5893
+ deleteMetaValue(db, CLEANUP_KEY);
5894
+ } finally {
5895
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
5896
+ }
5897
+ }
5898
+ var JOB_NAME, VERIFY_PAGE_SIZE, CLEANUP_KEY, CLEANUP_NEEDS_VACUUM, CLEANUP_NEEDS_CHECKPOINT, CLEANUP_LOCK_WAIT_MS, scrubSecretsJob;
5899
+ var init_scrub_secrets = __esm({
5900
+ "src/db/jobs/scrub-secrets.ts"() {
5901
+ "use strict";
5902
+ init_transaction();
5903
+ init_store2();
5904
+ init_hint();
5905
+ init_redact();
5906
+ init_logger();
5907
+ JOB_NAME = "scrub-secrets";
5908
+ VERIFY_PAGE_SIZE = 500;
5909
+ CLEANUP_KEY = "scrub-secrets:file-cleanup";
5910
+ CLEANUP_NEEDS_VACUUM = "vacuum";
5911
+ CLEANUP_NEEDS_CHECKPOINT = "checkpoint";
5912
+ CLEANUP_LOCK_WAIT_MS = 200;
5913
+ scrubSecretsJob = {
5914
+ name: JOB_NAME,
5915
+ version: 1,
5916
+ description: "Remove secrets from stored commands and metadata",
5917
+ // Redacting what's already redacted changes nothing, so a rerun only needs the file cleaned again
5918
+ prepare: (db) => setMetaValue(db, CLEANUP_KEY, CLEANUP_NEEDS_VACUUM),
5919
+ runBatch: runBatch5,
5920
+ verify: verify4,
5921
+ cleanup: { isPending: (db) => getMetaValue(db, CLEANUP_KEY) !== null, run: cleanUpFile }
5922
+ };
5923
+ }
5924
+ });
5925
+
5598
5926
  // src/db/jobs/index.ts
5599
5927
  var JOBS;
5600
5928
  var init_jobs = __esm({
@@ -5605,10 +5933,12 @@ var init_jobs = __esm({
5605
5933
  init_summary_anchors();
5606
5934
  init_drop_old_sessions();
5607
5935
  init_ticket_refs();
5936
+ init_scrub_secrets();
5608
5937
  JOBS = [
5609
5938
  createProjectDimensionsJob({ probeDirectory }),
5610
5939
  summaryAnchorsJob,
5611
5940
  ticketRefsJob,
5941
+ scrubSecretsJob,
5612
5942
  dropOldSessionsJob
5613
5943
  ];
5614
5944
  }
@@ -5773,9 +6103,22 @@ function recordFailure(db, job, message) {
5773
6103
  debugLog(`data job ${job.name}: couldn't record the failure`, error);
5774
6104
  }
5775
6105
  }
6106
+ function runPendingCleanups(db, jobs) {
6107
+ for (const job of jobs) {
6108
+ if (!job.cleanup || !isJobDone(getJobRow(db, job.name), job)) continue;
6109
+ try {
6110
+ if (job.cleanup.isPending(db)) job.cleanup.run(db);
6111
+ } catch (error) {
6112
+ debugLog(`data job ${job.name}: cleanup`, error);
6113
+ }
6114
+ }
6115
+ }
5776
6116
  function runPendingJobs(db, jobs, options) {
5777
6117
  const pending = jobs.filter((job) => !isJobDone(getJobRow(db, job.name), job));
5778
- if (pending.length === 0) return { backup: "not-needed", jobs: [] };
6118
+ if (pending.length === 0) {
6119
+ runPendingCleanups(db, jobs);
6120
+ return { backup: "not-needed", jobs: [] };
6121
+ }
5779
6122
  const backup = options.ensureBackup();
5780
6123
  if (backup !== "ready") return { backup, jobs: [] };
5781
6124
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
@@ -5792,6 +6135,7 @@ function runPendingJobs(db, jobs, options) {
5792
6135
  results.push(result);
5793
6136
  if (result.outcome !== "done") break;
5794
6137
  }
6138
+ runPendingCleanups(db, jobs);
5795
6139
  return { backup: "ready", jobs: results };
5796
6140
  }
5797
6141
  var DEFAULT_BATCH_SIZE, BATCH_LOCK_WAIT_MS, NEWER_VERSION_ERROR, SupersededError;
@@ -5935,6 +6279,23 @@ function formatDataLines(status) {
5935
6279
  if (status.latestBackup) lines.push(import_chalk4.default.dim(` backup: ${status.latestBackup}`));
5936
6280
  return lines;
5937
6281
  }
6282
+ function printShellHookWarnings() {
6283
+ const shell = detectShell();
6284
+ if (!shell) return;
6285
+ const health = shellHookHealth(shell);
6286
+ if (health.isOutdated) {
6287
+ log.plain("");
6288
+ log.plain(
6289
+ ` ${import_chalk4.default.yellow("\u26A0")} Your ${shell.name} hook is from an older version and may not record terminal commands.`
6290
+ );
6291
+ log.plain(import_chalk4.default.dim(" Run workscribe init to update it, then open a new terminal"));
6292
+ }
6293
+ if (health.hasChronicleHook) {
6294
+ log.plain("");
6295
+ log.plain(` ${import_chalk4.default.yellow("\u26A0")} An old Chronicle hook in ${shell.rcFile} errors at every prompt.`);
6296
+ log.plain(import_chalk4.default.dim(" Run workscribe init to remove it"));
6297
+ }
6298
+ }
5938
6299
  function registerStatus(program) {
5939
6300
  program.command("status").description("Show capture status and event counts").action(() => {
5940
6301
  const config = readConfig();
@@ -5976,6 +6337,7 @@ function registerStatus(program) {
5976
6337
  ` ${import_chalk4.default.yellow("!")} ${import_chalk4.default.yellow(String(pendingAlerts.length))} pending security alert${pendingAlerts.length === 1 ? "" : "s"}` + import_chalk4.default.dim(" (run: workscribe alerts)")
5977
6338
  );
5978
6339
  }
6340
+ printShellHookWarnings();
5979
6341
  const hookHealth = checkHookHealth();
5980
6342
  if (hookHealth.installed && !hookHealth.pinned) {
5981
6343
  log.plain("");
@@ -6034,6 +6396,7 @@ var init_status = __esm({
6034
6396
  init_paths();
6035
6397
  init_alerts2();
6036
6398
  init_claude_code();
6399
+ init_shell_hook();
6037
6400
  init_detect();
6038
6401
  init_actions();
6039
6402
  init_notices();
@@ -6455,7 +6818,7 @@ var init_heading = __esm({
6455
6818
 
6456
6819
  // src/summary/payload.ts
6457
6820
  function locationContextFrom(config) {
6458
- return { projects: config.projects ?? {}, homeDir: import_os9.default.homedir() };
6821
+ return { projects: config.projects ?? {}, homeDir: import_os8.default.homedir() };
6459
6822
  }
6460
6823
  function gitMovesField(row) {
6461
6824
  if (!MENTIONS_GIT.test(row.command)) return {};
@@ -6536,11 +6899,11 @@ function threadToPayload(thread, block, options = {}) {
6536
6899
  alongside: block.threads.filter((other) => other.id !== thread.id).map((other) => sessionLocation(other, options.context) ?? UNKNOWN_LOCATION)
6537
6900
  };
6538
6901
  }
6539
- var import_os9, import_path25, DEFAULT_LOCATION_CONTEXT, MENTIONS_GIT;
6902
+ var import_os8, import_path25, DEFAULT_LOCATION_CONTEXT, MENTIONS_GIT;
6540
6903
  var init_payload = __esm({
6541
6904
  "src/summary/payload.ts"() {
6542
6905
  "use strict";
6543
- import_os9 = __toESM(require("os"));
6906
+ import_os8 = __toESM(require("os"));
6544
6907
  import_path25 = __toESM(require("path"));
6545
6908
  init_format();
6546
6909
  init_event_metadata();
@@ -6550,7 +6913,7 @@ var init_payload = __esm({
6550
6913
  init_grouper();
6551
6914
  init_blocks();
6552
6915
  init_heading();
6553
- DEFAULT_LOCATION_CONTEXT = { projects: {}, homeDir: import_os9.default.homedir() };
6916
+ DEFAULT_LOCATION_CONTEXT = { projects: {}, homeDir: import_os8.default.homedir() };
6554
6917
  MENTIONS_GIT = /(^|[\s;&|(])git\s/;
6555
6918
  }
6556
6919
  });
@@ -7865,18 +8228,18 @@ async function postSummary({
7865
8228
  }
7866
8229
  try {
7867
8230
  status.info("Posting to webhook\u2026");
7868
- const body = isSlackWebhookUrl(webhookUrl) ? formatForSlack(summary, import_os10.default.userInfo().username) : summary;
8231
+ const body = isSlackWebhookUrl(webhookUrl) ? formatForSlack(summary, import_os9.default.userInfo().username) : summary;
7869
8232
  await postToWebhook(webhookUrl, body);
7870
8233
  status.success("Posted.");
7871
8234
  } catch (error) {
7872
8235
  log.error(`Webhook failed: ${error.message}`);
7873
8236
  }
7874
8237
  }
7875
- var import_os10;
8238
+ var import_os9;
7876
8239
  var init_webhook2 = __esm({
7877
8240
  "src/summary/webhook.ts"() {
7878
8241
  "use strict";
7879
- import_os10 = __toESM(require("os"));
8242
+ import_os9 = __toESM(require("os"));
7880
8243
  init_logger();
7881
8244
  init_webhook();
7882
8245
  init_slack();
@@ -8028,6 +8391,28 @@ var init_day = __esm({
8028
8391
  }
8029
8392
  });
8030
8393
 
8394
+ // src/summary/output-format.ts
8395
+ function keepsStdoutClean(format) {
8396
+ return format === "json" || format === "text";
8397
+ }
8398
+ var SUMMARY_FORMATS;
8399
+ var init_output_format = __esm({
8400
+ "src/summary/output-format.ts"() {
8401
+ "use strict";
8402
+ SUMMARY_FORMATS = ["markdown", "json", "text"];
8403
+ }
8404
+ });
8405
+
8406
+ // src/summary/plain-text.ts
8407
+ function toPlainText(markdown) {
8408
+ return markdown.replace(/^#{1,6} +/gm, "").replace(/^([ \t]*)[*•] +/gm, "$1- ").replace(/\*\*([^*\n]+)\*\*/g, "$1").replace(/`([^`\n]+)`/g, "$1").replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, "$1 ($2)").trim();
8409
+ }
8410
+ var init_plain_text = __esm({
8411
+ "src/summary/plain-text.ts"() {
8412
+ "use strict";
8413
+ }
8414
+ });
8415
+
8031
8416
  // src/cli/day-notes.ts
8032
8417
  function printExcludedFooter(sessionPayloads, excluded) {
8033
8418
  const excludedCounts = countExcludedEvents(sessionPayloads, excluded);
@@ -8103,10 +8488,11 @@ async function runWeeklySummary({ options, config, provider, loadDay }) {
8103
8488
  }
8104
8489
  const payload = { weekStart, weekEnd: today, by: options.by ?? "project", days };
8105
8490
  const summary = await writeWeeklySummary(provider, payload);
8106
- const isJson = config.summary?.format === "json";
8107
- if (isJson) log.plain(weekJson(payload, config.summary?.includeCategories ?? []));
8491
+ const format = options.format ?? config.summary?.format;
8492
+ if (format === "json") log.plain(weekJson(payload, config.summary?.includeCategories ?? []));
8493
+ else if (format === "text") log.plain(toPlainText(summary));
8108
8494
  else printSummary(summary);
8109
- await postSummary({ options, config, summary, statusToStderr: isJson });
8495
+ await postSummary({ options, config, summary, statusToStderr: keepsStdoutClean(format) });
8110
8496
  }
8111
8497
  var WEEK_GIT_BUDGET_MS;
8112
8498
  var init_weekly = __esm({
@@ -8117,6 +8503,8 @@ var init_weekly = __esm({
8117
8503
  init_payload();
8118
8504
  init_webhook2();
8119
8505
  init_day_commits();
8506
+ init_output_format();
8507
+ init_plain_text();
8120
8508
  init_week();
8121
8509
  WEEK_GIT_BUDGET_MS = DAY_GIT_BUDGET_MS;
8122
8510
  }
@@ -8132,14 +8520,24 @@ function parseWeekLayout(value) {
8132
8520
  if (!layout) fail("--by must be project or day");
8133
8521
  return layout;
8134
8522
  }
8523
+ function addSummaryFormatOption(command) {
8524
+ return command.option("--format <format>", "print as markdown, json or text, overriding summary.format");
8525
+ }
8526
+ function parseSummaryFormat(value) {
8527
+ if (value === void 0) return void 0;
8528
+ const format = SUMMARY_FORMATS.find((candidate) => candidate === value);
8529
+ if (!format) fail("--format must be markdown, json or text");
8530
+ return format;
8531
+ }
8135
8532
  async function runWeekCommand(options) {
8136
8533
  const by = parseWeekLayout(options.by);
8534
+ const format = parseSummaryFormat(options.format);
8137
8535
  const config = readConfig();
8138
8536
  printLegacyAiNotice(config);
8139
8537
  requireThreadData();
8140
8538
  try {
8141
8539
  await runWeeklySummary({
8142
- options: { ...options, by },
8540
+ options: { ...options, by, format },
8143
8541
  config,
8144
8542
  provider: createSummaryWriter(config),
8145
8543
  loadDay: loadDayThreads
@@ -8150,13 +8548,14 @@ async function runWeekCommand(options) {
8150
8548
  }
8151
8549
  function registerWeek(program) {
8152
8550
  const weekCommand = program.command("week").description("Summarize the current week (Mon\u2013today) \u2014 the same as summary --week").option("--fresh", "accepted for 1.x scripts \u2014 the week is always rendered fresh").option("--slack", "post to Slack webhook from config").option("--webhook <url>", "post to a custom webhook URL");
8153
- addWeekLayoutOption(weekCommand).action(runWeekCommand);
8551
+ addSummaryFormatOption(addWeekLayoutOption(weekCommand)).action(runWeekCommand);
8154
8552
  }
8155
8553
  var WEEK_LAYOUTS;
8156
8554
  var init_week2 = __esm({
8157
8555
  "src/cli/week.ts"() {
8158
8556
  "use strict";
8159
8557
  init_config();
8558
+ init_output_format();
8160
8559
  init_weekly();
8161
8560
  init_writer();
8162
8561
  init_notices();
@@ -8201,8 +8600,9 @@ function printCapturedEvents(sessionPayloads, excluded) {
8201
8600
  }
8202
8601
  function registerSummary(program) {
8203
8602
  const summaryCommand = program.command("summary").description("Generate a work summary").option("--date <YYYY-MM-DD>", "date to summarize (default: today)").option("--week", "summarize the current week").option("--fresh", "ignore saved summaries and render the template").option("--verbose", "show all captured events, including those left out of the summary").option("--slack", "post summary to Slack webhook from config").option("--webhook <url>", "post summary to a custom webhook URL");
8204
- addWeekLayoutOption(summaryCommand).action(async (options) => {
8603
+ addSummaryFormatOption(addWeekLayoutOption(summaryCommand)).action(async (options) => {
8205
8604
  if (options.by !== void 0 && !options.week) fail("--by only applies to --week");
8605
+ const formatOption = parseSummaryFormat(options.format);
8206
8606
  if (options.week) {
8207
8607
  await runWeekCommand(options);
8208
8608
  return;
@@ -8225,7 +8625,8 @@ function registerSummary(program) {
8225
8625
  log.plain(`No activity captured for ${date}`);
8226
8626
  return;
8227
8627
  }
8228
- const jsonMode = config.summary?.format === "json";
8628
+ const format = formatOption ?? config.summary?.format;
8629
+ const keepsStdoutToOutput = keepsStdoutClean(format);
8229
8630
  let day;
8230
8631
  try {
8231
8632
  day = await renderDay({ date, blocks, config, provider, fresh: options.fresh });
@@ -8236,11 +8637,13 @@ function registerSummary(program) {
8236
8637
  const { sessionPayloads, saved, fullSummary, needsGeneration } = day;
8237
8638
  const excluded = buildExcludedSet(config.summary?.includeCategories ?? []);
8238
8639
  if (needsGeneration && options.verbose) {
8239
- if (jsonMode) log.notice("--verbose is skipped when summary.format is json, to keep the JSON output clean");
8640
+ if (keepsStdoutToOutput) log.notice(`--verbose is skipped with the ${format} format, to keep the output clean`);
8240
8641
  else printCapturedEvents(sessionPayloads, excluded);
8241
8642
  }
8242
- if (jsonMode) {
8643
+ if (format === "json") {
8243
8644
  log.plain(toJsonOutput(day, date));
8645
+ } else if (format === "text") {
8646
+ log.plain(toPlainText(fullSummary));
8244
8647
  } else {
8245
8648
  log.plain("");
8246
8649
  log.plain(fullSummary);
@@ -8248,7 +8651,7 @@ function registerSummary(program) {
8248
8651
  printSavedNote(saved);
8249
8652
  if (needsGeneration) printExcludedFooter(sessionPayloads, excluded);
8250
8653
  }
8251
- await postSummary({ options, config, summary: fullSummary, statusToStderr: jsonMode });
8654
+ await postSummary({ options, config, summary: fullSummary, statusToStderr: keepsStdoutToOutput });
8252
8655
  });
8253
8656
  }
8254
8657
  var import_chalk8;
@@ -8265,6 +8668,8 @@ var init_summary = __esm({
8265
8668
  init_notices();
8266
8669
  init_webhook2();
8267
8670
  init_day();
8671
+ init_output_format();
8672
+ init_plain_text();
8268
8673
  init_day_notes();
8269
8674
  init_thread_day();
8270
8675
  init_week2();
@@ -8560,12 +8965,15 @@ var init_clear = __esm({
8560
8965
 
8561
8966
  // src/cli/uninstall.ts
8562
8967
  function completionRcFiles() {
8563
- return [".zshrc", ".bashrc", ".bash_profile"].map((file) => import_path28.default.join(import_os11.default.homedir(), file));
8968
+ return [".zshrc", ".bashrc", ".bash_profile"].map((file) => import_path28.default.join(import_os10.default.homedir(), file));
8564
8969
  }
8565
8970
  function removeCompletionsAndToolHooks() {
8566
8971
  for (const rcFile of completionRcFiles()) {
8567
8972
  if (removeCompletions(rcFile)) log.success(`Shell completions removed from ${rcFile}`);
8568
8973
  }
8974
+ for (const rcFile of CHRONICLE_RC_FILES.map((file) => import_path28.default.join(import_os10.default.homedir(), file))) {
8975
+ if (removeChronicleHook(rcFile)) log.success(`Old Chronicle hook removed from ${rcFile}`);
8976
+ }
8569
8977
  if (removeFishCompletions()) log.success("Fish completions removed");
8570
8978
  const outcome = claudeCodeIntegration.uninstall?.();
8571
8979
  if (outcome === "removed") log.success("Claude Code session hook removed from ~/.claude/settings.json");
@@ -8577,10 +8985,10 @@ function removeCompletionsAndToolHooks() {
8577
8985
  }
8578
8986
  function detectShellRcFiles() {
8579
8987
  const candidates = [
8580
- { name: "zsh", rcFile: import_path28.default.join(import_os11.default.homedir(), ".zshrc") },
8581
- { name: "bash", rcFile: import_path28.default.join(import_os11.default.homedir(), ".bashrc") },
8582
- { name: "bash", rcFile: import_path28.default.join(import_os11.default.homedir(), ".bash_profile") },
8583
- { name: "fish", rcFile: import_path28.default.join(import_os11.default.homedir(), ".config", "fish", "conf.d", "workscribe.fish") }
8988
+ { name: "zsh", rcFile: import_path28.default.join(import_os10.default.homedir(), ".zshrc") },
8989
+ { name: "bash", rcFile: import_path28.default.join(import_os10.default.homedir(), ".bashrc") },
8990
+ { name: "bash", rcFile: import_path28.default.join(import_os10.default.homedir(), ".bash_profile") },
8991
+ { name: "fish", rcFile: import_path28.default.join(import_os10.default.homedir(), ".config", "fish", "conf.d", "workscribe.fish") }
8584
8992
  ];
8585
8993
  return candidates.filter((candidate) => import_fs28.default.existsSync(candidate.rcFile) && isHookInstalled(candidate.rcFile));
8586
8994
  }
@@ -8637,13 +9045,13 @@ function registerUninstall(program) {
8637
9045
  log.plain("");
8638
9046
  });
8639
9047
  }
8640
- var import_fs28, import_path28, import_os11, import_prompts4, import_chalk11;
9048
+ var import_fs28, import_path28, import_os10, import_prompts4, import_chalk11, CHRONICLE_RC_FILES;
8641
9049
  var init_uninstall = __esm({
8642
9050
  "src/cli/uninstall.ts"() {
8643
9051
  "use strict";
8644
9052
  import_fs28 = __toESM(require("fs"));
8645
9053
  import_path28 = __toESM(require("path"));
8646
- import_os11 = __toESM(require("os"));
9054
+ import_os10 = __toESM(require("os"));
8647
9055
  import_prompts4 = __toESM(require("prompts"));
8648
9056
  import_chalk11 = __toESM(require("chalk"));
8649
9057
  init_paths();
@@ -8652,6 +9060,7 @@ var init_uninstall = __esm({
8652
9060
  init_logger();
8653
9061
  init_agents();
8654
9062
  init_actions();
9063
+ CHRONICLE_RC_FILES = [".zshrc", ".bashrc"];
8655
9064
  }
8656
9065
  });
8657
9066
 
@@ -8884,7 +9293,8 @@ var init_profile = __esm({
8884
9293
  function registerNote(program) {
8885
9294
  program.command("note <text>").description("Attach a free-text annotation to the current session").action((text) => {
8886
9295
  const directory = currentDirectory();
8887
- const context = getRepoContext(directory, readConfig());
9296
+ const config = readConfig();
9297
+ const context = getRepoContext(directory, config);
8888
9298
  const { repoName, branch } = context;
8889
9299
  insertEvent({
8890
9300
  captured_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8895,7 +9305,7 @@ function registerNote(program) {
8895
9305
  repo_name: repoName,
8896
9306
  branch,
8897
9307
  category: "note",
8898
- metadata: JSON.stringify({ text }),
9308
+ metadata: JSON.stringify({ text: redact(text, config.redact.extra) }),
8899
9309
  source: null,
8900
9310
  file_path: null,
8901
9311
  dimensions: eventDimensions(context)
@@ -8909,6 +9319,7 @@ var init_note = __esm({
8909
9319
  init_db();
8910
9320
  init_git();
8911
9321
  init_dimensions3();
9322
+ init_redact();
8912
9323
  init_config();
8913
9324
  init_working_directory();
8914
9325
  init_logger();
@@ -8938,11 +9349,11 @@ function registerBugReport(program) {
8938
9349
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
8939
9350
  const reportPath = import_path30.default.join(WORKSCRIBE_DIR, `bug-report-${timestamp}.txt`);
8940
9351
  const systemInfo = [
8941
- `Workscribe version : ${"3.0.0"}`,
9352
+ `Workscribe version : ${"3.1.0"}`,
8942
9353
  `Node.js version : ${process.version}`,
8943
9354
  `Platform : ${process.platform}`,
8944
9355
  `Architecture : ${process.arch}`,
8945
- `OS release : ${import_os12.default.release()}`,
9356
+ `OS release : ${import_os11.default.release()}`,
8946
9357
  `Config path : ${CONFIG_PATH}`,
8947
9358
  `Data directory : ${WORKSCRIBE_DIR}`
8948
9359
  ].join("\n");
@@ -8969,7 +9380,7 @@ function registerBugReport(program) {
8969
9380
  process.exit(1);
8970
9381
  }
8971
9382
  const params = new URLSearchParams({
8972
- title: `Bug report \u2014 workscribe ${"3.0.0"}`,
9383
+ title: `Bug report \u2014 workscribe ${"3.1.0"}`,
8973
9384
  body: `<!-- Attach ${import_path30.default.basename(reportPath)} or paste the relevant sections below -->
8974
9385
 
8975
9386
  **Describe the issue:**
@@ -8987,12 +9398,12 @@ function registerBugReport(program) {
8987
9398
  log.plain("");
8988
9399
  });
8989
9400
  }
8990
- var import_fs30, import_os12, import_path30, import_chalk13, GITHUB_ISSUES_URL, DEBUG_LOG_TAIL_LINES;
9401
+ var import_fs30, import_os11, import_path30, import_chalk13, GITHUB_ISSUES_URL, DEBUG_LOG_TAIL_LINES;
8991
9402
  var init_bug_report = __esm({
8992
9403
  "src/cli/bug-report.ts"() {
8993
9404
  "use strict";
8994
9405
  import_fs30 = __toESM(require("fs"));
8995
- import_os12 = __toESM(require("os"));
9406
+ import_os11 = __toESM(require("os"));
8996
9407
  import_path30 = __toESM(require("path"));
8997
9408
  import_chalk13 = __toESM(require("chalk"));
8998
9409
  init_paths();
@@ -9060,7 +9471,7 @@ function reportRun(result) {
9060
9471
  function registerUpgrade(program) {
9061
9472
  program.command("upgrade").description("Bring your stored data up to date after updating Workscribe (backs it up first)").action(() => {
9062
9473
  const announced = /* @__PURE__ */ new Set();
9063
- const result = runDataJobs("3.0.0", {
9474
+ const result = runDataJobs("3.1.0", {
9064
9475
  onProgress: ({ job }) => {
9065
9476
  if (announced.has(job.name)) return;
9066
9477
  announced.add(job.name);
@@ -9550,7 +9961,7 @@ function isNewer(latest, current) {
9550
9961
  return latestPatch > currentPatch;
9551
9962
  }
9552
9963
  function checkForUpdate() {
9553
- const current = "3.0.0";
9964
+ const current = "3.1.0";
9554
9965
  const cache = readCache();
9555
9966
  if (cache && isNewer(cache.latestVersion, current)) {
9556
9967
  process.stderr.write(
@@ -9573,8 +9984,8 @@ function checkForUpdate() {
9573
9984
  function buildTelemetryPayload(config) {
9574
9985
  return {
9575
9986
  installId: config.telemetry.installId,
9576
- cliVersion: "3.0.0",
9577
- platform: import_os13.default.platform(),
9987
+ cliVersion: "3.1.0",
9988
+ platform: import_os12.default.platform(),
9578
9989
  nodeVersion: process.version
9579
9990
  };
9580
9991
  }
@@ -9598,12 +10009,12 @@ function pingTelemetry() {
9598
10009
  debugLog("pingTelemetry", error);
9599
10010
  });
9600
10011
  }
9601
- var import_fs31, import_os13, CHECK_INTERVAL_MS;
10012
+ var import_fs31, import_os12, CHECK_INTERVAL_MS;
9602
10013
  var init_update_check = __esm({
9603
10014
  "src/utils/update-check.ts"() {
9604
10015
  "use strict";
9605
10016
  import_fs31 = __toESM(require("fs"));
9606
- import_os13 = __toESM(require("os"));
10017
+ import_os12 = __toESM(require("os"));
9607
10018
  init_paths();
9608
10019
  init_config();
9609
10020
  init_logger();
@@ -9651,10 +10062,10 @@ var require_main = __commonJS({
9651
10062
  pingTelemetry();
9652
10063
  }
9653
10064
  if (shouldRunDataJobs(process.argv.slice(2))) {
9654
- runDataJobsBeforeCommand(invokedCommand, "3.0.0");
10065
+ runDataJobsBeforeCommand(invokedCommand, "3.1.0");
9655
10066
  }
9656
10067
  var program = new import_commander.Command();
9657
- program.name("workscribe").description("Automatic developer work journal").version("3.0.0");
10068
+ program.name("workscribe").description("Automatic developer work journal").version("3.1.0");
9658
10069
  registerCapture(program);
9659
10070
  registerHook(program);
9660
10071
  registerInit(program);