@workscribe/cli 3.0.1 → 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();
@@ -4373,10 +4517,10 @@ function installIntoFolder(options) {
4373
4517
  const outcome = installSkill({
4374
4518
  sourceDir: options.sourceDir,
4375
4519
  skillsFolder: target.skillsFolder,
4376
- version: "3.0.1",
4520
+ version: "3.1.0",
4377
4521
  force: options.force
4378
4522
  });
4379
- return describeInstallOutcome({ ...target, outcome, version: "3.0.1" });
4523
+ return describeInstallOutcome({ ...target, outcome, version: "3.1.0" });
4380
4524
  } catch (error) {
4381
4525
  return describeInstallFailure({ ...target, error });
4382
4526
  }
@@ -4395,8 +4539,8 @@ function removeSkills(agents, options) {
4395
4539
  }
4396
4540
  function formatAgentSkillLine(agent, homeDir = import_os6.default.homedir()) {
4397
4541
  const location = displayPath(import_path19.default.join(homeDir, agent.skillsFolder, SKILL_NAME), homeDir);
4398
- const installState = readInstallState(import_path19.default.join(homeDir, agent.skillsFolder), "3.0.1");
4399
- const status = describeSkillStatus(installState, "3.0.1");
4542
+ const installState = readInstallState(import_path19.default.join(homeDir, agent.skillsFolder), "3.1.0");
4543
+ const status = describeSkillStatus(installState, "3.1.0");
4400
4544
  return `${agent.name.padEnd(AGENT_NAME_COLUMN_WIDTH)} ${STATUS_COLOURS[status.level](status.text)} ${import_chalk2.default.dim(location)}`;
4401
4545
  }
4402
4546
  function formatUndetectedAgentLine(agent) {
@@ -4688,10 +4832,10 @@ async function runAIToolsStep() {
4688
4832
  }
4689
4833
  async function offerSkillInstall(group, sourceDir) {
4690
4834
  const target = describeTarget(group);
4691
- const state = readInstallState(target.skillsFolder, "3.0.1");
4835
+ const state = readInstallState(target.skillsFolder, "3.1.0");
4692
4836
  const knownOutcome = outcomeWithoutPrompt(state);
4693
4837
  if (knownOutcome) {
4694
- printMessage(describeInstallOutcome({ ...target, outcome: knownOutcome, version: "3.0.1" }));
4838
+ printMessage(describeInstallOutcome({ ...target, outcome: knownOutcome, version: "3.1.0" }));
4695
4839
  return;
4696
4840
  }
4697
4841
  const { installSkill: shouldInstall } = await (0, import_prompts2.default)({
@@ -5668,6 +5812,117 @@ var init_ticket_refs = __esm({
5668
5812
  }
5669
5813
  });
5670
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
+
5671
5926
  // src/db/jobs/index.ts
5672
5927
  var JOBS;
5673
5928
  var init_jobs = __esm({
@@ -5678,10 +5933,12 @@ var init_jobs = __esm({
5678
5933
  init_summary_anchors();
5679
5934
  init_drop_old_sessions();
5680
5935
  init_ticket_refs();
5936
+ init_scrub_secrets();
5681
5937
  JOBS = [
5682
5938
  createProjectDimensionsJob({ probeDirectory }),
5683
5939
  summaryAnchorsJob,
5684
5940
  ticketRefsJob,
5941
+ scrubSecretsJob,
5685
5942
  dropOldSessionsJob
5686
5943
  ];
5687
5944
  }
@@ -5846,9 +6103,22 @@ function recordFailure(db, job, message) {
5846
6103
  debugLog(`data job ${job.name}: couldn't record the failure`, error);
5847
6104
  }
5848
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
+ }
5849
6116
  function runPendingJobs(db, jobs, options) {
5850
6117
  const pending = jobs.filter((job) => !isJobDone(getJobRow(db, job.name), job));
5851
- 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
+ }
5852
6122
  const backup = options.ensureBackup();
5853
6123
  if (backup !== "ready") return { backup, jobs: [] };
5854
6124
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
@@ -5865,6 +6135,7 @@ function runPendingJobs(db, jobs, options) {
5865
6135
  results.push(result);
5866
6136
  if (result.outcome !== "done") break;
5867
6137
  }
6138
+ runPendingCleanups(db, jobs);
5868
6139
  return { backup: "ready", jobs: results };
5869
6140
  }
5870
6141
  var DEFAULT_BATCH_SIZE, BATCH_LOCK_WAIT_MS, NEWER_VERSION_ERROR, SupersededError;
@@ -8120,6 +8391,28 @@ var init_day = __esm({
8120
8391
  }
8121
8392
  });
8122
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
+
8123
8416
  // src/cli/day-notes.ts
8124
8417
  function printExcludedFooter(sessionPayloads, excluded) {
8125
8418
  const excludedCounts = countExcludedEvents(sessionPayloads, excluded);
@@ -8195,10 +8488,11 @@ async function runWeeklySummary({ options, config, provider, loadDay }) {
8195
8488
  }
8196
8489
  const payload = { weekStart, weekEnd: today, by: options.by ?? "project", days };
8197
8490
  const summary = await writeWeeklySummary(provider, payload);
8198
- const isJson = config.summary?.format === "json";
8199
- 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));
8200
8494
  else printSummary(summary);
8201
- await postSummary({ options, config, summary, statusToStderr: isJson });
8495
+ await postSummary({ options, config, summary, statusToStderr: keepsStdoutClean(format) });
8202
8496
  }
8203
8497
  var WEEK_GIT_BUDGET_MS;
8204
8498
  var init_weekly = __esm({
@@ -8209,6 +8503,8 @@ var init_weekly = __esm({
8209
8503
  init_payload();
8210
8504
  init_webhook2();
8211
8505
  init_day_commits();
8506
+ init_output_format();
8507
+ init_plain_text();
8212
8508
  init_week();
8213
8509
  WEEK_GIT_BUDGET_MS = DAY_GIT_BUDGET_MS;
8214
8510
  }
@@ -8224,14 +8520,24 @@ function parseWeekLayout(value) {
8224
8520
  if (!layout) fail("--by must be project or day");
8225
8521
  return layout;
8226
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
+ }
8227
8532
  async function runWeekCommand(options) {
8228
8533
  const by = parseWeekLayout(options.by);
8534
+ const format = parseSummaryFormat(options.format);
8229
8535
  const config = readConfig();
8230
8536
  printLegacyAiNotice(config);
8231
8537
  requireThreadData();
8232
8538
  try {
8233
8539
  await runWeeklySummary({
8234
- options: { ...options, by },
8540
+ options: { ...options, by, format },
8235
8541
  config,
8236
8542
  provider: createSummaryWriter(config),
8237
8543
  loadDay: loadDayThreads
@@ -8242,13 +8548,14 @@ async function runWeekCommand(options) {
8242
8548
  }
8243
8549
  function registerWeek(program) {
8244
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");
8245
- addWeekLayoutOption(weekCommand).action(runWeekCommand);
8551
+ addSummaryFormatOption(addWeekLayoutOption(weekCommand)).action(runWeekCommand);
8246
8552
  }
8247
8553
  var WEEK_LAYOUTS;
8248
8554
  var init_week2 = __esm({
8249
8555
  "src/cli/week.ts"() {
8250
8556
  "use strict";
8251
8557
  init_config();
8558
+ init_output_format();
8252
8559
  init_weekly();
8253
8560
  init_writer();
8254
8561
  init_notices();
@@ -8293,8 +8600,9 @@ function printCapturedEvents(sessionPayloads, excluded) {
8293
8600
  }
8294
8601
  function registerSummary(program) {
8295
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");
8296
- addWeekLayoutOption(summaryCommand).action(async (options) => {
8603
+ addSummaryFormatOption(addWeekLayoutOption(summaryCommand)).action(async (options) => {
8297
8604
  if (options.by !== void 0 && !options.week) fail("--by only applies to --week");
8605
+ const formatOption = parseSummaryFormat(options.format);
8298
8606
  if (options.week) {
8299
8607
  await runWeekCommand(options);
8300
8608
  return;
@@ -8317,7 +8625,8 @@ function registerSummary(program) {
8317
8625
  log.plain(`No activity captured for ${date}`);
8318
8626
  return;
8319
8627
  }
8320
- const jsonMode = config.summary?.format === "json";
8628
+ const format = formatOption ?? config.summary?.format;
8629
+ const keepsStdoutToOutput = keepsStdoutClean(format);
8321
8630
  let day;
8322
8631
  try {
8323
8632
  day = await renderDay({ date, blocks, config, provider, fresh: options.fresh });
@@ -8328,11 +8637,13 @@ function registerSummary(program) {
8328
8637
  const { sessionPayloads, saved, fullSummary, needsGeneration } = day;
8329
8638
  const excluded = buildExcludedSet(config.summary?.includeCategories ?? []);
8330
8639
  if (needsGeneration && options.verbose) {
8331
- 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`);
8332
8641
  else printCapturedEvents(sessionPayloads, excluded);
8333
8642
  }
8334
- if (jsonMode) {
8643
+ if (format === "json") {
8335
8644
  log.plain(toJsonOutput(day, date));
8645
+ } else if (format === "text") {
8646
+ log.plain(toPlainText(fullSummary));
8336
8647
  } else {
8337
8648
  log.plain("");
8338
8649
  log.plain(fullSummary);
@@ -8340,7 +8651,7 @@ function registerSummary(program) {
8340
8651
  printSavedNote(saved);
8341
8652
  if (needsGeneration) printExcludedFooter(sessionPayloads, excluded);
8342
8653
  }
8343
- await postSummary({ options, config, summary: fullSummary, statusToStderr: jsonMode });
8654
+ await postSummary({ options, config, summary: fullSummary, statusToStderr: keepsStdoutToOutput });
8344
8655
  });
8345
8656
  }
8346
8657
  var import_chalk8;
@@ -8357,6 +8668,8 @@ var init_summary = __esm({
8357
8668
  init_notices();
8358
8669
  init_webhook2();
8359
8670
  init_day();
8671
+ init_output_format();
8672
+ init_plain_text();
8360
8673
  init_day_notes();
8361
8674
  init_thread_day();
8362
8675
  init_week2();
@@ -8980,7 +9293,8 @@ var init_profile = __esm({
8980
9293
  function registerNote(program) {
8981
9294
  program.command("note <text>").description("Attach a free-text annotation to the current session").action((text) => {
8982
9295
  const directory = currentDirectory();
8983
- const context = getRepoContext(directory, readConfig());
9296
+ const config = readConfig();
9297
+ const context = getRepoContext(directory, config);
8984
9298
  const { repoName, branch } = context;
8985
9299
  insertEvent({
8986
9300
  captured_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8991,7 +9305,7 @@ function registerNote(program) {
8991
9305
  repo_name: repoName,
8992
9306
  branch,
8993
9307
  category: "note",
8994
- metadata: JSON.stringify({ text }),
9308
+ metadata: JSON.stringify({ text: redact(text, config.redact.extra) }),
8995
9309
  source: null,
8996
9310
  file_path: null,
8997
9311
  dimensions: eventDimensions(context)
@@ -9005,6 +9319,7 @@ var init_note = __esm({
9005
9319
  init_db();
9006
9320
  init_git();
9007
9321
  init_dimensions3();
9322
+ init_redact();
9008
9323
  init_config();
9009
9324
  init_working_directory();
9010
9325
  init_logger();
@@ -9034,7 +9349,7 @@ function registerBugReport(program) {
9034
9349
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
9035
9350
  const reportPath = import_path30.default.join(WORKSCRIBE_DIR, `bug-report-${timestamp}.txt`);
9036
9351
  const systemInfo = [
9037
- `Workscribe version : ${"3.0.1"}`,
9352
+ `Workscribe version : ${"3.1.0"}`,
9038
9353
  `Node.js version : ${process.version}`,
9039
9354
  `Platform : ${process.platform}`,
9040
9355
  `Architecture : ${process.arch}`,
@@ -9065,7 +9380,7 @@ function registerBugReport(program) {
9065
9380
  process.exit(1);
9066
9381
  }
9067
9382
  const params = new URLSearchParams({
9068
- title: `Bug report \u2014 workscribe ${"3.0.1"}`,
9383
+ title: `Bug report \u2014 workscribe ${"3.1.0"}`,
9069
9384
  body: `<!-- Attach ${import_path30.default.basename(reportPath)} or paste the relevant sections below -->
9070
9385
 
9071
9386
  **Describe the issue:**
@@ -9156,7 +9471,7 @@ function reportRun(result) {
9156
9471
  function registerUpgrade(program) {
9157
9472
  program.command("upgrade").description("Bring your stored data up to date after updating Workscribe (backs it up first)").action(() => {
9158
9473
  const announced = /* @__PURE__ */ new Set();
9159
- const result = runDataJobs("3.0.1", {
9474
+ const result = runDataJobs("3.1.0", {
9160
9475
  onProgress: ({ job }) => {
9161
9476
  if (announced.has(job.name)) return;
9162
9477
  announced.add(job.name);
@@ -9646,7 +9961,7 @@ function isNewer(latest, current) {
9646
9961
  return latestPatch > currentPatch;
9647
9962
  }
9648
9963
  function checkForUpdate() {
9649
- const current = "3.0.1";
9964
+ const current = "3.1.0";
9650
9965
  const cache = readCache();
9651
9966
  if (cache && isNewer(cache.latestVersion, current)) {
9652
9967
  process.stderr.write(
@@ -9669,7 +9984,7 @@ function checkForUpdate() {
9669
9984
  function buildTelemetryPayload(config) {
9670
9985
  return {
9671
9986
  installId: config.telemetry.installId,
9672
- cliVersion: "3.0.1",
9987
+ cliVersion: "3.1.0",
9673
9988
  platform: import_os12.default.platform(),
9674
9989
  nodeVersion: process.version
9675
9990
  };
@@ -9747,10 +10062,10 @@ var require_main = __commonJS({
9747
10062
  pingTelemetry();
9748
10063
  }
9749
10064
  if (shouldRunDataJobs(process.argv.slice(2))) {
9750
- runDataJobsBeforeCommand(invokedCommand, "3.0.1");
10065
+ runDataJobsBeforeCommand(invokedCommand, "3.1.0");
9751
10066
  }
9752
10067
  var program = new import_commander.Command();
9753
- program.name("workscribe").description("Automatic developer work journal").version("3.0.1");
10068
+ program.name("workscribe").description("Automatic developer work journal").version("3.1.0");
9754
10069
  registerCapture(program);
9755
10070
  registerHook(program);
9756
10071
  registerInit(program);