friction-log 0.0.0-stage → 0.6.1
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 +54 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +13 -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/main.d.ts +1 -0
- package/dist/main.js +503 -0
- package/dist/main.js.map +1 -0
- package/dist/node-guard.d.ts +14 -0
- package/dist/node-guard.js +36 -0
- package/dist/node-guard.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,54 @@
|
|
|
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.1] - 2026-10-10
|
|
11
|
+
|
|
12
|
+
First release cut for the tag-driven `publish-npm.yml` workflow (npm Trusted Publishing), which publishes with `--provenance`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- The CLI now refuses to start on a Node version below the `engines.node` range (currently `>=22`), exiting 1 with a stderr message that names the required and the running version. Previously npm only warned, and the first database command crashed with a SIGSEGV. The check runs in a small entry module (`dist/cli.js`) before the real CLI, and thus `better-sqlite3`, is loaded; `--help` and `--version` are refused as well. (11dc376f)
|
|
17
|
+
|
|
18
|
+
## [0.6.0] - 2026-10-10
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
0.6.0 was published by hand from a build of commit `48926fca`, because npm accepts a Trusted Publisher entry only for a package that already exists; it therefore has no provenance attestation. There is deliberately no `friction-log/v0.6.0` git tag: pushing it would trigger `publish-npm.yml`, which refuses a registry version without an attestation and would fail red.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- 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).
|
|
27
|
+
- `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.
|
|
28
|
+
- The npm package ships `LICENSE`, `README.md`, and this changelog next to `dist/` and `templates/`.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `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.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `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.
|
|
37
|
+
- `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.
|
|
38
|
+
|
|
39
|
+
### Security
|
|
40
|
+
|
|
41
|
+
Runtime (shipped):
|
|
42
|
+
|
|
43
|
+
- 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.
|
|
44
|
+
- `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.
|
|
45
|
+
|
|
46
|
+
Development-only (not shipped, not part of the published package):
|
|
47
|
+
|
|
48
|
+
- `tsx` to `^4.22.4` (resolved 4.22.4), which pulls `esbuild` 0.28.1.
|
|
49
|
+
- `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`.
|
|
50
|
+
- Lockfile bumps for `nanoid`, `postcss`, `source-map-js`, `picomatch`, and `tinyglobby`.
|
|
51
|
+
|
|
52
|
+
### Notes
|
|
53
|
+
|
|
54
|
+
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,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point. Static imports are evaluated before any code in the importing
|
|
3
|
+
// module, and the real CLI reaches better-sqlite3 through them. So this file
|
|
4
|
+
// imports only the dependency-free guard and loads the CLI dynamically after
|
|
5
|
+
// the Node version check has passed.
|
|
6
|
+
import { nodeGuardMessage, readEnginesNode } from "./node-guard.js";
|
|
7
|
+
const message = nodeGuardMessage(process.versions.node, readEnginesNode());
|
|
8
|
+
if (message) {
|
|
9
|
+
process.stderr.write(`${message}\n`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
await import("./main.js");
|
|
13
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,6EAA6E;AAC7E,6EAA6E;AAC7E,6EAA6E;AAC7E,qCAAqC;AACrC,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEpE,MAAM,OAAO,GAAG,gBAAgB,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE,eAAe,EAAE,CAAC,CAAC;AAC3E,IAAI,OAAO,EAAE,CAAC;IACZ,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,OAAO,IAAI,CAAC,CAAC;IACrC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AACD,MAAM,MAAM,CAAC,WAAW,CAAC,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Friction, Session, Task } from "../types.js";
|
|
2
|
+
export interface BilanzCommandInput {
|
|
3
|
+
sessionId?: string;
|
|
4
|
+
dbPath?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface BilanzCommandOutput {
|
|
7
|
+
session: Session;
|
|
8
|
+
toolsExercised: string[];
|
|
9
|
+
frictions: Array<Friction & {
|
|
10
|
+
tasks: Task[];
|
|
11
|
+
}>;
|
|
12
|
+
formatted: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function runBilanz(input: BilanzCommandInput): Promise<BilanzCommandOutput>;
|
|
15
|
+
interface BilanzInput {
|
|
16
|
+
session: Session;
|
|
17
|
+
toolsExercised: string[];
|
|
18
|
+
frictions: Array<Friction & {
|
|
19
|
+
tasks: Task[];
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export declare function formatBilanz(b: BilanzInput): string;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { FrictionDb } from "../db.js";
|
|
2
|
+
import { defaultDbPath } from "../paths.js";
|
|
3
|
+
import { loadScanner } from "../scanners/index.js";
|
|
4
|
+
export async function runBilanz(input) {
|
|
5
|
+
const db = new FrictionDb(input.dbPath ?? defaultDbPath());
|
|
6
|
+
try {
|
|
7
|
+
const session = input.sessionId
|
|
8
|
+
? db.getSession(input.sessionId)
|
|
9
|
+
: db.getMostRecentSession();
|
|
10
|
+
if (!session) {
|
|
11
|
+
throw new Error(input.sessionId
|
|
12
|
+
? `friction-log: session ${input.sessionId} not found in db`
|
|
13
|
+
: `friction-log: no sessions in db yet. Run friction-log scan first.`);
|
|
14
|
+
}
|
|
15
|
+
const frictions = db.listFrictionsForSession(session.id);
|
|
16
|
+
const withTasks = frictions.map((f) => ({
|
|
17
|
+
...f,
|
|
18
|
+
tasks: db.listTasksForFriction(f.id),
|
|
19
|
+
}));
|
|
20
|
+
const toolsExercised = await extractToolsExercised(session);
|
|
21
|
+
const formatted = formatBilanz({
|
|
22
|
+
session,
|
|
23
|
+
toolsExercised,
|
|
24
|
+
frictions: withTasks,
|
|
25
|
+
});
|
|
26
|
+
return { session, toolsExercised, frictions: withTasks, formatted };
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
db.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function extractToolsExercised(session) {
|
|
33
|
+
if (!session.transcriptPath)
|
|
34
|
+
return [];
|
|
35
|
+
try {
|
|
36
|
+
const scanner = loadScanner(session.adapter);
|
|
37
|
+
const result = await scanner.scan({
|
|
38
|
+
sessionId: session.id,
|
|
39
|
+
transcriptPath: session.transcriptPath,
|
|
40
|
+
});
|
|
41
|
+
const tools = new Set();
|
|
42
|
+
for (const c of result.frictionCandidates) {
|
|
43
|
+
if (c.toolSurface)
|
|
44
|
+
tools.add(c.toolSurface);
|
|
45
|
+
}
|
|
46
|
+
return [...tools].sort();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function formatBilanz(b) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
lines.push(`## Dogfood bilanz`);
|
|
55
|
+
lines.push("");
|
|
56
|
+
lines.push(`Session: \`${b.session.id}\` (${b.session.startedAt}${b.session.endedAt ? ` to ${b.session.endedAt}` : ""})`);
|
|
57
|
+
if (b.session.projectPaths && b.session.projectPaths.length > 0) {
|
|
58
|
+
lines.push(`Project paths: ${b.session.projectPaths.map((p) => `\`${p}\``).join(", ")}`);
|
|
59
|
+
}
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push(`**Tools exercised** (${b.toolsExercised.length}):`);
|
|
62
|
+
if (b.toolsExercised.length === 0) {
|
|
63
|
+
lines.push(" _none captured by the scan adapter_");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
for (const t of b.toolsExercised) {
|
|
67
|
+
lines.push(` - ${t}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
lines.push("");
|
|
71
|
+
const filed = b.frictions.filter((f) => f.tasks.length > 0);
|
|
72
|
+
const unfiled = b.frictions.filter((f) => f.tasks.length === 0 && f.status === "open");
|
|
73
|
+
lines.push(`**Frictions noticed** (${b.frictions.length}):`);
|
|
74
|
+
if (b.frictions.length === 0) {
|
|
75
|
+
lines.push(" _none recorded for this session_");
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
for (const f of b.frictions) {
|
|
79
|
+
const marker = f.tasks.length > 0 ? "filed" : f.status;
|
|
80
|
+
lines.push(` - [${marker}] id=${f.id} ${f.toolSurface ? `(${f.toolSurface}) ` : ""}${f.title}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
lines.push("");
|
|
84
|
+
lines.push(`**Tasks filed** (${filed.length}):`);
|
|
85
|
+
if (filed.length === 0) {
|
|
86
|
+
lines.push(" _none yet_");
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
for (const f of filed) {
|
|
90
|
+
for (const t of f.tasks) {
|
|
91
|
+
const ref = t.externalRef ?? t.sinkTarget ?? "?";
|
|
92
|
+
lines.push(` - friction id=${f.id} -> ${t.sinkName}: ${ref}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
lines.push("");
|
|
97
|
+
if (unfiled.length > 0) {
|
|
98
|
+
lines.push(`**Open frictions without a filed task** (${unfiled.length}) -- consider \`friction-log file <id>\`:`);
|
|
99
|
+
for (const f of unfiled) {
|
|
100
|
+
lines.push(` - id=${f.id} ${f.toolSurface ? `(${f.toolSurface}) ` : ""}${f.title}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push("");
|
|
103
|
+
}
|
|
104
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=bilanz.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bilanz.js","sourceRoot":"","sources":["../../src/commands/bilanz.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type DigestGroupBy, type DigestRow } from "../db.js";
|
|
2
|
+
export interface DigestCommandInput {
|
|
3
|
+
groupBy: DigestGroupBy;
|
|
4
|
+
last?: string;
|
|
5
|
+
dbPath?: string;
|
|
6
|
+
configPath?: string;
|
|
7
|
+
includePeers?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* One peer's contribution to `digest --include-peers`. `rows` is always
|
|
11
|
+
* computed with the identical GROUP BY SQL as the local digest (see
|
|
12
|
+
* buildPeerSection), so the two are directly comparable, except for the
|
|
13
|
+
* `recurrences` column which is patched in from the raw export data (see
|
|
14
|
+
* countPeerRecurrences). A missing/corrupt peer file degrades to an empty,
|
|
15
|
+
* error-annotated section rather than throwing; a peer file with some
|
|
16
|
+
* malformed individual records degrades those specific records only,
|
|
17
|
+
* reported via `skipped`.
|
|
18
|
+
*/
|
|
19
|
+
export interface DigestPeerSection {
|
|
20
|
+
origin: string;
|
|
21
|
+
sourcePath: string;
|
|
22
|
+
rows: DigestRow[];
|
|
23
|
+
/** Count of raw entries in the peer's `records` array that failed
|
|
24
|
+
* validation (missing/non-string title, unknown status, unparseable
|
|
25
|
+
* capturedAt) and were dropped rather than silently miscounted. */
|
|
26
|
+
skipped: number;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface DigestCommandOutput {
|
|
30
|
+
groupBy: DigestGroupBy;
|
|
31
|
+
sinceIso: string | null;
|
|
32
|
+
rows: DigestRow[];
|
|
33
|
+
peers?: DigestPeerSection[];
|
|
34
|
+
}
|
|
35
|
+
export declare function runDigest(input: DigestCommandInput): DigestCommandOutput;
|
|
36
|
+
export declare function formatDigest(output: DigestCommandOutput): string;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { FrictionDb } from "../db.js";
|
|
5
|
+
import { defaultDbPath } from "../paths.js";
|
|
6
|
+
import { parseAge } from "./list.js";
|
|
7
|
+
export function runDigest(input) {
|
|
8
|
+
const db = new FrictionDb(input.dbPath ?? defaultDbPath());
|
|
9
|
+
try {
|
|
10
|
+
const sinceIso = parseAge(input.last) ?? null;
|
|
11
|
+
const rows = db.digest(input.groupBy, sinceIso ?? undefined);
|
|
12
|
+
const out = { groupBy: input.groupBy, sinceIso, rows };
|
|
13
|
+
if (input.includePeers) {
|
|
14
|
+
const config = loadConfig(input.configPath);
|
|
15
|
+
const peerPaths = config.syncExport?.peerPaths ?? [];
|
|
16
|
+
out.peers = peerPaths.map((p) => buildPeerSection(p, input.groupBy, sinceIso));
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
db.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validates one raw JSON array entry from a peer's `records`. Returns null
|
|
26
|
+
* (caller counts it as `skipped`) rather than silently defaulting a bad
|
|
27
|
+
* field, because a wrong default here would otherwise corrupt the digest
|
|
28
|
+
* numbers without any visible sign: an unknown/missing `status` used to
|
|
29
|
+
* fall through to the db's default of 'open', and a non-ISO `capturedAt`
|
|
30
|
+
* used to pass a `--last` window check unpredictably (it is compared as a
|
|
31
|
+
* plain string against another ISO string, and most garbage strings sort
|
|
32
|
+
* after any real date).
|
|
33
|
+
*/
|
|
34
|
+
function normalizePeerRecord(raw) {
|
|
35
|
+
if (!raw || typeof raw !== "object")
|
|
36
|
+
return null;
|
|
37
|
+
const rec = raw;
|
|
38
|
+
if (typeof rec.title !== "string" || rec.title.trim() === "")
|
|
39
|
+
return null;
|
|
40
|
+
if (!isFrictionStatus(rec.status))
|
|
41
|
+
return null;
|
|
42
|
+
if (typeof rec.capturedAt !== "string")
|
|
43
|
+
return null;
|
|
44
|
+
// Lenient on input, canonical in storage: Date.parse accepts many
|
|
45
|
+
// non-ISO spellings ('Jan 1 1999'), and every downstream comparison
|
|
46
|
+
// (--last windows, recurrence cutoffs) is a plain string compare
|
|
47
|
+
// against ISO timestamps — so a parseable-but-non-ISO value must be
|
|
48
|
+
// re-rendered as ISO or it sorts above every real date.
|
|
49
|
+
const capturedAtDate = new Date(rec.capturedAt);
|
|
50
|
+
if (Number.isNaN(capturedAtDate.getTime()))
|
|
51
|
+
return null;
|
|
52
|
+
return {
|
|
53
|
+
toolSurface: typeof rec.toolSurface === "string" ? rec.toolSurface : null,
|
|
54
|
+
title: rec.title,
|
|
55
|
+
description: typeof rec.description === "string" ? rec.description : null,
|
|
56
|
+
capturedAt: capturedAtDate.toISOString(),
|
|
57
|
+
severity: isSeverity(rec.severity) ? rec.severity : null,
|
|
58
|
+
category: typeof rec.category === "string" ? rec.category : null,
|
|
59
|
+
status: rec.status,
|
|
60
|
+
// Unlike status/capturedAt/title, an invalid severity or source
|
|
61
|
+
// degrades gracefully to a legitimate "unknown" value instead of
|
|
62
|
+
// dropping the whole record: severity is already nullable in the
|
|
63
|
+
// schema, and 'import' is a real, meaningful "unclassified" source.
|
|
64
|
+
source: isFrictionSource(rec.source) ? rec.source : "import",
|
|
65
|
+
recurrenceOfId: typeof rec.recurrenceOfId === "number" ? rec.recurrenceOfId : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reads one peer's sync-export JSON (read-only; never mutates it), replays
|
|
70
|
+
* its valid records into a throwaway :memory: FrictionDb via insertFriction,
|
|
71
|
+
* and reuses db.digest() so peer totals/open/filed/resolved/wontfix/
|
|
72
|
+
* avg-hours numbers are computed with exactly the same SQL as the local
|
|
73
|
+
* digest. `tasks` is never populated for peer data (the export payload
|
|
74
|
+
* carries no task history), so avgHoursToTriage is always null for peer
|
|
75
|
+
* rows: expected, not a bug.
|
|
76
|
+
*
|
|
77
|
+
* `recurrences` is the one column NOT taken from the scratch db's own
|
|
78
|
+
* recurrence_of_id (see countPeerRecurrences for why) and is patched onto
|
|
79
|
+
* the rows afterward.
|
|
80
|
+
*/
|
|
81
|
+
function buildPeerSection(peerPath, groupBy, sinceIso) {
|
|
82
|
+
const fallbackOrigin = basename(peerPath);
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = readFileSync(peerPath, "utf8");
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
origin: fallbackOrigin,
|
|
90
|
+
sourcePath: peerPath,
|
|
91
|
+
rows: [],
|
|
92
|
+
skipped: 0,
|
|
93
|
+
error: `could not read peer file: ${err.message}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
let payload;
|
|
97
|
+
try {
|
|
98
|
+
payload = JSON.parse(raw);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return {
|
|
102
|
+
origin: fallbackOrigin,
|
|
103
|
+
sourcePath: peerPath,
|
|
104
|
+
rows: [],
|
|
105
|
+
skipped: 0,
|
|
106
|
+
error: `could not parse peer file as JSON: ${err.message}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const origin = typeof payload.origin === "string" && payload.origin.trim()
|
|
110
|
+
? payload.origin
|
|
111
|
+
: fallbackOrigin;
|
|
112
|
+
if (!Array.isArray(payload.records)) {
|
|
113
|
+
return {
|
|
114
|
+
origin,
|
|
115
|
+
sourcePath: peerPath,
|
|
116
|
+
rows: [],
|
|
117
|
+
skipped: 0,
|
|
118
|
+
error: 'peer file is missing a "records" array',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
let skipped = 0;
|
|
122
|
+
const validRecords = [];
|
|
123
|
+
for (const rawRecord of payload.records) {
|
|
124
|
+
const normalized = normalizePeerRecord(rawRecord);
|
|
125
|
+
if (!normalized) {
|
|
126
|
+
skipped++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
validRecords.push(normalized);
|
|
130
|
+
}
|
|
131
|
+
const scratch = new FrictionDb(":memory:");
|
|
132
|
+
try {
|
|
133
|
+
for (const rec of validRecords) {
|
|
134
|
+
const inserted = scratch.insertFriction({
|
|
135
|
+
toolSurface: rec.toolSurface,
|
|
136
|
+
title: rec.title,
|
|
137
|
+
description: rec.description,
|
|
138
|
+
capturedAt: rec.capturedAt,
|
|
139
|
+
severity: rec.severity,
|
|
140
|
+
category: rec.category,
|
|
141
|
+
source: rec.source,
|
|
142
|
+
});
|
|
143
|
+
if (rec.status !== "open") {
|
|
144
|
+
scratch.updateFrictionStatus(inserted.id, rec.status);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const rows = scratch.digest(groupBy, sinceIso ?? undefined);
|
|
148
|
+
const recurrenceCounts = countPeerRecurrences(validRecords, groupBy, sinceIso);
|
|
149
|
+
const withRecurrences = rows.map((r) => ({
|
|
150
|
+
...r,
|
|
151
|
+
recurrences: recurrenceCounts.get(r.group) ?? 0,
|
|
152
|
+
}));
|
|
153
|
+
return { origin, sourcePath: peerPath, rows: withRecurrences, skipped };
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
return {
|
|
157
|
+
origin,
|
|
158
|
+
sourcePath: peerPath,
|
|
159
|
+
rows: [],
|
|
160
|
+
skipped,
|
|
161
|
+
error: `could not replay peer records: ${err.message}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
scratch.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Derives the `recurrences` count directly from the exported records' own
|
|
170
|
+
* `recurrenceOfId !== null` flag, grouped exactly like db.digest()'s SQL
|
|
171
|
+
* (`coalesce(column, '(unset)')`) and filtered by the same `--last` window.
|
|
172
|
+
*
|
|
173
|
+
* This does NOT read the scratch db's recurrence_of_id column, and
|
|
174
|
+
* deliberately never re-derives or dereferences one. Two independent
|
|
175
|
+
* reasons: (1) a peer's numeric recurrenceOfId is a different machine's
|
|
176
|
+
* AUTOINCREMENT id, meaningless (and possibly FK-invalid) once replayed
|
|
177
|
+
* into another database; (2) recomputing it via insertFriction's own
|
|
178
|
+
* open-root heuristic against the replayed subset actively disagrees with
|
|
179
|
+
* the origin's real bookkeeping (a friction the origin explicitly linked as
|
|
180
|
+
* a recurrence can fail to re-derive as one once replayed with a different
|
|
181
|
+
* insertion order or status mix, silently changing the count). Counting the
|
|
182
|
+
* boolean flag from the source data sidesteps both problems.
|
|
183
|
+
*/
|
|
184
|
+
function countPeerRecurrences(records, groupBy, sinceIso) {
|
|
185
|
+
const counts = new Map();
|
|
186
|
+
for (const rec of records) {
|
|
187
|
+
if (sinceIso && rec.capturedAt < sinceIso)
|
|
188
|
+
continue;
|
|
189
|
+
if (rec.recurrenceOfId == null)
|
|
190
|
+
continue;
|
|
191
|
+
const group = groupValueFor(rec, groupBy);
|
|
192
|
+
counts.set(group, (counts.get(group) ?? 0) + 1);
|
|
193
|
+
}
|
|
194
|
+
return counts;
|
|
195
|
+
}
|
|
196
|
+
function groupValueFor(rec, groupBy) {
|
|
197
|
+
switch (groupBy) {
|
|
198
|
+
case "tool":
|
|
199
|
+
return rec.toolSurface ?? "(unset)";
|
|
200
|
+
case "category":
|
|
201
|
+
return rec.category ?? "(unset)";
|
|
202
|
+
case "severity":
|
|
203
|
+
return rec.severity ?? "(unset)";
|
|
204
|
+
case "source":
|
|
205
|
+
return rec.source;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const SEVERITIES = ["low", "medium", "high", "critical"];
|
|
209
|
+
const SOURCES = ["scan", "manual", "import"];
|
|
210
|
+
const STATUSES = [
|
|
211
|
+
"open",
|
|
212
|
+
"filed",
|
|
213
|
+
"resolved",
|
|
214
|
+
"wontfix",
|
|
215
|
+
];
|
|
216
|
+
function isSeverity(v) {
|
|
217
|
+
return typeof v === "string" && SEVERITIES.includes(v);
|
|
218
|
+
}
|
|
219
|
+
function isFrictionSource(v) {
|
|
220
|
+
return typeof v === "string" && SOURCES.includes(v);
|
|
221
|
+
}
|
|
222
|
+
function isFrictionStatus(v) {
|
|
223
|
+
return typeof v === "string" && STATUSES.includes(v);
|
|
224
|
+
}
|
|
225
|
+
export function formatDigest(output) {
|
|
226
|
+
const { groupBy, sinceIso, rows, peers } = output;
|
|
227
|
+
const window = sinceIso ? `since ${sinceIso}` : "all-time";
|
|
228
|
+
const parts = [];
|
|
229
|
+
if (rows.length === 0) {
|
|
230
|
+
parts.push(`digest by ${groupBy} (${window}): no frictions match`);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
parts.push(`digest by ${groupBy} (${window})`, "", renderDigestTable(rows));
|
|
234
|
+
}
|
|
235
|
+
if (peers) {
|
|
236
|
+
for (const peer of peers) {
|
|
237
|
+
const skippedNote = peer.skipped > 0
|
|
238
|
+
? `, ${peer.skipped} record(s) skipped as malformed`
|
|
239
|
+
: "";
|
|
240
|
+
parts.push("", `peer origin=${peer.origin} (${peer.sourcePath})${skippedNote}:`);
|
|
241
|
+
if (peer.error) {
|
|
242
|
+
parts.push(` WARNING: ${peer.error}`);
|
|
243
|
+
}
|
|
244
|
+
else if (peer.rows.length === 0) {
|
|
245
|
+
parts.push(" no frictions match");
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
parts.push(indent(renderDigestTable(peer.rows), " "));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return parts.join("\n");
|
|
253
|
+
}
|
|
254
|
+
function renderDigestTable(rows) {
|
|
255
|
+
const header = [
|
|
256
|
+
"group",
|
|
257
|
+
"total",
|
|
258
|
+
"open",
|
|
259
|
+
"filed",
|
|
260
|
+
"resolved",
|
|
261
|
+
"wontfix",
|
|
262
|
+
"open%",
|
|
263
|
+
"recurrences",
|
|
264
|
+
"avg-h-triage",
|
|
265
|
+
];
|
|
266
|
+
const body = rows.map((r) => [
|
|
267
|
+
r.group,
|
|
268
|
+
String(r.total),
|
|
269
|
+
String(r.open),
|
|
270
|
+
String(r.filed),
|
|
271
|
+
String(r.resolved),
|
|
272
|
+
String(r.wontfix),
|
|
273
|
+
r.total > 0 ? `${Math.round((r.open / r.total) * 100)}%` : "-",
|
|
274
|
+
String(r.recurrences),
|
|
275
|
+
r.avgHoursToTriage == null ? "-" : r.avgHoursToTriage.toFixed(1),
|
|
276
|
+
]);
|
|
277
|
+
const widths = header.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
|
|
278
|
+
const sep = widths.map((w) => "-".repeat(w)).join(" ");
|
|
279
|
+
const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(" ");
|
|
280
|
+
return [fmt(header), sep, ...body.map(fmt)].join("\n");
|
|
281
|
+
}
|
|
282
|
+
function indent(text, prefix) {
|
|
283
|
+
return text
|
|
284
|
+
.split("\n")
|
|
285
|
+
.map((line) => prefix + line)
|
|
286
|
+
.join("\n");
|
|
287
|
+
}
|
|
288
|
+
//# sourceMappingURL=digest.js.map
|