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.
Files changed (113) hide show
  1. package/CHANGELOG.md +54 -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 +13 -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/main.d.ts +1 -0
  56. package/dist/main.js +503 -0
  57. package/dist/main.js.map +1 -0
  58. package/dist/node-guard.d.ts +14 -0
  59. package/dist/node-guard.js +36 -0
  60. package/dist/node-guard.js.map +1 -0
  61. package/dist/paths.d.ts +12 -0
  62. package/dist/paths.js +34 -0
  63. package/dist/paths.js.map +1 -0
  64. package/dist/scanners/claude-code.d.ts +6 -0
  65. package/dist/scanners/claude-code.js +202 -0
  66. package/dist/scanners/claude-code.js.map +1 -0
  67. package/dist/scanners/index.d.ts +4 -0
  68. package/dist/scanners/index.js +12 -0
  69. package/dist/scanners/index.js.map +1 -0
  70. package/dist/sinks/agent-tasks.d.ts +36 -0
  71. package/dist/sinks/agent-tasks.js +153 -0
  72. package/dist/sinks/agent-tasks.js.map +1 -0
  73. package/dist/sinks/github-issues.d.ts +20 -0
  74. package/dist/sinks/github-issues.js +98 -0
  75. package/dist/sinks/github-issues.js.map +1 -0
  76. package/dist/sinks/index.d.ts +11 -0
  77. package/dist/sinks/index.js +48 -0
  78. package/dist/sinks/index.js.map +1 -0
  79. package/dist/sinks/linear.d.ts +25 -0
  80. package/dist/sinks/linear.js +157 -0
  81. package/dist/sinks/linear.js.map +1 -0
  82. package/dist/sinks/markdown-file.d.ts +7 -0
  83. package/dist/sinks/markdown-file.js +54 -0
  84. package/dist/sinks/markdown-file.js.map +1 -0
  85. package/dist/sinks/stdout-json.d.ts +15 -0
  86. package/dist/sinks/stdout-json.js +49 -0
  87. package/dist/sinks/stdout-json.js.map +1 -0
  88. package/dist/templates/auth-expiry.yml +30 -0
  89. package/dist/templates/doc-gap.yml +30 -0
  90. package/dist/templates/output-overflow.yml +25 -0
  91. package/dist/templates/schema-drift.yml +26 -0
  92. package/dist/templates/tool-error.yml +34 -0
  93. package/dist/templates/tool-missing-capability.yml +26 -0
  94. package/dist/templates/workflow-friction.yml +26 -0
  95. package/dist/templates.d.ts +5 -0
  96. package/dist/templates.js +88 -0
  97. package/dist/templates.js.map +1 -0
  98. package/dist/types.d.ts +79 -0
  99. package/dist/types.js +2 -0
  100. package/dist/types.js.map +1 -0
  101. package/docs/commands.md +74 -0
  102. package/docs/design.md +25 -0
  103. package/docs/sinks.md +79 -0
  104. package/docs/storage.md +20 -0
  105. package/docs/sync-export.md +23 -0
  106. package/package.json +64 -4
  107. package/templates/auth-expiry.yml +30 -0
  108. package/templates/doc-gap.yml +30 -0
  109. package/templates/output-overflow.yml +25 -0
  110. package/templates/schema-drift.yml +26 -0
  111. package/templates/tool-error.yml +34 -0
  112. package/templates/tool-missing-capability.yml +26 -0
  113. 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
- # 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,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
@@ -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