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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +504 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/bilanz.d.ts +23 -0
- package/dist/commands/bilanz.js +106 -0
- package/dist/commands/bilanz.js.map +1 -0
- package/dist/commands/digest.d.ts +36 -0
- package/dist/commands/digest.js +288 -0
- package/dist/commands/digest.js.map +1 -0
- package/dist/commands/export.d.ts +36 -0
- package/dist/commands/export.js +118 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/file.d.ts +18 -0
- package/dist/commands/file.js +51 -0
- package/dist/commands/file.js.map +1 -0
- package/dist/commands/import.d.ts +31 -0
- package/dist/commands/import.js +283 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.js +256 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +16 -0
- package/dist/commands/list.js +57 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/log.d.ts +18 -0
- package/dist/commands/log.js +52 -0
- package/dist/commands/log.js.map +1 -0
- package/dist/commands/rm.d.ts +9 -0
- package/dist/commands/rm.js +22 -0
- package/dist/commands/rm.js.map +1 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +73 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/search.d.ts +15 -0
- package/dist/commands/search.js +37 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/commands/sync-export.d.ts +46 -0
- package/dist/commands/sync-export.js +80 -0
- package/dist/commands/sync-export.js.map +1 -0
- package/dist/commands/update.d.ts +12 -0
- package/dist/commands/update.js +24 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +145 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +81 -0
- package/dist/db.js +548 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.js +34 -0
- package/dist/paths.js.map +1 -0
- package/dist/scanners/claude-code.d.ts +6 -0
- package/dist/scanners/claude-code.js +202 -0
- package/dist/scanners/claude-code.js.map +1 -0
- package/dist/scanners/index.d.ts +4 -0
- package/dist/scanners/index.js +12 -0
- package/dist/scanners/index.js.map +1 -0
- package/dist/sinks/agent-tasks.d.ts +36 -0
- package/dist/sinks/agent-tasks.js +153 -0
- package/dist/sinks/agent-tasks.js.map +1 -0
- package/dist/sinks/github-issues.d.ts +20 -0
- package/dist/sinks/github-issues.js +98 -0
- package/dist/sinks/github-issues.js.map +1 -0
- package/dist/sinks/index.d.ts +11 -0
- package/dist/sinks/index.js +48 -0
- package/dist/sinks/index.js.map +1 -0
- package/dist/sinks/linear.d.ts +25 -0
- package/dist/sinks/linear.js +157 -0
- package/dist/sinks/linear.js.map +1 -0
- package/dist/sinks/markdown-file.d.ts +7 -0
- package/dist/sinks/markdown-file.js +54 -0
- package/dist/sinks/markdown-file.js.map +1 -0
- package/dist/sinks/stdout-json.d.ts +15 -0
- package/dist/sinks/stdout-json.js +49 -0
- package/dist/sinks/stdout-json.js.map +1 -0
- package/dist/templates/auth-expiry.yml +30 -0
- package/dist/templates/doc-gap.yml +30 -0
- package/dist/templates/output-overflow.yml +25 -0
- package/dist/templates/schema-drift.yml +26 -0
- package/dist/templates/tool-error.yml +34 -0
- package/dist/templates/tool-missing-capability.yml +26 -0
- package/dist/templates/workflow-friction.yml +26 -0
- package/dist/templates.d.ts +5 -0
- package/dist/templates.js +88 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/commands.md +74 -0
- package/docs/design.md +25 -0
- package/docs/sinks.md +79 -0
- package/docs/storage.md +20 -0
- package/docs/sync-export.md +23 -0
- package/package.json +64 -4
- package/templates/auth-expiry.yml +30 -0
- package/templates/doc-gap.yml +30 -0
- package/templates/output-overflow.yml +25 -0
- package/templates/schema-drift.yml +26 -0
- package/templates/tool-error.yml +34 -0
- package/templates/tool-missing-capability.yml +26 -0
- 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
|
-
#
|
|
1
|
+
# friction-log
|
|
2
2
|
|
|
3
|
-
|
|
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
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
|