logwork-cli 1.0.0 → 1.1.0
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 +44 -1
- package/RELEASE.md +1 -1
- package/bin/logwork.mjs +3 -0
- package/commands/commits.mjs +82 -0
- package/commands/setup.mjs +4 -0
- package/commands/skill.mjs +91 -0
- package/docs/mcp-setup.md +22 -1
- package/lib/agent-skill.mjs +16 -0
- package/lib/commit-workflow.mjs +402 -0
- package/lib/mcp-server.mjs +41 -1
- package/package.json +2 -1
- package/skills/logwork/SKILL.md +43 -0
package/README.md
CHANGED
|
@@ -67,12 +67,14 @@ Sessions are stored in macOS Keychain or Windows Credential Manager. The default
|
|
|
67
67
|
```text
|
|
68
68
|
logwork Open the terminal UI
|
|
69
69
|
logwork query [period] Booked vs logged hours with entry ids (today, yesterday, this-week, last-week, this-month, last-month)
|
|
70
|
+
logwork commits [period] Draft logwork lines from your git commits (--repo, --author)
|
|
70
71
|
logwork quick [-m text] Log one entry for today with guided prompts
|
|
71
72
|
logwork edit <id> ... Change an existing RO entry (replaces it)
|
|
72
73
|
logwork delete <id> Delete a submitted or approved RO entry
|
|
73
74
|
logwork reconcile Compare RO and Jira hours by day (read-only)
|
|
74
75
|
logwork history Local apply history
|
|
75
76
|
logwork login|logout|status [ro|jira]
|
|
77
|
+
logwork skill install Install the AI agent skill (Claude Code, Codex)
|
|
76
78
|
logwork setup Install the runtime and print MCP configs
|
|
77
79
|
logwork mcp Run the MCP stdio server (used by IDE configs)
|
|
78
80
|
logwork reminder ... Weekday logwork reminder
|
|
@@ -82,6 +84,47 @@ logwork doctor Write a sanitized diagnostics report
|
|
|
82
84
|
|
|
83
85
|
Add `--help` to any command for its options.
|
|
84
86
|
|
|
87
|
+
## Log Work From Your Commits
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
logwork commits yesterday
|
|
93
|
+
logwork commits this-week --repo ~/code/portal --repo ~/code/api
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
How the draft is built:
|
|
97
|
+
|
|
98
|
+
- **Which commits:** your own (`git config user.email`, or `--author`) on every local and remote branch. Stashes and merge commits are skipped.
|
|
99
|
+
- **Grouping:** per day, by repo + ticket key + kind of work.
|
|
100
|
+
- **Worklog task:** chosen from the kind of work.
|
|
101
|
+
|
|
102
|
+
| Commit | Worklog task |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `feat` | Feature Development |
|
|
105
|
+
| `fix`, hotfix | Bug Fix |
|
|
106
|
+
| `refactor`, `perf`, review | Code Review & Refactoring |
|
|
107
|
+
| `test` | Testing Activities |
|
|
108
|
+
| `ci`, `build`, release, deploy | Deployment & Release |
|
|
109
|
+
| `docs`, design | Requirements & Design |
|
|
110
|
+
| `chore` | Other Activities |
|
|
111
|
+
|
|
112
|
+
Conventional prefixes are used first, then keywords in the subject.
|
|
113
|
+
- **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.
|
|
114
|
+
|
|
115
|
+
Nothing is written. Paste the draft into `/logwork`, or let your AI agent use it.
|
|
116
|
+
|
|
117
|
+
For agents:
|
|
118
|
+
- **MCP tool `draft_from_commits`** returns the same draft as `draftText`.
|
|
119
|
+
- **MCP prompt `log_from_commits`** gives an agent the full procedure:
|
|
120
|
+
1. find gaps with `get_logwork`;
|
|
121
|
+
2. draft from commits;
|
|
122
|
+
3. check task names with `list_tasks`;
|
|
123
|
+
4. rewrite vague descriptions;
|
|
124
|
+
5. stay within booked hours;
|
|
125
|
+
6. get your approval, then `log_work`.
|
|
126
|
+
- **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.
|
|
127
|
+
|
|
85
128
|
## Logwork Line Format
|
|
86
129
|
|
|
87
130
|
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 +197,7 @@ claude mcp add --transport stdio logwork -- node ~/.logwork-cli/bin/logwork.mjs
|
|
|
154
197
|
|
|
155
198
|
Restart or reload the IDE's MCP tools after saving.
|
|
156
199
|
|
|
157
|
-
The server exposes
|
|
200
|
+
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
201
|
1. Call the tool without `confirm`. It returns a preview with a `batchId` or `previewId` and a summary.
|
|
159
202
|
2. After the user approves, call the same tool again with only that id and `confirm: true`.
|
|
160
203
|
|
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
|
@@ -435,6 +435,10 @@ export function formatSetupUserInstructions({
|
|
|
435
435
|
'GitHub Copilot / VS Code mcp.json:',
|
|
436
436
|
JSON.stringify(vscodeMcpConfig(entryPath), null, 2),
|
|
437
437
|
'',
|
|
438
|
+
'AI agent skill (log work from your commits)',
|
|
439
|
+
'- Claude Code / Codex: run `logwork skill install`.',
|
|
440
|
+
'- Other MCP clients: use the MCP prompt "log_from_commits".',
|
|
441
|
+
'',
|
|
438
442
|
'Verify',
|
|
439
443
|
'- Restart or reload your IDE MCP tools after pasting config.',
|
|
440
444
|
'- 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. |
|
|
@@ -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:
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { CONFIG } from './config.mjs';
|
|
4
|
+
import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
|
|
5
|
+
import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
6
|
+
|
|
7
|
+
// Builds a logwork draft from the user's own git commits:
|
|
8
|
+
// commits (all branches, this author, in the period)
|
|
9
|
+
// -> grouped per day by repo + ticket + kind of work
|
|
10
|
+
// -> each group gets a default RO worklog task and a description
|
|
11
|
+
// -> the day's hours (RO booked - logged, or a standard day) are split across
|
|
12
|
+
// groups by how much work each group holds
|
|
13
|
+
// -> a "+<hours> <worklog task> | <description>" block for log_work.
|
|
14
|
+
// Nothing is written anywhere; the agent shows the draft, the user edits and
|
|
15
|
+
// approves it, then log_work previews and applies it as usual.
|
|
16
|
+
|
|
17
|
+
const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
|
|
18
|
+
const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
|
|
19
|
+
const FIELD = '\x1f';
|
|
20
|
+
const RECORD = '\x1e';
|
|
21
|
+
const MAX_DESCRIPTION_LENGTH = 240;
|
|
22
|
+
|
|
23
|
+
// Default RO catalog task per kind of work. These are the shared default task
|
|
24
|
+
// names; a project with its own catalog may need a different name (list_tasks).
|
|
25
|
+
export const WORK_KINDS = Object.freeze({
|
|
26
|
+
feature: { task: 'Feature Development', label: 'feature' },
|
|
27
|
+
fix: { task: 'Bug Fix', label: 'fix' },
|
|
28
|
+
refactor: { task: 'Code Review & Refactoring', label: 'refactor/review' },
|
|
29
|
+
test: { task: 'Testing Activities', label: 'test' },
|
|
30
|
+
release: { task: 'Deployment & Release', label: 'build/deploy' },
|
|
31
|
+
design: { task: 'Requirements & Design', label: 'docs/design' },
|
|
32
|
+
other: { task: 'Other Activities', label: 'other' }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const CONVENTIONAL_KINDS = new Map([
|
|
36
|
+
['feat', 'feature'], ['feature', 'feature'],
|
|
37
|
+
['fix', 'fix'], ['bugfix', 'fix'], ['hotfix', 'fix'], ['revert', 'fix'],
|
|
38
|
+
['refactor', 'refactor'], ['perf', 'refactor'], ['style', 'refactor'], ['review', 'refactor'],
|
|
39
|
+
['test', 'test'], ['tests', 'test'], ['e2e', 'test'],
|
|
40
|
+
['ci', 'release'], ['build', 'release'], ['release', 'release'], ['deploy', 'release'],
|
|
41
|
+
['docs', 'design'], ['doc', 'design'], ['design', 'design'], ['spec', 'design'],
|
|
42
|
+
['chore', 'other']
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Keyword fallback for non-conventional subjects, strongest signal first.
|
|
46
|
+
const KEYWORD_KINDS = [
|
|
47
|
+
[/\b(fix|fixes|fixed|bug|hotfix|crash|regression)\b/i, 'fix'],
|
|
48
|
+
[/\b(tests?|e2e|unit test|coverage|qa)\b/i, 'test'],
|
|
49
|
+
[/\b(deploy|deployment|release|bump|pipeline|docker|helm|k8s|kubernetes)\b/i, 'release'],
|
|
50
|
+
[/\b(refactor|cleanup|clean up|rename|review|lint|format)\b/i, 'refactor'],
|
|
51
|
+
[/\b(docs?|readme|design|spec|requirements?|diagram)\b/i, 'design']
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export function classifyCommit(subject = '', body = '') {
|
|
55
|
+
const conventional = CONVENTIONAL_RE.exec(subject);
|
|
56
|
+
if (conventional) {
|
|
57
|
+
const kind = CONVENTIONAL_KINDS.get(conventional[1].toLowerCase());
|
|
58
|
+
if (kind) {
|
|
59
|
+
return kind;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const text of [subject, body]) {
|
|
63
|
+
for (const [pattern, kind] of KEYWORD_KINDS) {
|
|
64
|
+
if (pattern.test(text)) {
|
|
65
|
+
return kind;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return 'feature';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function cleanSubject(subject = '') {
|
|
73
|
+
return String(subject).replace(CONVENTIONAL_RE, '').trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseGitLog(output = '') {
|
|
77
|
+
const commits = [];
|
|
78
|
+
for (const chunk of String(output).split(RECORD)) {
|
|
79
|
+
if (!chunk.trim()) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const [hash, timestamp, authorName, authorEmail, subject, body = '', stats = ''] = chunk.split(FIELD);
|
|
83
|
+
if (!hash || !timestamp) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
let additions = 0;
|
|
87
|
+
let deletions = 0;
|
|
88
|
+
let files = 0;
|
|
89
|
+
for (const line of stats.split(/\r?\n/)) {
|
|
90
|
+
const match = /^(\d+|-)\t(\d+|-)\t/.exec(line.trim());
|
|
91
|
+
if (match) {
|
|
92
|
+
files += 1;
|
|
93
|
+
additions += match[1] === '-' ? 0 : Number(match[1]);
|
|
94
|
+
deletions += match[2] === '-' ? 0 : Number(match[2]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
commits.push({
|
|
98
|
+
hash: hash.trim(),
|
|
99
|
+
timestamp: Number(timestamp),
|
|
100
|
+
authorName: authorName.trim(),
|
|
101
|
+
authorEmail: authorEmail.trim(),
|
|
102
|
+
subject: subject.trim(),
|
|
103
|
+
body: body.trim(),
|
|
104
|
+
files,
|
|
105
|
+
additions,
|
|
106
|
+
deletions
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return commits;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
|
|
113
|
+
const root = (await runGit(repo, ['rev-parse', '--show-toplevel'])).trim();
|
|
114
|
+
const authorFilter = author || (await runGit(root, ['config', 'user.email']).catch(() => '')).trim();
|
|
115
|
+
if (!authorFilter) {
|
|
116
|
+
throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
|
|
117
|
+
}
|
|
118
|
+
const output = await runGit(root, [
|
|
119
|
+
'log',
|
|
120
|
+
// Every local and remote branch (work often sits on feature branches), but
|
|
121
|
+
// not refs/stash, whose "WIP on"/"index on" commits are not work items.
|
|
122
|
+
'--branches',
|
|
123
|
+
'--remotes',
|
|
124
|
+
'--no-merges',
|
|
125
|
+
`--since=${from}T00:00:00`,
|
|
126
|
+
`--until=${to}T00:00:00`,
|
|
127
|
+
`--author=${authorFilter}`,
|
|
128
|
+
'--regexp-ignore-case',
|
|
129
|
+
`--format=${RECORD}%H${FIELD}%at${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${FIELD}`,
|
|
130
|
+
'--numstat'
|
|
131
|
+
]);
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
const commits = parseGitLog(output)
|
|
134
|
+
.filter((commit) => !seen.has(commit.hash) && seen.add(commit.hash))
|
|
135
|
+
.map((commit) => ({ ...commit, repo: root, date: todayLocalDateISO(new Date(commit.timestamp * 1000)) }))
|
|
136
|
+
.filter((commit) => commit.date >= from && commit.date < to);
|
|
137
|
+
return { root, author: authorFilter, commits };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function groupCommits(commits = []) {
|
|
141
|
+
const groups = new Map();
|
|
142
|
+
for (const commit of [...commits].sort((a, b) => a.timestamp - b.timestamp)) {
|
|
143
|
+
const tickets = [...new Set(`${commit.subject}\n${commit.body}`.match(TICKET_RE) || [])];
|
|
144
|
+
const kind = classifyCommit(commit.subject, commit.body);
|
|
145
|
+
const key = [commit.repo, tickets[0] || '', kind].join('|');
|
|
146
|
+
const group = groups.get(key) || {
|
|
147
|
+
repo: commit.repo,
|
|
148
|
+
repoName: commit.repo.split(/[\\/]/).filter(Boolean).pop() || commit.repo,
|
|
149
|
+
tickets: [],
|
|
150
|
+
kind,
|
|
151
|
+
task: WORK_KINDS[kind].task,
|
|
152
|
+
subjects: [],
|
|
153
|
+
commitCount: 0,
|
|
154
|
+
linesChanged: 0,
|
|
155
|
+
hashes: []
|
|
156
|
+
};
|
|
157
|
+
group.tickets = [...new Set([...group.tickets, ...tickets])];
|
|
158
|
+
const subject = cleanSubject(commit.subject);
|
|
159
|
+
if (subject && !group.subjects.includes(subject)) {
|
|
160
|
+
group.subjects.push(subject);
|
|
161
|
+
}
|
|
162
|
+
group.commitCount += 1;
|
|
163
|
+
group.linesChanged += commit.additions + commit.deletions;
|
|
164
|
+
group.hashes.push(commit.hash.slice(0, 8));
|
|
165
|
+
groups.set(key, group);
|
|
166
|
+
}
|
|
167
|
+
return [...groups.values()].map((group) => ({
|
|
168
|
+
...group,
|
|
169
|
+
description: buildDescription(group),
|
|
170
|
+
weight: group.commitCount + Math.min(3, group.linesChanged / 200)
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function buildDescription(group) {
|
|
175
|
+
const ticketSuffix = group.tickets.length ? ` (${group.tickets.join(', ')})` : '';
|
|
176
|
+
const room = MAX_DESCRIPTION_LENGTH - ticketSuffix.length;
|
|
177
|
+
const subjects = group.subjects.map((subject) => subject.replace(TICKET_RE, '').replace(/\s*[[(]\s*[)\]]\s*/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
178
|
+
let text = '';
|
|
179
|
+
for (const subject of subjects) {
|
|
180
|
+
const next = text ? `${text}; ${subject}` : subject;
|
|
181
|
+
if (next.length > room) {
|
|
182
|
+
text = text || subject.slice(0, Math.max(0, room - 1)).trim();
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
text = next;
|
|
186
|
+
}
|
|
187
|
+
return `${text || group.repoName}${ticketSuffix}`.replace(/\|/g, '/');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Splits targetHours across groups by weight in hourStep increments. Every
|
|
191
|
+
// group that gets time gets at least one step; when the day has fewer steps than
|
|
192
|
+
// groups, the heaviest groups win and the rest get 0 (still listed).
|
|
193
|
+
export function allocateHours(groups, targetHours, step = CONFIG.hourStep || 0.5) {
|
|
194
|
+
const totalSteps = Math.max(0, Math.floor((Number(targetHours) || 0) / step + 1e-9));
|
|
195
|
+
const ordered = groups.map((group, index) => ({ index, weight: group.weight || 1 }))
|
|
196
|
+
.sort((a, b) => b.weight - a.weight);
|
|
197
|
+
const steps = new Array(groups.length).fill(0);
|
|
198
|
+
if (!totalSteps || !groups.length) {
|
|
199
|
+
return steps.map(() => 0);
|
|
200
|
+
}
|
|
201
|
+
const funded = ordered.slice(0, Math.min(totalSteps, ordered.length));
|
|
202
|
+
for (const item of funded) {
|
|
203
|
+
steps[item.index] = 1;
|
|
204
|
+
}
|
|
205
|
+
let remaining = totalSteps - funded.length;
|
|
206
|
+
const totalWeight = funded.reduce((sum, item) => sum + item.weight, 0);
|
|
207
|
+
const shares = funded.map((item) => ({ ...item, exact: (item.weight / totalWeight) * remaining }));
|
|
208
|
+
for (const share of shares) {
|
|
209
|
+
const whole = Math.floor(share.exact);
|
|
210
|
+
steps[share.index] += whole;
|
|
211
|
+
remaining -= whole;
|
|
212
|
+
}
|
|
213
|
+
shares.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)));
|
|
214
|
+
for (let i = 0; remaining > 0; i = (i + 1) % shares.length) {
|
|
215
|
+
steps[shares[i].index] += 1;
|
|
216
|
+
remaining -= 1;
|
|
217
|
+
}
|
|
218
|
+
return steps.map((count) => Number((count * step).toFixed(2)));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function formatDayHeading(localDateISO) {
|
|
222
|
+
const [year, month, day] = localDateISO.split('-').map(Number);
|
|
223
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
224
|
+
const weekday = date.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' });
|
|
225
|
+
const monthName = date.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' });
|
|
226
|
+
return `${weekday}, ${String(day).padStart(2, '0')} ${monthName} ${year}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isWeekend(localDateISO) {
|
|
230
|
+
const [year, month, day] = localDateISO.split('-').map(Number);
|
|
231
|
+
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
232
|
+
return weekday === 0 || weekday === 6;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Remaining RO time per day: sum over projects of max(0, booked - logged), so
|
|
236
|
+
// over-logging one project does not hide a gap on another.
|
|
237
|
+
export function remainingHoursByDay(roResult) {
|
|
238
|
+
const byDay = new Map();
|
|
239
|
+
for (const day of roResult?.days || []) {
|
|
240
|
+
const projects = day.projects || [];
|
|
241
|
+
const remaining = projects.length
|
|
242
|
+
? projects.reduce((sum, project) => sum + Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)), 0)
|
|
243
|
+
: Math.max(0, Number(day.bookedHours || 0) - Number(day.loggedHours || 0));
|
|
244
|
+
byDay.set(day.date, {
|
|
245
|
+
bookedHours: Number(day.bookedHours || 0),
|
|
246
|
+
loggedHours: Number(day.loggedHours || 0),
|
|
247
|
+
remainingHours: Number(remaining.toFixed(2))
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return byDay;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function draftLogworkFromCommits({
|
|
254
|
+
period = 'today',
|
|
255
|
+
from,
|
|
256
|
+
to,
|
|
257
|
+
repos,
|
|
258
|
+
author,
|
|
259
|
+
useBookings = true,
|
|
260
|
+
cwd = process.cwd(),
|
|
261
|
+
runGit = runGitCommand,
|
|
262
|
+
queryRo = queryLogwork
|
|
263
|
+
} = {}) {
|
|
264
|
+
const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
|
|
265
|
+
const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, String(repo))))];
|
|
266
|
+
|
|
267
|
+
const repoResults = [];
|
|
268
|
+
const commits = [];
|
|
269
|
+
for (const repo of repoPaths) {
|
|
270
|
+
try {
|
|
271
|
+
const result = await readRepoCommits({ repo, from: range.from, to: range.to, author, runGit });
|
|
272
|
+
repoResults.push({ path: result.root, author: result.author, commitCount: result.commits.length });
|
|
273
|
+
commits.push(...result.commits);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
repoResults.push({ path: repo, error: error.message, commitCount: 0 });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let bookings = null;
|
|
280
|
+
let bookingsError = '';
|
|
281
|
+
if (useBookings) {
|
|
282
|
+
try {
|
|
283
|
+
bookings = remainingHoursByDay(await queryRo({ from: range.from, to: range.to, includeEntries: false, cwd }));
|
|
284
|
+
} catch (error) {
|
|
285
|
+
bookingsError = error.message;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const days = [];
|
|
290
|
+
for (let date = range.from; date < range.to; date = addLocalDays(date, 1)) {
|
|
291
|
+
const dayCommits = commits.filter((commit) => commit.date === date);
|
|
292
|
+
if (!dayCommits.length) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const groups = groupCommits(dayCommits);
|
|
296
|
+
const booking = bookings?.get(date);
|
|
297
|
+
let targetHours;
|
|
298
|
+
let hoursSource;
|
|
299
|
+
if (booking) {
|
|
300
|
+
targetHours = booking.remainingHours;
|
|
301
|
+
hoursSource = booking.bookedHours ? 'ro_remaining' : 'ro_no_booking';
|
|
302
|
+
} else if (bookings) {
|
|
303
|
+
// RO answered but has no row for this day: nothing is booked.
|
|
304
|
+
targetHours = 0;
|
|
305
|
+
hoursSource = 'ro_no_booking';
|
|
306
|
+
} else {
|
|
307
|
+
targetHours = isWeekend(date) ? 0 : (CONFIG.standardDayHours || 8);
|
|
308
|
+
hoursSource = isWeekend(date) ? 'weekend' : 'standard_day';
|
|
309
|
+
}
|
|
310
|
+
const hours = allocateHours(groups, targetHours);
|
|
311
|
+
days.push({
|
|
312
|
+
date,
|
|
313
|
+
targetHours,
|
|
314
|
+
hoursSource,
|
|
315
|
+
...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours } : {}),
|
|
316
|
+
groups: groups.map((group, index) => ({
|
|
317
|
+
hours: hours[index],
|
|
318
|
+
task: group.task,
|
|
319
|
+
kind: group.kind,
|
|
320
|
+
description: group.description,
|
|
321
|
+
tickets: group.tickets,
|
|
322
|
+
repo: group.repoName,
|
|
323
|
+
commitCount: group.commitCount,
|
|
324
|
+
linesChanged: group.linesChanged,
|
|
325
|
+
commits: group.hashes
|
|
326
|
+
}))
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const draftText = days
|
|
331
|
+
.map((day) => {
|
|
332
|
+
const lines = day.groups.filter((group) => group.hours > 0)
|
|
333
|
+
.map((group) => `+${group.hours} ${group.task} | ${group.description}`);
|
|
334
|
+
return lines.length ? [formatDayHeading(day.date), ...lines].join('\n') : '';
|
|
335
|
+
})
|
|
336
|
+
.filter(Boolean)
|
|
337
|
+
.join('\n\n');
|
|
338
|
+
|
|
339
|
+
const result = {
|
|
340
|
+
status: commits.length ? 'draft' : 'no_commits',
|
|
341
|
+
range,
|
|
342
|
+
repos: repoResults,
|
|
343
|
+
bookings: useBookings ? (bookings ? 'ro' : 'unavailable') : 'skipped',
|
|
344
|
+
...(bookingsError ? { bookingsError } : {}),
|
|
345
|
+
commitCount: commits.length,
|
|
346
|
+
days,
|
|
347
|
+
draftText
|
|
348
|
+
};
|
|
349
|
+
result.summary = formatCommitDraftSummary(result);
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function formatCommitDraftSummary(result) {
|
|
354
|
+
const lines = [];
|
|
355
|
+
if (!result.commitCount) {
|
|
356
|
+
lines.push(`No commits found from ${result.range.from} to ${addLocalDays(result.range.to, -1)}.`);
|
|
357
|
+
} else {
|
|
358
|
+
lines.push(`${result.commitCount} commit(s) from ${result.range.from} to ${addLocalDays(result.range.to, -1)}.`);
|
|
359
|
+
}
|
|
360
|
+
for (const repo of result.repos) {
|
|
361
|
+
lines.push(repo.error ? `- ${repo.path}: ${repo.error}` : `- ${repo.path} (${repo.author}): ${repo.commitCount} commit(s)`);
|
|
362
|
+
}
|
|
363
|
+
if (result.bookings === 'unavailable') {
|
|
364
|
+
lines.push(`RO bookings unavailable (${result.bookingsError || 'unknown error'}); hours assume a ${CONFIG.standardDayHours || 8}h weekday.`);
|
|
365
|
+
}
|
|
366
|
+
for (const day of result.days) {
|
|
367
|
+
const source = {
|
|
368
|
+
ro_remaining: `RO remaining ${day.targetHours}h (booked ${day.bookedHours}h, logged ${day.loggedHours}h)`,
|
|
369
|
+
ro_no_booking: 'no RO booking: 0h suggested',
|
|
370
|
+
weekend: 'weekend: 0h suggested',
|
|
371
|
+
standard_day: `standard day ${day.targetHours}h`
|
|
372
|
+
}[day.hoursSource];
|
|
373
|
+
lines.push('', `${formatDayHeading(day.date)} - ${source}`);
|
|
374
|
+
for (const group of day.groups) {
|
|
375
|
+
lines.push(` +${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)]`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (result.draftText) {
|
|
379
|
+
lines.push('', 'Draft for log_work (review with the user first; worklog task names must exist in list_tasks for the project):', '', result.draftText);
|
|
380
|
+
}
|
|
381
|
+
return lines.join('\n');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function runGitCommand(cwd, args) {
|
|
385
|
+
return new Promise((resolveValue, reject) => {
|
|
386
|
+
const child = spawn('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
387
|
+
let stdout = '';
|
|
388
|
+
let stderr = '';
|
|
389
|
+
child.stdout.setEncoding('utf8');
|
|
390
|
+
child.stderr.setEncoding('utf8');
|
|
391
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
392
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
393
|
+
child.on('error', reject);
|
|
394
|
+
child.on('close', (code) => {
|
|
395
|
+
if (code === 0) {
|
|
396
|
+
resolveValue(stdout);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
reject(new Error(stderr.trim().split('\n')[0] || `git ${args[0]} failed with exit code ${code}`));
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
}
|
package/lib/mcp-server.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ApiError } from './api.mjs';
|
|
5
5
|
import { queryApplyLedger } from './apply-ledger.mjs';
|
|
6
|
+
import { readLogworkSkill } from './agent-skill.mjs';
|
|
7
|
+
import { draftLogworkFromCommits } from './commit-workflow.mjs';
|
|
6
8
|
import {
|
|
7
9
|
applyLogworkBatch,
|
|
8
10
|
formatToolResponse,
|
|
@@ -73,7 +75,8 @@ Logwork text format (log_work and log_jira_work):
|
|
|
73
75
|
- Lines without "|" are accepted only when the text is itself a catalog task name (no description is written), or when defaultWorklogTask is passed (the text then becomes the description).
|
|
74
76
|
- Jira needs exactly one issue key such as SCB-213 in the line.
|
|
75
77
|
|
|
76
|
-
Recommended RO flow: get_logwork (what is booked/logged) ->
|
|
78
|
+
Recommended RO flow: get_logwork (what is booked/logged) -> draft_from_commits (the user's git commits as draft lines) -> list_tasks(projectId) -> log_work(text) -> user approval -> log_work(batchId, confirm: true).
|
|
79
|
+
Base every line on the user's commits or on what the user tells you; never invent work. The log_from_commits prompt has the full procedure.
|
|
77
80
|
Fixing existing RO entries (logworkId from get_logwork): submitted/approved entries -> edit_entry (hours, worklog task, type of work, description; apply replaces the entry with a new id, approved immediately); rejected entries -> resubmit_entry; removing an entry -> delete_entry.
|
|
78
81
|
If a preview entry is unresolved: worklog_task_required / task_not_found means fix the worklog task (see availableTaskNames); project reasons mean ask the user to pick a project (projectOverrides) or save a mapping with map_project.`;
|
|
79
82
|
|
|
@@ -128,6 +131,20 @@ server.registerTool('list_tasks', {
|
|
|
128
131
|
}
|
|
129
132
|
}, withAuthRequiredHandling(async ({ projectId }) => formatToolResponse(await listWorklogTasks({ projectId }))));
|
|
130
133
|
|
|
134
|
+
server.registerTool('draft_from_commits', {
|
|
135
|
+
title: 'Draft logwork from git commits',
|
|
136
|
+
description: 'Read-only: read the user\'s own git commits (all branches, git user.email) in one or more repos for a period, group them per day by repo + ticket + kind of work, suggest an RO worklog task per group (feat -> Feature Development, fix -> Bug Fix, refactor/review -> Code Review & Refactoring, test -> Testing Activities, ci/build/release -> Deployment & Release, docs -> Requirements & Design), and split each day\'s remaining RO hours (booked - logged) across the groups. Returns draftText in the log_work line format. Review it with the user, check task names with list_tasks, then pass it to log_work.',
|
|
137
|
+
annotations: READ_ONLY,
|
|
138
|
+
inputSchema: {
|
|
139
|
+
period: z.enum(PERIOD_VALUES).optional().describe('Preset period. Defaults to today.'),
|
|
140
|
+
repos: z.array(z.string().min(1)).max(20).optional().describe('Absolute paths of the git repos the user worked in (for example the open workspace folders). Defaults to the MCP server working directory.'),
|
|
141
|
+
author: z.string().min(1).optional().describe('Git author filter (email or name). Defaults to each repo\'s git config user.email.'),
|
|
142
|
+
useBookings: z.boolean().optional().describe('Split hours by RO remaining booked time per day (default true). When false or RO is unavailable, a weekday counts as 8h.')
|
|
143
|
+
}
|
|
144
|
+
}, withAuthRequiredHandling(async ({ period, repos, author, useBookings = true }) => {
|
|
145
|
+
return formatToolResponse(await draftLogworkFromCommits({ period, repos, author, useBookings }));
|
|
146
|
+
}));
|
|
147
|
+
|
|
131
148
|
server.registerTool('log_work', {
|
|
132
149
|
title: 'Log work to Resource Optimiser',
|
|
133
150
|
description: `Resource Optimiser only: create new RO entries (worklog task + hours + date + type of work + description) from a weekly logwork block. ${TWO_STEP} The preview resolves the project (bookings, mappings, ticket prefixes) and worklog task per line; unresolved lines block apply. Use log_jira_work for Jira.`,
|
|
@@ -373,6 +390,29 @@ server.registerTool('update', {
|
|
|
373
390
|
return formatToolResponse(await installUpdate({ version, confirm, stdio: 'pipe' }));
|
|
374
391
|
});
|
|
375
392
|
|
|
393
|
+
server.registerPrompt('log_from_commits', {
|
|
394
|
+
title: 'Log work from my commits',
|
|
395
|
+
description: 'Step-by-step procedure for logging accurate Resource Optimiser (and optionally Jira) work from the user\'s git commits.',
|
|
396
|
+
argsSchema: {
|
|
397
|
+
period: z.string().optional().describe('today, yesterday, this_week, last_week, this_month or last_month. Defaults to today.'),
|
|
398
|
+
repos: z.string().optional().describe('Comma-separated absolute repo paths. Defaults to the open workspace.')
|
|
399
|
+
}
|
|
400
|
+
}, ({ period, repos }) => ({
|
|
401
|
+
messages: [{
|
|
402
|
+
role: 'user',
|
|
403
|
+
content: {
|
|
404
|
+
type: 'text',
|
|
405
|
+
text: [
|
|
406
|
+
readLogworkSkill(),
|
|
407
|
+
'',
|
|
408
|
+
`Period: ${period || 'today'}.`,
|
|
409
|
+
repos ? `Repos: ${repos}.` : 'Repos: the folders open in this workspace (ask me if there are others).',
|
|
410
|
+
'Start with get_logwork, then draft_from_commits.'
|
|
411
|
+
].join('\n')
|
|
412
|
+
}
|
|
413
|
+
}]
|
|
414
|
+
}));
|
|
415
|
+
|
|
376
416
|
export async function startMcpServer() {
|
|
377
417
|
const transport = new StdioServerTransport();
|
|
378
418
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "logwork-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Log work to Resource Optimiser and Jira from the terminal, an MCP server for AI IDEs, and a terminal UI.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"bin/",
|
|
39
39
|
"commands/",
|
|
40
40
|
"lib/",
|
|
41
|
+
"skills/",
|
|
41
42
|
"docs/",
|
|
42
43
|
"examples/mcp/",
|
|
43
44
|
"setup.sh",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: logwork
|
|
3
|
+
description: Log work hours to Resource Optimiser (RO) and Jira from the user's own git commits with the logwork MCP tools. Use when the user asks to log work, fill in logwork or timesheets for a day or week, check missing hours, or fix existing RO entries.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Logwork from commits
|
|
7
|
+
|
|
8
|
+
Goal: every logged line is backed by real work (the user's commits or what the user tells you), uses a valid RO worklog task, has a clear description, and fits the day's booked hours.
|
|
9
|
+
|
|
10
|
+
## Steps
|
|
11
|
+
|
|
12
|
+
1. **Find the gaps.** Call `get_logwork` for the period. A day/project still needs time when booked > logged. Skip days that are already full.
|
|
13
|
+
2. **Collect the evidence.** Call `draft_from_commits` with the same period and `repos` set to every repo the user worked in (the open workspace folders; ask the user for others). It groups the user's commits per day by repo, ticket and kind of work, suggests a worklog task per group, and splits each day's remaining RO hours across the groups. Treat its hours as a starting point, not a fact.
|
|
14
|
+
3. **Check the worklog tasks.** For each project involved, call `list_tasks(projectId)`. Keep the suggested task when it exists in that catalog; otherwise pick the catalog task whose description matches the work.
|
|
15
|
+
4. **Write the descriptions.** One line per piece of work: what was done and the outcome, with ticket keys, in English, under about 200 characters, no `|`. Rewrite vague commit subjects ("wip", "fix", "update"); use `get_jira_issue` to read the ticket when the subject alone is unclear.
|
|
16
|
+
5. **Set the hours.**
|
|
17
|
+
- Stay within the remaining booked hours per day and project unless the user says otherwise.
|
|
18
|
+
- Use 0.5h steps.
|
|
19
|
+
- Do not log weekends or days without a booking unless the user asks.
|
|
20
|
+
- Work without commits (meetings, reviews, support) only comes from the user; ask, then log it as `Other Activities` or `Code Review & Refactoring`.
|
|
21
|
+
6. **Resolve projects.** Ticket prefixes and mappings pick the project. If a line stays unresolved, ask the user and offer `map_project` so it is automatic next time.
|
|
22
|
+
7. **Get approval, then write.**
|
|
23
|
+
- Show the final block to the user.
|
|
24
|
+
- On approval, call `log_work(text)` and show its preview summary.
|
|
25
|
+
- Then call `log_work(batchId, confirm: true)`.
|
|
26
|
+
- If the user also logs Jira, do the same with `log_jira_work`. Jira needs exactly one issue key per line.
|
|
27
|
+
|
|
28
|
+
## Line format
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
Friday, 06 Nov 2026
|
|
32
|
+
+2 Feature Development | Implement SSO login with ADFS (STM-4)
|
|
33
|
+
+1 Bug Fix | Fix date picker timezone offset (SAP-42)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`+<hours> <worklog task> | <description>`. The task is an RO catalog name or `#<id>`.
|
|
37
|
+
|
|
38
|
+
## Rules
|
|
39
|
+
|
|
40
|
+
- Never invent work that is not in the commits or in what the user told you. When unsure, ask.
|
|
41
|
+
- Never set `confirm: true` without the user's explicit approval of that preview.
|
|
42
|
+
- Never ask for or pass passwords, OTPs or tokens. On `authRequired`, call `login` and let the user finish in the terminal.
|
|
43
|
+
- To fix an existing entry, use `edit_entry` (submitted or approved entries) or `resubmit_entry` (rejected entries). Do not log a duplicate.
|