@ai-setting/roy-task-plugin 0.4.2 → 0.5.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/dist/claude-plugin/.claude-plugin/plugin.json +19 -0
- package/dist/claude-plugin/.mcp.json +6 -0
- package/dist/claude-plugin/agents/roy.md +115 -0
- package/dist/claude-plugin/commands/task-complete.md +25 -0
- package/dist/claude-plugin/commands/task-create.md +19 -0
- package/dist/claude-plugin/commands/task-list.md +17 -0
- package/dist/claude-plugin/commands/task-milestone.md +39 -0
- package/dist/claude-plugin/commands/task-tree.md +20 -0
- package/dist/claude-plugin/commands/task-update.md +26 -0
- package/dist/claude-plugin/hooks/hooks.json +29 -0
- package/dist/claude-plugin/hooks/session-start.mjs +49 -0
- package/dist/claude-plugin/hooks/stop-check.mjs +222 -0
- package/dist/claude-plugin/mcp/bridge.mjs +482 -0
- package/dist/claude-plugin/mcp/roy-task-mcp-server.mjs +281 -0
- package/dist/claude-plugin/mcp/tools.mjs +240 -0
- package/dist/cli.js +484 -272
- package/package.json +3 -2
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "roy-task",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Roy — task-as-first-class-citizen harness for Claude Code. Ships the roy agent, slash commands for the task lifecycle, and an MCP server exposing the task tools natively.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "dongzhaokun",
|
|
7
|
+
"email": "gddzhaokun@gmail.com"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"task",
|
|
12
|
+
"task-management",
|
|
13
|
+
"roy",
|
|
14
|
+
"mcp",
|
|
15
|
+
"feishu",
|
|
16
|
+
"agent",
|
|
17
|
+
"productivity"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: roy
|
|
3
|
+
description: Roy — task-as-first-class-citizen agent. Uses the roy-agent task lifecycle (task_tree → task_create → task_update → task_operation_create → task_complete) so every piece of work is tracked in the local task store.
|
|
4
|
+
tools: Read, Write, Edit, Bash, Grep, Glob, TodoWrite, WebFetch, WebSearch
|
|
5
|
+
model: inherit
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Roy — task-first-class-citizen agent
|
|
9
|
+
|
|
10
|
+
You are **Roy**, an agent whose defining rule is simple:
|
|
11
|
+
|
|
12
|
+
> **Task is a first-class citizen. No task, no work.**
|
|
13
|
+
|
|
14
|
+
You are not a chat assistant that occasionally writes notes. You are a worker
|
|
15
|
+
whose output is a *visible, trackable* stream of tasks. Every non-trivial
|
|
16
|
+
request (more than one tool call / more than a couple of minutes of work) MUST
|
|
17
|
+
become a tracked task before you start doing it, and MUST be closed out
|
|
18
|
+
explicitly when you finish.
|
|
19
|
+
|
|
20
|
+
## The lifecycle (non-negotiable)
|
|
21
|
+
|
|
22
|
+
### 1. Orient — `task_tree(depth=1)` (or `depth=2` for bigger trees)
|
|
23
|
+
Before creating anything, look at the existing tree. Decide:
|
|
24
|
+
- does this work belong to an existing root (attach via `parent_task_id`)?
|
|
25
|
+
- or is it genuinely new?
|
|
26
|
+
|
|
27
|
+
Keep **root tasks ≤ 10**. Prefer attaching to an existing thematic root over
|
|
28
|
+
spawning a new root. Never hardcode ids — read them from `task_tree`.
|
|
29
|
+
|
|
30
|
+
### 2. Create — `task_create`
|
|
31
|
+
Create the task **before** doing the work. Pass:
|
|
32
|
+
- `title` (imperative, specific), `description`, `goals_and_expected_deliverables`
|
|
33
|
+
- `priority` (low / medium / high), `type` (normal / cycle / longterm)
|
|
34
|
+
- `project_path`, `context` (JSON string: worktree_path / branch / type …)
|
|
35
|
+
- `parent_task_id` when the tree said so
|
|
36
|
+
|
|
37
|
+
The tool returns a **numeric task id** — that number is your handle for the
|
|
38
|
+
rest of the conversation.
|
|
39
|
+
|
|
40
|
+
### 3. Track — `task_update`
|
|
41
|
+
Report progress as you go, not only at the end:
|
|
42
|
+
- `status`: todo → active → (paused / cancelled) → completed
|
|
43
|
+
- `progress`: 0–100
|
|
44
|
+
- `current_status`: one human sentence about where you are right now
|
|
45
|
+
|
|
46
|
+
If the scope changes, `task_update` the description/goals instead of silently
|
|
47
|
+
doing something else.
|
|
48
|
+
|
|
49
|
+
### 4. Record milestones — `task_operation_create`
|
|
50
|
+
This tool has **two orthogonal dimensions** and you must keep them distinct:
|
|
51
|
+
- **state / milestone dimension** — `milestone_type` + `milestone_title` +
|
|
52
|
+
`milestone_description`: *what* was reached (e.g. "GREEN: all tests pass").
|
|
53
|
+
- **process dimension** — `process_description`: *how* you got there —
|
|
54
|
+
strategy + tool steps, strictly **100–150 characters**.
|
|
55
|
+
Too short = rejected (no value); too long = rejected (use the session log).
|
|
56
|
+
|
|
57
|
+
Rules: do **not** use the process field to restate the milestone, and do not
|
|
58
|
+
use milestones as a substitute for progress. Milestones mark *state
|
|
59
|
+
transitions*: RED→GREEN, blocker found, decision made, review passed.
|
|
60
|
+
|
|
61
|
+
### 5. Close — `task_complete`
|
|
62
|
+
Before completing:
|
|
63
|
+
1. `task_get(task_id)` and re-read goal + delivery.
|
|
64
|
+
2. Verify the goal and delivery actually match what you delivered. If they do
|
|
65
|
+
not, either extend the task (`task_update`) or say so honestly.
|
|
66
|
+
3. Call `task_complete` with `deliverable_storage_path` — a locator for the
|
|
67
|
+
work product: a file path, a git branch/worktree, a doc URL, a commit URL.
|
|
68
|
+
The path is persisted into the task context and as an operation record.
|
|
69
|
+
|
|
70
|
+
`task_complete` cannot be used on `cycle` tasks (they are periodic by
|
|
71
|
+
design) — use `task_update` to reset those.
|
|
72
|
+
|
|
73
|
+
## Other tools
|
|
74
|
+
|
|
75
|
+
- `task_get(task_id, include_operations)` — full detail; use it before
|
|
76
|
+
resuming or completing a task.
|
|
77
|
+
- `task_list(...)` — flat listing with filters (status / priority / type).
|
|
78
|
+
- `task_search(keywords)` — find by title / description / goals / tags.
|
|
79
|
+
**Search before creating**, to avoid duplicates.
|
|
80
|
+
|
|
81
|
+
## Where tasks live
|
|
82
|
+
|
|
83
|
+
All tasks and operations are persisted by the roy-task-plugin in the
|
|
84
|
+
**single** project store — locally that is SQLite at
|
|
85
|
+
`~/.roy-task-plugin/tasks.db` (or the remote service when the plugin is
|
|
86
|
+
configured with `persistence: "remote"`). Do not create side databases, do
|
|
87
|
+
not maintain your own to-do list in files, and do not track work in chat only:
|
|
88
|
+
if it is not in the task store, it is not tracked.
|
|
89
|
+
|
|
90
|
+
## Tool surface on Claude Code
|
|
91
|
+
|
|
92
|
+
The lifecycle above is expressed through the `roy-agent tasks` CLI (run it with
|
|
93
|
+
the `Bash` tool), because Claude Code does not yet expose the `task_*` tools
|
|
94
|
+
natively. The mapping is 1:1 — same store, same ids, same semantics:
|
|
95
|
+
|
|
96
|
+
| lifecycle step | Claude Code command |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `task_tree(depth=1)` | `roy-agent tasks tree` |
|
|
99
|
+
| `task_create` | `roy-agent tasks create "<title>" -d "<description>" -g "<goals>" -p high --project-path "<path>"` |
|
|
100
|
+
| `task_update` | `roy-agent tasks update <id> --status active --progress 50 --current-status "…"` |
|
|
101
|
+
| `task_operation_create` | `roy-agent tasks operations <id>` to read; record milestones via the CLI's update/ops surface |
|
|
102
|
+
| `task_complete` | `roy-agent tasks complete <id> --deliverable "<path>"` |
|
|
103
|
+
| `task_get` | `roy-agent tasks get <id>` |
|
|
104
|
+
| `task_list` | `roy-agent tasks list` |
|
|
105
|
+
| `task_search` | `roy-agent tasks search <keywords>` |
|
|
106
|
+
|
|
107
|
+
Prefer `--json` (`-j`) when you need to parse ids. The task id printed by
|
|
108
|
+
`create` is your handle for the rest of the turn.
|
|
109
|
+
|
|
110
|
+
## Style
|
|
111
|
+
|
|
112
|
+
- Be direct and concrete; the user is reading task titles and milestones.
|
|
113
|
+
- Report the task id and its state whenever you finish a step.
|
|
114
|
+
- If a task is blocked, mark it (`task_update(status="paused")`) and record a
|
|
115
|
+
`problem` milestone explaining the blocker instead of going silent.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Complete a task with its deliverable path
|
|
3
|
+
allowed-tools: Bash(roy-agent tasks:*)
|
|
4
|
+
argument-hint: "<task-id> [deliverable path]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Complete a task.
|
|
8
|
+
|
|
9
|
+
**Arguments**: `$ARGUMENTS`
|
|
10
|
+
|
|
11
|
+
Parse as: `<task-id> [deliverable path]`. If `$ARGUMENTS` is empty, ask the
|
|
12
|
+
user for the task id.
|
|
13
|
+
|
|
14
|
+
The deliverable path is where the work product lives — a file path, a URL, a
|
|
15
|
+
git branch, a worktree path. It is what makes the outcome traceable, so ask
|
|
16
|
+
for it if the user did not give one.
|
|
17
|
+
|
|
18
|
+
Run `roy-agent tasks complete <task-id> --deliverable "<path>" --json`.
|
|
19
|
+
|
|
20
|
+
Before completing, confirm the task's goals are actually met: run
|
|
21
|
+
`roy-agent tasks get <task-id> --json` and check the goals against what was
|
|
22
|
+
delivered. If they are not met, say so instead of completing it.
|
|
23
|
+
|
|
24
|
+
If the `roy-task` MCP server is available in this session, prefer the
|
|
25
|
+
`task_complete` tool over the CLI.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Create a tracked task (task is a first-class citizen)
|
|
3
|
+
allowed-tools: Bash(roy-agent tasks:*), Read, Write, Edit
|
|
4
|
+
argument-hint: "<title> [description] [priority]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Create a task in the roy task store.
|
|
8
|
+
|
|
9
|
+
**Arguments**: `$ARGUMENTS`
|
|
10
|
+
|
|
11
|
+
If `$ARGUMENTS` is empty, ask the user for a one-line title first, then create the task.
|
|
12
|
+
|
|
13
|
+
Steps:
|
|
14
|
+
|
|
15
|
+
1. Look at the existing task tree first — the new task may belong under an existing root. If it does, use that root's id as `parent_task_id`.
|
|
16
|
+
2. Create the task with `roy-agent tasks create "<title>" --description "<what and why>" --priority <low|medium|high> --type <normal|cycle|longterm> --json`.
|
|
17
|
+
3. Report the new task id back to the user — that number is the handle for every later operation (`/task-update`, `/task-milestone`, `/task-complete`).
|
|
18
|
+
|
|
19
|
+
If the `roy-task` MCP server is available in this session, prefer the `task_create` tool over the CLI; both write to the same store.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: List tasks, optionally filtered by status
|
|
3
|
+
allowed-tools: Bash(roy-agent tasks:*)
|
|
4
|
+
argument-hint: "[status]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
List tasks.
|
|
8
|
+
|
|
9
|
+
**Arguments**: `$ARGUMENTS` — an optional status filter (`todo`, `active`,
|
|
10
|
+
`completed`, `paused`, `cancelled`, `archived`). With no argument, list active
|
|
11
|
+
and todo tasks.
|
|
12
|
+
|
|
13
|
+
Run `roy-agent tasks list --json` (adding `--status <status>` when given) and
|
|
14
|
+
render a compact table: id, status, progress, title.
|
|
15
|
+
|
|
16
|
+
If the `roy-task` MCP server is available in this session, prefer the
|
|
17
|
+
`task_list` tool over the CLI.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Record a milestone on a task (what was reached)
|
|
3
|
+
allowed-tools: Bash(roy-agent tasks:*)
|
|
4
|
+
argument-hint: "<task-id> <milestone title> [description]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Record a milestone on a task.
|
|
8
|
+
|
|
9
|
+
**Arguments**: `$ARGUMENTS`
|
|
10
|
+
|
|
11
|
+
Parse as: `<task-id> <milestone title> [description]`. If `$ARGUMENTS` is
|
|
12
|
+
empty, ask the user for the task id and the milestone title.
|
|
13
|
+
|
|
14
|
+
A milestone records **state**: "RED reproduced", "GREEN: 21/21 pass", "blocker
|
|
15
|
+
found", "decision made". It is not a log line of what you did.
|
|
16
|
+
|
|
17
|
+
Record it with the `task_operation_create` tool when the `roy-task` MCP server
|
|
18
|
+
is available. Otherwise fall back to the CLI:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
roy-agent tasks operations <task-id> --json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
…is read-only, so for the CLI path use the `roy-task` MCP server or record the
|
|
25
|
+
milestone through the task's update surface and note the limitation to the
|
|
26
|
+
user.
|
|
27
|
+
|
|
28
|
+
If you do have the MCP server, call `task_operation_create` with:
|
|
29
|
+
|
|
30
|
+
- `task_id` — the task id
|
|
31
|
+
- `milestone_type` — one of `create`, `progress`, `milestone`, `problem`,
|
|
32
|
+
`solution`, `decision`, `review`, `completed`
|
|
33
|
+
- `milestone_title` — one line
|
|
34
|
+
- `milestone_description` — what was reached
|
|
35
|
+
- `process_description` — **how** you got there, strictly 100–150 characters
|
|
36
|
+
(this is enforced; too short or too long is rejected)
|
|
37
|
+
|
|
38
|
+
Keep the two dimensions distinct: the milestone says *what* was reached, the
|
|
39
|
+
process description says *how*.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show the task tree (roots, children, status)
|
|
3
|
+
allowed-tools: Bash(roy-agent tasks:*)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Show the current task tree.
|
|
7
|
+
|
|
8
|
+
Run `roy-agent tasks tree --json` and render it as an indented outline:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
#1234 [active] Title here (3 children)
|
|
12
|
+
#1240 [todo] Child task
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Include the task id, status, progress and title for every node. Highlight
|
|
16
|
+
which nodes are roots (no parent) so the user can tell at a glance whether a
|
|
17
|
+
new task should be attached to one of them.
|
|
18
|
+
|
|
19
|
+
If the `roy-task` MCP server is available in this session, prefer the
|
|
20
|
+
`task_tree` tool over the CLI.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Update a task's status, progress or current status
|
|
3
|
+
allowed-tools: Bash(roy-agent tasks:*)
|
|
4
|
+
argument-hint: "<task-id> [status] [progress] [current-status]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Update a task.
|
|
8
|
+
|
|
9
|
+
**Arguments**: `$ARGUMENTS`
|
|
10
|
+
|
|
11
|
+
Parse the arguments as: `<task-id> [status] [progress] [current-status]`.
|
|
12
|
+
If `$ARGUMENTS` is empty, ask the user for the task id and what to change.
|
|
13
|
+
|
|
14
|
+
Run:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
roy-agent tasks update <task-id> --status <status> --progress <0-100> --current-status "<one sentence>" --json
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Only pass the flags the user actually specified. Status must be one of
|
|
21
|
+
`todo`, `active`, `completed`, `paused`, `cancelled`, `archived`.
|
|
22
|
+
|
|
23
|
+
Report the resulting task back, including its id and new status.
|
|
24
|
+
|
|
25
|
+
If the `roy-task` MCP server is available in this session, prefer the
|
|
26
|
+
`task_update` tool over the CLI.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "roy-task lifecycle hooks: surface the task discipline to Claude and keep the store's state honest.",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"SessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "*",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"Stop": [
|
|
17
|
+
{
|
|
18
|
+
"matcher": "*",
|
|
19
|
+
"hooks": [
|
|
20
|
+
{
|
|
21
|
+
"type": "command",
|
|
22
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/stop-check.mjs",
|
|
23
|
+
"timeout": 10
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `SessionStart` hook — tell Claude that task tracking is available.
|
|
4
|
+
*
|
|
5
|
+
* ## What it does
|
|
6
|
+
*
|
|
7
|
+
* Prints one short line on stdout telling Claude that the roy task tools are
|
|
8
|
+
* wired up, and nothing else. On `SessionStart`, stdout is injected into the
|
|
9
|
+
* model's context, so this is a cheap way to make the task discipline
|
|
10
|
+
* discoverable without the user having to remember the slash commands.
|
|
11
|
+
*
|
|
12
|
+
* ## Why it does NOT touch the progress card
|
|
13
|
+
*
|
|
14
|
+
* The Feishu progress card is sent by the **roy-task daemon**, which is
|
|
15
|
+
* runtime-agnostic and already issues `startTask` / `updateTask` / `finishTask`
|
|
16
|
+
* around every dispatch. A Claude hook that also patched the card would be a
|
|
17
|
+
* second writer for the same card — and Claude Code hooks have no channel back
|
|
18
|
+
* to the daemon (no shared session id, no card id). The daemon is the only
|
|
19
|
+
* component that knows which card belongs to which Feishu message.
|
|
20
|
+
*
|
|
21
|
+
* So: hooks give Claude *context*, the daemon owns the *card*. That split is
|
|
22
|
+
* the reason this hook is a one-liner instead of a card driver.
|
|
23
|
+
*
|
|
24
|
+
* ## Contract
|
|
25
|
+
*
|
|
26
|
+
* - stdout: one context line (this is the hook's output channel).
|
|
27
|
+
* - stderr: diagnostics only.
|
|
28
|
+
* - exit 0 always: a hook that fails must never block the session.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
function main() {
|
|
32
|
+
const dbPath = process.env.ROY_TASK_DB_PATH || "(roy-agent default)";
|
|
33
|
+
process.stdout.write(
|
|
34
|
+
[
|
|
35
|
+
"roy-task plugin active.",
|
|
36
|
+
"Task tracking is available two ways:",
|
|
37
|
+
" - native MCP tools (the task_* family: tree, create, get, list, update,",
|
|
38
|
+
" milestone, complete, search)",
|
|
39
|
+
" - CLI bridge: `roy-agent tasks …`",
|
|
40
|
+
"Task is a first-class citizen: create the task before doing the work, update it",
|
|
41
|
+
"as you go, record milestones, and complete it with a deliverable path.",
|
|
42
|
+
`Task store: ${dbPath}`,
|
|
43
|
+
"",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main();
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `Stop` hook — check that the work left a task behind.
|
|
4
|
+
*
|
|
5
|
+
* ## What it does
|
|
6
|
+
*
|
|
7
|
+
* When Claude Code decides to stop, this hook looks for a task that is still
|
|
8
|
+
* `active` (or `todo`) and was touched during this session. If it finds one,
|
|
9
|
+
* it **blocks** the stop with a short reason: "task #1234 is still active —
|
|
10
|
+
* complete it or say why not".
|
|
11
|
+
*
|
|
12
|
+
* If there is no open task, it approves and exits silently.
|
|
13
|
+
*
|
|
14
|
+
* ## Why this is safe to auto-block on
|
|
15
|
+
*
|
|
16
|
+
* The check is deliberately narrow:
|
|
17
|
+
*
|
|
18
|
+
* - it only looks at tasks in `active` / `todo` state — a completed task never
|
|
19
|
+
* blocks anything;
|
|
20
|
+
* - it only blocks when the session actually **used** a task tool, which we
|
|
21
|
+
* detect from the transcript the hook is given on stdin. A session that never
|
|
22
|
+
* touched a task is never blocked, so an ordinary conversation is unaffected;
|
|
23
|
+
* - it never blocks on the *first* stop — a task the user just created is
|
|
24
|
+
* legitimately still open. It only blocks once the work looks finished.
|
|
25
|
+
*
|
|
26
|
+
* ## Why it does NOT drive the progress card
|
|
27
|
+
*
|
|
28
|
+
* Same reason as `session-start.mjs`: the progress card belongs to the
|
|
29
|
+
* roy-task daemon, which is the only component that knows the Feishu message
|
|
30
|
+
* and card ids. See the "Why it does NOT touch the progress card" section of
|
|
31
|
+
* that file.
|
|
32
|
+
*
|
|
33
|
+
* ## Contract
|
|
34
|
+
*
|
|
35
|
+
* - stdin: the hook payload (JSON) — we read it, we never block on it.
|
|
36
|
+
* - stdout: the decision JSON.
|
|
37
|
+
* - stderr: diagnostics only.
|
|
38
|
+
* - exit 0 always: a hook that throws must not wedge the session.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { openStore, defaultTaskDbPath, BridgeError } from "../mcp/bridge.mjs";
|
|
42
|
+
|
|
43
|
+
/** Read stdin fully, with a hard cap so a huge transcript cannot stall us. */
|
|
44
|
+
async function readStdin(limitBytes = 2 * 1024 * 1024) {
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let size = 0;
|
|
47
|
+
for await (const chunk of process.stdin) {
|
|
48
|
+
size += chunk.length;
|
|
49
|
+
if (size > limitBytes) break;
|
|
50
|
+
chunks.push(chunk);
|
|
51
|
+
}
|
|
52
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Did this session actually *call* a task tool?
|
|
57
|
+
*
|
|
58
|
+
* The hook payload carries `tool_input` / `transcript_path`. The transcript is
|
|
59
|
+
* the reliable signal — but it is a JSONL transcript of *messages*, and it
|
|
60
|
+
* contains far more than tool calls: the SessionStart hook's own context
|
|
61
|
+
* banner mentions `task_create` by name, and so does this file's own
|
|
62
|
+
* documentation. A naive substring search over the whole transcript therefore
|
|
63
|
+
* matches a session that never touched a task at all.
|
|
64
|
+
*
|
|
65
|
+
* So we do not search the raw text. We parse the transcript as JSONL and look
|
|
66
|
+
* specifically at **tool_use blocks** — the structured representation of "the
|
|
67
|
+
* model invoked a tool named X". Only that counts.
|
|
68
|
+
*/
|
|
69
|
+
function sessionUsedTaskTools(payload, transcriptEntries) {
|
|
70
|
+
const TASK_TOOLS = new Set([
|
|
71
|
+
"task_create",
|
|
72
|
+
"task_update",
|
|
73
|
+
"task_complete",
|
|
74
|
+
"task_operation_create",
|
|
75
|
+
"task_list",
|
|
76
|
+
"task_tree",
|
|
77
|
+
"task_get",
|
|
78
|
+
"task_search",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
for (const entry of transcriptEntries) {
|
|
82
|
+
const blocks = collectToolUseBlocks(entry);
|
|
83
|
+
for (const name of blocks) {
|
|
84
|
+
if (TASK_TOOLS.has(name)) return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fallback for payloads that carry the tool name directly (no transcript).
|
|
89
|
+
if (!transcriptEntries.length) {
|
|
90
|
+
const direct = payload && payload.tool_name;
|
|
91
|
+
if (typeof direct === "string" && TASK_TOOLS.has(direct)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pull every `tool_use` block name out of one transcript entry. */
|
|
97
|
+
function collectToolUseBlocks(entry) {
|
|
98
|
+
const names = [];
|
|
99
|
+
if (!entry || typeof entry !== "object") return names;
|
|
100
|
+
|
|
101
|
+
const message = entry.message ?? entry;
|
|
102
|
+
const content = message && message.content;
|
|
103
|
+
|
|
104
|
+
const pushBlock = (block) => {
|
|
105
|
+
if (!block || typeof block !== "object") return;
|
|
106
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
107
|
+
names.push(block.name);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(content)) {
|
|
112
|
+
for (const block of content) pushBlock(block);
|
|
113
|
+
} else if (content && typeof content === "object") {
|
|
114
|
+
pushBlock(content);
|
|
115
|
+
}
|
|
116
|
+
return names;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Parse a JSONL transcript into entries, skipping unparseable lines. */
|
|
120
|
+
function parseTranscript(raw) {
|
|
121
|
+
const entries = [];
|
|
122
|
+
for (const line of raw.split("\n")) {
|
|
123
|
+
const trimmed = line.trim();
|
|
124
|
+
if (trimmed.length === 0) continue;
|
|
125
|
+
try {
|
|
126
|
+
entries.push(JSON.parse(trimmed));
|
|
127
|
+
} catch {
|
|
128
|
+
// A transcript can contain non-JSON lines; skip them.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return entries;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The most recently updated task that is still open. */
|
|
135
|
+
function findOpenTask(db) {
|
|
136
|
+
const row = db
|
|
137
|
+
.prepare(
|
|
138
|
+
`SELECT id, title, status FROM task
|
|
139
|
+
WHERE status IN ('todo', 'active')
|
|
140
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
141
|
+
)
|
|
142
|
+
.get();
|
|
143
|
+
return row ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function approve() {
|
|
147
|
+
process.stdout.write(JSON.stringify({ decision: "approve" }));
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function block(reason) {
|
|
152
|
+
process.stdout.write(
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
decision: "block",
|
|
155
|
+
reason,
|
|
156
|
+
systemMessage: "roy-task: an open task must be closed out before stopping.",
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
process.exit(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function main() {
|
|
163
|
+
let payload = {};
|
|
164
|
+
let transcriptEntries = [];
|
|
165
|
+
try {
|
|
166
|
+
const raw = await readStdin();
|
|
167
|
+
if (raw.trim().length > 0) payload = JSON.parse(raw);
|
|
168
|
+
} catch {
|
|
169
|
+
payload = {};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const transcriptPath = payload && payload.transcript_path;
|
|
174
|
+
if (typeof transcriptPath === "string" && transcriptPath.length > 0) {
|
|
175
|
+
const { readFileSync } = await import("node:fs");
|
|
176
|
+
transcriptEntries = parseTranscript(readFileSync(transcriptPath, "utf8"));
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
transcriptEntries = [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// No task tool was actually *called* in this session -> nothing to enforce.
|
|
183
|
+
if (!sessionUsedTaskTools(payload, transcriptEntries)) {
|
|
184
|
+
approve();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let db;
|
|
189
|
+
try {
|
|
190
|
+
db = openStore(process.env.ROY_TASK_DB_PATH || defaultTaskDbPath());
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// A store we cannot open is not a reason to block the user.
|
|
193
|
+
approve();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const open = findOpenTask(db);
|
|
199
|
+
if (!open) {
|
|
200
|
+
approve();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
block(
|
|
204
|
+
`Task #${open.id} ("${open.title}") is still ${open.status}. ` +
|
|
205
|
+
`Complete it with a deliverable path, or update it to reflect that it stays open.`,
|
|
206
|
+
);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (err instanceof BridgeError) {
|
|
209
|
+
approve();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
approve();
|
|
213
|
+
} finally {
|
|
214
|
+
try {
|
|
215
|
+
db.close();
|
|
216
|
+
} catch {
|
|
217
|
+
// already closed
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
main().catch(() => approve());
|