diff --git a/README.md b/README.md index 9ba0d30..4fe26df 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,9 @@ tracked against; any block can become one. - **One active task, tracked for you.** Time tracking is the built-in **Time tracking** extension (devlog-time). Posting on a task canvas, **Start** in its header, or **Start** in the status bar makes it the - active task; it stays active - until you post on another task, press Stop, lock the machine, go idle or - sleep. Devlog keeps running in the tray to watch. A block with an explicit + active task; it stays active until you start another task or press Stop. + Locking the machine, going idle or sleeping pauses the clock, and it + resumes on the same task. Devlog keeps running in the tray to watch. A block with an explicit duration like `[2h]` or `[45m]` overrides tracking for that window when you know better. - **Todos are blocks, and always in view.** A todo is a block with a @@ -68,8 +68,9 @@ tracked against; any block can become one. beneath it, to get it out of the sidebar. Archived things stay readable and searchable, and one click brings them back. - **Activity timeline.** Alongside your notes, Devlog records lock/unlock, - idle, sleep and which window was in front (app and title, so a browser tab - or a Teams call shows up). A per-day **Timeline** view lays it all out next + idle and sleep (while time is tracked) and, with the **Window tracking** + extension, which window was in front (app and title, so a browser tab or a + Teams call shows up). A per-day **Timeline** view lays it all out next to your notes; the weekly review adds screen time by kind (coding, meetings, browser…). - **Commits become blocks, branches become events.** Map a canvas to the git @@ -79,8 +80,8 @@ tracked against; any block can become one. timeline without cluttering the stream. - **Weekly review and summary.** The review rolls the week up by day and by client → project → task with tracked time, plus a per-day breakdown of what - you wrote, task time and screen time. The **Summary** view answers the - timesheet question directly: hours per client for any range, rounded to + you wrote, task time and screen time. The **Summary** page (from the Time + tracking extension, ⌘⇧H) answers the timesheet question directly: hours per client for any range, rounded to the nearest 15 minutes (or whatever you set), with projects and tasks one click away. Made for Friday. - **Pasted evidence just works.** Paste or drop an image into the composer @@ -89,9 +90,10 @@ tracked against; any block can become one. see it full size. Paste a stack trace, a diff, a log excerpt or a shell session and it lands in a code block instead of being mangled into paragraphs. -- **Jot from anywhere.** The composer stays at the bottom of every view, with - a picker for which canvas the block goes to, and ⌘P / ⌘K opens a quick - switcher that jumps to any canvas or view by fuzzy name. +- **Jot from anywhere.** The composer stays at the bottom of the canvas, + review and timeline views, with a picker for which canvas the block goes + to, and ⌘P (or ⌘K outside the editor) opens a quick switcher that jumps to + any canvas, page or view by fuzzy name. - **Its own chrome.** No menu bar: the app draws the title bar Slack-style, with a hamburger menu on the left and search in the middle. Pick a colour theme in Settings (Graphite by default; Ocean, Forest, Ember, Aubergine, @@ -127,21 +129,24 @@ existing devlog** (a clone from another machine). ``` README.md -devlog.json ← { "format": 3 }: the storage format +devlog.json ← { "format": 4, … }: the storage format, and the extensions the devlog uses +devlog.lock.json ← the exact extension files in use, pinned by hash .gitattributes ← block files and activity logs merge by keeping both sides entries/ ← the old journal (retired; only in devlogs that used it) canvases/ k3/ ← shard: the first two characters of the id k3m9x2q7vd/ ← a canvas (a client, "Acme Corp") - canvas.md ← title, parent, task flag, repos, archived flag + the surface + canvas.md ← title, parent, type (task flag), repos, archived flag, extension fields + the surface assets/ ← images pasted into the surface entries/2026/09/2026-09-19.md ← its stream, same day-file format 7w/ 7wq0dz4hbe/ ← "Website", parent: k3m9x2q7vd … activity/ - desktop-4f1a/2026/09/2026-09-19.jsonl ← activity log, one folder per machine + desktop-4f1a/2026/09/2026-09-19.jsonl ← the app's activity log, one folder per machine laptop-9c02/… +extensions/ + builtin.devlog-time/… ← extensions' own synced data (the time log, the window log…) ``` Canvases are named by a random 10-character id (lowercase letters and digits, @@ -157,7 +162,7 @@ is a short front-matter block followed by the surface: title: Website parent: k3m9x2q7vd created: 2026-09-19T10:00:00.000Z -repo: C:\src\acme-site +repo: "C:\\src\\acme-site" alias: website --- @@ -171,18 +176,20 @@ slugs), so old activity logs and links still find it. ### Older devlogs -This version reads and writes storage format 3 only. A devlog from before -Devlog 0.5 (format 1 or 2, or a 0.2 `pages/` + `categories/` layout) is -refused with a message: open it once with Devlog 0.5, which upgrades it, +This version writes storage format 4. A format 3 devlog is upgraded when you +open it (its todo lists move into the streams; see *Todos*). A devlog from +before Devlog 0.5 (format 1 or 2, or a 0.2 `pages/` + `categories/` layout) +is refused with a message: open it once with Devlog 0.5, which upgrades it, then again with this version. A devlog written by a newer Devlog is refused too, rather than misread. ## Canvases, surfaces and tasks Click a canvas in the sidebar to open it. The header shows its breadcrumbs, -the canvases inside it as chips, and actions: **Edit** (rename, move under -another canvas, its type, repositories), **Archive**, and whatever -extensions add for canvases of their type (**Start** / **Stop** on a task). +the canvases inside it as chips, and actions: **Properties** (rename, move +under another canvas, its type, repositories, extension fields), +**Archive**, and whatever extensions add for canvases of their type +(**Start** / **Stop** on a task, next to the title). The **surface** sits above the stream. It opens rendered: links open in the browser, images open full size. **Add surface** / **Edit surface** (or a @@ -212,9 +219,9 @@ task** when another is running, **■ Stop** once it is the active one). The status bar's **Start** is a split button: while a task canvas is on screen its main part reads **▶ Start** *that task* and starts it in one click; the ▾ beside it opens the list of every task (the canvas on screen, -or the tasks inside it, first; type to filter; Enter picks), and a name that -matches nothing offers **+ New task**, made under the canvas on screen and -started. **Stop** sits next to it while a task is active. +or the tasks inside it, first; type to filter; Enter picks), and **+ New +task** makes a task by the name you typed, under the canvas on screen, and +starts it. **Stop** sits next to it while a task is active. **Block pages.** A standup, a call, a bug: post a block for it (Alt+Enter opens it), write your notes inside, and later edit the block itself into the @@ -229,9 +236,9 @@ block (the top and bottom edges still reorder); a block on another day of the same canvas works too. **Move → Out of this block** takes one back out, beside the block it was in; **Move** to another canvas puts it at that canvas's top level. Making a block with notes inside it a task (**Make -task**, ⌘⇧Enter) moves those notes into the new task's stream; the block stays -behind as the link to it. The quick switcher (⌘K / Go to…) lists the pages -you opened lately first. +task**) moves those notes into the new task's stream; the block stays +behind as the link to it. The quick switcher (⌘P, or Go to… in the top bar) +lists the pages you opened lately first. **Hide** on a block collapses it (with what is inside it) into a "1 hidden block" stub so a busy stream reads cleanly; click the stub to look inside and @@ -266,8 +273,8 @@ collapses to a thin strip showing the open count. bullets, numbers and checkboxes are stripped. New todos go into the page on screen: the block you have open, or the canvas. - **Scope.** *Here* shows the page on screen and everything inside it; *All* - shows every open todo. On the review, summary and timeline views it is - always everything. + shows every open todo. Anywhere but a canvas or block page (review, + timeline, Summary, Timesheet) it is always everything. - **Grouping.** Todos sit under the canvases and blocks they live in, in the order they appear there. A heading with nothing of its own and a single heading beneath it merges into it ("Acme Corp / Website"). Click a heading @@ -275,8 +282,8 @@ collapses to a thin strip showing the open count. among the ones beside it. - **Working a todo.** Click it to open it as a page: its checkbox is at the top, and what you write goes inside it. The count beside a todo is how - much is inside. **Task** on its block turns it into a task canvas and - starts the clock. + much is inside. **Make task** on its block turns it into a task canvas and + starts the clock (it is then a task link, no longer a todo). - **Done.** Tick the box, in the panel or in the stream. The todo stays where it is, struck through (a run of them folds into one line), and moves to *Done* in the panel for two weeks. The timeline shows when it was ticked. @@ -307,8 +314,10 @@ Time tracking is the built-in **Time tracking** extension (devlog-time): without it Devlog has no tasks, no clock, no Summary and no Timesheet. A devlog that tracked time before 0.17 gets it added and allowed on each machine that tracked time, with the active task and idle setting carried -over, so nothing changes. Its settings (Settings → Time tracking) are the -idle minutes and whether its log is synced with the devlog. +over, so nothing changes; the first devlog a new install opens gets it the +same way. Anywhere else, add it in Settings → Extensions. Its settings +(Settings → Time tracking) are the idle minutes and whether its log is +synced with the devlog. There is one active task at a time, and a task is a canvas of the task type. The workflow: write a line or two to wrap up what you were doing, then @@ -334,7 +343,7 @@ the day with **Restore**. Time from an explicit `[2h]` marker is not editable there: edit the block instead. Quitting Devlog stops the clock, so it keeps running in the tray when you close the window. -When you know better than the tracker, say so in the block: `[2h] Acme sync` +When you know better than the clock, say so in the block: `[2h] Acme sync` or `[45m] code review` counts exactly that much for the block's canvas, ending at the block's time, and replaces whatever was tracked in that window. Such blocks do not switch the active task. @@ -343,21 +352,22 @@ Window tracking records every focus change, and if you alt-tab a lot that is a lot of sub-second flips. The raw log keeps all of them; the views clean them up: the Windows task switcher, Start menu, search box, lock screen and the like are dropped outright, and any focus shorter than a threshold -(**Settings → Ignore window switches shorter than**, default 5 seconds) is +(**Settings → Activity → Ignore window switches shorter than**, default 5 seconds) is folded into the window you were actually working in. So a 20-minute Outlook session that you alt-tabbed out of and back into five times shows as 20 minutes of Outlook. What the clock does (start, task switches, stop, heartbeats) goes to the extension's own append-only log, and what the machine does (lock, idle, -sleep) and git events go to the app's; both are one JSON file per day and one -folder per machine (`extensions/builtin.devlog-time//…` and +sleep) and git events go to the app's; both are one JSON-lines file per day +and one folder per machine (`extensions/builtin.devlog-time//…` and `activity//YYYY/MM/YYYY-MM-DD.jsonl`, where `` is the host name plus a short id kept in the app's data folder). Older time, from before -the extension, stays in `activity/` and still counts. By default it lives in +the extension, stays in `activity/` and still counts. By default both live in the devlog repository, so the review and timeline add up time from every -machine you work on (window titles included, so consider what they contain); -Settings can keep it on this machine only. Each machine only appends to its +machine you work on; Settings → Activity and Settings → Time tracking can +each keep theirs on this machine only. Window titles (with Window tracking) +always go into the devlog, so consider what they contain. Each machine only appends to its own files, so syncing never conflicts. Each machine's events are replayed on their own (locking the laptop does not pause the desktop), and where two machines both tracked time at once, the task picked or machine woken most @@ -369,7 +379,7 @@ written, and so nothing committed, while the machine is locked, idle or asleep), and git events from watched repositories. **Window tracking** (which app and window title is in front, for screen -time in the timeline, review and summary) is the built-in **Window +time in the timeline and review) is the built-in **Window tracking** extension (devlog-focus): add it in Settings → Extensions and allow it. It runs unrestricted, so you are asked whether you trust it: it starts a small helper to read the window in front (PowerShell on Windows, `osascript` @@ -393,15 +403,17 @@ branch at a time, and the routing below does the rest. were made. It is off by default; linking alone only captures new commits. - **New commits land where you are working.** Devlog watches the repository's reflogs and, on every commit, adds a read-only block: repo, - branch, short hash and message. If the active task sits beneath the linked - canvas (a task under that client), the block goes on the task; otherwise on + branch, short hash and message. If the active task (with time tracking) + sits beneath the linked canvas (a task under that client), the block goes + on the task; otherwise on the linked canvas itself. Reply to it, move it or delete it, but not edit it. Everything else git records is captured as an activity event rather than a block, so the stream stays readable: creating a branch, switching branches (with where from), pushing, merging, rebasing, pulling, resetting and -stashing. They appear on the day's Timeline with the repo name. Commits in +stashing. They appear on the day's Timeline with the repo name (they are +recorded while the Time tracking extension is on). Commits in the devlog repository itself are ignored. ## Weekly review @@ -458,7 +470,8 @@ that client are two separate measures, and a target is not taken on by the canvases inside. **Sending to Jira.** Add the built-in **Jira worklogs** extension in -Settings → Extensions and allow it (it is sandboxed: it only talks to Jira). +Settings → Extensions and allow it (it is sandboxed, and says it talks only +to Jira). It gets a page of its own in Settings, marked until it is set up: the Jira address and your account email (saved in the devlog), and an API token (kept on this computer only; for Data Center leave the email empty and use a @@ -534,8 +547,8 @@ Started on the git sync. Pull before push; rebase on conflicts. The app only ever **appends** to a block file; nothing already written is changed. Each HTML comment (invisible when rendered) is one record: `add` creates a block, `edit` replaces its text, `set` changes its position, -`hidden` flag, kind (`commit`, `done`, or `task` with the task canvas's id) -or other fields, and `delete` removes it (the record stays behind). The +`hidden` flag, kind (`todo`, `commit`, `timesheet`, or `task` with the task +canvas's id), a todo's `done` time, or other fields, and `delete` removes it (the record stays behind). The blocks you see are what you get by replaying the records in order. - **Order** comes from the `pos` keys, which sort as text: reordering a block @@ -570,8 +583,8 @@ app show up too). Delete it any time; it is rebuilt from the files. ## Extensions -Integrations (time export, calendars, …) come as extensions that a devlog -opts into. Open **Extensions** from the quick switcher or Settings: +Features beyond notes (time tracking, window tracking, Jira, CMS, …) come as +extensions that a devlog opts into. Open **Extensions** from the quick switcher or Settings: - **Add** one from a GitHub repository's releases (`owner/repo` and a version range such as `^1.0.0`), from a single `https://…/x.devlog-ext.zip`, or one @@ -581,7 +594,8 @@ opts into. Open **Extensions** from the quick switcher or Settings: - **Allow** it before it runs, on each machine: the dialog shows what it says it talks to and lets you choose what it may **read** and **add blocks to**: the whole devlog, or chosen canvases (with everything inside - them). A new version asks again. + them). A new version of a downloaded extension asks again; a built-in one + keeps what you allowed when the app updates it. - It runs in its own process with **no access to your files or other programs**, and cannot see other extensions. An extension that needs more (such as window tracking) says so, and runs unrestricted only if you tick @@ -593,9 +607,11 @@ opts into. Open **Extensions** from the quick switcher or Settings: passwords) are encrypted with the OS keychain on each machine and never written to the devlog. Per-canvas values (a Jira issue, a client id) appear as fields in the canvas dialog, are stored in `canvas.md`, and are - inherited by canvases inside. -- Its **commands** appear in the quick switcher; blocks it writes are marked - as its own and are read-only. + inherited by canvases inside unless the field says otherwise (hour + targets are not). +- Its **commands** appear in the quick switcher (and, if it says so, in + menus, on keys and in the note box); its **pages** are listed with the + views; blocks it writes are marked as its own and are read-only. - **Check for updates** re-resolves the version ranges and pins what changed. **Remove** takes it out of the devlog (its data stays). @@ -613,8 +629,9 @@ Writing one: `packages/extension-api/README.md`; the design: | App quit | Commit and push pending changes (up to 20s) | The status bar shows the current state (uncommitted changes, committing, -pushing, up to date, error) and the branch; the activity log is committed -with every sync but does not count as uncommitted changes. A pull that +pushing, up to date, error) and the branch; the activity log and +extensions' data are committed with every sync but do not count as +uncommitted changes. A pull that conflicts is backed out (never left half-rebased) and reported. Errors such as a failed push are shown and retried on the next tick; nothing is ever lost because the files are already on disk. @@ -643,24 +660,23 @@ away (or as soon as the download finishes), after the usual final commit and push. The active task survives the restart, so tracking loses only a few seconds. -To cut a release, bump the version and tag it: - -```sh -npm version minor # or patch / major; commits and tags vX.Y.Z -git push --follow-tags -``` +To cut a release, bump `version` in `package.json` (and `package-lock.json`) +and commit it, then run the **Build** workflow by hand (Actions → Build → +Run workflow) with `release_version` set to that version; it tags the +commit `vX.Y.Z`. Pushing a `vX.Y.Z` tag (`npm version minor && git push +--follow-tags`) does the same. CI then runs typecheck, unit tests, the build and the Playwright smoke test; only if all pass does it package Windows and macOS builds and publish a GitHub release with the installers and the `latest*.yml` manifests that installed -apps read. macOS apps must be code-signed for auto-update to apply; unsigned -macOS builds still run but will not self-update. +apps read. CI does not code-sign macOS builds, so they run but do not update +themselves; the Windows build does. ## Development ```sh npm run typecheck # main + renderer -npm test # unit tests: file format, store, git sync (uses a local bare remote) +npm test # unit tests: file formats, store, git sync (local bare remote), extensions, time and timesheets, updates npm run smoke # builds, then drives the real app with Playwright (needs a display; use xvfb-run on Linux) npm run screens # builds, seeds a demo devlog and screenshots every view in light and dark mode ``` @@ -680,6 +696,7 @@ Code map: | `packages/core/src/node/repoIndex.ts` | `RepoIndex`: SQLite cache for listings and full-text search | | `packages/core/src/node/manifest.ts` | `devlog.json` (format check, extensions, settings) and `devlog.lock.json` | | `packages/core/src/extensions.ts` | Extension manifests, sources and ids, version ranges, grants | +| `packages/core/src/timesheet.ts` | Timesheet rules: sessions, quarter-hour rounding, trims, the week's block format | | `packages/core/src/node/extensionFiles.ts` | The file broker behind an extension's private folders | | `packages/extension-api/` | Types, wire protocol and test harness for extension authors | | `src/main/extensions/` | Installer, sandboxed extension process, manager (consent, API) | @@ -689,12 +706,18 @@ Code map: | `src/shared/activity.ts` | Pure event → segment logic, app classification, roll-ups | | `src/shared/review.ts` | Weekly roll-up: week math, tracked/explicit/estimated time | | `src/main/activity/` | Commit watcher | +| `src/main/workingCopy.ts` | Checks that a folder picked for linking is a git working copy | | `builtin-extensions/` | Extensions that ship with Devlog (devlog-time, devlog-focus, Jira, CMS) | | `packages/ui/` | `@devlog/ui`: React components and the view bridge for extension views | | `src/main/updates.ts` | Silent auto-update via electron-updater and GitHub Releases | | `src/shared/updates.ts` | Pure "is now a good moment to restart" policy | -| `src/main/protocol.ts` | `devlog://asset/…` scheme serving images from the repo | +| `src/main/protocol.ts` | `devlog://asset/…` (images from the repo) and `devlog-ext://` (extension views) | | `src/main/ipc.ts`, `src/preload/` | IPC surface exposed to the renderer as `window.devlog` | | `src/renderer/src/components/` | React UI: top bar, sidebar tree, canvas view, composer, settings | | `src/renderer/src/editor/` | TipTap extensions: highlighted code, asset images, Slack keys | +| `scripts/` | Built-in extension bundler, Playwright smoke test, screenshots | | `docs/DESIGN.md` | Design notes and rationale | +| `docs/EXTENSIONS.md` | Extensions: packages, consent, sandbox, the API by topic | +| `docs/TIMESHEETS.md` | Timesheets: rounding, trims, destinations, storage | +| `docs/TIME-EXTENSION.md` | How time tracking became the devlog-time extension | +| `docs/BLOCK-PAGES.md` | Block pages and todos as blocks | diff --git a/builtin-extensions/README.md b/builtin-extensions/README.md index ad51037..1767cc6 100644 --- a/builtin-extensions/README.md +++ b/builtin-extensions/README.md @@ -2,12 +2,14 @@ Extensions that ship inside the Devlog app, one folder each (with a `devlog-extension.json`). A devlog enables one with `"": "builtin"` in -its `devlog.json`; it still runs sandboxed and still asks before it starts. +its `devlog.json`; it still asks before it starts, and runs sandboxed unless +it says it needs more (Window tracking does, and asks you to trust it). +Once allowed, a built-in keeps its grant when the app updates it. Packaged into the app's resources as `extensions/`. An extension written in TypeScript keeps its sources in `src/`: `src/main.ts` is bundled to `dist/main.js` (its manifest's `main`), and each `src/views/.tsx` to `dist/views/.html` with its script and -styles (React and `@devlog/ui` included), by `scripts/build-builtins.mjs`, -which `npm run build` runs first. `dist/` is not committed; `src/` is not -packaged. +styles (React and `@devlog/ui` included), by `scripts/build-builtins.mjs`. +`npm run build` runs it first (and so do `dev`, `package`, `smoke` and +`screens`). `dist/` is not committed; `src/` is not packaged. diff --git a/builtin-extensions/devlog-focus/devlog-extension.json b/builtin-extensions/devlog-focus/devlog-extension.json index 5a05eb3..1a3e386 100644 --- a/builtin-extensions/devlog-focus/devlog-extension.json +++ b/builtin-extensions/devlog-focus/devlog-extension.json @@ -2,7 +2,7 @@ "name": "devlog-focus", "displayName": "Window tracking", "version": "1.1.0", - "description": "Records which window is in front (app and title) while this computer is unlocked, for screen time in the timeline, review and summary. Kept per machine in the devlog. Runs unrestricted: it starts a small helper (PowerShell, osascript or xdotool) to read the window in front.", + "description": "Records which window is in front (app and title) while this computer is unlocked, for screen time in the timeline and review. Kept per machine in the devlog. Runs unrestricted: it starts a small helper (PowerShell, osascript or xdotool) to read the window in front.", "api": "^1.2.0", "main": "main.js", "permissions": { "unrestricted": true }, diff --git a/docs/BLOCK-PAGES.md b/docs/BLOCK-PAGES.md index f2187d0..3fad393 100644 --- a/docs/BLOCK-PAGES.md +++ b/docs/BLOCK-PAGES.md @@ -13,14 +13,14 @@ apart; the breadcrumb runs Client / Project / Standup / Sep 28 / …, and you can go up a level. In a stream, a block with blocks inside it no longer shows them inline as a -thread. It shows a chip ("3 notes") that opens its page. +thread. It shows a chip ("▸ 3 blocks inside") that opens its page. Todos become ordinary blocks that can sit anywhere in that tree (phase 2), and the todo panel is a view over them. The standup, for example: in the Standup task, type "Sep 28" and post it with -Alt+Enter. That starts the task (posting on a task canvas does) and opens the -new block's page. Notes and action items go inside it. Anything worth keeping +Alt+Enter. That starts the task (posting on a task canvas does, with the +time tracking extension, devlog-time) and opens the new block's page. Notes and action items go inside it. Anything worth keeping at the top is an edit of the block, the page's surface. The Standup task then reads as a list of dated blocks, each one openable. @@ -48,18 +48,20 @@ reads as a list of dated blocks, each one openable. 3. **Ticking a todo off no longer writes a "✓" block;** the todo shows as done where it is, and the timeline shows when it was ticked. Existing "✓" blocks stay as history. -4. **Todo panel order:** grouped by canvas (collapsible sections; a chain - of canvases with a single path collapses into one heading), then in the - order the todos appear in their streams. Drag to reorder within a group. - There is no ranking across groups. +4. **Todo panel order:** grouped under the canvases and blocks the todos + sit in (collapsible sections; a chain with a single path collapses into + one heading), then in the order the todos appear in their streams. Drag + to reorder among the todos beside it (the same day file, inside the same + block). There is no ranking across groups. Ticked todos stay in a Done + section for 14 days. 5. **Opening a page:** double-click a block, click its chip, or its Open action. On a page, double-clicking the surface edits it. -6. **Making a block with blocks inside it a task** moves those blocks into - the new task canvas; the block stays where it was as the link to it - (phase 3). -7. **A task is a canvas you can record time against.** Block pages do not - track time on their own; a page inside a task canvas belongs to that - task, so posting anywhere in it makes the task active. +6. **Making a block with blocks inside it a task** (**Make task**, from the + time extension, through `devlog.promote`) moves those blocks into the new + task canvas; the block stays where it was as the link to it (phase 3). +7. **A task is a canvas you can record time against** (with devlog-time). + Block pages do not track time on their own; a page inside a task canvas + belongs to that task, so posting anywhere in it makes the task active. ## Phases @@ -72,7 +74,7 @@ reads as a list of dated blocks, each one openable. 2. **Todos as blocks.** The conversion (format 4), `[ ]` and pasted checklists posting one todo per line, a checkbox in the stream, the todo page (checkbox in the header), the panel built from the index (grouped - by canvas; Here is the page on screen and everything beneath it), + by canvas and block; Here is everything inside the page on screen), extension API 1.4 (`parentId` when adding, todo state, a todo query). 3. **Polish.** Move under another block, making a block with children a task (decision 6), recent pages in the quick switcher. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 73503ee..e60abf8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -27,8 +27,9 @@ │ RepoIndex SQLite cache: listings + full-text search│ │ SyncManager simple-git: commit / fetch / rebase / push│ │ ActivityLog per-machine JSON-lines log │ -│ manifest storage format check │ -│ Tracker, CommitWatcher, protocol, SettingsStore, updater │ +│ manifest storage format, extensions, lockfile │ +│ ExtensionManager (each extension in its own process), │ +│ machine state, CommitWatcher, protocol, settings, updater│ └───────────────────────────────────────────────────────────┘ ``` @@ -37,27 +38,38 @@ dependency: a pure entry (types, file formats, hierarchy helpers, also used by the renderer) and a Node entry (store, index, sync, activity log, manifest). The desktop app is one client of it; an API server or CLI could be another. Keeping every file-format rule and every write in one unit-tested -package is the point: nothing else opens a day file. +package is the point: nothing else opens a day file. Extensions build on two +more packages: `@devlog/extension-api` (the API they are written against) +and `@devlog/ui` (the React kit for their views). The renderer never touches the file system. It sends Markdown strings and image bytes over IPC; the main process owns all paths, git and the asset server. `contextIsolation`, `sandbox` and a CSP are on; `devlog://` is the only way for the page to load a file, and the handler refuses anything -outside the open repository. +outside the open repository. Extension views load from `devlog-ext://` +in sandboxed frames (see `EXTENSIONS.md`). ## Storage format -- `devlog.json` – `{ "format": 3 }`, the storage format version. +- `devlog.json` – `{ "format": 4 }`, the storage format version, plus the + devlog's extensions and their settings. +- `devlog.lock.json` – the exact version of each extension (see + `EXTENSIONS.md`). - `.gitattributes` – `merge=union` for block files and activity logs. -- `entries/YYYY/MM/YYYY-MM-DD.md` – the journal, one file per local day. - Retired in 0.13.0: the app no longer offers it as a place to write, and - lists it (read-only, under Archived) only while it still holds blocks. -- `entries/YYYY/MM/assets/--.` – pasted images. -- `canvases///canvas.md` + `…/entries/…` + `…/todos.md` – every - other canvas, in the same day-file layout (see **Canvases** below). -- `activity//YYYY/MM/YYYY-MM-DD.jsonl` – the activity log. - -Each block file starts with `` and is an +- `canvases///canvas.md` + `…/entries/YYYY/MM/YYYY-MM-DD.md` – + every canvas: its properties and surface, and its stream, one file per + local day (see **Canvases** below). +- `…/entries/YYYY/MM/assets/--.` – pasted images. +- `entries/…` – the journal, the old built-in root canvas. Retired in + 0.13.0: the app no longer offers it as a place to write, and lists it + (read-only, under Archived) only while it still holds blocks. +- `activity//YYYY/MM/YYYY-MM-DD.jsonl` – the activity log, written + only while an extension provides time (see below). +- `extensions//…` – each extension's own files (for example + devlog-time's clock, the destinations' ledgers). + +Each block file starts with `` (the block file +format; it did not change when the storage format went to 4) and is an append-only log of records (`packages/core/src/format/oplog.ts`): ``` @@ -84,7 +96,7 @@ Reasons for this over alternatives: blocks. A seeded fuzz test round-trips hostile bodies. - **Day-relative image paths on disk, repo-relative in memory.** On disk `![shot](assets/x.png)` renders on GitHub. In memory everything is - normalised to `entries/2026/09/assets/x.png` so the renderer and the asset + normalised to `canvases/k2/k2x9…/entries/2026/09/assets/x.png` so the renderer and the asset protocol can resolve an image without knowing which file it came from, and an image pasted at 23:59 still resolves when the post lands in the next day's file (`../09/assets/x.png`). @@ -118,44 +130,50 @@ Reasons for this over alternatives: on concurrent inserts after the same block and on deleted predecessors. - **Compaction, built but not applied.** Files grow with every edit. `DevlogStore.compact({ quietSince, dryRun })` rewrites a file as one `add` - per live block carrying its current state, dropping superseded edits, moves and deleted blocks. It is the - only operation that rewrites a block file, so it only touches format 3 - files whose newest record is older than `quietSince` (another machine - must not still be appending to a file rewritten under it), replays the - result and refuses to write unless it gives exactly the same blocks, is - deterministic (two machines compacting the same file agree), holds the - file's lock, and keeps the index current. Git history keeps every dropped - record. The app does not call it yet. + per live block carrying its current state, dropping superseded edits, + moves and deleted blocks. It is the only operation that rewrites a block + file, so it only touches block files (format 3 header) whose newest record + is older than `quietSince` (another machine must not still be appending to + a file rewritten under it), replays the result and refuses to write unless + it gives exactly the same blocks, is deterministic (two machines + compacting the same file agree), holds the file's lock, and keeps the + index current. Git history keeps every dropped record. The app does not + call it yet. - Parsing tolerates hand edits: CRLF is fine, and anything before the first record is ignored rather than destroyed. Records without an id are skipped. -### Notes as nodes +### Blocks as a tree -Notes form a tree: a reply carries `parentId`, and siblings are ordered by -their order keys; replay returns them depth-first, threads contiguous. The -planners in `packages/core/src/format/oplog.ts` (`planAdd`, `planMove`, -`planDelete`, …) turn each operation into records; a property test checks -they give exactly what the old list operations (`insertEntry`, -`moveSubtree`, `removeSubtree`) did: +Blocks form a tree: a block inside another carries `parentId`, and siblings +are ordered by their order keys; replay returns them depth-first, children +contiguous. The planners in `packages/core/src/format/oplog.ts` (`planAdd`, +`planMove`, `planNest`, `planDelete`, …) turn each operation into records; +a property test checks them against plain list operations: -- **Reply** → the last child of the parent. -- **Insert after X** → placed after X's whole thread, as X's sibling. +- **Add inside** → the last child of the parent. +- **Insert after X** → placed after X and everything inside it, as X's + sibling. - **Insert before X** → placed directly before X, inheriting X's parent. -- **Delete** removes the note and its whole thread (the UI says how many). +- **Move inside or beside** (`planNest`) → one `set` with a new parent and + order key. +- **Delete** removes the block and everything inside it (the UI says how + many). -A reply written on a later day is stored in the parent's day file, so a -thread never splits across files. Replies render with `↳` and a deeper -heading level so GitHub shows the nesting without breaking Markdown. +A block written inside another on a later day is stored in its root block's +day file, so a subtree never splits across files. Every block is also a +page: the stream shows a chip for the blocks inside one, and opening it +shows them (see `BLOCK-PAGES.md`). -### Canvases, surfaces, tasks +### Canvases, surfaces, node types There is one container type. A **canvas** has a title, an optional parent, -a task flag, a list of repositories, an archived flag, a *surface* (free -markdown) and a *stream* (day files of blocks). The journal is the built-in -root canvas whose stream lives at `entries/`; every other canvas lives under -`canvases///` with `canvas.md` holding a tiny `key: value` front matter -followed by the surface markdown. No YAML library: the parser accepts +an optional node type, a list of repositories, an archived flag, fields +from extensions, a *surface* (free markdown) and a *stream* (day files of +blocks). Every canvas lives under `canvases///` with `canvas.md` +holding a tiny `key: value` front matter followed by the surface markdown. +(The journal, the old built-in root canvas at `entries/`, is retired and +read-only.) No YAML library: the parser accepts `key: value` lines and quoted values only. Ids are random: 10 characters from `0-9a-z` minus `i l o u` (about 50 @@ -166,45 +184,47 @@ used title slugs in one flat folder: readable, but they collided across machines and made `canvases/` one enormous directory.) The hierarchy is `parent: ` in the front matter, not the folder path. This was a deliberate trade against a nested directory tree: ids are what the activity -log, task blocks and tracker state point at, and a rename or a move under a +log, task blocks and the time extension's clock point at, and a rename or a move under a different client must not invalidate a month of time records or relocate files in git. `alias: ` lines keep older ids resolvable. `canvasPath` / `canvasLabel` in `packages/core/src/format/canvases.ts` walk parents for display, `buildCanvasTree` nests for the sidebar (non-tasks before tasks, then by title), and a canvas whose parent has gone simply shows at the top level. -Every store operation takes a canvas id; the helpers in `entries.ts` take -the canvas's entries base so day files and image links are computed the same +Every store operation takes a canvas id; the helpers in `format/blocks.ts` +take the canvas's entries base so day files and image links are computed the same way everywhere. Root-relative image paths start with `entries/` or `canvases/`, which is how `toDayRelative` recognises them. Surfaces use the same root-relative-in-memory, file-relative-on-disk rule with the canvas folder as the base and an `assets/` folder beside `canvas.md`. -**Tasks** are canvases with `task: true`, and that flag is the whole -tracking model: posting a user block on a task canvas makes it the active -task; posting anywhere else is just a note. This replaced the earlier -"posting on any page starts a task" rule, which made every jotted note start -a clock. A block turns into a task through `promoteToTask`: a new canvas is -created beneath the block's canvas (top-level for journal blocks) titled -from the block (`titleFromMarkdown`: first sentence, markup and duration -markers stripped, capped), the block's kind becomes `task` with -`meta.canvas` pointing at it, and the tracker is pointed at the new canvas. -The block keeps its text and stays editable; it is the record of when and -where the task began, and the chip on it is the link. `#task` on the first -line and ⌘⇧Enter are the same operation performed at post time -(`hasTaskTag` / `stripTaskTag`), so the round trip is one keystroke. +**Node types** come from extensions: `type: /` in +`canvas.md` (the task type is written `task: true`, as before node types +existed). The app has no types of its own. **Tasks** are the time +extension's (devlog-time): posting a user block on a task canvas makes it +the active task; posting anywhere else is just a note. A block turns into a +canvas of a type through `promoteBlock` (the extension's **Make task**, via +`devlog.promote`): a new canvas is created beneath the block's canvas, +titled from the block (`titleFromMarkdown`: first sentence, markup and +duration markers stripped, capped), the blocks inside it move there, and +the block's kind becomes `task` with `meta.canvas` pointing at it. The +block keeps its text and stays editable; it is the record of when and where +the task began, and the chip on it is the link. `#task` on the first line +and Ctrl+Shift+Enter are devlog-time's post command doing the same at post +time. **Hiding** is a flag on the block (`hidden=1` in the marker). The stream collapses each run of consecutive hidden top-level blocks into one stub and reveals them on click; search, review and summary still see them. It is a reading aid, not a deletion, so it never touches the tree. -**Reordering** within a day is drag-and-drop over `moveSubtree`: the block -and its thread are lifted out of the list and spliced back after or before -the anchor's thread. Timestamps are untouched, which is the answer to the -"how does this work with timestamps" question: a block's time is when it was -written and its position is where it is kept; the file already separated the -two. Dropping is refused across days because a day is a file. +**Reordering** within a day is drag-and-drop over `planMove`: one `set` +record with a new order key (and parent). Dropping on the middle of a block +puts the dragged block inside it, from any day. Timestamps are untouched, +which is the answer to the "how does this work with timestamps" question: a +block's time is when it was written and its position is where it is kept; +the file already separated the two. Reordering is refused across days +because a day is a file. **Todos** are blocks of kind `todo` in the day files, at any depth (storage format 4; see `BLOCK-PAGES.md`). `meta.done` is when one was ticked off; the @@ -216,10 +236,11 @@ canvases and blocks above them. Format 3 kept a per-canvas `todos.md`; of the day it was written. "✓" blocks written by earlier versions when a todo was ticked stay as history. -**Moving** a block (with its thread) to another canvas rewrites nothing but -the file it lives in: assets stay put and the serialised link becomes -`../../../../../entries/2026/09/assets/x.png`, which still renders on -GitHub. Ids are re-generated only on collision in the target day. +**Moving** a block (with everything inside it) to another canvas or file +appends `add` records to the target and `delete` records to the source; +nothing is rewritten. Assets stay put and the serialised link becomes +relative to the new file, which still renders on GitHub. Ids are +re-generated only on collision in the target day. The stream is a continuous timeline per canvas: the newest ten non-empty days, oldest first with day dividers, and older days load on scroll or via @@ -229,7 +250,7 @@ and day. Archiving is a flag, not a move: `archived: true` in `canvas.md`. Everything that reads canvases still sees archived ones; the sidebar tree, move -targets, commit watchers and the active task simply filter them out. Search +targets, commit watchers and the task picker simply filter them out. Search returns archived hits with a badge. Archiving a canvas flags every canvas beneath it; unarchiving reverses the same set. Keeping it a flag means git history stays linear and a mistaken archive is a one-line change. @@ -237,11 +258,12 @@ history stays linear and a mistaken archive is a one-line change. **Older formats.** Devlog 0.5 upgraded formats 1 and 2 (and 0.2's `pages/` + `categories/`) to format 3 when it opened a repository; that code was removed once every devlog had been upgraded (it is in git history, up to -release 0.5.3). The app now checks `devlog.json` on open -(`assertSupportedFormat`) and refuses anything but format 3 with a message -saying which Devlog version to use. Canvases that had slug ids keep them as -`alias` lines, which the store (`resolveCanvasId`, `aliasMap`), the -tracker's persisted task and the activity reader still follow. +release 0.5.3). On open the app upgrades format 3 to 4 +(`upgradeStorage`: todos move out of `todos.md` into the streams), then +checks `devlog.json` (`assertSupportedFormat`) and refuses anything but +format 4 with a message saying what to do. Canvases that had slug ids keep +them as `alias` lines, which the store (`resolveCanvasId`, `aliasMap`) and +the activity reader still follow. ### The local index @@ -260,44 +282,53 @@ reading. A schema version or repository change, or a corrupt file, just rebuilds it. The tests compare every listing and search answer from an indexed store with one that scans the files. -### Active task and the activity log - -The model is deliberately small: **one active task at a time, and the task -is a canvas flagged as one**. Posting a user block on a task canvas, pressing -Start in its header, or turning a block into a task makes it the active task -(`task` event with the canvas id). Stop (status bar, menu, tray) records -`task` with no canvas. The task survives restarts (persisted in user data and -re-announced with a `start` event) but time only accrues while Devlog is -running, which is why the window closes to the tray when tracking is on. - -Signals come from Electron's `powerMonitor`: `lock-screen`/`unlock-screen`, -`suspend`/`resume`, and a 15-second poll of `getSystemIdleState` for idle. -Each is written as an event. Foreground windows are not the core's: the -devlog-focus extension (`builtin-extensions/devlog-focus`, see -`docs/EXTENSIONS.md`) runs its own helper and keeps `focus` events in its -own folder, and the app merges them back into the activity the views read. -There are no native modules. - -Events go to `ActivityLog`: JSON lines, one file per local day, one folder -per machine (`activity/-/…`, the folder name kept in -`userData/machine.json`), in the repository by default or in user data by -setting. A machine only ever appends to its own files, so logs never -conflict in git. Reading merges every machine (and the pre-0.4 shared -`activity/YYYY/…` layout) and tags each event with its machine; the -segment builders replay each machine separately (a lock on the laptop must -not pause the desktop) and then flatten overlaps, the later-starting segment -winning, so time is never counted twice. Exclusions apply globally. Writes -never trigger the sync debounce (they would cause a commit every 30 s) and do -not count as unsaved work in the status (`quietPaths`); the interval sync -commits them. A `heartbeat` every -five minutes is the liveness signal: segment building treats a gap of more -than two heartbeats as "the app was not running", so a crash cannot inflate a -task by a weekend. Heartbeats are skipped while paused: -the lock/idle/suspend event already closed the segment, and a locked machine -that kept writing would also commit and push every five minutes. - -Pauses are tracked per reason (locked, idle, suspended) both in the tracker -and in the replay. The clock runs only when none applies: waking from sleep +### Time: devlog-time and the activity log + +Time tracking is the built-in extension devlog-time (see +`TIME-EXTENSION.md`); the app itself keeps no clock. The model is +deliberately small: **one active task at a time, and the task is a canvas +of the task type**. Posting a user block on a task canvas, pressing Start +in its header, or making a block a task makes it the active task. Stop +(status bar, Ctrl+Shift+., tray) stops it. The clock writes `start`, +`task`, `stop` and a `heartbeat` every five minutes to +`extensions/builtin.devlog-time//…`, keeps the active task in its +local `state.json` (so it survives restarts), and marks the task for the +app with `ui.highlight`. Time only accrues while Devlog is running, which +is why the window closes to the tray while the extension asks it to keep +running. + +The machine's state is the app's: Electron's `powerMonitor` +(`lock-screen`/`unlock-screen`, `suspend`/`resume`) and a 15-second poll of +`getSystemIdleState`, against the idle minutes the extension asked for +(`activity.idleAfter`). The main process keeps the paused reasons +(`machinePaused`), tells extensions (pause/resume), and writes +lock/idle/sleep events to `activity/` **only while an extension provides +time** (`provide.activity`). Foreground windows are not the core's either: +the devlog-focus extension keeps `focus` events in its own folder. The +views read one merged stream (`activityRange`): the core log, the time +provider's events and the focus provider's, replayed together. There are +no native modules. + +Event files are JSON lines, one file per local day, one folder per machine +(`-`, the name kept in `userData/machine.json`), in the +repository by default or on the machine only by setting. A machine only +ever appends to its own files, so logs never conflict in git. Reading +merges every machine (and the pre-0.4 shared `activity/YYYY/…` layout) and +tags each event with its machine; the segment builders replay each machine +separately (a lock on the laptop must not pause the desktop) and then +flatten overlaps, the later-starting segment winning, so time is never +counted twice. Exclusions apply globally. Writes to `activity/` and +`extensions/` never trigger the sync debounce (they would cause a commit +every 30 s) and do not count as unsaved work in the status (`quietPaths`); +the interval sync commits them. The heartbeat is the liveness signal: +segment building treats a gap of more than two heartbeats as "the app was +not running", so a crash cannot inflate a task by a weekend. Heartbeats are +skipped while paused: the lock/idle/suspend event already closed the +segment, and a locked machine that kept writing would also commit and push +every five minutes. + +Pauses are tracked per reason (locked, idle, suspended) both in the main +process and in the replay. The clock runs only when none applies: waking from sleep clears only "suspended", input after idle clears only "idle", and unlocking clears everything because the user is demonstrably back. An earlier version kept a single paused flag, so a laptop that woke in the background while @@ -329,7 +360,7 @@ shorter than `focusMinSeconds` (a setting, default 5 s) into its predecessor, and merges adjacent segments with the same app and title. Task segments are untouched: a task is a deliberate act, a focus flip is not. -Git capture (`commits.ts`) watches `.git/logs/` of every repository mapped to +Git capture (`src/main/activity/commits.ts`) watches `.git/logs/` of every repository mapped to a canvas, recursively (via `fs.watch` on the directory plus a 15 s poll), and reads only bytes appended to each reflog since the watch began. Lines are classified by which log they came from and their message: a commit or @@ -338,8 +369,8 @@ cherry-pick on `HEAD` is resolved with `git show` and emitted as a commit; `refs/heads/`, `update by push` under `refs/remotes/`, and merge, rebase, pull, reset and stash messages are emitted as events with a `GitAction`. A repository is linked to a canvas, and the commit lands on the active task -when that task lies beneath the linked canvas (`routeCommit`), otherwise on -the canvas itself. This is what "link repositories to clients" means in +(the canvas an extension highlights) when that task lies beneath the linked +canvas (`routeCommit`), otherwise on the canvas itself. This is what "link repositories to clients" means in practice: one client is one branch at a time, and the task you are on is the work the commit belongs to. Linking also imports the user's own commits from the last N days (`listRecentCommits`, filtered by the repository's @@ -348,16 +379,18 @@ linked canvas already shows the recent work; the watcher then takes over for anything new. The main process turns commits into `kind=commit` blocks with `repo`/`hash`/`branch`/`author` attributes in the marker (the store refuses `updateEntry` on such blocks and de-duplicates by hash) and everything else -into `type: 'git'` activity events tagged with the canvas, so they show on the -timeline without becoming blocks. The devlog repository itself is excluded so +into `type: 'git'` activity events tagged with the canvas (only while an +extension provides time), so they show on the timeline without becoming +blocks. The devlog repository itself is excluded so auto-sync commits do not feed back into the log. ### Weekly review `src/shared/review.ts` is pure and unit-tested: `weekStart` (Monday), `computeWeekTime`, and `buildReviewRows`. The main process supplies -`getRange(from, to)` (every day file across every canvas) and the activity log -for the same range. Notes are attributed to the local date of their +`getRange(from, to)` (every day file across every canvas) and the activity +for the same range (`activityRange`: the core log merged with the time and +focus extensions' events). Notes are attributed to the local date of their timestamp, not the file they sit in, so a reply written on Wednesday under Monday's thread counts for Wednesday. @@ -366,18 +399,19 @@ day with no events and no markers falls back to the old timestamp heuristic (each note owns the gap to the next, capped) and is marked `~` in the grid so the two are never confused. -Rows are a tree that mirrors the canvas tree: one row per canvas with -blocks or time in the range plus every ancestor, totals summed upward, -journal last, siblings ordered by time. The same tree -drives the per-day detail, which also lists the day's task segments and its -screen time by app kind (`classifyApp`: a small rule table over process names -and titles) and by app with the top titles on hover. +Rows are a tree that mirrors the canvas tree: one row per canvas with blocks +or time in the range plus every ancestor, totals summed upward, journal +last, siblings ordered by time. The same tree drives the per-day detail, +which also lists the day's task segments and its screen time (with +devlog-focus) by app kind (`classifyApp`: a small rule table over process +names and titles) and by app with the top titles on hover. -The **Summary** view reuses `computeWeekTime` and `buildReviewRows` over an -arbitrary date range and shows only the totals column, rounded with -`roundMinutes` to a granularity the user picks (default 15 minutes). Rounding -is per row and the exact minutes sit beside the rounded hours, so the number -on the invoice and the number that produced it are both visible. +The **Summary** page (devlog-time's, Ctrl+Shift+H) reuses `computeWeekTime` +and `buildReviewRows` over an arbitrary date range and shows only the totals +column, rounded with `roundMinutes` to a granularity the user picks (default +15 minutes). Rounding is per row and the exact minutes sit beside the +rounded hours, so the number on the invoice and the number that produced it +are both visible. The **Timeline** view slices one day into fixed intervals (`bucketizeDay` in `activity.ts`): per bucket, the overlap of every task and (cleaned) focus @@ -409,8 +443,10 @@ follows the system. ### Auto-update -`electron-updater` against GitHub Releases, `autoDownload` on, no UI. The -only decision the app makes is *when* to restart, and that lives in +`electron-updater` against GitHub Releases, `autoDownload` on. The UI is +small: the status bar offers **Update now** once one is downloaded, and +Settings shows the version with **Check now**. Otherwise the only decision +the app makes is *when* to restart, and that lives in `src/shared/updates.ts` as a pure function over signals the main process samples once a minute: screen locked (`powerMonitor`), window visible and focused, system idle seconds, sync in flight, and an "editor busy" count the @@ -420,9 +456,10 @@ minutes, or idle for fifteen, means install now; an update older than a day installs at the first minute without input. The first two minutes after launch and any moment with a sync or an unsaved edit are always off-limits. -Installing runs the same shutdown work as quitting (tracker `stop` event, -final commit and push), then `quitAndInstall(silent, runAfter)`, so the new -version relaunches by itself and restores the active task from user data. +Installing runs the same shutdown work as quitting (extensions stop first, +so the clock's `stop` is in the final commit; then commit and push), then +`quitAndInstall(silent, runAfter)`, so the new version relaunches by itself +and devlog-time restores the active task from its local state. `autoInstallOnAppQuit` covers the case where the user quits first. Releases are gated in CI on the smoke test, which is the practical guarantee behind "the new version is working": a build that cannot post a note, sync, or @@ -431,7 +468,7 @@ show the review never gets a `latest.yml`. ## Editor TipTap 3 with StarterKit, the official `@tiptap/markdown` extension for -Markdown in/out, and two custom extensions: +Markdown in/out, and three custom extensions: - `DevlogImage` keeps `src` as the repo-relative path (which is what gets serialised) but renders through `devlog://asset/…`. @@ -443,7 +480,8 @@ Markdown in/out, and two custom extensions: through to the input rule); Shift+Enter starts a new paragraph (so `- `, `1. `, `>` and ``` shortcuts work on every line), a hard break inside a list item, or a newline inside a code block; Mod+Enter always posts; Escape - cancels; ↑ in an empty composer edits the previous note. + cancels; Alt+Enter posts and opens the new block's page; Mod+K adds a + link; ↑ in an empty composer edits the previous note. There is deliberately no toolbar. Formatting is discoverable through the placeholder hint, the markdown shortcuts, and a bubble menu (bold, italic, @@ -461,12 +499,13 @@ so the evidence survives verbatim instead of being re-flowed into paragraphs. Everything else falls through to TipTap, which understands pasted Markdown. -The composer is docked under every view, not just the page feed, with a -picker for the target page: the point of the app is to jot as things happen, -and the thing that happened is rarely on the page you are looking at. The -target follows the page you open and stays put while you look at the -timeline or summary. ⌘P / ⌘K opens a quick switcher over pages, categories -and views. +The composer is docked under the canvas, review and timeline views (not on +extension pages), with a picker for the target canvas: the point of the app +is to jot as things happen, and the thing that happened is rarely on the +page you are looking at. The target follows the canvas you open and stays +put while you look at the timeline or review. Ctrl+P (or Ctrl+K outside the +editor) opens a quick switcher over canvases, recent block pages, views, +extension pages and extension commands. Posting clears the editor optimistically before the write completes, so keystrokes typed immediately after Enter are kept; the draft is restored if @@ -490,7 +529,8 @@ the debounce, "Sync now" and quit can never overlap. One run is: 3. Refresh status; emit `remote-changes` if the pull changed the tree so the UI reloads. -Triggers: a debounce after each store change (default 30 s), an interval +Triggers: a debounce after each store change (default 30 s; writes under +`activity/` and `extensions/` excepted), an interval (default 5 min), startup pull, manual, and `before-quit` (bounded to 20 s so quitting can't hang on a dead network). @@ -502,6 +542,16 @@ force-pushes or rewrites history. `GIT_TERMINAL_PROMPT=0` guarantees git cannot block on a credential prompt, and a 90 s silence timeout kills a stalled network call. +## Extensions + +Anything beyond notes, canvases and git is an extension: time tracking +(devlog-time), window tracking (devlog-focus), and the Jira and CMS +destinations are built-ins that use the same API as anyone else's. Each +runs in its own Node process, sandboxed unless you trust it, listed in +`devlog.json` and pinned in `devlog.lock.json`, and it asks before it +starts. Its views run in sandboxed frames and talk to it through the app. +See `EXTENSIONS.md` for the model and `packages/extension-api` for the API. + ## Things intentionally left out (for now) - Multiple devlogs open at once (switching is supported). diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 3687144..3b55035 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -1,21 +1,18 @@ -# Extensions: design (draft) +# Extensions -Status: **milestone 1 built** (0.7.0): manifests and sources, the lockfile, -installing from GitHub releases / URLs / built-ins, consent with read and -write scopes, the sandboxed extension process, the file broker, secrets, -settings, canvas fields, commands and notifications. Cards, destinations and -devlog-focus are later milestones. Tracks issue #1 (with #2 time export, #3 -custom block types and #16 window tracking). How to write one: -`packages/extension-api/README.md`. +Features beyond notes come as extensions a devlog opts into: time tracking +(devlog-time), window tracking (devlog-focus), Jira worklogs (devlog-jira) +and CMS timesheets (devlog-cms) ship with the app. The extension API is at +1.7 (`EXTENSION_API_VERSION` in `packages/core/src/extensions.ts`); each +member says in `packages/extension-api/src/index.ts` which version it +arrived in. How to write one: `packages/extension-api/README.md`. -## Goals +## Principles -- Let a devlog opt into integrations (pushing tracked time to Jira and CMS, - Outlook-backed blocks, link previews) without growing the core app. - **The devlog stays Just A Notebook.** It *names* the extensions it wants and holds their non-secret settings; it never contains their code, their secrets, or anything they need at runtime besides that. -- Opening or pulling a devlog must never run code that arrived through git. +- Opening or pulling a devlog never runs code that arrived through git. - A leaked or public devlog repository must not leak any credential. - Extensions go through the same data layer as the app (`@devlog/core`), so they cannot break the file-format rules (append-only block files, marker @@ -23,512 +20,366 @@ custom block types and #16 window tracking). How to write one: - **Extensions cannot see each other's data**, and see the devlog itself only where you let them. - **Safe without containers**: no Docker, no VM. Safety comes from what the - runtime and the API allow (see *Safety in v1*). -- Features that not everyone wants live in extensions, even first-party - ones. Window tracking is the first to move (see *Window tracking as an - extension*). + runtime and the API allow (see *Sandbox*). +- Features not everyone wants live in extensions, even first-party ones. Non-goals: two-way sync with trackers (Devlog records what happened; it is -not a project-management tool), a public extension marketplace, a package -manager, network sandboxing in v1. +not a project-management tool), a public marketplace, a package manager, +network sandboxing (for now). -## What an extension is +## Packages and the manifest -A folder (shipped as one `.zip`) with a manifest, `devlog-extension.json`, -and bundled code: +An extension is a folder (shipped as one `.zip`) with a manifest, +`devlog-extension.json`, and bundled code: ```jsonc { "name": "devlog-jira", - "displayName": "Jira time export", + "displayName": "Jira worklogs", "version": "1.2.0", - "api": "^1.0.0", // extension API version it was built for + "api": "^1.3.0", // the extension API range it was built for "main": "main.js", // one bundled file, no dependencies - "cards": { "worklog": "cards/worklog.json" }, // Adaptive Card templates (see Cards) "contributes": { - "destinations": [{ "id": "jira", "label": "Jira worklogs" }], + "destinations": [{ "id": "worklogs", "label": "Jira" }], "canvasFields": [{ "key": "issue", "label": "Jira issue", "placeholder": "ACME-123" }], - "settings": [{ "key": "baseUrl", "label": "Jira URL" }], - "secrets": [{ "key": "token", "label": "API token" }], - "unfurl": ["*.atlassian.net"] // links it can turn into cards + "settings": [{ "key": "baseurl", "label": "Jira address", "type": "url", "required": true }], + "secrets": [{ "key": "token", "label": "API token", "required": true }], + "commands": [{ "id": "check", "label": "Check the Jira connection" }], + "check": "check" // a command that tests the settings }, "permissions": { - "read": true, // wants to read blocks; you choose the scope + "read": false, // read blocks; you choose the scope "write": false, // add blocks; you choose the scope - "network": ["*.atlassian.net"] // declared and shown; not enforced in v1 + "network": ["*.atlassian.net"] // declared and shown; not enforced }, "appendOnly": ["**/*.jsonl"] // its own repo files that git should union-merge } ``` -- **Not an npm package.** Extensions are not JavaScript libraries and no - JavaScript project would ever depend on one, so they are not published to - npm. Authors can still use npm and a bundler (esbuild) to *build* one; - what ships is the bundle. -- **Bundled, dependency-free.** One `main.js`, no `node_modules`. Installing - is "download one zip, check its hash, unpack", with no dependency tree to - resolve, audit or trust, and nothing that resembles a package manager. -- **`contributes`** declares everything the app shows (destinations, canvas - fields, settings, secret prompts, link handlers) so the UI can be built - without running the extension. -- **`permissions`** are shown before first activation. Read and write are - granted with a scope you pick (see *Reading and writing the devlog*). -- Authors write against the extension API's types and a small test - harness, kept in this repository (`packages/extension-api`) and consumed - from git. The runtime object is injected by the app at activation. - -## Where extensions come from - -Releases on GitHub (or any HTTPS URL), not a registry: +- **Not an npm package, bundled and dependency-free.** One `main.js`, no + `node_modules`: installing is "download one zip, check its hash, unpack". + Authors can use npm and a bundler (esbuild) to *build* one. +- **`contributes`** declares what the app shows without running the + extension: `canvasFields`, `settings`, `secrets`, `commands`, + `destinations`, `views`, `nodeTypes`, and a settings `check` command. + Field keys are lowercase (`[a-z][a-z0-9_-]*`); fields take a `type` + (text, url, email, number, select, checkbox, password), `required`, + `description`, and for canvas fields `"inherited": false` (1.7). +- **`permissions`**: `read`, `write` (each granted with a scope you pick), + `network` (shown, not enforced), `unrestricted` (runs outside the sandbox + if you trust it), `send` (sends timesheets through other extensions' + destinations, 1.7). All are shown before it first runs. + +## Sources, ids and the lockfile + +Named in `devlog.json`: ```json { - "format": 3, + "format": 4, "extensions": { "sdeken/devlog-jira": "^1.2.0", - "sdeken/devlog-cms": "0.3.1", - "devlog-focus": "builtin" + "my-ext": "https://example.com/my-ext.devlog-ext.zip", + "devlog-time": "builtin" } } ``` -- `owner/repo` with a version range: the app lists the repository's - releases (GitHub's API), picks the newest tag matching the range, and - downloads the release's `*.devlog-ext.zip` asset. Private repositories - work with a GitHub token from the secret store. -- A plain `https://…/x.zip` pins one exact file. -- `builtin` names a first-party extension that ships inside the app (no - download), like devlog-focus. -- A lockfile, `devlog.lock.json`, written by the app, records the exact - version, URL and SHA-256 each entry resolved to. Every machine runs - byte-identical code, and an update is a visible, committed change - ("devlog: update extensions"), never a silent drift. -- **Update extensions** (a command) re-checks the ranges, shows what - changed, rewrites the lockfile and commits it. Pulling a lockfile that - another machine updated leads to a download and a consent prompt, since - the code is new. -- Unpacked into `userData/extensions//`, shared by every devlog that - pins the same bytes; nothing is written into the devlog. -- **Development:** a machine-local override (in user data, never in the - devlog) maps an extension to a folder, for working on one without - releasing it. - -An extension's **id** comes from where it was fetched, not from its -manifest (`sdeken/devlog-jira` → `sdeken.devlog-jira`; built-ins are -`builtin.`). The id names its folders, secrets and settings, so one -extension cannot claim another's name to read its data. - -## Settings - -- **Per devlog** (Jira base URL, CMS project list): in `devlog.json` under - `"settings": { "sdeken.devlog-jira": { … } }`, edited through the app. -- **Per canvas** (this client's Jira issue, CMS client): in the canvas's own - `canvas.md`, as namespaced front-matter keys, e.g. - `ext.sdeken.devlog-jira.issue: ACME-123`. They follow the canvas through - renames and moves, and the canvas dialog shows them as ordinary fields - (from `contributes.canvasFields`). Task canvases inherit from the nearest - ancestor that sets one, as time already rolls up in the review. - *Prerequisite:* the `canvas.md` parser currently drops unknown keys; it - must keep them. -- **Secrets** (tokens, the CMS password): never in the repository. Kept in - user data per extension, encrypted with the OS keychain (Electron - `safeStorage`: DPAPI on Windows, Keychain on macOS, libsecret on Linux), - through `ctx.secrets`. +- `owner/repo` with a version range: the app lists the repository's GitHub + releases, picks the newest tag matching the range, and downloads the + release's `*.devlog-ext.zip` asset. Private repositories work with a + GitHub token kept on this computer (Extensions page). +- `"name": "https://…/x.zip"` pins one exact file. +- `builtin` names an extension that ships inside the app. +- **Ids** come from where an extension was fetched, not from its manifest: + `sdeken/devlog-jira` → `sdeken.devlog-jira`, a URL entry → `url.`, + a built-in → `builtin.`. The id names its folders, secrets and + settings, so one extension cannot claim another's name to read its data. +- **`devlog.lock.json`** records the exact version, URL and SHA-256 of each + GitHub or URL entry (built-ins and development folders are not locked), + so every machine runs byte-identical code. It is committed with the next + commit like any other change. +- **Check for updates** (Extensions page) re-checks the ranges, downloads + what changed, rewrites the lockfile and says what was updated; changed + code must be allowed again. A lockfile updated on another machine is + used, and asks for consent, the next time the devlog is opened here. +- Downloads are unpacked into `userData/extensions//`, shared by + every devlog that pins the same bytes; nothing is written into the devlog. +- **Development:** a machine-local override (`extension-dev.json` in user + data, never in the devlog) maps a devlog.json key to a folder, for working + on one without releasing it. + +## Consent, grants and trust + +The model: **you trust what you install**, like a VS Code extension, but +with limits enforced by the runtime, and trust is explicit: + +- An extension runs only once allowed, per machine and per devlog (consent + is keyed by the devlog folder). The dialog shows its name, version, + source and permissions, and lets you set the **read** and **write** + scopes: the whole devlog, or chosen canvases (each with everything inside + it). +- Consent is per build: a new build of a downloaded extension asks again. + A **built-in** keeps its grant (and trust) when the app brings a new + build, since it is part of the app you updated. +- Changing a grant restarts the extension with the new one; revoking stops + it until you allow it again. **Remove** takes it out of `devlog.json` and + forgets this machine's consent. +- Grants are kept on this machine with the consent, not in the repository, + so nothing that arrives through git can widen them. +- **Unrestricted (trusted).** An extension that needs what the sandbox + forbids (start a platform helper, read files) says + `"permissions": { "unrestricted": true }`. It runs only if you tick "I + trust it" when allowing it, and then without the permission model: full + file-system and process access, the app's environment, and + `ctx.packageDir` (its own unpacked folder). Window tracking is one. + +## Settings, secrets and canvas fields + +- **Per devlog** (the Jira address): in `devlog.json` under + `"settings": { "": { … } }`, edited on the extension's page in + Settings, checked by type when saved. `required` settings and secrets + mark the extension as needing setup until set. +- **Per canvas** (this client's Jira issue): in the canvas's `canvas.md` as + namespaced front-matter keys, `ext..: value`. They follow the + canvas through renames and moves, show as fields in canvas properties + (a page per extension), and canvases inside use the value unless they set + their own (a field with `"inherited": false` applies to its canvas only). +- **Secrets** (tokens, passwords): never in the repository. Kept in user + data per extension, encrypted with the OS keychain (Electron + `safeStorage`), through `ctx.secrets`. ## Reading and writing the devlog -Reading is a permission, like writing. When an extension asks for `read` or -`write`, you grant it one of: - -- **the whole devlog**, or -- **specific canvases** (each with everything beneath it), picked in the - consent dialog and changeable later from the extension's settings. - -Everything the API returns is filtered by the grant: `canvases()` lists only -granted canvases (and the ancestors needed to name them), `blocks()` and -search return nothing outside them, `timesheet()` only entries on them, and -writes outside them fail. With no read grant an extension still gets what -it needs to work: its own settings, the canvas fields it declared on the -canvases it was granted, and its own files. - -Grants are kept on this machine with the consent (see *Trust*), not in the -repository, so nothing that arrives through git can widen them. +Everything the API returns is filtered by the grant: `canvases()` lists the +granted canvases (and the ancestors needed to name them), `blocks()`, +`search()`, `todos()`, `range()` and `activity()` return nothing outside +them, and writes outside them fail. With no read grant an extension still +gets its own settings, the canvas fields it declared, and its own files. A +canvas the extension keeps (`managedCanvas`) is its own whatever it was +granted. Blocks an extension writes carry an `ext=` attribute and are automatic -blocks (read-only, muted brace), like captured commits. They are ordinary -markdown in the day files, so they survive the extension going away. +blocks (read-only in the app, muted brace). They are ordinary markdown in +the day files, so they survive the extension going away. ## Its own files -Besides the devlog, each extension has two private folders it manages -however it likes (JSON, JSON lines, SQLite, markdown): +Besides the devlog, each extension has two private folders: | Store | Where | Synced | For | |---|---|---|---| | `ctx.files.repo` | `extensions//` in the devlog | yes, committed with everything else | data every machine should see: logs, the record of what was sent | -| `ctx.files.local` | `userData/extension-data///` | no | machine-only state: cursors, caches, anything bulky or noisy | - -The core ignores `extensions/` when listing, indexing and searching. For -git, the manifest's `appendOnly` globs get a `merge=union` line in -`.gitattributes` (as activity logs do), so two machines appending never -conflict. Like activity logs, extension writes don't count as unsaved work -and ride along with the next interval commit. - -Uninstalling leaves both folders alone (the devlog keeps the data, and git -has it anyway). Removing the data is a separate, explicit action. - -## Safety in v1 - -The v1 line is: **no direct access to the file system**, and nothing else -that would get around it. An extension: - -- **cannot touch files.** Its main half runs in its own process, started - with Node's permission model (`--permission`) and *no* file allowances: - every `fs` call, from any code in the bundle, fails in the runtime. The - code itself is sent over the message channel and evaluated, so the - process needs no read access even to load it. Its own folders are reached - only through `ctx.files`, a broker in the app that takes relative paths, - refuses absolute paths, `..` and symlinks out of the folder, writes - atomically and caps sizes. +| `ctx.files.local` | `userData/extension-data///` | no | machine-only state: cursors, caches, preferences | + +The core ignores `extensions/` when listing, indexing and searching. The +manifest's `appendOnly` globs get a `merge=union` line in `.gitattributes`, +so two machines appending never conflict. Extension writes don't count as +unsaved work and ride along with the next interval commit. Uninstalling +leaves both folders alone; there is no command to delete an extension's +data. + +## Sandbox + +An extension: + +- **cannot touch files.** It runs in its own process, a Node child of the + app binary (`ELECTRON_RUN_AS_NODE`) started with Node's permission model + (`--permission`) and read access to exactly one file, the host script. + Its code is sent over the message channel and evaluated, so it needs no + read access to load. Its own folders are reached only through + `ctx.files`, a broker in the app that takes relative paths, refuses + absolute paths, `..` and symlinks out of the folder, writes atomically + and caps sizes. - **cannot start programs, load native code or spawn workers** (the same - permission model denies child processes, addons, workers and WASI). The - things that need them stay in the app and are offered as narrow - capabilities instead, or the extension asks to run unrestricted (below). + permission model denies them). Node built-ins can be required, but the + calls fail with `ERR_ACCESS_DENIED`. - **sees the devlog only through its grant**, and only its own folders, settings and secrets. - **cannot reach other extensions, the app's windows or Electron.** Each - extension has its own message channel to the app and nothing else; there - is no API to list or talk to other extensions. -- **cannot take the app down**: a crash or hang kills only its process - (memory is capped, and an unresponsive one is stopped and reported). -- **UI without code**: what extensions show inside the notebook is Adaptive - Cards (below), rendered by the app. No extension code runs in the - notebook's window. - -**Unrestricted extensions (trusted).** An extension that needs to do what -the sandbox forbids (start a platform helper, read files) says so with -`"permissions": { "unrestricted": true }`. It then runs only if you say, in -the consent dialog, that you trust it and its author, and it runs without -the permission model: full file-system and process access, the app's -environment, and `ctx.packageDir` (its own unpacked folder, for scripts it -ships). Everything else (consent per build, a new build asks again, its -private folders, grants for the devlog API) stays the same. This is the -middle ground: the sandbox is the default, and leaving it is an explicit, -per-extension decision you make, not something the app grants itself. - -**Not in v1: the network.** An extension can make any request; the domains -it declares are shown at consent but not enforced (enforcing needs all -traffic to go through the app). The consequence to keep in mind: an -extension can send out whatever it can read, which is one reason read -access is scoped. - -**How it is run.** Electron's `utilityProcess` silently ignores -`--permission` (checked: file reads, child processes and workers all -succeed in it), so each extension runs as a Node child of the app binary -(`ELECTRON_RUN_AS_NODE`) with `--permission`, read access to exactly one -file (the host script, copied out of `app.asar` into user data), a heap cap, -and an environment stripped to a few locale and Windows variables. The -builds must keep Electron's `RunAsNode` fuse on. Tests run a probe -extension in that process and check that file access, child processes and -workers are refused with `ERR_ACCESS_DENIED`. - -## Cards: rich blocks and link previews - -Slack and Teams don't share a card format: Teams (and Outlook, Webex, -Windows widgets) use **Adaptive Cards**, an open JSON format from -Microsoft; Slack uses its own Block Kit. What both do share is unfurling -links from a page's Open Graph / oEmbed metadata. Since Outlook is a likely -extension, Devlog uses Adaptive Cards: - -- **Templates in the extension, data in the block.** An extension ships card - templates (`cards` in the manifest). A block it writes has a namespaced - kind (`x-sdeken.devlog-outlook.meeting`), a readable markdown body, and the - card's data as a trailing fenced block (```` ```card ````). The app renders - the template with that data (Adaptive Cards templating) in the notebook's - theme; without the extension, the block shows its markdown and the data - fence stays folded away. Search and the index read the markdown. -- **Actions** go back to the extension: `Action.Execute` calls its main - half, which can answer with new card data (written as an `edit` record); - `Action.OpenUrl` opens the browser after the app checks the link. -- **Link previews**: an extension that declares `unfurl` domains is asked to - turn a pasted link on them into card data (a Jira issue key, title and - status), stored with the block like any other card. Generic Open Graph - previews for any link could be a small first-party extension of their own. -- A sandboxed-iframe renderer for things cards cannot express is possible - later, but not planned: cards keep extension code out of the window. - -## Window tracking as an extension - -Recording the foreground window is useful to some people and noise to -others, and it is the most privacy-sensitive thing Devlog records. It is a -first-party extension, **devlog-focus** ("Window tracking"), built into the -app (nothing to download) and off until a devlog adds it (#16). - -- **Nothing about windows is in the core.** devlog-focus runs unrestricted - (you are asked whether you trust it) and starts its own platform helper: - PowerShell with `GetForegroundWindow` on Windows, an `osascript` loop on - macOS, `xdotool` on Linux. 0.8 had the app run the helper and pass a - window feed to the extension; 0.9 moved it into the extension and removed - the feed from the API (1.2). -- **What stays core:** the task clock. Task switches, start/stop, - lock/unlock, idle/active, sleep/wake and heartbeats stay in the core - activity log, because tracked time, the review and timesheets depend on - them. Extensions hear about locks and sleep through `ctx.activity`, - straight from the OS whether or not time is tracked; devlog-focus records - nothing while paused. -- **Its data** is in its own synced folder, one JSON-lines file per machine - and day: `extensions/builtin.devlog-focus//YYYY/MM/.jsonl`, - `{"t", "app", "title"}` per line, append-only and union-merged. -- **What it gives back:** `ctx.provide.focus(from, to)` returns its events, - which the app merges into the activity it hands the timeline, review and - summary. Those views draw focus exactly as before. -- **Existing data:** `focus` events already in the core activity logs stay - there and still show. New ones come from the extension. - -## Trust - -The honest model: **you trust what you install**, like a VS Code extension, -but with the limits above enforced by the runtime rather than by -convention. The app makes that trust explicit and hard to grant by accident: - -- The first activation of a given extension *hash* in a given devlog asks, - showing name, version, source and requested permissions, and lets you set - the read and write scopes. Approvals and scopes are stored on this - machine, so a devlog pulled onto a new machine, or a lockfile changed by - another machine, asks again. -- You can revoke or narrow a grant at any time; the extension is restarted - without it. - -## Runtime and API - -One process per extension (the main half), talking to the app over a -message channel that carries the API below. What is built (milestone 1) is -defined in `packages/extension-api/src/index.ts`; the sketch also shows what -later milestones add (`timesheet`, `destinations`, `cards`, `unfurl`). - -```ts -export function activate(ctx: DevlogContext): void | Promise - -interface DevlogContext { - devlog: { // everything filtered by the grant - canvases(): Promise // with this extension's canvas fields - blocks(canvasId: string, date: string): Promise // needs read - search(query: string): Promise // needs read - timesheet(weekStart: string): Promise // the approved timesheet (see TIMESHEETS.md) - addBlock(canvasId: string, markdown: string, opts: { kind?: string; card?: unknown; meta?: Record }): Promise // needs write - } - settings: { get(key: string): T | undefined } // devlog-level, from devlog.json - secrets: { get(key: string): Promise; set(key: string, value: string): Promise } - files: { repo: ExtensionFiles; local: ExtensionFiles } // this extension's own folders - activity: { on(event: 'pause' | 'resume' | 'task', cb: (ev: ActivityEvent) => void): void } // read-only - ui: { notify(message: string): void; confirm(message: string): Promise } - destinations: { register(id: string, destination: Destination): void } - commands: { register(id: string, label: string, run: () => Promise): void } - cards: { onAction(verb: string, run: (data: unknown) => Promise): void } - unfurl: { register(run: (url: string) => Promise<{ kind: string; markdown: string; card: unknown } | null>): void } -} - -interface ExtensionFiles { - read(path: string): Promise - readText(path: string): Promise - write(path: string, data: string | Uint8Array): Promise // atomic - append(path: string, text: string): Promise - list(dir?: string): Promise> - remove(path: string): Promise -} - -interface Destination { - /** Group a final timesheet's entries into this system's lines (mapping and grouping only). */ - lines(sheet: Timesheet, sent: SentRecord[]): Promise - /** Send the lines; return an external id per line for the record. */ - submit(lines: DestinationLine[]): Promise -} -``` - -## Reading what the app recorded, kept canvases, sending through others (built, API 1.7) - -What the Timesheet and Summary needed to move into devlog-time: - -- `devlog.activity(from, to)`: what the app recorded (its own log of locks, - idle, sleep, git events and corrections, merged with what extensions - provide: time, focus), limited to what it may read (task time on canvases - it may not read shows as none; window titles only with read access to the - whole devlog). `devlog.range(from, to)`: the day files with blocks written - in a range. -- `devlog.managedCanvas(key, { title })`: a canvas the extension keeps - (`devlog.managed: /` in canvas.md): its own whatever it was - granted; `addBlock` there takes a `kind` of its own (not todo, task, - commit or done) and any `date`, and `editBlock` works on every block there. -- `destinations.list()` / `preview(to, sheet)` / `send(to, sheet)`: other - extensions' destinations, through the app, for an extension that declares - `permissions.send` (shown when you allow it). The app builds each - destination's sheet (labels, its own canvas fields) as before; the sender - keeps the record. -- `ui.openPage(viewId)`; canvas fields with `"inherited": false` (a target - applies to its canvas only). -- Views: a shortcut pressed inside a view that it does not use (Ctrl, Alt or - Cmd with a key, F-keys) is passed to the app (`key`), so Ctrl+K works - from an extension's page. -- A built-in extension keeps its grant when the app brings a new build. - -## Node types, commands, events and app state (built, API 1.6) - -What time tracking needed to move out of the app (`TIME-EXTENSION.md`); -nothing in it is specific to time. - -**Node types.** `contributes.nodeTypes` (`id`, `label`, `icon`, -`placeholder`) declares kinds of canvas. A canvas carries one as -`type: /` in canvas.md (the time extension's task keeps -the older `task: true`); the app shows its icon and label, offers it under -**Type** in canvas properties and as **New *label* inside…** on a canvas's -menu, and adds the placeholder to the note box on such canvases. A type -whose extension is gone reads as a plain canvas. `canvases()` reports each -canvas's `type`. - -**Commands** take a context: `commands.register(id, (context) => …)` gets -`{ source, canvasId?, date?, blockId? }` (where it was run from: the quick -switcher, a keybinding, a menu, the note box, a view or the tray; and the -canvas on screen or whose menu it was, and the block). Canvases outside the -grant are left out. In `contributes.commands`: - -- `keybinding` (`Mod+Shift+S`; Mod is Ctrl, or Cmd on a Mac; a plain key - or Shift+key is refused, F-keys excepted) runs it with the screen's - context; -- `nodeType` offers it only on canvases of that type (its own id, or a full - name); -- `menus`: `canvas` (a canvas's right-click menu), `block` (the actions on - a block), `tray`; -- `post: true` makes it a way to post from the note box: its keybinding (or - `#` on the first line, with `tag`, taken off before posting) posts the - note as usual, then runs the command on the new block (`source: 'post'`). - -**Writes.** `devlog.createCanvas({ title, parentId, type })` (inside a -canvas it may write to; top level only with write access everywhere), -`devlog.updateCanvas(id, { title, parentId, type, archived })` (types: its -own, and it may clear only its own), `devlog.editBlock(…)` (blocks it -added), and `devlog.promote(canvasId, date, blockId, { type })`: the block -becomes the link to a new canvas of that type just inside its canvas, and -what was written inside the block moves there. - -**Events.** `devlog.onBlockAdded(cb)` hears each block posted in the app, on -canvases it may read. `activity.on(cb)` hears pause (locked, idle, asleep) -and resume; `activity.idleAfter(minutes)` asks for idle detection (the app -uses the shortest any extension asks for). - -**The app around the window.** `ui.pick(items)` shows a quick pick and -resolves to the chosen id; `ui.open({ canvasId, date?, blockId? })` shows a -canvas or a block's page; `ui.highlight(canvasId)` marks its current canvas -(the running task) in the sidebar, and commits from a linked repository land -on it when it is inside the linked canvas; `app.setTrayLabel(text)` and -`app.keepRunning(true)` (close to the tray instead of quitting). - -**Time.** `provide.activity((from, to) => TimeEvent[])` hands the app -`start`, `task`, `stop` and `heartbeat` events (per machine); the timeline, -review, summary and timesheet replay them with the app's own record of -locks, idle and sleep, which the app keeps (in `activity/`) while some -extension provides time. Summary and Timesheet show only then. - -**Views** can also sit in a canvas's header (`placement: 'canvasHeader'`, -26 px high, optionally only for a `nodeType`). Every view gets a `context` -message with where it is: a header view its canvas, a status bar item the -canvas (and block page) on screen, a popover the context of the view that -opened it; a `command` a view runs carries that context. `@devlog/ui` has -`useViewContext()`. - -The `shaper` test fixture (`tests/fixtures/extensions/shaper`) exercises all -of this through the real host process; `devlog-time` is the real user. - -## Views (built, API 1.5) - -An extension can ship pages of its own, declared in `contributes.views` -(`id`, `title`, `entry`: an .html file in the package, `placement`, `icon`): - -- `page`: listed in the sidebar with the app's own views; fills the main area. -- `statusbar`: a slot in the status bar, 22 px high; the page asks for a - width (`resize`) and gets it within 24–360 px. -- `popover`: opened by another of its views (`popover`), anchored to it; - closes on Escape, a click outside, or `close`. + has its own message channel to the app and nothing else. The one + exception is sending a timesheet through another extension's destination, + with `permissions.send`, through the app. +- **cannot take the app down**: a crash kills only its process (heap capped + at 256 MB, environment stripped to a few locale variables); one that does + not start within 30 seconds is stopped, and calls that take too long fail. +- **shows UI only in sandboxed frames**: its pages (views) run in frames + sandboxed to scripts, with no network and no access to the window around + them, and reach the extension only through the app (see *Views*). + +**The network is not restricted.** An extension can make any request; the +domains it declares are shown at consent but not enforced. So an extension +can send out whatever it can read, which is one reason read access is +scoped. + +Electron's `utilityProcess` silently ignores `--permission`, hence the Node +child; the builds must keep Electron's `RunAsNode` fuse on. Tests run a +probe extension in that process and check that file access, child processes +and workers are refused. + +## What an extension can do + +### Read and write (1.0, 1.4, 1.6, 1.7) + +`devlog.canvases()`, `field()`, `days()`, `blocks()`, `search()`, +`addBlock(canvasId, markdown, { meta, parentId, date, todo, kind })`, +`todos({ doneSince })` (1.4). Todos are blocks (`kind: 'todo'`, `meta.done` +once ticked); `parentId` with the block's day-file `date` adds a block +inside another. + +1.6: `createCanvas({ title, parentId, type })` (inside a canvas it may write +to; top level only with write access everywhere), +`updateCanvas(id, { title, parentId, type, archived })` (types: its own, and +it may clear only its own), `editBlock(…)` (blocks it added), and +`promote(canvasId, date, blockId, { type })`: the block becomes the link to +a new canvas of that type just inside its canvas, and what was written +inside the block moves there. + +1.7: `activity(from, to)`: what the app recorded (its log of locks, idle, +sleep, git events and corrections, merged with what extensions provide: +time, focus); task time on canvases it may not read shows as none, window +titles only with read access to the whole devlog. `range(from, to)`: the +day files with blocks written in a range. `managedCanvas(key, { title })`: +a canvas it keeps (`devlog.managed: /`), where `addBlock` takes a +`kind` of its own (not todo, task, commit or done) and any `date`, and +`editBlock` works on every block. + +### Node types (1.6) + +`contributes.nodeTypes` (`id`, `label`, `icon`, `placeholder`) declares +kinds of canvas. A canvas carries one as `type: /` in +canvas.md (the time extension's task keeps the older `task: true`); the app +shows its icon and label, offers it under **Type** in canvas properties and +as **New *label* inside…** on a canvas's menu, and adds the placeholder to +the note box. A type whose extension is gone reads as a plain canvas. + +### Commands (1.0, 1.6) + +`commands.register(id, (context) => …)`; commands appear in the quick +switcher, and a string one returns is shown (a `check` command's result). +The context (1.6) is `{ source, canvasId?, date?, blockId? }`: where it ran +from (switcher, keybinding, menu, note box, view, tray) and the canvas and +block it ran on, without canvases outside the grant. In +`contributes.commands`: + +- `keybinding` (`Mod+Shift+S`; Mod is Ctrl, or Cmd on a Mac; a plain key or + Shift+key is refused, F-keys excepted); +- `nodeType`: offered only on canvases of that type; +- `menus`: `canvas` (right-click menu), `block` (the actions on a block), + `tray`; +- `post: true`: a way to post from the note box: its keybinding (or + `#` on the first line, with `tag`, taken off before posting) posts + the note, then runs the command on the new block. + +### Events and the app around the window (1.0, 1.6, 1.7) + +`devlog.onBlockAdded(cb)` hears blocks posted in the app, on canvases it may +read. `activity.on(cb)` hears pause (locked, idle, asleep) and resume; +`activity.idleAfter(minutes)` asks for idle detection (the app uses the +shortest any extension asks for). `ui.notify`, `ui.confirm`; `ui.pick(items)` +shows a quick pick; `ui.open({ canvasId, date?, blockId? })`; +`ui.openPage(viewId)` (1.7); `ui.highlight(canvasId)` marks its current +canvas (the running task) in the sidebar, and commits from a linked +repository land on it when it is inside the linked canvas; +`app.setTrayLabel(text)`, `app.keepRunning(true)` (close to the tray +instead of quitting). + +### Views (1.5, 1.6) + +Pages of its own, declared in `contributes.views` (`id`, `title`, `entry`: +an .html file in the package, `placement`, `icon`, `nodeType`): + +- `page`: listed in the sidebar and quick switcher with the app's views; + fills the main area. +- `statusbar`: a slot in the status bar, 22 px high; it asks for a width + (`resize`) and gets it within 24–360 px. +- `popover`: opened by another of its views, anchored to it; closes on + Escape, a click outside, or `close`. +- `canvasHeader` (1.6): beside a canvas's title (26 px high), optionally + only for a `nodeType`. Pages load from `devlog-ext:///`, only from the extension's own folder and only while it runs, in a frame -sandboxed to scripts (no same origin: no access to the window around it or -its storage) under a CSP with no network (`connect-src 'none'`), no frames -and no forms. A view talks to its extension only through the app: -`postMessage` to the parent (`ready`, `call`, `resize`, `popover`, `close`, -`open` a canvas or block, run a `command`); the app answers (`reply`), -relays what the extension `views.post`s (`message`), and sends the app's -colours as CSS custom properties (`theme`) when the page is ready and -whenever they change. The extension answers with -`ctx.views.handle(viewId, (method, args) => …)`. The message types are in -`@devlog/extension-api/view`. - -## Todos and blocks inside blocks (built, API 1.4) - -Todos are blocks (`kind: 'todo'`, `meta.done` once ticked). `devlog.addBlock` -takes `{ parentId, date }` to add a block inside another (the block's day -file) and `{ todo: true }` to add a todo; `devlog.todos({ doneSince })` lists -the open todos (and recently ticked ones) on the canvases the extension may -read, with the blocks each sits inside. - -## Destinations (built, API 1.3) +sandboxed to scripts (no same origin) under a CSP with no network, frames +or forms. A view talks to its extension only through the app, by +`postMessage` (`ready`, `call`, `resize`, `popover`, `close`, `open`, +`command`, and `key` for a shortcut it did not use, so Ctrl+K and the like +still reach the app); the app answers (`reply`), relays what the extension +`views.post`s (`message`), and sends the app's colours (`theme`) and where +the view is (`context`: a header view's canvas, the canvas and block page +on screen for a status bar item, the opener's for a popover). The extension +answers with `ctx.views.handle(viewId, (method, args) => …)`. Message types: +`@devlog/extension-api/view`. `@devlog/ui` wraps them with React +components, hooks (`useCall`, `useMessages`, `useViewContext`…) and the +app's look. + +### Destinations (1.3, 1.7) An extension declares `contributes.destinations` and registers each with -`ctx.destinations.register(id, { preview(sheet), send(sheet) })`. When you -press **Send to …** on a final week, the app builds the sheet: every entry -with its task and client names and this extension's own canvas fields, -inherited down the tree. Pressing Send is the consent for that week's -entries, so a destination needs no read grant. The app shows the preview -(new, changed, removed, already sent, not for this destination and why), -calls `send`, and records the summary as a reply under the week's -timesheet. The extension keeps its own ledger of what it sent. - -**devlog-jira** (built in, sandboxed) is the first: one worklog per entry -on the entry's Jira issue (canvas field `issue`), REST v2, Basic auth with an -account email and API token (Cloud) or a personal access token (Data -Center), ledger in `sent/.json`. - -**devlog-cms** (built in, sandboxed) drives the CMS web pages: form login -(`j_security_check`, cookies kept in memory for the send), the week page -parsed for assignment rows and each day's `timesheet_id` link, and the -day's form submitted with `hrs_worked` in decimal hours. Hours per -assignment (canvas field `assignment`) per day; the preview compares with -what CMS shows, so only differing days are sent. Ledger in -`sent/.json` (assignment|day → hours) so days it filled and that no -longer have time are set back to 0. The password is a secret, kept on the -computer. - -## Time export (#2) - -See `TIMESHEETS.md`. Time goes through a weekly **timesheet** (core): a -synopsis you review and shuffle, rounded once to quarter hours, stored in a -managed *Timesheets* canvas together with a record of every submission. -Extensions contribute **destinations** (Jira by task with start times, -built; CMS by assignment per day, built): mapping fields, grouping and -sending. No business -rules: you finalise the hours. **Sending is always something you do**, by -button, after a preview; there are no scheduled or automatic exports. - -## Milestones - -1. Core plumbing: `devlog.json` extensions + lockfile, GitHub-release - fetching, keeping unknown `canvas.md` keys, `safeStorage` secret store, - the file broker and per-extension folders, read/write grants and the - consent prompt, the extension process (with the `--permission` check), - built-in extensions, the API types and test harness. -2. **devlog-focus** (done, 0.8.0): window tracking moved out of the core, - which proves the loop end to end on a real feature. -3. Timesheets in core and the app: draft, grid, managed canvas, send - record (`TIMESHEETS.md`); CSV destination. -4. Jira and CMS destinations. -5. Cards: Adaptive Card blocks, actions and link unfurling; then Outlook as - the first real card-based extension. -6. **devlog-time** (done, 0.17.0): time tracking moved out of the core on - API 1.6 (node types, commands in menus and the note box, canvas writes, - events, app state, time events), with views built on `@devlog/ui`. - -## Decided - -- **Write grants** are scoped like read grants (the whole devlog or chosen - canvases), chosen separately in the consent dialog. -- **Card data** lives in the block (a trailing ```` ```card ```` fence), so a - day file is complete and readable in git on its own. +`destinations.register(id, { preview(sheet), send(sheet) })`. The app builds +the sheet it gets: every entry of a week's timesheet with its task and +client names and this extension's own canvas fields, inherited down the +tree. Pressing Send is the consent for that week's entries, so a +destination needs no read grant. The extension keeps its own ledger of what +it sent. + +With `permissions.send` (1.7), an extension can use other extensions' +destinations: `destinations.list()`, `preview(to, sheet)` and +`send(to, sheet)` (only final weeks), through the app. devlog-time's +Timesheet page does this and writes what was sent under the week. + +### Providing data (1.1, 1.6) + +`provide.focus((from, to) => FocusEvent[])`: window focus for the timeline +and review. `provide.activity((from, to) => TimeEvent[])` (1.6): `start`, +`task`, `stop` and `heartbeat` events (per machine), which the app replays +with its own record of locks, idle and sleep; while some extension provides +time, the app logs those (and git events) in `activity/`. + +## Built-in extensions + +- **devlog-time** (Time tracking): the task node type, the clock, the + status bar item, task header button and picker, post as task, Make task, + the Timesheet and Summary pages, hour targets, sending through Jira and + CMS. See `TIME-EXTENSION.md` and `TIMESHEETS.md`. Written in TypeScript + (`src/`, bundled at build time) with views on `@devlog/ui`. +- **devlog-focus** (Window tracking): runs unrestricted to start a platform + helper (PowerShell `GetForegroundWindow` on Windows, an `osascript` loop + on macOS, `xdotool` on Linux) and records the window in front, per + machine and day, in `extensions/builtin.devlog-focus//…` + (`{"t", "app", "title"}` per line), nothing while paused; it gives the + events back through `provide.focus`. Focus events older than 0.8 stay in + the app's activity log and still show. +- **devlog-jira** (Jira worklogs, sandboxed): one worklog per entry on the + entry's Jira issue (canvas field `issue`), REST v2, Basic auth (email and + API token, Cloud) or a Bearer personal access token (Data Center), ledger + in `sent/.json`. +- **devlog-cms** (CMS timesheets, sandboxed): drives the CMS web pages (it + has no API): form login (`j_security_check`, cookies kept in memory for + the send), the week page parsed for assignment rows and each day's + `timesheet_id` link, and the day's form submitted with `hrs_worked` in + decimal hours. Hours per assignment (canvas field `assignment`) per day; + the preview compares with what CMS shows, so only differing days are + sent. Its ledger (`sent/.json`, assignment|day → hours) lets days it + filled that no longer have time go back to 0. The password is a secret. + +## API reference + +- `packages/extension-api/src/index.ts`: `DevlogContext` and every type an + extension sees, with the version each member arrived in. +- `protocol.ts`: the wire protocol between the app and the extension + process; `view.ts`: view messages; `testing.ts`: an in-memory `ctx` for + unit tests. +- `tests/fixtures/extensions/probe` and `shaper`: test extensions that + exercise the API through the real host process. + +## Not built yet + +- **Cards**: Adaptive Card blocks rendered by the app from an extension's + templates, with the card's data in the block, and actions going back to + the extension; Outlook as a card-based extension. +- **Link previews**: an extension turning a pasted link on its domains + into a card. +- A CSV destination. +- Enforcing the declared network domains. +- A command to delete an extension's data. diff --git a/docs/TIME-EXTENSION.md b/docs/TIME-EXTENSION.md index 46707e6..44cef8a 100644 --- a/docs/TIME-EXTENSION.md +++ b/docs/TIME-EXTENSION.md @@ -1,108 +1,70 @@ -# Time tracking as an extension: design +# Time tracking as an extension -Status: done. Steps 1–3 in 0.17.0 (API 1.6, `devlog-time`, its Start/Stop -views); step 4 (the Timesheet and Summary as its pages, sending through the -app, API 1.7) and step 5 (hour targets) in 0.18.0. What was built follows -this plan with these differences: +Status: done. Todos became blocks in 0.15.0; views and `@devlog/ui` +(API 1.5) came in 0.16.0; the API 1.6 additions, `devlog-time` and its +Start/Stop views in 0.17.0; the Timesheet and Summary as its pages (API 1.7) +and hour targets in 0.18.0. There was no API 2.0: the additions came as +1.5, 1.6 and 1.7, all additive, so older extensions keep loading. -- The API stayed 1.x (1.6): everything is additive, so extensions written - for 1.0–1.5 keep loading. -- The clock's events live in `extensions/builtin.devlog-time//`; - the app keeps logging locks, idle and sleep (and git events) in - `activity//` while an extension provides time, and its views - replay both together with the older log. -- The Weekly review and the Timeline stay in the app, drawing their time - from `provide.activity`; the Timesheet and Summary are devlog-time's - pages, reading what the app recorded through `devlog.activity`. -- A built-in extension keeps its grant when the app brings a new build (it - is part of the app you updated), so an update never stops the clock. -- Targets are measured against the timesheet hours (reported), the week's - on screen and, for a month, the other weeks as saved or drafted. -- Quick pick is `ui.pick`; the canvas an extension considers current is - `ui.highlight` (the sidebar marks it, and commits route to it). +## What lives where -## The idea +Time tracking is not part of the app. The built-in extension `devlog-time` +brings everything that goes with it; without it there is no task type, no +Start button, no Timesheet or Summary, and nothing about time is recorded. -Time tracking stops being part of the app. A built-in extension, -`devlog-time`, brings everything that goes with it; without it there is no -task type, no Start button, no timesheet, nothing recorded and no heartbeat -commits. - -| Moves to `devlog-time` | Stays in core | +| `devlog-time` | The app | |---|---| | The **task** node type (icon, label, posting on it starts it) | Canvases, blocks, pages, todos | -| The active task, start/stop, idle/lock/sleep pauses, heartbeats | Notes counts in the Weekly review | -| The status bar Start/Stop, the tray label, the Stop hotkey | The Timeline's notes and git events | -| Post as task (Ctrl+Shift+Enter, `#task`), the Task block action, Make task on a todo | The destination registry (Jira and CMS are fed by the time extension instead of the app) | -| The Timesheet view, the managed Timesheets canvas, Send to… | Canvas fields, settings pages, the sandbox | -| Summary (hours per client), the Weekly review's time columns, task stretches on the Timeline | Machine state (lock, idle, sleep) for now | -| Tracking settings (idle minutes, activity log in the repository) | Git capture for now | +| The clock: one active task, paused while locked, idle or asleep, heartbeats | Machine state: it notes lock, idle and sleep, tells extensions, and logs them while an extension provides time | +| The status bar item, the Start/Stop button on task canvases, the task picker, the tray label, Stop (Ctrl+Shift+.) | The Weekly review and the Timeline, drawing their time from the extension's events | +| Post as task (Ctrl+Shift+Enter, `#task`), **Make task** on a block or todo | Git capture (commits as blocks; git events while time is tracked) | +| The Timesheet and Summary pages, the Timesheets canvas, sending through Jira and CMS | The destination registry (Jira and CMS register there; devlog-time sends through the app) | +| Hour targets | Canvas fields, settings pages, the sandbox | +| Its settings: idle minutes, whether its log is synced | | + +Later, the same way: **machine state** (lock, idle, sleep) could become its +own extension, and **git** (commit capture, branch events) too, for people +who don't want it in their streams. -Later, the same way: **machine state** (lock, idle, sleep) becomes its own -extension, separate from both core and `devlog-time`; **git** (commit -capture, branch events) becomes an extension too, for people who don't want -it in their streams. +## What the API gained -## Extension API 2.0 +- 1.5: views (pages, status bar items, popovers) and `@devlog/ui`. +- 1.6: node types; commands with keybindings, menus (canvas, block, tray) + and note-box variants; canvas header views; canvas writes and `promote`; + block events; pause/resume and idle detection; `ui.pick`, `ui.open`, + `ui.highlight`; tray label and keep-running; `provide.activity` (time + events the app replays with its own record of the machine's state). +- 1.7: `devlog.activity` and `devlog.range` (reading what the app recorded + and the blocks in a range), canvases an extension keeps, sending through + other extensions' destinations, `ui.openPage`, fields that are not + inherited. -1. **Node types:** id, label, icon, a hint for the note box; stored as - `type:` in canvas.md; a Type choice in canvas properties. `task: true` - canvases count as the time extension's type when it is present and as - plain canvases when not; no files rewritten. -2. **Status bar items are rendered, not described:** an item is a small - view (below) in a slot of fixed height; it asks for a width and may not - get it. Menus can't fit in the slot, so an item can ask for a **popover** - anchored to it (another view) or a quick pick. -3. **Quick pick:** the quick-switcher list with items the extension gives. -4. **Keybindings and placements:** a keybinding on a command, optionally - only on canvases of a given type; items in the canvas right-click menu - and header; block actions; note-box post variants (Ctrl+Shift+Enter - becomes the time extension's "post as task"). -5. **Events and writes:** "block posted"; creating canvases and setting - their type; editing blocks the extension wrote (a timesheet save is an - edit). -6. **System state from the app:** lock, unlock, sleep, wake, idle past N - minutes. The extension stays sandboxed. -7. **Time providers:** "minutes per canvas per day" and "task stretches for a - day"; the Weekly review, Timeline and anything else draw their time parts - from these and hide them when there is no provider. -8. **Views:** an extension ships HTML and script, shown in a sandboxed frame - with no access to files, talking to its own process through the app. - The app injects its theme. A shared **`@devlog/ui`** package (React - components and the app's design tokens) that views bundle, so extensions - look like the app; each frame carries its own React. -9. **Tray and keeping running:** set the tray label; ask to keep running in - the tray when the window closes. +See `EXTENSIONS.md` for each. ## Data -- Existing time lives in `activity//` (task switches, locks, - heartbeats). The extension reads it as history and writes its own events to - `extensions/builtin.devlog-time//` from then on; old files stay. -- Timesheets stay blocks in the managed Timesheets canvas (history in the - notebook, clean merges); the extension becomes their author. -- Jira and CMS are unchanged; the send flow moves into the time extension's - view with the app in between. -- Old task-link blocks keep rendering as links without the extension. -- One-time switch: with `trackingEnabled` on, the extension is added and - allowed, so nothing changes for you. +- The clock's events go to `extensions/builtin.devlog-time//` + (or stay on this machine, with *Keep time in the devlog* off). The app + logs locks, idle, sleep and git events in `activity//` while an + extension provides time. Time from before 0.17 stays in `activity/`; the + app replays all of it together. +- Timesheets are blocks in the Timesheets canvas, which devlog-time keeps + (the canvas the app made before is taken over as it is); it sends through + the app to the Jira and CMS destinations, which are unchanged. +- Task-link blocks keep rendering as links without the extension, and task + canvases read as plain canvases. +- One-time switch (0.17): on a machine that tracked time, the extension is + added to the devlog and allowed, with the active task and idle setting + carried over. A built-in extension keeps its grant when the app brings a + new build, so an update never stops the clock. ## Hour targets -Weekly or monthly targets on any node, as canvas fields of the time -extension, shown against actual hours in the timesheet. **Targets overlap -and each applies on its own**: 168 h for Drury in September and 20 h a week -for a task under Drury are two separate measures, each compared with the -hours under its own node for its own period. A target is not inherited by -the nodes below it. - -## Order - -1. Todos as blocks (`BLOCK-PAGES.md` phase 2): the Task block action and - Make task on a todo become the same hook. -2. Extension API 2.0, items 1–7 and 9, tested with a small made-up extension. -3. `devlog-time`, built in: tracker, task type, commands and hotkeys; the - app becomes provider-driven. Tracking leaves core here. -4. Views and `@devlog/ui` (item 8); the timesheet and Summary as the - extension's pages; status bar items as views; the send flow; smoke rewrite. -5. Hour targets. +Weekly or monthly targets on any canvas, as canvas fields of the time +extension (*Hours per week*, *Hours per month*), shown on the Timesheet +against the timesheet hours (reported) under the canvas for its own period: +the week on screen, and each month the week touches (other weeks as saved, +or as drafted from tracked time). **Targets overlap and each applies on its +own**: 168 h for Globex in September and 20 h a week for a task under Globex +are two separate measures. A target is not inherited by the canvases below +it. diff --git a/docs/TIMESHEETS.md b/docs/TIMESHEETS.md index 1b44587..1a5f458 100644 --- a/docs/TIMESHEETS.md +++ b/docs/TIMESHEETS.md @@ -1,14 +1,13 @@ -# Timesheets: design (draft) - -Status: **the timesheet is built** (0.9.0): the arithmetic in -`@devlog/core` (`timesheet.ts`: rounding, sessions, draft entries, suggested -trims, the stored model), the draft (`src/shared/timesheet.ts`), the weekly -**Timesheet** view, and storage in the managed Timesheets canvas. -Sending is built too (0.10.0): destinations in the extension API and the -built-in **Jira worklogs** extension (devlog-jira); **CMS timesheets** -(devlog-cms, 0.12.0) fills in the CMS web form (it has no API). Split and -merge of entries are not built. Replaces the simple "export the review's -rows" idea (issue #2). +# Timesheets: design + +Status: built. The arithmetic is in `@devlog/core` (`timesheet.ts`: +rounding, sessions, draft entries, suggested trims, the stored model), the +week's draft in `src/shared/timesheet.ts`, and the weekly **Timesheet** page +with its storage in the Timesheets canvas belongs to the time extension +(devlog-time) since 0.18.0. Sending goes to destinations other extensions +provide: **Jira worklogs** (devlog-jira) and **CMS timesheets** (devlog-cms, +which fills in the CMS web form; CMS has no API). Split, merge, dragging +entries, and a per-destination view in the grid are not built. ## The problem @@ -36,24 +35,26 @@ and what was reported. ``` activity log + blocks ──► draft timesheet ──(you adjust)──► final timesheet │ │ - sessions, rounding, per-destination views + sessions, rounding, per-destination preview suggested trims (Jira: per task, with times CMS: per client per day) │ - preview ──► submit ──► recorded + preview ──► send ──► recorded ``` Sending is always something you do: pick a week and a destination, look at the preview, press **Send**. There are no scheduled or automatic exports. -- **The timesheet is core, destinations are extensions.** The synopsis comes - from Devlog's own tracking data, and several destinations share one - synopsis. It is useful on its own (history, CSV). Extensions (#1) - contribute destinations: which canvases they take, their ids, how lines - are grouped, and how to send them. Destinations contain **no business - rules** (caps, minimums): those are yours to apply while adjusting. +- **The timesheet is devlog-time's, destinations are other extensions.** + The synopsis comes from the tracked time the app replays, and several + destinations share one synopsis. It is useful on its own (history). + Destination extensions decide which canvases they take, their ids, how + lines are grouped, and how to send them. Destinations contain **no + business rules** (caps, minimums): those are yours to apply while + adjusting. - **One timesheet per week**, Monday to Sunday like the review. -- **Using it (as built):** open **Timesheet** in the sidebar. A week with no +- **Using it:** open **Timesheet** in the sidebar (a page of the time + extension). A week with no saved timesheet shows a draft from tracked time (not saved until you change something, or press Save draft). The week is a grid: a column per day plus the week, a row per task grouped under its client (with the @@ -63,7 +64,7 @@ the preview, press **Send**. There are no scheduled or automatic exports. suggested trim for that day (click to apply). **Rebuild from tracked time** replaces the entries with a fresh draft; **Mark final** approves the week and makes it read-only until you Reopen it. Every change is saved - within a second. + within a second. Hour targets on canvases show above the grid. ## The timesheet @@ -73,9 +74,9 @@ A list of **entries**, each one piece of work: |---|---| | date, start, minutes | when; `start` is local time, rounded to the quarter hour | | canvas | the task (or client/project) canvas it belongs to | -| client | derived: the nearest ancestor canvas marked as a client | +| client | derived: the top-level canvas the task is under (a column in the stored table, not a field) | | note | optional; empty by default (see *Comments*) | -| source | tracked / explicit (`[2h]` marker) / estimated / manual | +| source | tracked (explicit `[2h]` markers already applied) / estimated / manual | | worked | the unrounded minutes, kept for the record | ### Rounding (the firm rule) @@ -123,24 +124,23 @@ each suggestion. line is `Σ round(each session)`. The difference is the inflation (or, more rarely, a deficit). - **Suggestion:** take the difference out of that client's longest sessions - that day, 15 minutes at a time (longest first, one step each in turn), - never taking a session below 15 minutes. A deficit adds to the longest + that day, 15 minutes at a time, each step from whichever session is then + the longest, never taking a session below 15 minutes. A deficit adds to the longest sessions the same way. Balancing within the same client and day keeps one client's short tasks from being paid for out of another client's hours. -- If it can't be evened out (every session is already 15 minutes), the - grid says so: "Acme, Tue: 3 h reported for 55 min worked". -- The same numbers show per week, so a week can be checked at a glance. +- A client's day cell shows what was reported and worked when you hover it; + the grid's footer has the day totals, reported and worked. -Suggestions are never applied silently. They appear as marked changes in -the grid. +Suggestions are never applied silently. They show as a button on the +client's day cell (−0:15, or +0:15 for a deficit); clicking it applies that +day's suggestions for the client. ### Editing ("shuffling") -A grid for the week: drag an entry to another task or day, change start or -duration (in 15-minute steps), split an entry, merge two, add a manual one -(e.g. a call that wasn't tracked), drop one, edit the note. The grid shows, -live and per destination, what would be sent, alongside the time actually -worked (for example "CMS · Acme · Tue: 9 h 15 m reported, 8 h 50 m worked"). +Click a cell to edit its entries below the grid: day, start, duration +(15-minute steps), task, note; remove one, or add one (a call that wasn't +tracked). **+ Add a task…** adds a row for a task with no time yet. What +each destination would get shows in its Send preview. Editing never changes the activity log or blocks. The timesheet is a separate record, and the original tracked time stays available alongside it. @@ -152,7 +152,8 @@ Each destination (from an extension) declares: - **Mapping:** which canvases it takes, and their external ids, as per-canvas fields, looked up by **walking up the canvas tree** to the nearest canvas that sets one: - - Jira: `jira.issue: ACME-123`, normally on each task canvas. A fallback + - Jira: `ext.builtin.devlog-jira.issue: ACME-123` (canvas properties → + Jira worklogs), normally on each task canvas. A fallback issue is just the key set on those tasks (or on a parent canvas, which all tasks beneath it then inherit). - CMS: `ext.builtin.devlog-cms.assignment: 12345` (or the project name), @@ -163,28 +164,28 @@ Each destination (from an extension) declares: A canvas with no mapping for a destination (nothing up the tree) is not sent there, so the full-time client never reaches Jira. An entry under a destination's canvases that can't be mapped (a task with no issue key) is - flagged in the grid before sending. + listed in the Send preview as not sent, with the reason. - **Grouping:** how entries become lines. Jira: one worklog per entry (issue, start, duration, note). CMS: one line per assignment per day (the sum of that day's entries). CMS weeks run Sunday to Saturday, so a Devlog week touches two; a day only opens in CMS on the day itself (and not outside the assignment's dates), so time on a later day is held back until then. -- **Send:** submit the lines; return an external id per line. +- **Preview and send:** show what would be created, changed or removed, + then send it, reporting what went through, what failed, and a one-line + summary. ### Comments - **CMS** takes a description per assignment per day. devlog-cms sends the day's entry notes joined with `; ` when there are any, and otherwise keeps what CMS has. Later, an LLM could draft it from that day's notes. -- **Jira** worklog comments are optional too, and meetings often have none. - Empty by default, with a per-entry "fill from notes" that takes the - top-level blocks written on the task during that session. Revisit once - it's clear how the notes get used in practice. +- **Jira** takes the entry's note as the worklog comment (empty when there + is none). -What was sent (lines, the ids they mapped to at the time, external ids) is -recorded with the timesheet, so an old week reads the same after mappings -change. No dated settings are needed. +What was sent is kept by each destination in its own ledger (below), so an +old week reads the same after mappings change. No dated settings are +needed. ## Storage: a managed canvas @@ -196,15 +197,19 @@ before, which devlog-time takes as its own): - **One block per week** (`kind=timesheet`, `week=2026-09-21`): the body is a readable markdown table of the entries (date, start, duration, task, - client, note), so the record is human-readable in git with no app needed. + client, note), so the record is human-readable in git with no app needed, + followed by the exact data in a `devlog-timesheet` fence, which is what is + read back. Edits while drafting are ordinary `edit` records (append-only, so the history of the shuffling is kept too). - **What was sent** is kept by each destination extension in its own synced folder (devlog-jira: `extensions/builtin.devlog-jira/sent/.json`, - entry id → issue, worklog id and what was sent; devlog-cms: assignment - and day → hours). Sending again compares - against it, so a second press, or a second machine, sends only what - changed, and corrections after the fact send differences. Each send also + entry id → issue, worklog id and what was sent; devlog-cms: + `extensions/builtin.devlog-cms/sent/.json`, assignment and day → + hours). Jira compares against its ledger, so a second press, or a second + machine, sends only what changed, and corrections after the fact send + differences; CMS compares against what CMS shows, and uses its ledger to + set days it filled before back to 0. Each send also leaves a read-only reply under the week's timesheet block ("Sent to Jira: 3 worklogs created, 1 updated (4:15 on 2 issues)"), written by devlog-time, so the history reads in the notebook itself. devlog-time reaches the @@ -216,16 +221,18 @@ before, which devlog-time takes as its own): ## Where the pieces live -- `@devlog/core`: building the draft (sessions, rounding, layout), the - inflation arithmetic and suggested trims, the entry model, reading and - writing timesheet blocks, and resolving a canvas's mapping by walking up - the tree. Pure and unit-tested. +- `@devlog/core`: sessions, rounding, layout, the inflation arithmetic and + suggested trims, the entry model, the timesheet block format, and + resolving a canvas's mapping by walking up the tree (`inheritedField`). + Pure and unit-tested. +- `src/shared/timesheet.ts`: the week's draft (tracked time, and the + review's estimates for untracked days), bundled into devlog-time's page. - devlog-time: the weekly timesheet grid and the Summary (pages built on `@devlog/ui`), the Timesheets canvas, preview and send, hour targets. - App: what it recorded (`devlog.activity`), block ranges, the kept canvas, and passing sheets to the destinations. - Extensions: Jira and CMS destinations (mapping fields, grouping, sending, - credentials). A built-in CSV destination (#4) needs no extension. + credentials). ## Open questions diff --git a/package.json b/package.json index 3ca7f1f..07b68eb 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,12 @@ "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "test": "vitest run", "test:watch": "vitest", - "package": "electron-vite build && electron-builder", - "package:mac": "electron-vite build && electron-builder --mac", - "package:win": "electron-vite build && electron-builder --win", - "package:linux": "electron-vite build && electron-builder --linux", + "package": "npm run build && electron-builder", + "package:mac": "npm run build && electron-builder --mac", + "package:win": "npm run build && electron-builder --win", + "package:linux": "npm run build && electron-builder --linux", "smoke": "npm run build && node scripts/smoke.mjs", - "screens": "electron-vite build && node scripts/screens.mjs", + "screens": "npm run build && node scripts/screens.mjs", "smoke:ci": "node scripts/smoke.mjs" }, "dependencies": { diff --git a/packages/core/README.md b/packages/core/README.md index 8de1202..63e3c26 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,8 +8,10 @@ Two entry points: | Import | Contents | Runs in | | ------------------- | ------------------------------------------------------------- | ---------------- | -| `@devlog/core` | Types, block and canvas file formats, pure hierarchy helpers | Anywhere | -| `@devlog/core/node` | `DevlogStore`, `RepoIndex`, `SyncManager` (git), `ActivityLog`, `assertSupportedFormat` | Node 22.13+ only | +| `@devlog/core` | Types, block and canvas file formats, pure hierarchy helpers, timesheet rules, extension manifests and grants | Anywhere | +| `@devlog/core/node` | `DevlogStore`, `RepoIndex`, `SyncManager` (git), `ActivityLog`, `devlog.json` and lock-file helpers, `ExtensionFileStore`, `assertSupportedFormat` | Node 22.13+ only | + +(`@devlog/core/types` is the types alone.) ## Rules @@ -37,7 +39,8 @@ import { DevlogStore, RepoIndex, SyncManager, assertSupportedFormat } from '@dev const store = new DevlogStore(root) await store.initLayout() -await assertSupportedFormat(root) // format 3 only; throws with a message otherwise +await store.upgradeStorage() // 3 → 4: todo lists move into the streams +await assertSupportedFormat(root) // format 4 only; throws with a message otherwise const sync = new SyncManager(root, { intervalMinutes: 5, debounceSeconds: 30, autoPush: true, pullOnStart: true }) const index = RepoIndex.open(dbPath, root) // optional cache; files stay the truth store.attachIndex(index) @@ -61,7 +64,8 @@ format. The index uses The tests cover the file formats (including a fuzz test of marker escaping), every store operation, indexed-vs-scanned equivalence, git sync against local -bare remotes and the per-machine activity log. +bare remotes, the per-machine activity log, compaction, extension manifests +and `devlog.json`, and the timesheet rules. ```sh npx vitest run packages/core diff --git a/packages/core/src/format/canvases.ts b/packages/core/src/format/canvases.ts index 993eb2f..33d008d 100644 --- a/packages/core/src/format/canvases.ts +++ b/packages/core/src/format/canvases.ts @@ -8,14 +8,15 @@ * A canvas is a client, a project, a task, a topic — anything you want to * write about. Every canvas has a *surface* (free-form markdown: links, * how-tos, credentials, a research scratchpad) and a *stream* of blocks - * (dated posts). Canvases nest through `parentId`. A canvas flagged `task` - * is something time is tracked against. + * (dated posts). Canvases nest through `parentId`. A canvas can have a + * node type from an extension (the time extension's task is one). * - * canvases//canvas.md ← front matter + the surface markdown - * canvases//entries/2026/09/… ← the stream, one day file per day - * canvases//assets/ ← images pasted into the surface + * canvases///canvas.md ← front matter + the surface markdown + * canvases///entries/2026/09/… ← the stream, one day file per day + * canvases///assets/ ← images pasted into the surface * - * The journal is the built-in root canvas whose stream lives at `entries/`. + * The journal, the old built-in root canvas whose stream lives at + * `entries/`, is retired: read-only, and listed only while it has blocks. */ import type { CanvasMeta } from '../types' diff --git a/packages/core/src/node/manifest.ts b/packages/core/src/node/manifest.ts index 9e0083d..dbe7c76 100644 --- a/packages/core/src/node/manifest.ts +++ b/packages/core/src/node/manifest.ts @@ -1,8 +1,9 @@ /** - * The repository manifest, `devlog.json`: `{ "format": 3 }`, the storage - * format the repository is in. This version of Devlog reads and writes - * format 3 only; older repositories must be opened once with Devlog 0.5, - * which upgrades them. + * The repository manifest, `devlog.json`: `{ "format": 4 }`, the storage + * format the repository is in, plus the devlog's extensions and their + * settings. This version of Devlog reads and writes format 4 only; + * `DevlogStore.upgradeStorage()` brings format 3 up to it, and formats 1 + * and 2 must be opened once with Devlog 0.5, which upgrades them to 3. */ import { promises as fs } from 'node:fs' import path from 'node:path' @@ -20,9 +21,10 @@ export async function readStorageFormat(root: string): Promise { } /** - * Refuse a repository this version cannot safely read or write: one from - * before format 3, or one written by a newer Devlog. Call after - * `DevlogStore.initLayout()`, which stamps new, empty repositories. + * Refuse a repository this version cannot safely read or write: one in an + * older format, or one written by a newer Devlog. Call after + * `DevlogStore.initLayout()`, which stamps new, empty repositories, and + * `upgradeStorage()`. */ export async function assertSupportedFormat(root: string): Promise { const format = await readStorageFormat(root) diff --git a/packages/extension-api/README.md b/packages/extension-api/README.md index 9b085a1..18b6320 100644 --- a/packages/extension-api/README.md +++ b/packages/extension-api/README.md @@ -1,10 +1,14 @@ # Writing a Devlog extension -An extension adds something to Devlog without growing the app: sending time -to Jira, fields on canvases, commands in the quick switcher. It runs in its -own process with **no access to files, other programs or other extensions**; -everything goes through the `ctx` object it is given, filtered by what the -user allowed. The design is in `docs/EXTENSIONS.md`. +An extension adds something to Devlog without growing the app: time +tracking, sending time to Jira, fields on canvases, commands, pages of its +own. It runs in its own process with **no access to files, other programs +or other extensions** (unless the user trusts it; see below); everything +goes through the `ctx` object it is given, filtered by what the user +allowed. How it fits together is in `docs/EXTENSIONS.md`; the reference is +`src/index.ts`, every member commented with the API version that added it. +This version of Devlog provides API **1.7.0**; declare the lowest you need +(`"api": "^1.6.0"`) and newer apps keep loading it. ## The files @@ -32,22 +36,36 @@ my-extension/ } ``` -- **Fields** (`settings`, `secrets`, `canvasFields`) take `key`, `label`, - and optionally `description` (shown under the field), `placeholder`, - `required: true` (the extension's card says "Needs: …" until it is set), - and for settings and canvas fields a `type`: `text` (default), `url`, - `email`, `number`, `select` (with `options: [{ value, label }]`), - `checkbox` or `textarea`. Values are checked by type when saved. +- **Fields** (`settings`, `secrets`, `canvasFields`) take `key` (lowercase + letters, digits, `_`), `label`, and optionally `description` (shown under + the field), `placeholder`, and for settings and canvas fields a `type`: + `text` (default), `url`, `email`, `number`, `select` (with + `options: [{ value, label }]`), `checkbox` or `textarea`. Settings and + secrets can be `required: true` (the extension's card says "Needs: …" + until it is set), and settings are checked by type when saved. A canvas + field is inherited by the canvases inside its canvas unless it says + `inherited: false` (1.7). - **`check`** names one of your commands that tests the settings (a login, say); the settings page offers **Test** / **Save and test** and shows the text the command returns, or its error. -- **`contributes`** is what the app shows without running your code: canvas - fields (in the canvas dialog, stored in `canvas.md` as - `ext..`), devlog-wide settings (stored in `devlog.json`), secrets - (the OS keychain on each machine) and commands (the quick switcher). +- **`contributes`** is what the app shows without running your code: + - `canvasFields` (in the canvas's Properties, stored in `canvas.md` as + `ext..`), `settings` (stored in `devlog.json`), `secrets` (the + OS keychain on each machine); + - `commands` (the quick switcher), each optionally with a `keybinding` + (`Mod+Shift+S`), `menus` (`canvas`, `block`, `tray`), a `nodeType` it + is offered on, and `post: true` / `tag` to make it a way to post from + the note box (1.6); + - `views` (1.5): HTML pages from your package, `placement` `page` (in the + sidebar), `statusbar`, `popover` or `canvasHeader` (1.6, optionally for + one `nodeType`); + - `nodeTypes` (1.6): kinds of canvas, with a label and icon; + - `destinations` (1.3): places a finished timesheet can be sent. - **`permissions`**: `read` / `write` ask for access to notes; the user - picks the whole devlog or chosen canvases. `network` lists the domains you - talk to; it is shown to the user (not enforced in v1). + picks the whole devlog or chosen canvases. `send` (1.7) lets it send + through other extensions' destinations. `network` lists the domains you + talk to; it is shown to the user, not enforced. `unrestricted` asks to + run outside the sandbox (below). ## The code @@ -70,19 +88,25 @@ The full API, with comments, is `src/index.ts`. In short: | | | |---|---| -| `ctx.devlog` | `canvases()`, `field(canvasId, key)`, `days(canvasId)`, `blocks(canvasId, date)`, `search(q)`, `addBlock(canvasId, markdown, { meta, parentId, date, todo })`, `todos({ doneSince })`: all limited to what the user granted. Adding inside a block, adding todos and `todos()` are 1.4 | +| `ctx.id`, `ctx.apiVersion`, `ctx.machine` | its id, the app's API version, this machine's folder name for per-machine files (1.1) | +| `ctx.packageDir` | the extension's unpacked folder, for scripts it ships; only when it runs unrestricted, else null (1.2) | +| `ctx.devlog` | `canvases()`, `field(canvasId, key)`, `days(canvasId)`, `blocks(canvasId, date)`, `search(q)`, `addBlock(canvasId, markdown, { meta, parentId, date, todo, kind })`, `todos({ doneSince })` (1.4); `createCanvas`, `updateCanvas`, `editBlock`, `promote(canvasId, date, blockId, { type })`, `onBlockAdded(cb)` (1.6); `activity(from, to)`, `range(from, to)`, `managedCanvas(key, { title })` (1.7). All limited to what the user granted | | `ctx.files.repo` / `ctx.files.local` | private folders (synced with the devlog / this machine only): `read`, `readText`, `write`, `append`, `list`, `stat`, `remove`, relative paths only | | `ctx.settings` | `get(key)`, `onChange(cb)` | | `ctx.secrets` | `get`, `set`, `delete` | -| `ctx.activity.on(cb)` | pause (locked, idle, asleep), resume, task changes | -| `ctx.ui` | `notify(message)`, `confirm(message)` | -| `ctx.commands.register(id, run)` | for commands declared in the manifest | -| `ctx.machine` | this machine's folder name, for per-machine files (1.1) | -| `ctx.packageDir` | the extension's unpacked folder, for scripts it ships; only when it runs unrestricted, else null (1.2) | -| `ctx.provide.focus(fn)` | focus events for the app's timeline, review and summary (1.1) | -| `ctx.views.handle(viewId, (method, args) => …)`, `ctx.views.post(viewId, message)` | answer calls from your views' pages and push messages to them (1.5; see docs/EXTENSIONS.md, Views) | -| `ctx.destinations.register(id, { preview, send })` | a place finished timesheets can be sent, declared in `contributes.destinations` (1.3) | - +| `ctx.activity` | `on(cb)`: pause (locked, idle, asleep) and resume; `idleAfter(minutes)` (1.6) | +| `ctx.ui` | `notify(message)`, `confirm(message)`; `pick(items)`, `open({ canvasId, date, blockId })`, `highlight(canvasId)` (1.6); `openPage(viewId)` (1.7) | +| `ctx.app` | `setTrayLabel(label)`, `keepRunning(on)` (1.6) | +| `ctx.commands.register(id, run)` | for commands declared in the manifest; `run(context)` says where it was run from and on which canvas or block (1.6) | +| `ctx.views.handle(viewId, (method, args) => …)`, `ctx.views.post(viewId, message)` | answer calls from your views' pages and push messages to them (1.5; see `docs/EXTENSIONS.md`, Views) | +| `ctx.destinations` | `register(id, { preview, send })` for those declared in `contributes.destinations` (1.3); `list()`, `preview(to, sheet)`, `send(to, sheet)` with `permissions.send` (1.7) | +| `ctx.provide` | `focus(fn)`: focus events for the timeline, review and summary (1.1); `activity(fn)`: time events the app replays in its views (1.6) | + +Views are built with `@devlog/ui` (React components and the bridge to your +process); `builtin-extensions/devlog-time` is the complete example: a node +type, commands in menus and keys, status bar, popover, canvas header and +page views, a managed canvas, and sending through other extensions' +destinations. `builtin-extensions/devlog-jira` is a complete destination (sandboxed: settings, a secret, a canvas field, a ledger in its own files, fetch). `builtin-extensions/devlog-focus` is a complete, small example (window @@ -91,12 +115,11 @@ per-machine JSON-lines files, and data given back to the app. What does **not** work, by design: `fs`, `child_process`, `worker_threads`, native addons (all refused by the runtime), and `require` of anything but -Node's built-ins, so bundle your dependencies. If your extension really -needs files or programs (a platform helper, say), declare -`"permissions": { "unrestricted": true }`: it then runs without the sandbox, -but only after the user says they trust it (esbuild: +Node's built-ins, so bundle your dependencies (esbuild: `esbuild src/main.ts --bundle --platform=node --format=cjs --outfile=main.js`). -`fetch` works. +`fetch` works. If your extension really needs files or programs (a platform +helper, say), declare `"permissions": { "unrestricted": true }`: it then +runs without the sandbox, but only after the user says they trust it. ## Testing @@ -112,6 +135,16 @@ await t.run('send') expect(t.notifications).toEqual(['Sent']) ``` +Options also take `write`, `activity` (what `devlog.activity` returns), +`destinations`, `confirm` and `now`. The harness records what the extension +did (`added`, `created`, `files`, `secrets`, `opened`, `openedPages`, +`picks`, `app`: tray label, keep running, idle minutes, highlight; +`viewMessages`) and drives it: `run(command, context)`, `post(canvasId, +date, block)` (a block posted in the app), `notice(n)` (pause/resume), +`setSettings`, `answerPick`, `viewCall(view, method, …args)`, +`preview`/`send` on its destinations, and `focus`/`timeEvents` for what it +provides. + ## Trying it in Devlog Point Devlog at your folder without releasing anything: in the app's user diff --git a/tests/devlog-time.test.ts b/tests/devlog-time.test.ts index 8c0fdaa..8a6a9c0 100644 --- a/tests/devlog-time.test.ts +++ b/tests/devlog-time.test.ts @@ -246,8 +246,8 @@ describe('devlog-time timesheets', () => { describe('hour targets', () => { const canvas = (id: string, title: string, parentId: string | null, fields: Record = {}): CanvasMeta => ({ id, title, parentId, task: false, archived: false, createdAt: '', updatedAt: '', repos: [], hasSurface: false, fields }) - // 168 h for Drury in September, and 20 h a week for a task under Drury: two separate measures. - const canvases = [canvas('drury', 'Drury', null, { target_month: '168' }), canvas('site', 'Site', 'drury'), canvas('fix', 'Fix login', 'site', { target_week: '20' }), canvas('acme', 'Acme', null)] + // 168 h for Globex in September, and 20 h a week for a task under Globex: two separate measures. + const canvases = [canvas('globex', 'Globex', null, { target_month: '168' }), canvas('site', 'Site', 'globex'), canvas('fix', 'Fix login', 'site', { target_week: '20' }), canvas('acme', 'Acme', null)] let n = 0 const e = (canvasId: string, date: string, minutes: number): TimesheetEntry => ({ id: `e${++n}`, date, start: `${date}T09:00:00.000Z`, minutes, canvasId, worked: minutes, source: 'tracked' }) @@ -267,10 +267,10 @@ describe('hour targets', () => { it('measures each target on its own: its canvas and what is inside, in its own period, not taken on by canvases inside', () => { const week = [e('fix', '2026-09-22', 600), e('site', '2026-09-23', 120), e('acme', '2026-09-23', 60)] - const earlier = [e('fix', '2026-09-08', 480), e('drury', '2026-08-31', 300)] // 31 Aug is outside September + const earlier = [e('fix', '2026-09-08', 480), e('globex', '2026-08-31', 300)] // 31 Aug is outside September const rows = targetsFor(canvases, '2026-09-21', week, new Map([['2026-09', [...week, ...earlier]]])) expect(rows).toEqual([ - { canvasId: 'drury', period: 'month', key: '2026-09', target: 168 * 60, actual: 600 + 120 + 480 }, + { canvasId: 'globex', period: 'month', key: '2026-09', target: 168 * 60, actual: 600 + 120 + 480 }, { canvasId: 'fix', period: 'week', key: '2026-09-21', target: 20 * 60, actual: 600 } ]) })