friction-log 0.0.0-stage → 0.6.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +98 -2
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +504 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/commands/bilanz.d.ts +23 -0
  8. package/dist/commands/bilanz.js +106 -0
  9. package/dist/commands/bilanz.js.map +1 -0
  10. package/dist/commands/digest.d.ts +36 -0
  11. package/dist/commands/digest.js +288 -0
  12. package/dist/commands/digest.js.map +1 -0
  13. package/dist/commands/export.d.ts +36 -0
  14. package/dist/commands/export.js +118 -0
  15. package/dist/commands/export.js.map +1 -0
  16. package/dist/commands/file.d.ts +18 -0
  17. package/dist/commands/file.js +51 -0
  18. package/dist/commands/file.js.map +1 -0
  19. package/dist/commands/import.d.ts +31 -0
  20. package/dist/commands/import.js +283 -0
  21. package/dist/commands/import.js.map +1 -0
  22. package/dist/commands/init.d.ts +44 -0
  23. package/dist/commands/init.js +256 -0
  24. package/dist/commands/init.js.map +1 -0
  25. package/dist/commands/list.d.ts +16 -0
  26. package/dist/commands/list.js +57 -0
  27. package/dist/commands/list.js.map +1 -0
  28. package/dist/commands/log.d.ts +18 -0
  29. package/dist/commands/log.js +52 -0
  30. package/dist/commands/log.js.map +1 -0
  31. package/dist/commands/rm.d.ts +9 -0
  32. package/dist/commands/rm.js +22 -0
  33. package/dist/commands/rm.js.map +1 -0
  34. package/dist/commands/scan.d.ts +25 -0
  35. package/dist/commands/scan.js +73 -0
  36. package/dist/commands/scan.js.map +1 -0
  37. package/dist/commands/search.d.ts +15 -0
  38. package/dist/commands/search.js +37 -0
  39. package/dist/commands/search.js.map +1 -0
  40. package/dist/commands/sync-export.d.ts +46 -0
  41. package/dist/commands/sync-export.js +80 -0
  42. package/dist/commands/sync-export.js.map +1 -0
  43. package/dist/commands/update.d.ts +12 -0
  44. package/dist/commands/update.js +24 -0
  45. package/dist/commands/update.js.map +1 -0
  46. package/dist/config.d.ts +31 -0
  47. package/dist/config.js +145 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/db.d.ts +81 -0
  50. package/dist/db.js +548 -0
  51. package/dist/db.js.map +1 -0
  52. package/dist/index.d.ts +15 -0
  53. package/dist/index.js +16 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/paths.d.ts +12 -0
  56. package/dist/paths.js +34 -0
  57. package/dist/paths.js.map +1 -0
  58. package/dist/scanners/claude-code.d.ts +6 -0
  59. package/dist/scanners/claude-code.js +202 -0
  60. package/dist/scanners/claude-code.js.map +1 -0
  61. package/dist/scanners/index.d.ts +4 -0
  62. package/dist/scanners/index.js +12 -0
  63. package/dist/scanners/index.js.map +1 -0
  64. package/dist/sinks/agent-tasks.d.ts +36 -0
  65. package/dist/sinks/agent-tasks.js +153 -0
  66. package/dist/sinks/agent-tasks.js.map +1 -0
  67. package/dist/sinks/github-issues.d.ts +20 -0
  68. package/dist/sinks/github-issues.js +98 -0
  69. package/dist/sinks/github-issues.js.map +1 -0
  70. package/dist/sinks/index.d.ts +11 -0
  71. package/dist/sinks/index.js +48 -0
  72. package/dist/sinks/index.js.map +1 -0
  73. package/dist/sinks/linear.d.ts +25 -0
  74. package/dist/sinks/linear.js +157 -0
  75. package/dist/sinks/linear.js.map +1 -0
  76. package/dist/sinks/markdown-file.d.ts +7 -0
  77. package/dist/sinks/markdown-file.js +54 -0
  78. package/dist/sinks/markdown-file.js.map +1 -0
  79. package/dist/sinks/stdout-json.d.ts +15 -0
  80. package/dist/sinks/stdout-json.js +49 -0
  81. package/dist/sinks/stdout-json.js.map +1 -0
  82. package/dist/templates/auth-expiry.yml +30 -0
  83. package/dist/templates/doc-gap.yml +30 -0
  84. package/dist/templates/output-overflow.yml +25 -0
  85. package/dist/templates/schema-drift.yml +26 -0
  86. package/dist/templates/tool-error.yml +34 -0
  87. package/dist/templates/tool-missing-capability.yml +26 -0
  88. package/dist/templates/workflow-friction.yml +26 -0
  89. package/dist/templates.d.ts +5 -0
  90. package/dist/templates.js +88 -0
  91. package/dist/templates.js.map +1 -0
  92. package/dist/types.d.ts +79 -0
  93. package/dist/types.js +2 -0
  94. package/dist/types.js.map +1 -0
  95. package/docs/commands.md +74 -0
  96. package/docs/design.md +25 -0
  97. package/docs/sinks.md +79 -0
  98. package/docs/storage.md +20 -0
  99. package/docs/sync-export.md +23 -0
  100. package/package.json +64 -4
  101. package/templates/auth-expiry.yml +30 -0
  102. package/templates/doc-gap.yml +30 -0
  103. package/templates/output-overflow.yml +25 -0
  104. package/templates/schema-drift.yml +26 -0
  105. package/templates/tool-error.yml +34 -0
  106. package/templates/tool-missing-capability.yml +26 -0
  107. package/templates/workflow-friction.yml +26 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to `friction-log` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.6.0] - 2026-10-10
11
+
12
+ First release published to npm (`npm i -g friction-log`). Earlier versions ran from a local build of this repository. The entries below cover everything since the 0.5.0 milestone tag.
13
+
14
+ ### Added
15
+
16
+ - Opt-in `sync_export` config block and a `sync-export` command: a deterministic, atomic, no-op-skipping export of the local database, written through after each of the six mutating commands (`log`, `update`, `rm`, `file`, `import`, `scan`). Without the config block everything is an exact no-op. See [sync export](docs/sync-export.md).
17
+ - `digest --include-peers`: merges the exports of other machines into the digest, with origin-labeled sections, by replaying them into an in-memory database and reusing the regular digest query.
18
+ - The npm package ships `LICENSE`, `README.md`, and this changelog next to `dist/` and `templates/`.
19
+
20
+ ### Changed
21
+
22
+ - `engines.node` is now `>=22`, matching `better-sqlite3` 13 (which declares `>=22` and ships prebuilt binaries inside its own package, so the install needs no compiler and no download step). Node 20 is no longer a supported runtime.
23
+
24
+ ### Fixed
25
+
26
+ - `log --session <id>` no longer fails with a raw `FOREIGN KEY constraint failed` when the session id is not yet in the `sessions` table: the row is now created before the friction is inserted.
27
+ - `better-sqlite3` is bumped to `^13`, so the package installs on Node 26. `^11` has no prebuilt binary for that ABI and its source build fails against Node 26's V8 headers.
28
+
29
+ ### Security
30
+
31
+ Runtime (shipped):
32
+
33
+ - The declared `yaml` floor is raised to `^2.8.3` so installs cannot resolve an older release. The lockfile already resolved 2.9.0, so only the declared range changes.
34
+ - `better-sqlite3` moves from 11.10.0 to 13.0.1 (see Changed and Fixed). Its install-time download chain (`prebuild-install`, `tar-fs`, `rc`, `minimist`, and related packages) is gone from the lockfile.
35
+
36
+ Development-only (not shipped, not part of the published package):
37
+
38
+ - `tsx` to `^4.22.4` (resolved 4.22.4), which pulls `esbuild` 0.28.1.
39
+ - `vitest` to `^4.1.6` (resolved 4.1.11) and `vite` to 8.3.0, now built on `rolldown` 1.2.8 instead of `rollup`.
40
+ - Lockfile bumps for `nanoid`, `postcss`, `source-map-js`, `picomatch`, and `tinyglobby`.
41
+
42
+ ### Notes
43
+
44
+ Node 20 is unsupported: `better-sqlite3` 13 requires Node 22 or newer, and on Node 20 the process crashes with SIGSEGV on the first database command.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lan Nguyen Si
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # friction-log
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Capture, query, and infer agent-workflow frictions. SQLite-backed, sink-pluggable, zero-config default.
4
+
5
+ > Most agent tooling helps a model *write* the code. `friction-log` keeps a structured record of the moments where the agent's tools, MCP verbs, or harness behave unexpectedly, so the friction doesn't evaporate between sessions and the dogfood loop stays honest.
6
+
7
+ ## Overview
8
+
9
+ Two recurring patterns in agent-driven development go unaddressed by most tooling: the per-friction reflex (the agent notices a tool acting unexpectedly mid-task, mentally notes it, then moves on and the note evaporates) and the end-of-session bilanz (a retrospective naming tools exercised, frictions observed, tasks filed, easy to skip if nothing makes it cheap). `friction-log` lowers the cost of both: a one-line `log` for the per-moment capture, a one-command `file` to push the friction into whatever issue tracker the team uses, and a passive Stop-hook scan plus `bilanz` so missed frictions still get a second chance at the session boundary. The data isn't the goal; the goal is the inferences a few weeks of accumulated data enable (which tools cause the most friction, which categories recur, how long frictions take to become fixes), which is why the schema (SQLite + FTS5) is the foundation everything else builds on.
10
+
11
+ **Status:** M5 (this release) completes the v1 surface. `init` writes a YAML config and optionally installs the Claude Code Stop-hook in one command (with a `--yes` non-interactive mode for scripted bootstrap). `import --format markdown-frontmatter <dir>` bulk-loads existing markdown notes into the database, idempotent on re-run via a content-hash dedup. Four templates round out the v1 set (`tool-missing-capability`, `auth-expiry`, `schema-drift`, `doc-gap`), all auto-picked by matching the friction's `category`.
12
+
13
+ ## Key features
14
+
15
+ - One-command `init`: detects the local environment and writes config, optionally installing the Claude Code Stop-hook
16
+ - Structured `log`, `list`, `search` (FTS5), `export`, and `digest` (aggregations) over a local SQLite store
17
+ - Five pluggable filing sinks: `markdown-file` (default, zero-dependency), `stdout-json`, `github-issues`, `agent-tasks`, `linear`
18
+ - Idempotent `scan` of Claude Code transcripts (deduped on session, tool, and title) and `import` of existing markdown notes (content-hash deduped)
19
+ - Optional multi-machine `sync-export`: deterministic, config-gated JSON file dump with read-only peer merge into `digest`
20
+ - Auto-linked recurrence detection on repeated (tool, title) matches
21
+
22
+ ## Install / quick start
23
+
24
+ Install from npm (Node.js 22 or later):
25
+
26
+ ```bash
27
+ npm i -g friction-log
28
+ friction-log --version
29
+
30
+ # Log a friction you noticed
31
+ friction-log log \
32
+ --title "tasks_list returns 149kB blob" \
33
+ --tool "mcp:agent-tasks/tasks_list" \
34
+ --category output-overflow \
35
+ --severity high
36
+
37
+ # See it in the local database
38
+ friction-log list
39
+
40
+ # Render and file it via the default markdown sink
41
+ friction-log file 1
42
+ ```
43
+
44
+ The global install puts a `friction-log` command on PATH, which the Stop-hook below needs.
45
+
46
+ To run from a local build instead (for development on this repository):
47
+
48
+ ```bash
49
+ git clone https://github.com/LanNguyenSi/agent-dx && cd agent-dx
50
+ cd packages/friction-log && npm install && npm run build
51
+ node dist/cli.js --help
52
+ ```
53
+
54
+ A markdown record lands under `~/.local/share/friction-log/frictions/` with full frontmatter, ready to commit, paste into a chat, or pipe into another tool.
55
+
56
+ ## Usage
57
+
58
+ Wire automatic capture into every Claude Code session with a Stop-hook, then review with `bilanz`:
59
+
60
+ ```jsonc
61
+ // ~/.claude/settings.json
62
+ {
63
+ "hooks": {
64
+ "Stop": [
65
+ {
66
+ "matcher": "",
67
+ "hooks": [{ "type": "command", "command": "friction-log scan --silent --stdin-payload" }]
68
+ }
69
+ ]
70
+ }
71
+ }
72
+ ```
73
+
74
+ ```bash
75
+ friction-log bilanz
76
+ ```
77
+
78
+ See [Command reference](./docs/commands.md) for every subcommand, including manual transcript scanning; run any command with `--help` for its complete flag list.
79
+
80
+ ## Documentation
81
+
82
+ - [Command reference](./docs/commands.md): full subcommand table, Stop-hook wiring, manual scan, templates
83
+ - [Sinks](./docs/sinks.md): configuration for `markdown-file`, `stdout-json`, `github-issues`, `agent-tasks`, `linear`
84
+ - [Sync-export](./docs/sync-export.md): optional multi-machine file merge, format, and write-through semantics
85
+ - [Storage](./docs/storage.md): SQLite schema and `recurrence_of_id` matching rule
86
+ - [Design notes](./docs/design.md): ADR for the sink options bag, roadmap, and how this relates to `slop-detector`
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ npm install
92
+ npm run typecheck
93
+ npm run build
94
+ npm test
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT. See [LICENSE](./LICENSE).
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Option } from "commander";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { runBilanz } from "./commands/bilanz.js";
7
+ import { formatDigest, runDigest } from "./commands/digest.js";
8
+ import { runExport } from "./commands/export.js";
9
+ import { runFile } from "./commands/file.js";
10
+ import { runImport } from "./commands/import.js";
11
+ import { runInit } from "./commands/init.js";
12
+ import { formatTable, runList } from "./commands/list.js";
13
+ import { runLog } from "./commands/log.js";
14
+ import { runRm } from "./commands/rm.js";
15
+ import { payloadToScanInput, runScan, summarize, } from "./commands/scan.js";
16
+ import { runSearch } from "./commands/search.js";
17
+ import { runSyncExport } from "./commands/sync-export.js";
18
+ import { runUpdate } from "./commands/update.js";
19
+ import { parseSinkOpts } from "./config.js";
20
+ import { availableSinks } from "./sinks/index.js";
21
+ function readPackageVersion() {
22
+ try {
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const pkgPath = join(here, "..", "package.json");
25
+ const raw = readFileSync(pkgPath, "utf8");
26
+ const parsed = JSON.parse(raw);
27
+ return typeof parsed.version === "string" ? parsed.version : "0.0.0";
28
+ }
29
+ catch {
30
+ return "0.0.0";
31
+ }
32
+ }
33
+ const SEVERITY_CHOICES = ["low", "medium", "high", "critical"];
34
+ const STATUS_CHOICES = ["open", "filed", "resolved", "wontfix"];
35
+ const SOURCE_CHOICES = ["scan", "manual", "import"];
36
+ const SCANNER_CHOICES = ["claude-code"];
37
+ const DIGEST_GROUP_CHOICES = [
38
+ "tool",
39
+ "category",
40
+ "severity",
41
+ "source",
42
+ ];
43
+ const EXPORT_FORMAT_CHOICES = ["json", "csv", "md"];
44
+ const IMPORT_FORMAT_CHOICES = ["markdown-frontmatter"];
45
+ const program = new Command();
46
+ program
47
+ .name("friction-log")
48
+ .description("Capture, query, and infer agent-workflow frictions.")
49
+ .version(readPackageVersion());
50
+ program
51
+ .command("log")
52
+ .description("Manually record a friction.")
53
+ .requiredOption("--title <title>", "Short title describing the friction")
54
+ .option("--description <text>", "Longer description / reproduction notes")
55
+ .option("--tool <surface>", "Tool surface that caused the friction (e.g. mcp:agent-tasks/tasks_list)")
56
+ .option("--category <name>", "Category (e.g. output-overflow, tool-error)")
57
+ .addOption(new Option("--severity <level>", "Severity level").choices([
58
+ ...SEVERITY_CHOICES,
59
+ ]))
60
+ .option("--session <id>", "Session id to associate with this friction")
61
+ .option("--recurrence-of <id>", "Mark this friction as a recurrence of an existing one", (v) => Number(v))
62
+ .option("--db <path>", "Override database path (default: XDG)")
63
+ .action((opts) => {
64
+ const recurrenceOfRaw = opts.recurrenceOf;
65
+ let recurrenceOfId;
66
+ if (recurrenceOfRaw !== undefined) {
67
+ if (typeof recurrenceOfRaw !== "number" ||
68
+ !Number.isInteger(recurrenceOfRaw) ||
69
+ recurrenceOfRaw <= 0) {
70
+ process.stderr.write(`friction-log: --recurrence-of must be a positive integer, got "${String(recurrenceOfRaw)}"\n`);
71
+ process.exit(2);
72
+ }
73
+ recurrenceOfId = recurrenceOfRaw;
74
+ }
75
+ const out = runLog({
76
+ title: opts.title,
77
+ description: opts.description,
78
+ tool: opts.tool,
79
+ category: opts.category,
80
+ severity: opts.severity,
81
+ sessionId: opts.session,
82
+ recurrenceOfId,
83
+ dbPath: opts.db,
84
+ });
85
+ const recurrenceTag = out.recurrenceOfId != null ? ` recurrence_of=${out.recurrenceOfId}` : "";
86
+ process.stdout.write(`friction id=${out.id} captured_at=${out.capturedAt}${recurrenceTag}\n`);
87
+ });
88
+ program
89
+ .command("list")
90
+ .description("List frictions with optional filters.")
91
+ .addOption(new Option("--status <status>", "Filter by status").choices([
92
+ ...STATUS_CHOICES,
93
+ ]))
94
+ .option("--tool <surface>", "Filter by tool surface")
95
+ .option("--category <name>", "Filter by category")
96
+ .addOption(new Option("--source <source>", "Filter by source").choices([
97
+ ...SOURCE_CHOICES,
98
+ ]))
99
+ .option("--age <span>", "Only frictions newer than e.g. 14d, 4w, 12h")
100
+ .option("--limit <n>", "Max rows (default 100)", (v) => Number(v))
101
+ .option("--json", "Emit JSON instead of a table")
102
+ .option("--db <path>", "Override database path")
103
+ .action((opts) => {
104
+ const out = runList({
105
+ status: opts.status,
106
+ tool: opts.tool,
107
+ category: opts.category,
108
+ source: opts.source,
109
+ age: opts.age,
110
+ limit: typeof opts.limit === "number" ? opts.limit : undefined,
111
+ dbPath: opts.db,
112
+ });
113
+ if (opts.json) {
114
+ process.stdout.write(JSON.stringify(out.frictions, null, 2) + "\n");
115
+ }
116
+ else {
117
+ process.stdout.write(formatTable(out.frictions) + "\n");
118
+ }
119
+ });
120
+ program
121
+ .command("file <frictionId>")
122
+ .description("Push a friction to a configured sink. Default sink: markdown-file.")
123
+ .addOption(new Option("--sink <name>", "Sink to use")
124
+ .choices([...availableSinks])
125
+ .default("markdown-file"))
126
+ .option("--template <name>", "Template override (defaults to friction.category match)")
127
+ .option("--sink-target <value>", "Sink-specific target (markdown-file: directory path)")
128
+ .option("--sink-opt <key=value>", "Per-sink option override, repeatable (e.g. --sink-opt repo=owner/name)", (value, previous = []) => [...previous, value], [])
129
+ .option("--config <path>", "Override config file path (default: $XDG_CONFIG_HOME/friction-log/config.yml)")
130
+ .option("--db <path>", "Override database path")
131
+ .action(async (frictionId, opts) => {
132
+ const id = Number(frictionId);
133
+ if (!Number.isInteger(id) || id <= 0) {
134
+ process.stderr.write(`friction-log: <frictionId> must be a positive integer, got "${frictionId}"\n`);
135
+ process.exit(2);
136
+ }
137
+ try {
138
+ const sinkOptPairs = Array.isArray(opts.sinkOpt)
139
+ ? opts.sinkOpt
140
+ : [];
141
+ const sinkOpts = sinkOptPairs.length
142
+ ? parseSinkOpts(sinkOptPairs)
143
+ : undefined;
144
+ const out = await runFile({
145
+ frictionId: id,
146
+ sink: opts.sink,
147
+ template: opts.template,
148
+ sinkTarget: opts.sinkTarget,
149
+ sinkOpts,
150
+ configPath: opts.config,
151
+ dbPath: opts.db,
152
+ });
153
+ process.stdout.write(`filed friction id=${id} via sink=${out.sinkName} target=${out.sinkTarget}\n${out.message}\n`);
154
+ }
155
+ catch (err) {
156
+ process.stderr.write(`${err.message}\n`);
157
+ process.exit(1);
158
+ }
159
+ });
160
+ program
161
+ .command("scan")
162
+ .description("Scan a transcript for candidate frictions and store them.")
163
+ .option("--session <id>", "Session id (defaults to derivation from --transcript filename)")
164
+ .option("--transcript <path>", "Path to the transcript file (e.g. ~/.claude/projects/.../<id>.jsonl)")
165
+ .addOption(new Option("--adapter <name>", "Scanner adapter").choices([
166
+ ...SCANNER_CHOICES,
167
+ ]))
168
+ .option("--silent", "Never throw, exit 0 always (Stop-hook mode)")
169
+ .option("--stdin-payload", "Read a JSON Stop-hook payload from stdin to derive session+transcript")
170
+ .option("--db <path>", "Override database path")
171
+ .action(async (opts) => {
172
+ try {
173
+ let baseInput = {
174
+ sessionId: opts.session,
175
+ transcriptPath: opts.transcript,
176
+ adapter: opts.adapter,
177
+ dbPath: opts.db,
178
+ };
179
+ if (opts.stdinPayload) {
180
+ const payload = await readStdinPayload();
181
+ const derived = payloadToScanInput(payload, baseInput.adapter);
182
+ baseInput = {
183
+ sessionId: derived.sessionId ?? baseInput.sessionId,
184
+ transcriptPath: derived.transcriptPath ?? baseInput.transcriptPath,
185
+ adapter: derived.adapter ?? baseInput.adapter,
186
+ dbPath: baseInput.dbPath,
187
+ };
188
+ }
189
+ const out = await runScan(baseInput);
190
+ if (!opts.silent) {
191
+ process.stdout.write(summarize(out, out.sessionId) + "\n");
192
+ }
193
+ }
194
+ catch (err) {
195
+ if (opts.silent) {
196
+ process.stderr.write(`friction-log scan (silent): ${err.message}\n`);
197
+ process.exit(0);
198
+ }
199
+ process.stderr.write(`${err.message}\n`);
200
+ process.exit(1);
201
+ }
202
+ });
203
+ program
204
+ .command("bilanz")
205
+ .description("Format a session-boundary bilanz: tools, frictions, tasks.")
206
+ .option("--session <id>", "Session id (defaults to most-recent in db)")
207
+ .option("--db <path>", "Override database path")
208
+ .action(async (opts) => {
209
+ try {
210
+ const out = await runBilanz({ sessionId: opts.session, dbPath: opts.db });
211
+ process.stdout.write(out.formatted);
212
+ }
213
+ catch (err) {
214
+ process.stderr.write(`${err.message}\n`);
215
+ process.exit(1);
216
+ }
217
+ });
218
+ program
219
+ .command("rm <frictionId>")
220
+ .description("Delete a friction (and any task rows pointing at it) from the local store.")
221
+ .option("--db <path>", "Override database path")
222
+ .action((frictionId, opts) => {
223
+ const id = Number(frictionId);
224
+ if (!Number.isInteger(id) || id <= 0) {
225
+ process.stderr.write(`friction-log: <frictionId> must be a positive integer, got "${frictionId}"\n`);
226
+ process.exit(2);
227
+ }
228
+ try {
229
+ const out = runRm({ frictionId: id, dbPath: opts.db });
230
+ process.stdout.write(`removed friction id=${id} (${out.removed ? "ok" : "no-op"})\n`);
231
+ }
232
+ catch (err) {
233
+ process.stderr.write(`${err.message}\n`);
234
+ process.exit(1);
235
+ }
236
+ });
237
+ program
238
+ .command("update <frictionId>")
239
+ .description("Update a friction (status only in M2; more fields in later milestones).")
240
+ .addOption(new Option("--status <status>", "New status")
241
+ .choices([...STATUS_CHOICES])
242
+ .makeOptionMandatory(true))
243
+ .option("--db <path>", "Override database path")
244
+ .action((frictionId, opts) => {
245
+ const id = Number(frictionId);
246
+ if (!Number.isInteger(id) || id <= 0) {
247
+ process.stderr.write(`friction-log: <frictionId> must be a positive integer, got "${frictionId}"\n`);
248
+ process.exit(2);
249
+ }
250
+ try {
251
+ const out = runUpdate({
252
+ frictionId: id,
253
+ status: opts.status,
254
+ dbPath: opts.db,
255
+ });
256
+ process.stdout.write(`updated friction id=${out.id} status=${out.status}\n`);
257
+ }
258
+ catch (err) {
259
+ process.stderr.write(`${err.message}\n`);
260
+ process.exit(1);
261
+ }
262
+ });
263
+ program
264
+ .command("search <query>")
265
+ .description("Full-text search over title + description (FTS5).")
266
+ .addOption(new Option("--status <status>", "Filter by status").choices([
267
+ ...STATUS_CHOICES,
268
+ ]))
269
+ .option("--tool <surface>", "Filter by tool surface")
270
+ .option("--category <name>", "Filter by category")
271
+ .addOption(new Option("--source <source>", "Filter by source").choices([
272
+ ...SOURCE_CHOICES,
273
+ ]))
274
+ .option("--age <span>", "Only frictions newer than e.g. 14d, 4w, 12h")
275
+ .option("--limit <n>", "Max rows (default 100)", (v) => Number(v))
276
+ .option("--json", "Emit JSON instead of a table")
277
+ .option("--db <path>", "Override database path")
278
+ .action((query, opts) => {
279
+ try {
280
+ const out = runSearch({
281
+ query,
282
+ status: opts.status,
283
+ tool: opts.tool,
284
+ category: opts.category,
285
+ source: opts.source,
286
+ age: opts.age,
287
+ limit: typeof opts.limit === "number" ? opts.limit : undefined,
288
+ dbPath: opts.db,
289
+ });
290
+ if (opts.json) {
291
+ process.stdout.write(JSON.stringify(out.frictions, null, 2) + "\n");
292
+ }
293
+ else {
294
+ process.stdout.write(formatTable(out.frictions) + "\n");
295
+ }
296
+ }
297
+ catch (err) {
298
+ process.stderr.write(`${err.message}\n`);
299
+ process.exit(1);
300
+ }
301
+ });
302
+ program
303
+ .command("digest")
304
+ .description("Aggregations over frictions: counts, open-vs-filed, recurrences, avg hours to triage.")
305
+ .addOption(new Option("--group-by <field>", "Group by field")
306
+ .choices([...DIGEST_GROUP_CHOICES])
307
+ .makeOptionMandatory(true))
308
+ .option("--last <span>", "Restrict to frictions newer than e.g. 30d, 4w, 12h")
309
+ .option("--include-peers", "Also render read-only digest sections for each configured sync_export.peer_paths file")
310
+ .option("--json", "Emit JSON instead of a table")
311
+ .option("--config <path>", "Override config file path (only used by --include-peers)")
312
+ .option("--db <path>", "Override database path")
313
+ .action((opts) => {
314
+ try {
315
+ const out = runDigest({
316
+ groupBy: opts.groupBy,
317
+ last: opts.last,
318
+ dbPath: opts.db,
319
+ configPath: opts.config,
320
+ includePeers: Boolean(opts.includePeers),
321
+ });
322
+ if (opts.json) {
323
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
324
+ }
325
+ else {
326
+ process.stdout.write(formatDigest(out) + "\n");
327
+ }
328
+ if (out.peers) {
329
+ for (const peer of out.peers) {
330
+ if (peer.error) {
331
+ process.stderr.write(`friction-log: warning: peer digest source ${peer.sourcePath} (origin=${peer.origin}) skipped: ${peer.error}\n`);
332
+ }
333
+ else if (peer.skipped > 0) {
334
+ process.stderr.write(`friction-log: warning: peer digest source ${peer.sourcePath} (origin=${peer.origin}) skipped ${peer.skipped} malformed record(s)\n`);
335
+ }
336
+ }
337
+ }
338
+ }
339
+ catch (err) {
340
+ process.stderr.write(`${err.message}\n`);
341
+ process.exit(1);
342
+ }
343
+ });
344
+ program
345
+ .command("export")
346
+ .description("Export frictions as JSON, CSV, or Markdown.")
347
+ .addOption(new Option("--format <fmt>", "Output format")
348
+ .choices([...EXPORT_FORMAT_CHOICES])
349
+ .default("json"))
350
+ .option("--out <path>", "Write to a file instead of stdout")
351
+ .option("--query <text>", "Only export frictions matching an FTS5 query")
352
+ .addOption(new Option("--status <status>", "Filter by status").choices([
353
+ ...STATUS_CHOICES,
354
+ ]))
355
+ .option("--tool <surface>", "Filter by tool surface")
356
+ .option("--category <name>", "Filter by category")
357
+ .addOption(new Option("--source <source>", "Filter by source").choices([
358
+ ...SOURCE_CHOICES,
359
+ ]))
360
+ .option("--age <span>", "Only frictions newer than e.g. 14d, 4w, 12h")
361
+ .option("--limit <n>", "Max rows (default 100)", (v) => Number(v))
362
+ .option("--db <path>", "Override database path")
363
+ .action((opts) => {
364
+ try {
365
+ const out = runExport({
366
+ format: opts.format,
367
+ out: opts.out,
368
+ query: opts.query,
369
+ status: opts.status,
370
+ tool: opts.tool,
371
+ category: opts.category,
372
+ source: opts.source,
373
+ age: opts.age,
374
+ limit: typeof opts.limit === "number" ? opts.limit : undefined,
375
+ dbPath: opts.db,
376
+ });
377
+ if (out.out) {
378
+ process.stderr.write(`exported ${out.count} records (${out.format}) to ${out.out}\n`);
379
+ }
380
+ else {
381
+ process.stdout.write(out.rendered);
382
+ }
383
+ }
384
+ catch (err) {
385
+ process.stderr.write(`${err.message}\n`);
386
+ process.exit(1);
387
+ }
388
+ });
389
+ program
390
+ .command("sync-export")
391
+ .description("Write every friction as deterministic, origin-tagged JSON to the configured sync_export.path. " +
392
+ 'No-op error unless "sync_export" (path + origin) is set in config.yml or via FRICTION_LOG_SYNC_EXPORT_PATH/_ORIGIN.')
393
+ .option("--config <path>", "Override config file path")
394
+ .option("--db <path>", "Override database path")
395
+ .action((opts) => {
396
+ try {
397
+ const out = runSyncExport({
398
+ configPath: opts.config,
399
+ dbPath: opts.db,
400
+ });
401
+ process.stderr.write(`sync-export: wrote ${out.count} frictions (origin=${out.origin}) to ${out.path}\n`);
402
+ }
403
+ catch (err) {
404
+ process.stderr.write(`${err.message}\n`);
405
+ process.exit(1);
406
+ }
407
+ });
408
+ program
409
+ .command("init")
410
+ .description("Interactive setup: write config.yml, optionally install Stop-hook.")
411
+ .addOption(new Option("--sink <name>", "Default sink (skips the interactive prompt)").choices([...availableSinks]))
412
+ .option("-y, --yes", "Non-interactive; use --sink (or markdown-file fallback) and skip Stop-hook offer")
413
+ .option("--install-stop-hook", "Force-install the Claude Code Stop-hook (skip the interactive y/N)")
414
+ .option("--config <path>", "Override config file path")
415
+ .option("--sync-export-path <path>", "Opt in to sync-export: write this config's sync_export.path")
416
+ .option("--sync-export-origin <name>", "Opt in to sync-export: this machine's sync_export.origin label")
417
+ .option("--sync-export-peer <path>", "Peer sync-export file to read for `digest --include-peers`, repeatable", (value, previous = []) => [...previous, value], [])
418
+ .action(async (opts) => {
419
+ try {
420
+ const syncExportPath = opts.syncExportPath;
421
+ const syncExportOrigin = opts.syncExportOrigin;
422
+ if (Boolean(syncExportPath) !== Boolean(syncExportOrigin)) {
423
+ process.stderr.write("friction-log: --sync-export-path and --sync-export-origin must be given together\n");
424
+ process.exit(2);
425
+ }
426
+ const syncExportPeers = Array.isArray(opts.syncExportPeer)
427
+ ? opts.syncExportPeer
428
+ : [];
429
+ const out = await runInit({
430
+ configPath: opts.config,
431
+ sink: opts.sink,
432
+ yes: Boolean(opts.yes),
433
+ installStopHook: opts.installStopHook === true ? true : undefined,
434
+ syncExport: syncExportPath && syncExportOrigin
435
+ ? {
436
+ path: syncExportPath,
437
+ origin: syncExportOrigin,
438
+ peerPaths: syncExportPeers,
439
+ }
440
+ : undefined,
441
+ });
442
+ process.stdout.write(`init: ${out.configWritten ? "wrote" : "no change to"} ${out.configPath}\n` +
443
+ (out.stopHookWrittenTo
444
+ ? `init: Stop-hook installed at ${out.stopHookWrittenTo}\n`
445
+ : "") +
446
+ "\nNext steps:\n" +
447
+ out.nextSteps.map((s) => ` ${s}`).join("\n") +
448
+ "\n");
449
+ }
450
+ catch (err) {
451
+ process.stderr.write(`${err.message}\n`);
452
+ process.exit(1);
453
+ }
454
+ });
455
+ program
456
+ .command("import <path>")
457
+ .description("Bulk-ingest frictions from a directory of markdown files.")
458
+ .addOption(new Option("--format <fmt>", "Source format")
459
+ .choices([...IMPORT_FORMAT_CHOICES])
460
+ .default("markdown-frontmatter"))
461
+ .option("--db <path>", "Override database path")
462
+ .action((path, opts) => {
463
+ try {
464
+ const out = runImport({
465
+ format: opts.format,
466
+ path,
467
+ dbPath: opts.db,
468
+ });
469
+ process.stdout.write(`import: scanned=${out.scanned} imported=${out.imported} skipped=${out.skipped}\n`);
470
+ if (out.errors.length) {
471
+ process.stderr.write(`import: ${out.errors.length} errors:\n`);
472
+ for (const e of out.errors.slice(0, 10)) {
473
+ process.stderr.write(` ${e.file}: ${e.reason}\n`);
474
+ }
475
+ if (out.errors.length > 10) {
476
+ process.stderr.write(` ... and ${out.errors.length - 10} more\n`);
477
+ }
478
+ }
479
+ }
480
+ catch (err) {
481
+ process.stderr.write(`${err.message}\n`);
482
+ process.exit(1);
483
+ }
484
+ });
485
+ async function readStdinPayload() {
486
+ const chunks = [];
487
+ for await (const chunk of process.stdin) {
488
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
489
+ }
490
+ const text = Buffer.concat(chunks).toString("utf8").trim();
491
+ if (!text)
492
+ return {};
493
+ try {
494
+ return JSON.parse(text);
495
+ }
496
+ catch {
497
+ return {};
498
+ }
499
+ }
500
+ program.parseAsync(process.argv).catch((err) => {
501
+ process.stderr.write(`friction-log: ${err.message}\n`);
502
+ process.exit(1);
503
+ });
504
+ //# sourceMappingURL=cli.js.map