logwork-cli 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,16 +30,26 @@ logwork setup
30
30
 
31
31
  ## Upgrading from logwork-helper
32
32
 
33
- The package was renamed from `logwork-helper` to `logwork-cli`, and the `logwork-helper` command no longer exists. To upgrade:
33
+ The package was renamed from `logwork-helper` to `logwork-cli`, and the `logwork-helper` command no longer exists. Both packages install a `logwork` command, so remove the old one first (otherwise npm stops with `EEXIST`):
34
34
 
35
35
  ```bash
36
+ npm uninstall -g logwork-helper
36
37
  npm install -g logwork-cli
37
38
  logwork setup
38
39
  ```
39
40
 
40
41
  Most of the move is automatic:
41
42
 
42
- - **Old global package:** `logwork setup` removes the global `logwork-helper` package, so its `logwork` command does not conflict with the new one.
43
+ - **Old global package:** if it is still installed, `logwork setup` removes the global `logwork-helper` package before linking. You can also run `npx logwork-cli@latest setup` without uninstalling first.
44
+ - **`EEXIST ... bin/logwork` even after uninstalling:** the old package was linked from `~/.logwork-helper`. If that folder was deleted first, `npm uninstall` cannot remove its links. Remove the broken links, then install again:
45
+
46
+ ```bash
47
+ P="$(npm prefix -g)"
48
+ rm -f "$P/bin/logwork" "$P"/bin/logwork-helper* "$P/lib/node_modules/logwork-helper"
49
+ npm install -g logwork-cli && logwork setup
50
+ ```
51
+
52
+ `npx logwork-cli@latest setup` also removes those broken links (macOS).
43
53
  - **Local state:** on the first run, settings, project mappings, drafts, apply history and the reminder config are copied from `~/.logwork-helper` to `~/.logwork-cli`. The old directory is left in place.
44
54
  - **Login sessions:** your RO and Jira sessions are copied from the old Keychain / Credential Manager entry (`logwork-helper`) to the new one (`logwork-cli`), so you do not need to log in again.
45
55
  - **Repo mappings:** a repo that has `.logwork-helper.json` keeps using it until a `.logwork.json` exists.
@@ -67,12 +77,14 @@ Sessions are stored in macOS Keychain or Windows Credential Manager. The default
67
77
  ```text
68
78
  logwork Open the terminal UI
69
79
  logwork query [period] Booked vs logged hours with entry ids (today, yesterday, this-week, last-week, this-month, last-month)
80
+ logwork commits [period] Draft logwork lines from your git commits (--repo, --author)
70
81
  logwork quick [-m text] Log one entry for today with guided prompts
71
82
  logwork edit <id> ... Change an existing RO entry (replaces it)
72
83
  logwork delete <id> Delete a submitted or approved RO entry
73
84
  logwork reconcile Compare RO and Jira hours by day (read-only)
74
85
  logwork history Local apply history
75
86
  logwork login|logout|status [ro|jira]
87
+ logwork skill install Install the AI agent skill (Claude Code, Codex)
76
88
  logwork setup Install the runtime and print MCP configs
77
89
  logwork mcp Run the MCP stdio server (used by IDE configs)
78
90
  logwork reminder ... Weekday logwork reminder
@@ -82,6 +94,47 @@ logwork doctor Write a sanitized diagnostics report
82
94
 
83
95
  Add `--help` to any command for its options.
84
96
 
97
+ ## Log Work From Your Commits
98
+
99
+ Most logwork is the work you committed. logwork turns your commits into draft lines, and the RO booking decides how many hours there are to fill.
100
+
101
+ ```bash
102
+ logwork commits yesterday
103
+ logwork commits this-week --repo ~/code/portal --repo ~/code/api
104
+ ```
105
+
106
+ How the draft is built:
107
+
108
+ - **Which commits:** your own (`git config user.email`, or `--author`) on every local and remote branch. Stashes and merge commits are skipped.
109
+ - **Grouping:** per day, by repo + ticket key + kind of work.
110
+ - **Worklog task:** chosen from the kind of work.
111
+
112
+ | Commit | Worklog task |
113
+ |---|---|
114
+ | `feat` | Feature Development |
115
+ | `fix`, hotfix | Bug Fix |
116
+ | `refactor`, `perf`, review | Code Review & Refactoring |
117
+ | `test` | Testing Activities |
118
+ | `ci`, `build`, release, deploy | Deployment & Release |
119
+ | `docs`, design | Requirements & Design |
120
+ | `chore` | Other Activities |
121
+
122
+ Conventional prefixes are used first, then keywords in the subject.
123
+ - **Hours:** each day's RO remaining time (booked − logged, per project) is split across the groups in 0.5h steps, weighted by commits and lines changed. Days without a booking get 0h. Without RO access, a weekday counts as 8h.
124
+
125
+ Nothing is written. Paste the draft into `/logwork`, or let your AI agent use it.
126
+
127
+ For agents:
128
+ - **MCP tool `draft_from_commits`** returns the same draft as `draftText`.
129
+ - **MCP prompt `log_from_commits`** gives an agent the full procedure:
130
+ 1. find gaps with `get_logwork`;
131
+ 2. draft from commits;
132
+ 3. check task names with `list_tasks`;
133
+ 4. rewrite vague descriptions;
134
+ 5. stay within booked hours;
135
+ 6. get your approval, then `log_work`.
136
+ - **Claude Code / Codex:** `logwork skill install` installs the same procedure as a skill, in `~/.claude/skills/logwork` and `~/.codex/skills/logwork`. For other agents, `logwork skill` prints it so you can paste it into your rules.
137
+
85
138
  ## Logwork Line Format
86
139
 
87
140
  Every Resource Optimiser entry has a **worklog task** and a free-text **description**. The worklog task is an RO catalog item such as `Feature Development` or `Code Review & Refactoring`. Write each line as:
@@ -154,7 +207,7 @@ claude mcp add --transport stdio logwork -- node ~/.logwork-cli/bin/logwork.mjs
154
207
 
155
208
  Restart or reload the IDE's MCP tools after saving.
156
209
 
157
- The server exposes 15 tools. It also sends `instructions` covering the approval rules, the line format and the recommended call order. Every write is **two calls to the same tool**:
210
+ The server exposes 16 tools and one prompt (`log_from_commits`). It also sends `instructions` covering the approval rules, the line format and the recommended call order. Every write is **two calls to the same tool**:
158
211
  1. Call the tool without `confirm`. It returns a preview with a `batchId` or `previewId` and a summary.
159
212
  2. After the user approves, call the same tool again with only that id and `confirm: true`.
160
213
 
package/RELEASE.md CHANGED
@@ -67,7 +67,7 @@ The gate must pass all of the following:
67
67
  - Confirm the printed MCP config uses server name `logwork` and `node ~/.logwork-cli/bin/logwork.mjs mcp`.
68
68
  - Run `logwork --help` and `logwork --version`.
69
69
  - Run `logwork update check --force` against the published npm version.
70
- - Run `logwork mcp` through an MCP client smoke test and confirm it lists the 15 tools (`get_logwork`, `list_projects`, `list_tasks`, `log_work`, `edit_entry`, `resubmit_entry`, `delete_entry`, `log_jira_work`, `get_jira_issue`, `reconcile`, `get_history`, `map_project`, `login`, `reminder`, `update`).
70
+ - Run `logwork mcp` through an MCP client smoke test and confirm it lists the 16 tools (`get_logwork`, `draft_from_commits`, `list_projects`, `list_tasks`, `log_work`, `edit_entry`, `resubmit_entry`, `delete_entry`, `log_jira_work`, `get_jira_issue`, `reconcile`, `get_history`, `map_project`, `login`, `reminder`, `update`).
71
71
  - Confirm the `update` tool requires `confirm: true` for `action: "install"`, and `reconcile` takes preset periods only with no credential or confirmation fields.
72
72
  - Run `logwork reconcile this-week` and confirm it reports RO/Jira daily totals without writing either system.
73
73
  - On a machine with an older `logwork-helper` install, confirm the first run copies state to `~/.logwork-cli` and `logwork status` shows the existing session without a new login.
package/bin/logwork.mjs CHANGED
@@ -10,6 +10,7 @@ import { readPackageVersion } from '../lib/package-info.mjs';
10
10
  // logwork mcp -> MCP stdio server for IDEs and agent CLIs
11
11
  export const COMMANDS = [
12
12
  { name: 'query', usage: 'query [today|yesterday|this-week|last-week|this-month|last-month] [--json]', summary: 'Show booked vs logged hours with entry ids', load: () => import('../commands/query.mjs'), fn: 'run' },
13
+ { name: 'commits', usage: 'commits [period] [--repo path]... [--author email]', summary: 'Draft logwork lines from your git commits', load: () => import('../commands/commits.mjs'), fn: 'run' },
13
14
  { name: 'quick', usage: 'quick [-m "what you did"]', summary: 'Log one entry for today with guided prompts', load: () => import('../commands/quick.mjs'), fn: 'run' },
14
15
  { name: 'edit', usage: 'edit <id> [--hours h] [--task name|#id] [--desc text] [--type type]', summary: 'Change an existing RO entry (replaces it)', load: () => import('../commands/edit.mjs'), fn: 'runRoEditCommand' },
15
16
  { name: 'delete', usage: 'delete <id>', summary: 'Delete a submitted or approved RO entry', load: () => import('../commands/delete.mjs'), fn: 'runRoDeleteCommand' },
@@ -18,6 +19,7 @@ export const COMMANDS = [
18
19
  { name: 'login', usage: 'login [ro|jira] [--base-url url]', summary: 'Log in to Resource Optimiser (default) or Jira', load: () => import('../commands/auth.mjs'), fn: 'runLogin' },
19
20
  { name: 'logout', usage: 'logout [ro|jira]', summary: 'Remove the stored RO (default) or Jira session', load: () => import('../commands/auth.mjs'), fn: 'runLogout' },
20
21
  { name: 'status', usage: 'status', summary: 'Show RO and Jira login status', load: () => import('../commands/auth.mjs'), fn: 'runStatus' },
22
+ { name: 'skill', usage: 'skill [print|install] [--target claude|codex|all]', summary: 'Print or install the AI agent skill for logging from commits', load: () => import('../commands/skill.mjs'), fn: 'run' },
21
23
  { name: 'setup', usage: 'setup [--login|--no-login]', summary: 'Install the runtime and print MCP configs', load: () => import('../commands/setup.mjs'), fn: 'run' },
22
24
  { name: 'mcp', usage: 'mcp', summary: 'Run the MCP stdio server (used by IDE configs)', load: () => import('../lib/mcp-server.mjs'), fn: 'startMcpServer' },
23
25
  { name: 'reminder', usage: 'reminder enable|disable|status|test', summary: 'Manage the weekday logwork reminder', load: () => import('../commands/reminder.mjs'), fn: 'runReminderCli' },
@@ -75,6 +77,7 @@ Examples:
75
77
  logwork setup
76
78
  logwork login
77
79
  logwork query this-week
80
+ logwork commits yesterday --repo ~/code/app
78
81
  logwork quick -m "Fix login bug (SCB-12)"
79
82
  logwork edit 304032 --task "Bug Fix" --desc "Fix date picker timezone (SAP-42)"
80
83
 
@@ -0,0 +1,82 @@
1
+ import { draftLogworkFromCommits } from '../lib/commit-workflow.mjs';
2
+ import { normalizeReconciliationPeriod } from '../lib/reconciliation-workflow.mjs';
3
+
4
+ // `logwork commits [period] [--repo path]... [--author x] [--no-bookings] [--json]`:
5
+ // turn your git commits into a logwork draft. Read-only.
6
+ export function parseCommitsArgs(args = []) {
7
+ const options = { period: 'today', repos: [], author: undefined, useBookings: true, json: false, help: false };
8
+ const positional = [];
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const arg = args[index];
11
+ if (arg === '--help' || arg === '-h') {
12
+ options.help = true;
13
+ } else if (arg === '--json') {
14
+ options.json = true;
15
+ } else if (arg === '--no-bookings') {
16
+ options.useBookings = false;
17
+ } else if (arg === '--repo' || arg === '--author') {
18
+ const value = args[index + 1];
19
+ if (!value || value.startsWith('--')) {
20
+ throw new Error(`${arg} requires a value.`);
21
+ }
22
+ if (arg === '--repo') {
23
+ options.repos.push(value);
24
+ } else {
25
+ options.author = value;
26
+ }
27
+ index += 1;
28
+ } else if (arg.startsWith('--repo=')) {
29
+ options.repos.push(arg.slice('--repo='.length));
30
+ } else if (arg.startsWith('--author=')) {
31
+ options.author = arg.slice('--author='.length);
32
+ } else if (arg.startsWith('-')) {
33
+ throw new Error(`Unknown commits option: ${arg}`);
34
+ } else {
35
+ positional.push(arg);
36
+ }
37
+ }
38
+ if (positional.length > 1) {
39
+ throw new Error('commits accepts exactly one preset period.');
40
+ }
41
+ if (positional[0]) {
42
+ options.period = normalizeReconciliationPeriod(positional[0]);
43
+ }
44
+ return options;
45
+ }
46
+
47
+ export async function run(args = [], { draft = draftLogworkFromCommits, print = console.log } = {}) {
48
+ const options = parseCommitsArgs(args);
49
+ if (options.help) {
50
+ print(formatCommitsHelp());
51
+ return 0;
52
+ }
53
+ const result = await draft({
54
+ period: options.period,
55
+ repos: options.repos,
56
+ author: options.author,
57
+ useBookings: options.useBookings
58
+ });
59
+ print(options.json ? JSON.stringify(result, null, 2) : result.summary);
60
+ return 0;
61
+ }
62
+
63
+ export function formatCommitsHelp() {
64
+ return `Usage:
65
+ logwork commits [today|yesterday|this-week|last-week|this-month|last-month]
66
+ [--repo <path>]... [--author <email>] [--no-bookings] [--json]
67
+
68
+ Reads your commits (all branches, your git user.email) from each repo (default:
69
+ the current directory) and drafts logwork lines:
70
+ +<hours> <worklog task> | <description>
71
+
72
+ - Commits are grouped per day by repo, ticket key and kind of work.
73
+ - The kind of work picks the worklog task: feat -> Feature Development,
74
+ fix -> Bug Fix, refactor/perf/review -> Code Review & Refactoring,
75
+ test -> Testing Activities, ci/build/release -> Deployment & Release,
76
+ docs/design -> Requirements & Design, chore -> Other Activities.
77
+ - Hours split the day's RO remaining time (booked - logged) by how much work
78
+ each group holds. With --no-bookings or without login, a weekday is 8h.
79
+
80
+ Nothing is written. Review the draft, then paste it into /logwork or give it
81
+ to your AI agent (log_work).`;
82
+ }
@@ -4,6 +4,7 @@ import { delimiter, dirname, join, relative, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { redactText } from '../lib/auth-redaction.mjs';
6
6
  import { credentialStoreLabel } from '../lib/credential-store.mjs';
7
+ import { npmInvocation } from '../lib/npm-command.mjs';
7
8
  import { helperHome } from '../lib/paths.mjs';
8
9
  import {
9
10
  MCP_SERVER_NAME,
@@ -59,7 +60,7 @@ export async function installUserRuntime({
59
60
  sourceDir,
60
61
  targetDir,
61
62
  mkdirFn = fs.mkdir,
62
- copyFn = copyDirectory,
63
+ copyFn = copyRuntimeFiles,
63
64
  cleanupFn = sanitizeRuntimeMetadata,
64
65
  installFn = installDependencies,
65
66
  linkFn = linkGlobalBinaries,
@@ -103,6 +104,40 @@ async function refreshReminderSchedule(targetDir) {
103
104
  }
104
105
  }
105
106
 
107
+ // Copies only what the npm package ships (package.json "files", plus the
108
+ // manifest and lockfile). The target is also the state directory, so a git
109
+ // checkout's .env files, tests, archives or a repo-scope .logwork.json must
110
+ // never land there.
111
+ export async function copyRuntimeFiles(sourceDir, targetDir) {
112
+ const manifest = JSON.parse(await fs.readFile(join(sourceDir, 'package.json'), 'utf8'));
113
+ const listed = Array.isArray(manifest.files) ? manifest.files : [];
114
+ const names = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', ...listed.map((item) => String(item).replace(/[\\/]+$/, ''))];
115
+ for (const name of [...new Set(names)]) {
116
+ const sourcePath = join(sourceDir, name);
117
+ let stat;
118
+ try {
119
+ stat = await fs.stat(sourcePath);
120
+ } catch (error) {
121
+ if (error.code === 'ENOENT') {
122
+ // A lockfile from an earlier install that no longer matches this
123
+ // package.json would make `npm ci` fail; let npm install write a new one.
124
+ if (name === 'package-lock.json') {
125
+ await fs.rm(join(targetDir, name), { force: true });
126
+ }
127
+ continue;
128
+ }
129
+ throw error;
130
+ }
131
+ if (stat.isDirectory()) {
132
+ await fs.mkdir(join(targetDir, name), { recursive: true });
133
+ await copyDirectory(sourcePath, targetDir, sourceDir);
134
+ } else {
135
+ await fs.mkdir(dirname(join(targetDir, name)), { recursive: true });
136
+ await fs.copyFile(sourcePath, join(targetDir, name));
137
+ }
138
+ }
139
+ }
140
+
106
141
  async function copyDirectory(sourceDir, targetDir, rootDir) {
107
142
  const entries = await fs.readdir(sourceDir, { withFileTypes: true });
108
143
 
@@ -139,7 +174,8 @@ function installDependencies(targetDir) {
139
174
 
140
175
  function runCommand(command, args, options = {}) {
141
176
  return new Promise((resolveValue, reject) => {
142
- const child = spawn(command, args, options);
177
+ const invocation = command === 'npm' ? npmInvocation(args) : { command, args, shell: false };
178
+ const child = spawn(invocation.command, invocation.args, { ...options, shell: invocation.shell });
143
179
 
144
180
  child.on('error', reject);
145
181
  child.on('close', (code) => {
@@ -243,9 +279,73 @@ function isStaleSelfDependency(value) {
243
279
  return typeof value === 'string' && /^file:.*logwork-(helper|cli).*\.tgz$/i.test(value);
244
280
  }
245
281
 
282
+ const LEGACY_GLOBAL_LINKS = [
283
+ 'bin/logwork',
284
+ 'bin/logwork-helper',
285
+ 'bin/logwork-helper-install',
286
+ 'bin/logwork-helper-manual',
287
+ 'bin/logwork-helper-mcp',
288
+ 'bin/logwork-helper-setup-user',
289
+ 'lib/node_modules/logwork-helper'
290
+ ];
291
+
292
+ // The old package was installed with `npm link` from ~/.logwork-helper. When
293
+ // that folder is deleted first, its global links dangle: `npm rm -g` cannot
294
+ // read the package any more and leaves bin/logwork behind, which then blocks
295
+ // `npm install -g logwork-cli` and `npm link` with EEXIST. Remove only links
296
+ // that point into a logwork-helper path that no longer exists.
297
+ export async function removeDanglingLegacyLinks(globalPrefix, {
298
+ platform = process.platform,
299
+ lstatFn = fs.lstat,
300
+ statFn = fs.stat,
301
+ readlinkFn = fs.readlink,
302
+ unlinkFn = fs.unlink
303
+ } = {}) {
304
+ const removed = [];
305
+ if (!globalPrefix || platform === 'win32') {
306
+ return removed;
307
+ }
308
+ for (const relativePath of LEGACY_GLOBAL_LINKS) {
309
+ const path = join(globalPrefix, relativePath);
310
+ try {
311
+ if (!(await lstatFn(path)).isSymbolicLink()) {
312
+ continue;
313
+ }
314
+ if (!/logwork-helper/.test(await readlinkFn(path))) {
315
+ continue;
316
+ }
317
+ try {
318
+ await statFn(path);
319
+ continue;
320
+ } catch {
321
+ // Dangling: the target is gone.
322
+ }
323
+ await unlinkFn(path);
324
+ removed.push(path);
325
+ } catch {
326
+ // Missing or unreadable: nothing to clean.
327
+ }
328
+ }
329
+ return removed;
330
+ }
331
+
332
+ function readNpmGlobalPrefix(cwd) {
333
+ return new Promise((resolveValue) => {
334
+ const invocation = npmInvocation(['prefix', '-g']);
335
+ const child = spawn(invocation.command, invocation.args, { cwd, shell: invocation.shell, stdio: ['ignore', 'pipe', 'ignore'] });
336
+ let stdout = '';
337
+ child.stdout.setEncoding('utf8');
338
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
339
+ child.on('error', () => resolveValue(''));
340
+ child.on('close', (code) => resolveValue(code === 0 ? stdout.trim() : ''));
341
+ });
342
+ }
343
+
246
344
  export async function linkGlobalBinaries(targetDir, {
247
345
  runner = runCommand,
248
- commandFinder = findCommandOnPath
346
+ commandFinder = findCommandOnPath,
347
+ prefixReader = readNpmGlobalPrefix,
348
+ legacyLinkCleaner = removeDanglingLegacyLinks
249
349
  } = {}) {
250
350
  // Best effort: drop the pre-rename global package so its `logwork` bin does
251
351
  // not block linking this one. A no-op when it is not installed.
@@ -253,6 +353,10 @@ export async function linkGlobalBinaries(targetDir, {
253
353
  cwd: targetDir,
254
354
  stdio: 'ignore'
255
355
  }).catch(() => {});
356
+ await Promise.resolve()
357
+ .then(() => prefixReader(targetDir))
358
+ .then((prefix) => legacyLinkCleaner(prefix))
359
+ .catch(() => []);
256
360
 
257
361
  try {
258
362
  await runner('npm', ['link'], {
@@ -435,6 +539,10 @@ export function formatSetupUserInstructions({
435
539
  'GitHub Copilot / VS Code mcp.json:',
436
540
  JSON.stringify(vscodeMcpConfig(entryPath), null, 2),
437
541
  '',
542
+ 'AI agent skill (log work from your commits)',
543
+ '- Claude Code / Codex: run `logwork skill install`.',
544
+ '- Other MCP clients: use the MCP prompt "log_from_commits".',
545
+ '',
438
546
  'Verify',
439
547
  '- Restart or reload your IDE MCP tools after pasting config.',
440
548
  '- In the IDE, ask: Check my logwork for this week.',
@@ -0,0 +1,91 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { SKILL_NAME, readLogworkSkill } from '../lib/agent-skill.mjs';
5
+
6
+ // `logwork skill` print the agent skill (SKILL.md)
7
+ // `logwork skill install` copy it to ~/.claude/skills and ~/.codex/skills
8
+ export const SKILL_TARGETS = Object.freeze({
9
+ claude: (home) => join(home, '.claude', 'skills', SKILL_NAME, 'SKILL.md'),
10
+ codex: (home) => join(home, '.codex', 'skills', SKILL_NAME, 'SKILL.md')
11
+ });
12
+
13
+ export function parseSkillArgs(args = []) {
14
+ const options = { action: 'print', targets: [], dir: undefined, help: false };
15
+ const positional = [];
16
+ for (let index = 0; index < args.length; index += 1) {
17
+ const arg = args[index];
18
+ if (arg === '--help' || arg === '-h') {
19
+ options.help = true;
20
+ } else if (arg === '--target' || arg === '--dir') {
21
+ const value = args[index + 1];
22
+ if (!value || value.startsWith('--')) {
23
+ throw new Error(`${arg} requires a value.`);
24
+ }
25
+ if (arg === '--dir') {
26
+ options.dir = value;
27
+ } else {
28
+ options.targets.push(...value.split(','));
29
+ }
30
+ index += 1;
31
+ } else if (arg.startsWith('-')) {
32
+ throw new Error(`Unknown skill option: ${arg}`);
33
+ } else {
34
+ positional.push(arg);
35
+ }
36
+ }
37
+ if (positional.length > 1 || (positional[0] && !['print', 'install'].includes(positional[0]))) {
38
+ throw new Error('Usage: logwork skill [print|install] [--target claude|codex|all] [--dir <path>]');
39
+ }
40
+ options.action = positional[0] || 'print';
41
+ const targets = options.targets.length ? options.targets : ['all'];
42
+ options.targets = targets.includes('all') ? Object.keys(SKILL_TARGETS) : targets;
43
+ for (const target of options.targets) {
44
+ if (!SKILL_TARGETS[target]) {
45
+ throw new Error(`Unknown skill target: ${target}. Use claude, codex or all.`);
46
+ }
47
+ }
48
+ return options;
49
+ }
50
+
51
+ export async function run(args = [], {
52
+ home = homedir(),
53
+ print = console.log,
54
+ mkdirFn = mkdir,
55
+ writeFileFn = writeFile
56
+ } = {}) {
57
+ const options = parseSkillArgs(args);
58
+ if (options.help) {
59
+ print(formatSkillHelp());
60
+ return 0;
61
+ }
62
+ const content = `${readLogworkSkill({ withFrontmatter: true }).trim()}\n`;
63
+ if (options.action === 'print') {
64
+ print(content);
65
+ return 0;
66
+ }
67
+ const paths = options.dir
68
+ ? [join(resolve(options.dir), SKILL_NAME, 'SKILL.md')]
69
+ : options.targets.map((target) => SKILL_TARGETS[target](home));
70
+ for (const path of paths) {
71
+ await mkdirFn(join(path, '..'), { recursive: true });
72
+ await writeFileFn(path, content, 'utf8');
73
+ print(`Installed the logwork skill: ${path}`);
74
+ }
75
+ print('Restart the agent so it picks up the skill. The same procedure is served as the MCP prompt "log_from_commits".');
76
+ return 0;
77
+ }
78
+
79
+ export function formatSkillHelp() {
80
+ return `Usage:
81
+ logwork skill Print the agent skill (SKILL.md)
82
+ logwork skill install Install it for Claude Code and Codex
83
+ logwork skill install --target claude|codex|all
84
+ logwork skill install --dir <path> Install under <path>/logwork/SKILL.md
85
+ (for example a repo's .claude/skills)
86
+
87
+ The skill tells an AI agent how to log accurate work from your git commits:
88
+ get_logwork -> draft_from_commits -> list_tasks -> review with you -> log_work.
89
+ Other agents (Cursor, Copilot) can use the MCP prompt "log_from_commits", or
90
+ paste \`logwork skill\` output into their rules.`;
91
+ }
package/docs/mcp-setup.md CHANGED
@@ -73,7 +73,7 @@ If you used `logwork-helper` before, delete its old MCP entry. It points at `~/.
73
73
 
74
74
  ## Exposed Tools
75
75
 
76
- After editing the config, restart or reload the IDE. The server exposes 15 tools. Each one has a title, a description, a described input schema, and MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), so IDEs and agents can tell read-only tools from writes.
76
+ After editing the config, restart or reload the IDE. The server exposes 16 tools and one prompt. Each one has a title, a description, a described input schema, and MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), so IDEs and agents can tell read-only tools from writes.
77
77
 
78
78
  The server also sends MCP `instructions` that explain the approval rules, the line format and the recommended call order. Most clients pass these to the model automatically.
79
79
 
@@ -89,6 +89,7 @@ Previews expire after 1 hour. They are also lost when the server restarts.
89
89
  | Group | Tool | Kind | Purpose |
90
90
  |---|---|---|---|
91
91
  | Read | `get_logwork` | read-only | Booked vs logged RO hours by day and project, with entries (`logworkId`, worklog task, description, status). |
92
+ | Read | `draft_from_commits` | read-only | Your git commits (all branches, your author) for a period in the given `repos`, grouped per day into draft lines with a suggested worklog task and hours that fill the RO remaining booked time. Returns `draftText` for `log_work`. |
92
93
  | Read | `list_projects` | read-only | RO memberships (`projectMemberId`, `projectId`, `projectName`) and local mappings. |
93
94
  | Read | `list_tasks` | read-only | RO worklog task catalog for one `projectId`: id, name, type of work, and when to use each task. |
94
95
  | Read | `get_jira_issue` | read-only | Jira issue key, summary and status. |
@@ -106,7 +107,7 @@ Previews expire after 1 hour. They are also lost when the server restarts.
106
107
 
107
108
  ### Tool Name Changes From logwork-helper
108
109
 
109
- | logwork-helper (before 0.3.0) | logwork-cli |
110
+ | logwork-helper (0.2.x and earlier) | logwork-cli |
110
111
  |---|---|
111
112
  | `preview_logwork_batch`, `apply_logwork_batch` | `log_work` |
112
113
  | `query_logwork` | `get_logwork` |
@@ -172,8 +173,28 @@ Log 2 hours today for SCB-213 "maintenance mode UI" as Feature Development, with
172
173
  Set up the SCB ticket mapping to project 2621A-SIT-HTML BUILDER-PRJ.
173
174
  ```
174
175
 
176
+ **Prompt `log_from_commits`** (arguments: `period`, `repos`) returns the full procedure for logging from commits. It is the same text as the agent skill that `logwork skill install` installs for Claude Code and Codex.
177
+
175
178
  ## Common MCP Workflows
176
179
 
180
+ ### Log Work From Your Commits
181
+
182
+ Ask your assistant (or pick the `log_from_commits` prompt in clients that list MCP prompts):
183
+
184
+ ```text
185
+ Log my work for yesterday from my commits in this workspace.
186
+ ```
187
+
188
+ Expected flow:
189
+
190
+ 1. `get_logwork` shows which days and projects still have booked hours to fill.
191
+ 2. `draft_from_commits` (with `repos` set to the workspace folders) groups your commits into draft lines with hours.
192
+ 3. `list_tasks` confirms the worklog task names for each project.
193
+ 4. The assistant rewrites vague descriptions, shows you the block and waits for approval.
194
+ 5. `log_work(text)` previews the entries, then `log_work(batchId, confirm: true)` applies them.
195
+
196
+
197
+
177
198
  ### Query Logwork
178
199
 
179
200
  Ask your assistant:
@@ -19,6 +19,8 @@ npm install -g logwork-cli
19
19
  logwork setup
20
20
  ```
21
21
 
22
+ Upgrading from `logwork-helper`: run `npm uninstall -g logwork-helper` first. Both packages provide a `logwork` command, and npm refuses to overwrite it (`EEXIST`). If `~/.logwork-helper` was already deleted, the uninstall leaves broken links behind; remove them with `P="$(npm prefix -g)"; rm -f "$P/bin/logwork" "$P"/bin/logwork-helper* "$P/lib/node_modules/logwork-helper"`, or run `npx logwork-cli@latest setup`, which removes them.
23
+
22
24
  Authenticate each target from Terminal:
23
25
 
24
26
  ```bash
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // skills/logwork/SKILL.md is the single source for the agent procedure: it is
6
+ // shipped as an installable skill (`logwork skill install`) and served as the
7
+ // MCP prompt log_from_commits.
8
+ export const SKILL_NAME = 'logwork';
9
+ export const SKILL_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skills', SKILL_NAME, 'SKILL.md');
10
+
11
+ export function readLogworkSkill({ withFrontmatter = false, path = SKILL_PATH } = {}) {
12
+ // Normalise CRLF (Windows git checkouts) so the frontmatter strip and the
13
+ // installed copies are identical on every platform.
14
+ const text = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
15
+ return withFrontmatter ? text : text.replace(/^---\n[\s\S]*?\n---\n+/, '').trim();
16
+ }
@@ -1,6 +1,8 @@
1
1
  const DAY_HEADING_RE = /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+(\d{2})\s+([A-Za-z]{3})\s+(\d{4})$/;
2
2
  const ENTRY_RE = /^\+(\d+(?:\.\d+)?)\s+(.+)$/;
3
3
  const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
4
+ const MAX_DAY_HOURS = 24;
5
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
4
6
 
5
7
  const MONTHS = new Map([
6
8
  ['jan', 1],
@@ -67,6 +69,18 @@ export function parseWeeklyLogText(text) {
67
69
  });
68
70
  continue;
69
71
  }
72
+ // RO rejects more than 24h; catching it here keeps a batch from failing
73
+ // halfway after earlier entries were written.
74
+ const dayTotal = entries.filter((item) => item.date === currentDate).reduce((sum, item) => sum + item.hours, 0) + hours;
75
+ if (hours > MAX_DAY_HOURS || dayTotal > MAX_DAY_HOURS) {
76
+ errors.push({
77
+ line: lineNumber,
78
+ message: hours > MAX_DAY_HOURS
79
+ ? `Hours ${entry[1]} exceed ${MAX_DAY_HOURS}h for one entry.`
80
+ : `Entries for ${currentDate} add up to ${dayTotal}h, more than ${MAX_DAY_HOURS}h in one day.`
81
+ });
82
+ continue;
83
+ }
70
84
 
71
85
  const text = entry[2].trim();
72
86
  const { taskName, description, taskExplicit } = splitTaskAndDescription(text);
@@ -137,6 +151,15 @@ function parseHeadingDate(match, lineNumber, errors) {
137
151
  return null;
138
152
  }
139
153
 
154
+ const weekday = WEEKDAYS[date.getUTCDay()];
155
+ if (weekday !== match[1]) {
156
+ errors.push({
157
+ line: lineNumber,
158
+ message: `Date heading says ${match[1]} but ${match[2]} ${match[3]} ${match[4]} is a ${weekday}: ${match[0]}`
159
+ });
160
+ return null;
161
+ }
162
+
140
163
  return [
141
164
  String(year).padStart(4, '0'),
142
165
  String(month).padStart(2, '0'),
@@ -70,7 +70,11 @@ export async function previewLogworkBatch({
70
70
  defaultWorklogTask: normalizedDefaultTask
71
71
  }
72
72
  : enriched;
73
- return typeOfWork ? { ...output, typeOfWork } : output;
73
+ // typeOfWork also changes what gets written, so it is folded into the
74
+ // batchId the same way.
75
+ return typeOfWork
76
+ ? { ...output, typeOfWork, batchId: `${output.batchId}_${hash(`typeOfWork:${typeOfWork}`)}`, summary: `${output.summary}\n\nType of work for every entry: ${typeOfWork}` }
77
+ : output;
74
78
  }
75
79
 
76
80
  export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
@@ -394,6 +398,16 @@ export async function applyLogworkBatch({
394
398
  ok: false,
395
399
  error: error.message
396
400
  });
401
+ // Earlier entries are already in RO. Say so plainly: re-applying the same
402
+ // text would log them twice.
403
+ const written = results.filter((result) => result.ok);
404
+ const notTried = preview.entries.slice(results.length).map((item) => item.id);
405
+ error.message = `Entry ${entry.id} (+${entry.hours}h ${entry.taskName}) failed: ${error.message}` +
406
+ (written.length
407
+ ? ` Already written to RO: ${written.map((result) => `${result.entryId} (+${result.hours}h ${result.taskName})`).join(', ')}. Do not apply this batch again; preview only the entries that were not written.`
408
+ : ' Nothing was written.') +
409
+ (notTried.length ? ` Not attempted: ${notTried.join(', ')}.` : '');
410
+ error.partialResults = results;
397
411
  error.applyLedger = await recordRoApplyLedger({
398
412
  writer: ledgerWriter,
399
413
  preview,