Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
fd4d182
Draft design for extensions (issue #1)
claude Sep 25, 2026
f3b31b5
Draft timesheet design: weekly synopsis between the time log and dest…
claude Sep 25, 2026
3781860
Timesheet design: no business rules, firm rounding rule, suggested trims
claude Sep 25, 2026
81faf06
Timesheet core: firm rounding rule, sessions, draft entries, suggeste…
claude Sep 25, 2026
39b0594
Include the timesheet module in the renderer's typecheck
claude Sep 25, 2026
f8e84c1
Fast weekly review and summary; back/forward navigation; resizable to…
claude Sep 25, 2026
3706167
Fold completed todos ticked off in a row into one line in the stream
claude Sep 25, 2026
a5c131e
Folded completed todos: explicit Show all / Hide; header covers scrol…
claude Sep 25, 2026
875d10c
smoke-migrate: fuller diagnostics when the search check fails (#15)
claude Sep 25, 2026
2bfb084
Remove storage migrations; refuse anything but format 3
claude Sep 26, 2026
552a602
Todo comment actions, steadier scrolling, type-to-compose (0.6.0)
claude Sep 26, 2026
3a31f40
Record nothing while the machine is locked or asleep (0.6.1)
claude Sep 26, 2026
cf88d96
Extensions design: private files, isolation, window tracking as an ex…
claude Sep 26, 2026
5fa74cd
Extensions design: scoped read grants, GitHub releases, cards, v1 safety
claude Sep 26, 2026
07b8c1e
Extensions, milestone 1 (core): manifests, sources, grants, file broker
claude Sep 26, 2026
b0fcb2c
Extensions, milestone 1 (app): install, consent, sandboxed process, A…
claude Sep 26, 2026
91d3f2c
Window tracking as the built-in devlog-focus extension (0.8.0)
claude Sep 26, 2026
8b909e9
Trusted extensions; window tracking entirely in devlog-focus
claude Sep 26, 2026
a5a253b
Timesheet: weekly draft, adjustable grid, saved in a Timesheets canva…
claude Sep 26, 2026
1ee5d02
Send timesheets to Jira: destinations and the devlog-jira extension (…
claude Sep 27, 2026
ca772a4
Smoke: wait for the built-in extensions list before checking it
claude Sep 27, 2026
57b71ab
Timesheet as a grid: days across, tasks down, client and day totals (…
claude Sep 27, 2026
34d5fa8
Extension settings page: typed fields, validation, secrets on this co…
claude Sep 27, 2026
fd7da62
devlog-cms: send a finished timesheet to CMS
claude Sep 27, 2026
1c5e135
UI cleanup: paged settings, canvas menu, split Start, retire the journal
claude Sep 28, 2026
d457108
Block pages: every block opens as a page
claude Sep 28, 2026
2a5b90b
Todos are blocks (storage format 4)
claude Sep 29, 2026
669a33a
Block pages phase 3: nest and un-nest blocks, tasks take their notes
claude Sep 29, 2026
f19483e
Extension views (API 1.5): pages, status bar items, popovers
claude Sep 29, 2026
c2f88df
Add @devlog/ui kit, built-in bundling step, and canvas node types in …
claude Sep 29, 2026
523c598
Extension API 1.6: node types
claude Sep 29, 2026
760b896
Extension API 1.6: commands in menus, keys and the note box; canvas w…
claude Sep 29, 2026
55f01ad
Time tracking becomes the devlog-time built-in extension
claude Sep 29, 2026
d14064b
Docs for API 1.6 and devlog-time; version 0.17.0
claude Sep 29, 2026
de05e86
Extension API 1.7: activity and block ranges, kept canvases, sending …
claude Sep 29, 2026
18b16c6
Timesheet and Summary become devlog-time's pages
claude Sep 29, 2026
2b54281
Hour targets: weekly and monthly, per canvas, each measured on its own
claude Sep 29, 2026
a119c5c
Docs for API 1.7, the time pages and hour targets; version 0.18.0
claude Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ release/
.vite/
coverage/
*.tsbuildinfo
builtin-extensions/*/dist/
403 changes: 278 additions & 125 deletions README.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions builtin-extensions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Built-in extensions

Extensions that ship inside the Devlog app, one folder each (with a
`devlog-extension.json`). A devlog enables one with `"<name>": "builtin"` in
its `devlog.json`; it still runs sandboxed and still asks before it starts.
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/<view>.tsx` to `dist/views/<view>.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.
36 changes: 36 additions & 0 deletions builtin-extensions/devlog-cms/devlog-extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "devlog-cms",
"displayName": "CMS timesheets",
"version": "1.0.1",
"description": "Sends a finished timesheet to the Technology Partners consultant timesheet system (CMS): hours per assignment per day, on the assignment set on the task (or a canvas above it). Sending again only changes the days that differ from what CMS shows.",
"api": "^1.3.0",
"main": "main.js",
"contributes": {
"canvasFields": [
{
"key": "assignment",
"label": "CMS assignment",
"placeholder": "12345, or the project name",
"description": "The CMS assignment time on this canvas (and canvases inside it) goes to: its number, or its project name as CMS shows it. \"List my CMS assignments\" in the quick switcher shows them."
}
],
"settings": [
{ "key": "username", "label": "CMS username", "required": true, "placeholder": "jdoe" },
{
"key": "baseurl",
"label": "CMS address",
"type": "url",
"placeholder": "https://consultant.technologypartners.net/consultant",
"description": "Leave empty for the usual address."
}
],
"secrets": [{ "key": "password", "label": "CMS password", "required": true, "description": "Used to log in each time Devlog sends hours; CMS has no tokens." }],
"commands": [
{ "id": "check", "label": "Check the CMS login" },
{ "id": "assignments", "label": "List my CMS assignments" }
],
"check": "check",
"destinations": [{ "id": "hours", "label": "CMS" }]
},
"permissions": { "network": ["consultant.technologypartners.net"] }
}
353 changes: 353 additions & 0 deletions builtin-extensions/devlog-cms/main.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions builtin-extensions/devlog-focus/devlog-extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"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.",
"api": "^1.2.0",
"main": "main.js",
"permissions": { "unrestricted": true },
"appendOnly": ["**/*.jsonl"]
}
265 changes: 265 additions & 0 deletions builtin-extensions/devlog-focus/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
/**
* devlog-focus: window tracking as an extension.
*
* It runs unrestricted (you are asked whether you trust it), because
* reading the window in front needs a small platform helper: PowerShell with
* GetForegroundWindow on Windows, an osascript loop on macOS (window titles
* need the Accessibility permission), xdotool on Linux if installed.
*
* Each change is appended to a JSON-lines file per machine and day in its
* own folder in the devlog, and handed back to the app's timeline, review
* and summary:
*
* extensions/builtin.devlog-focus/<machine>/YYYY/MM/YYYY-MM-DD.jsonl
* {"t":"2026-09-26T09:14:03.120Z","app":"Code","title":"store.ts — devlog"}
*
* Nothing is recorded while the machine is locked or asleep.
*/
const { EventEmitter } = require('node:events')

const FILE_RE = /^([^/]+)\/(\d{4})\/(\d{2})\/(\d{4}-\d{2}-\d{2})\.jsonl$/

const pad = (n) => String(n).padStart(2, '0')
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`

function fileFor(machine, iso) {
const date = localDate(new Date(iso))
return `${machine}/${date.slice(0, 4)}/${date.slice(5, 7)}/${date}.jsonl`
}

// ---------------------------------------------------------------------------
// The platform helper
// ---------------------------------------------------------------------------

const WINDOWS_SCRIPT = `
$ErrorActionPreference = 'SilentlyContinue'
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class DevlogFG {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
}
"@
$last = ''
while ($true) {
$h = [DevlogFG]::GetForegroundWindow()
$sb = New-Object System.Text.StringBuilder 1024
[void][DevlogFG]::GetWindowText($h, $sb, 1024)
$procId = 0
[void][DevlogFG]::GetWindowThreadProcessId($h, [ref]$procId)
$name = ''
if ($procId -ne 0) { $p = Get-Process -Id $procId; if ($p) { $name = $p.ProcessName } }
$title = $sb.ToString()
$key = $name + '|' + $title
if ($key -ne $last) {
$last = $key
$o = @{ app = $name; title = $title } | ConvertTo-Json -Compress
[Console]::Out.WriteLine($o)
[Console]::Out.Flush()
}
Start-Sleep -Milliseconds 1000
}
`

const MAC_SCRIPT = `
set lastKey to ""
repeat
set n to ""
set t to ""
try
tell application "System Events"
set p to first application process whose frontmost is true
set n to name of p
try
set t to name of front window of p
end try
end tell
end try
set k to n & "|" & t
if k is not lastKey then
set lastKey to k
log k
end if
delay 1
end repeat
`

/** Emits `change` with { app, title } whenever the window in front changes. */
class ForegroundWatcher extends EventEmitter {
constructor() {
super()
this.child = null
this.timer = null
this.last = null
this.stopped = true
}

start() {
this.stopped = false
if (process.platform === 'win32') this.startWindows()
else if (process.platform === 'darwin') this.startMac()
else this.startLinux()
}

stop() {
this.stopped = true
if (this.timer) clearInterval(this.timer)
this.timer = null
if (this.child) {
this.child.kill()
this.child = null
}
}

emitChange(info) {
const key = `${info.app}|${info.title}`
if (key === this.last) return
this.last = key
this.emit('change', info)
}

startWindows() {
const encoded = Buffer.from(WINDOWS_SCRIPT, 'utf16le').toString('base64')
this.spawnHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], 'stdout', (line) => {
try {
const o = JSON.parse(line)
this.emitChange({ app: String(o.app || ''), title: String(o.title || '') })
} catch {
// not a line from the helper
}
})
}

startMac() {
this.spawnHelper('osascript', ['-e', MAC_SCRIPT], 'stderr', (line) => {
const idx = line.indexOf('|')
if (idx < 0) return
this.emitChange({ app: line.slice(0, idx), title: line.slice(idx + 1) })
})
}

startLinux() {
const { execFile } = require('node:child_process')
const { readFileSync } = require('node:fs')
execFile('xdotool', ['--version'], (err) => {
if (err || this.stopped) return
this.timer = setInterval(() => {
execFile('xdotool', ['getactivewindow', 'getwindowname', 'getwindowpid'], { timeout: 2000 }, (e, stdout) => {
if (e) return
const [title = '', pid = ''] = stdout.split('\n')
let app = ''
try {
app = readFileSync(`/proc/${pid.trim()}/comm`, 'utf8').trim()
} catch {
// the process is gone
}
this.emitChange({ app, title })
})
}, 2000)
})
}

spawnHelper(cmd, args, stream, onLine) {
const { spawn } = require('node:child_process')
let child
try {
child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
} catch {
return
}
this.child = child
let buf = ''
child[stream].setEncoding('utf8')
child[stream].on('data', (chunk) => {
buf += chunk
let nl
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).replace(/\r$/, '')
buf = buf.slice(nl + 1)
if (line.trim()) onLine(line)
}
})
child.on('error', () => undefined)
child.on('exit', () => {
this.child = null
// Restart after a pause unless told to stop.
if (!this.stopped) setTimeout(() => !this.stopped && this.start(), 15_000)
})
}
}

// ---------------------------------------------------------------------------
// The extension
// ---------------------------------------------------------------------------

/**
* Wire window changes to files and back. `watcher` is anything with
* start/stop that emits `change` ({ app, title }); tests pass a fake.
*/
function run(ctx, watcher) {
let last = null
let paused = false
let writes = Promise.resolve()

const record = (t, app, title) => {
const line = JSON.stringify({ t, app, title }) + '\n'
// Keep lines in order even if two changes arrive at once.
writes = writes.then(() => ctx.files.repo.append(fileFor(ctx.machine, t), line)).catch((err) => console.error('devlog-focus: write failed', err))
}

watcher.on('change', (w) => {
// The lock screen and waking up shuffle windows around; that is not work.
if (paused) return
last = { app: String(w.app || '').slice(0, 200), title: String(w.title || '').slice(0, 1000) }
record(new Date().toISOString(), last.app, last.title)
})

ctx.activity.on((n) => {
if (n.type === 'pause' && (n.reason === 'locked' || n.reason === 'suspended')) paused = true
else if (n.type === 'resume') {
paused = false
// Back from a lock, sleep or idle spell: the window in front may not
// have changed, so say again what it is, or its time would not resume.
if (last) record(n.t, last.app, last.title)
}
})

ctx.provide.focus(async (fromDate, toDate) => {
await writes
const events = []
for (const f of await ctx.files.repo.list()) {
const m = FILE_RE.exec(f.path)
if (!m || m[4] < fromDate || m[4] > toDate) continue
const text = (await ctx.files.repo.readText(f.path)) || ''
for (const line of text.split('\n')) {
if (!line.trim()) continue
try {
const o = JSON.parse(line)
if (typeof o.t === 'string') events.push({ t: o.t, app: String(o.app || ''), title: String(o.title || ''), machine: m[1] })
} catch {
// A torn last line (a crash mid-write): skip it.
}
}
}
return events
})

watcher.start()
return watcher
}

let running = null

exports.activate = (ctx) => {
running = run(ctx, new ForegroundWatcher())
}

exports.deactivate = () => {
running?.stop()
running = null
}

exports.run = run
61 changes: 61 additions & 0 deletions builtin-extensions/devlog-jira/devlog-extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "devlog-jira",
"displayName": "Jira worklogs",
"version": "1.1.0",
"description": "Sends a finished timesheet to Jira as worklogs, one per entry, on the issue set on the task (or a canvas above it). Sending again only creates, updates or deletes what changed.",
"api": "^1.3.0",
"main": "main.js",
"contributes": {
"canvasFields": [
{
"key": "issue",
"label": "Jira issue",
"placeholder": "ACME-123",
"description": "Time on this canvas, and on canvases inside it that don't set their own, is logged on this issue."
}
],
"settings": [
{
"key": "baseurl",
"label": "Jira address",
"type": "url",
"required": true,
"placeholder": "https://yourcompany.atlassian.net",
"description": "The address you open Jira at."
},
{
"key": "email",
"label": "Account email",
"type": "email",
"placeholder": "you@example.com",
"description": "Jira Cloud: the email you sign in with. Jira Data Center: leave empty and use a personal access token below."
}
],
"secrets": [
{
"key": "token",
"label": "API token",
"required": true,
"description": "Jira Cloud: create one at id.atlassian.com \u2192 Security \u2192 API tokens. Data Center: a personal access token from your Jira profile."
}
],
"commands": [
{
"id": "check",
"label": "Check the Jira connection"
}
],
"destinations": [
{
"id": "worklogs",
"label": "Jira"
}
],
"check": "check"
},
"permissions": {
"network": [
"*.atlassian.net"
]
}
}
Loading
Loading