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 +56 -3
- package/RELEASE.md +1 -1
- package/bin/logwork.mjs +3 -0
- package/commands/commits.mjs +82 -0
- package/commands/setup.mjs +111 -3
- package/commands/skill.mjs +91 -0
- package/docs/mcp-setup.md +23 -2
- package/docs/operations-guide.md +2 -0
- package/lib/agent-skill.mjs +16 -0
- package/lib/batch-parser.mjs +23 -0
- package/lib/batch-workflow.mjs +15 -1
- package/lib/commit-workflow.mjs +483 -0
- package/lib/keychain.mjs +19 -12
- package/lib/mcp-server.mjs +56 -5
- package/lib/npm-command.mjs +29 -0
- package/lib/paths.mjs +4 -1
- package/lib/release-doctor.mjs +4 -2
- package/lib/ro-delete-workflow.mjs +3 -7
- package/lib/ro-edit-workflow.mjs +66 -8
- package/lib/ro-entry-reader.mjs +39 -1
- package/lib/ro-resubmit-workflow.mjs +33 -20
- package/lib/update-service.mjs +5 -2
- package/package.json +2 -1
- package/skills/logwork/SKILL.md +44 -0
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/commands/setup.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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 (
|
|
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:
|
package/docs/operations-guide.md
CHANGED
|
@@ -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
|
+
}
|
package/lib/batch-parser.mjs
CHANGED
|
@@ -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'),
|
package/lib/batch-workflow.mjs
CHANGED
|
@@ -70,7 +70,11 @@ export async function previewLogworkBatch({
|
|
|
70
70
|
defaultWorklogTask: normalizedDefaultTask
|
|
71
71
|
}
|
|
72
72
|
: enriched;
|
|
73
|
-
|
|
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,
|