goaltracker 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nhat-TheDev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # GoalTracker MCP
2
+
3
+ **Persistent, structured project memory for AI coding agents.** GoalTracker is an MCP server that gives an agent like Claude Code a real plan to work from — `Goal → Spec → Milestone → Task → Note` — stored in a single SQLite file, so a context reset (or a completely different agent picking up the same work) is a 1-tool-call recovery, not a "let me re-read the whole conversation" guessing game.
4
+
5
+ ## Why
6
+
7
+ Long-running coding work rarely fits in one session. An ad-hoc todo list in chat doesn't survive a context reset, and it definitely doesn't survive a handoff to a different agent. GoalTracker fixes that by being the thing both agents actually read and write, not just describe:
8
+
9
+ - **A context-reset agent recovers everything in one call.** `goal_get_context` returns the goal, the confirmed spec, every milestone with its task counts, and the last checkpoint — no re-deriving the plan from memory.
10
+ - **A different agent (or a different session, hours or days later) sees the real state**, including *why* something is blocked, not just that it is.
11
+ - **The MCP never guesses on your behalf.** It stores and returns structured data; all planning judgment stays with the agent — and, for the two places that matter most (finalizing a spec, approving a too-small milestone), with you.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ npm install -g goaltracker
17
+ ```
18
+
19
+ Add it to your MCP client config (Claude Code, Claude Desktop, etc.):
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "goaltracker": {
25
+ "command": "npx",
26
+ "args": ["-y", "goaltracker"]
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ (If installed globally, `"command": "goaltracker"` works without `npx`.)
33
+
34
+ Then install the companion skill, which teaches the agent the correct call sequence — session warm-up, spec confirmation before breaking milestones, the milestone-approval flow, checkpoint habits:
35
+
36
+ ```bash
37
+ npx goaltracker install-skill # personal — ~/.claude/skills/, every project
38
+ npx goaltracker install-skill --project # this project only — ./.claude/skills/
39
+ ```
40
+
41
+ That's it. Data lives in a single SQLite file, created automatically on first run at `~/.goaltracker/goaltracker.db` (override with `GOALTRACKER_DB_PATH`). Schema upgrades apply themselves on startup — you never run a migration by hand.
42
+
43
+ ## A worked example
44
+
45
+ A goal, planned, confirmed with you, worked on at whatever pace you choose, handed off to a second agent, and closed — the way it actually plays out with the skill installed:
46
+
47
+ ![GoalTracker example flow: drafting and confirming a spec, breaking into milestones and tasks, approving an undersized milestone, presenting the final plan, picking a check-in cadence, working, checkpointing, a different agent resuming, and closing the goal](docs/example-flow.svg)
48
+
49
+ In short: nothing gets broken into milestones until you've confirmed the spec, and nothing starts running until you've confirmed the final plan and picked how often the agent checks in with you. `checkpoint_save` closes every stop. A completely different agent (a different session, hours or days later — even a different Claude) recovers everything with one `goal_get_context` call, including what the previous agent was doing and why, before `status_report` and a final check against the spec close the goal out.
50
+
51
+ ### Zooming in: a second agent finishes the goal
52
+
53
+ The diagram above shows the handoff as one dashed arrow. Here's what it actually looks like in practice — Session 1 ran out of context partway through; Session 2 is a fresh agent (could be a different Claude entirely) with no memory of any of it:
54
+
55
+ ![A second agent finishing a goal: Agent A's session ends, Agent B calls goal_list and goal_get_context to recover milestones/progress/why a task is blocked, reports status and asks the user for the missing piece, then finishes the remaining tasks and closes the goal](docs/example-flow-handoff.svg)
56
+
57
+ ```
58
+ goal_list({ status: "active" })
59
+ → [{ id: "g_42", title: "Add notification system", status: "active" }]
60
+
61
+ goal_get_context({ goal_id: "g_42" })
62
+ → {
63
+ goal: { title: "Add notification system", status: "active" },
64
+ spec: { overview: "...", acceptance_criteria: [ ... ] },
65
+ milestones: [
66
+ { milestone: { title: "M1 — Email delivery", status: "completed" }, task_counts: { completed: 3 } },
67
+ { milestone: { title: "M2 — Push notifications", status: "in_progress" }, task_counts: { completed: 2, in_progress: 1, blocked: 1 } }
68
+ ],
69
+ milestones_pending_approval: [],
70
+ progress: { completion_pct: 62, total_tasks: 8, completed: 5, in_progress: 1, blocked: 1, pending: 1 },
71
+ last_checkpoint: {
72
+ current_task_id: "t_17",
73
+ agent_summary: "M1 done and verified. Started M2 — push service is blocked: which push provider to use hasn't been decided yet.",
74
+ next_actions: ["Ask the user to pick a push provider", "Once decided, finish t_17 then t_18"]
75
+ }
76
+ }
77
+ ```
78
+
79
+ One call, and Agent B already knows exactly what happened — no need to ask the user to recap anything:
80
+
81
+ ```
82
+ Agent B: "Picking up 'Add notification system' — M1 (email) is done. M2 (push notifications) is
83
+ blocked: which push provider do you want — Firebase Cloud Messaging, or a custom service?"
84
+
85
+ [user picks Firebase Cloud Messaging]
86
+
87
+ task_add_note({ task_id: "t_17", content: "User chose Firebase Cloud Messaging as the push provider.", type: "decision" })
88
+ task_update_status({ task_id: "t_17", status: "in_progress" })
89
+ → [does the work] → task_update_status({ task_id: "t_17", status: "completed" })
90
+
91
+ task_list({ milestone_id: "m_2", status: "pending" })
92
+ → [{ id: "t_18", title: "Wire up the push notification settings page" }]
93
+
94
+ task_update_status({ task_id: "t_18", status: "in_progress" })
95
+ → [does the work] → task_update_status({ task_id: "t_18", status: "completed" })
96
+
97
+ status_report({ goal_id: "g_42" })
98
+ → { progress: { completion_pct: 100 }, blocked_tasks: [], acceptance_criteria: [ ... ] }
99
+
100
+ [Agent B checks acceptance_criteria against the actual task evidence]
101
+
102
+ goal_update_status({ goal_id: "g_42", status: "completed" })
103
+ ```
104
+
105
+ Agent A never told Agent B about the pending provider decision directly — it was written into `checkpoint_save`'s `agent_summary` / `next_actions` back in Session 1, and `goal_get_context` handed it straight to Agent B. The two agents never "talked"; the second one still finished the goal with full context.
106
+
107
+ ## How it works
108
+
109
+ - **Data only, no reasoning.** The MCP stores and returns structured data. All planning judgment — how to break down work, when a spec is good enough, whether a small milestone is intentional — stays with the agent (and, via the skill, with you).
110
+ - **One hierarchy, one DB.** `Goal → Spec → Milestone → Task → Note`, all in one SQLite file. The agent only ever needs to remember `goal_id`.
111
+ - **Fast warm-up.** `goal_get_context` is the single call that replaces re-reading everything.
112
+ - **Audit trail.** Every status change carries a timestamp and, for `blocked`/`cancelled`, a required reason.
113
+ - **One real gate.** Milestones with fewer than 2 active tasks require an explicit `milestone_approve` before work can start on them — everywhere else, the MCP never rejects a call.
114
+
115
+ Full schema, every tool's exact input/output, and the design rationale behind each decision: [docs/design/DESIGN.md](docs/design/DESIGN.md).
116
+
117
+ ## Tools (14 total)
118
+
119
+ | Group | Tools |
120
+ |---|---|
121
+ | Goal | `goal_create`, `goal_list`, `goal_get_context` ⭐ |
122
+ | Spec | `spec_set` |
123
+ | Milestone | `milestone_create`, `milestone_approve` |
124
+ | Task | `task_create`, `task_get`, `task_list`, `task_update_status`, `task_add_note` |
125
+ | Status | `status_report` ⭐ |
126
+ | Lifecycle | `goal_update_status`, `checkpoint_save` |
127
+
128
+ ⭐ = the two calls you'll use most: warm-up at the start of a session, review before closing one. See [DESIGN.md §3](docs/design/DESIGN.md) for full input/output shapes.
129
+
130
+ ## Configuration
131
+
132
+ | Variable | Default | Purpose |
133
+ |---|---|---|
134
+ | `GOALTRACKER_DB_PATH` | `~/.goaltracker/goaltracker.db` | Where the SQLite file lives |
135
+
136
+ ## Development
137
+
138
+ Building from source instead of installing the published package:
139
+
140
+ ```bash
141
+ git clone <this repo>
142
+ cd GoalTracker
143
+ npm install
144
+ npm run build # generates the embedded skill content, then compiles
145
+ npm run dev # run directly from src/ with tsx, no build step
146
+ npm run typecheck # tsc --noEmit
147
+ npm test # vitest
148
+ ```
149
+
150
+ To point an MCP client at a local build instead of the published package:
151
+
152
+ ```json
153
+ {
154
+ "mcpServers": {
155
+ "goaltracker": {
156
+ "command": "node",
157
+ "args": ["/absolute/path/to/GoalTracker/dist/index.js"]
158
+ }
159
+ }
160
+ }
161
+ ```
162
+
163
+ The skill source lives at [.claude/skills/goaltracker/SKILL.md](.claude/skills/goaltracker/SKILL.md) — edit it there. Every script above (`build`, `dev`, `typecheck`, `test`) regenerates `src/skillContent.ts` from it automatically before running; that generated file is gitignored, don't edit it directly.
@@ -0,0 +1,38 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { migration001Init } from './migrations/001_init.js';
6
+ import { migration002AddMilestoneApproval } from './migrations/002_add_milestone_approval.js';
7
+ // Future schema changes: add a new 00N_description.ts migration module and
8
+ // list it here. It auto-applies (in order, inside a transaction) against
9
+ // every existing user's DB the next time the server starts.
10
+ const migrations = [migration001Init, migration002AddMilestoneApproval];
11
+ const DEFAULT_DB_PATH = path.join(os.homedir(), '.goaltracker', 'goaltracker.db');
12
+ export function openDb(dbPath = process.env.GOALTRACKER_DB_PATH ?? DEFAULT_DB_PATH) {
13
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
14
+ const db = new Database(dbPath);
15
+ db.pragma('journal_mode = WAL');
16
+ db.pragma('foreign_keys = ON');
17
+ runMigrations(db);
18
+ return db;
19
+ }
20
+ function runMigrations(db) {
21
+ db.exec(`
22
+ CREATE TABLE IF NOT EXISTS _migrations (
23
+ version INTEGER PRIMARY KEY,
24
+ name TEXT NOT NULL,
25
+ applied_at TEXT NOT NULL
26
+ );
27
+ `);
28
+ const applied = new Set(db.prepare('SELECT version FROM _migrations').all().map((row) => row.version));
29
+ const insertMigration = db.prepare('INSERT INTO _migrations (version, name, applied_at) VALUES (?, ?, ?)');
30
+ const pending = migrations.filter((m) => !applied.has(m.version)).sort((a, b) => a.version - b.version);
31
+ for (const migration of pending) {
32
+ const apply = db.transaction(() => {
33
+ migration.up(db);
34
+ insertMigration.run(migration.version, migration.name, new Date().toISOString());
35
+ });
36
+ apply();
37
+ }
38
+ }
@@ -0,0 +1,72 @@
1
+ export const migration001Init = {
2
+ version: 1,
3
+ name: 'init',
4
+ up(db) {
5
+ db.exec(`
6
+ CREATE TABLE goals (
7
+ id TEXT PRIMARY KEY,
8
+ title TEXT NOT NULL,
9
+ description TEXT,
10
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','completed','archived')),
11
+ status_note TEXT,
12
+ created_at TEXT NOT NULL,
13
+ updated_at TEXT NOT NULL
14
+ );
15
+
16
+ CREATE TABLE specs (
17
+ goal_id TEXT PRIMARY KEY REFERENCES goals(id),
18
+ overview TEXT NOT NULL,
19
+ acceptance_criteria TEXT NOT NULL DEFAULT '[]',
20
+ constraints TEXT NOT NULL DEFAULT '[]',
21
+ out_of_scope TEXT NOT NULL DEFAULT '[]',
22
+ updated_at TEXT NOT NULL
23
+ );
24
+
25
+ CREATE TABLE milestones (
26
+ id TEXT PRIMARY KEY,
27
+ goal_id TEXT NOT NULL REFERENCES goals(id),
28
+ title TEXT NOT NULL,
29
+ description TEXT,
30
+ "order" INTEGER NOT NULL DEFAULT 0,
31
+ created_at TEXT NOT NULL
32
+ );
33
+
34
+ CREATE TABLE tasks (
35
+ id TEXT PRIMARY KEY,
36
+ milestone_id TEXT NOT NULL REFERENCES milestones(id),
37
+ goal_id TEXT NOT NULL REFERENCES goals(id),
38
+ title TEXT NOT NULL,
39
+ description TEXT,
40
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','in_progress','completed','blocked','cancelled')),
41
+ priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low','medium','high')),
42
+ status_reason TEXT,
43
+ created_at TEXT NOT NULL,
44
+ updated_at TEXT NOT NULL
45
+ );
46
+
47
+ CREATE TABLE notes (
48
+ id TEXT PRIMARY KEY,
49
+ task_id TEXT NOT NULL REFERENCES tasks(id),
50
+ content TEXT NOT NULL,
51
+ type TEXT NOT NULL DEFAULT 'progress' CHECK (type IN ('progress','blocker','decision','evidence','uncertainty')),
52
+ created_at TEXT NOT NULL
53
+ );
54
+
55
+ CREATE TABLE checkpoints (
56
+ id TEXT PRIMARY KEY,
57
+ goal_id TEXT NOT NULL REFERENCES goals(id),
58
+ current_task_id TEXT REFERENCES tasks(id),
59
+ agent_summary TEXT NOT NULL,
60
+ next_actions TEXT NOT NULL DEFAULT '[]',
61
+ saved_at TEXT NOT NULL
62
+ );
63
+
64
+ CREATE INDEX idx_tasks_goal ON tasks(goal_id);
65
+ CREATE INDEX idx_tasks_milestone ON tasks(milestone_id);
66
+ CREATE INDEX idx_tasks_status ON tasks(status);
67
+ CREATE INDEX idx_milestones_goal ON milestones(goal_id);
68
+ CREATE INDEX idx_notes_task ON notes(task_id);
69
+ CREATE INDEX idx_checkpoints_goal ON checkpoints(goal_id, saved_at DESC);
70
+ `);
71
+ },
72
+ };
@@ -0,0 +1,7 @@
1
+ export const migration002AddMilestoneApproval = {
2
+ version: 2,
3
+ name: 'add_milestone_approval',
4
+ up(db) {
5
+ db.exec(`ALTER TABLE milestones ADD COLUMN approved_at TEXT;`);
6
+ },
7
+ };
package/dist/index.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { z } from 'zod';
6
+ import { openDb } from './db/client.js';
7
+ import { goalTools } from './tools/goal.js';
8
+ import { specTools } from './tools/spec.js';
9
+ import { milestoneTools } from './tools/milestone.js';
10
+ import { taskTools } from './tools/task.js';
11
+ import { statusTools } from './tools/status.js';
12
+ import { checkpointTools } from './tools/checkpoint.js';
13
+ import { installSkill } from './installSkill.js';
14
+ async function runServer() {
15
+ const db = openDb();
16
+ const tools = [
17
+ ...goalTools(db),
18
+ ...specTools(db),
19
+ ...milestoneTools(db),
20
+ ...taskTools(db),
21
+ ...statusTools(db),
22
+ ...checkpointTools(db),
23
+ ];
24
+ const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
25
+ const server = new Server({ name: 'GoalTracker', version: '1.0.0' }, { capabilities: { tools: {} } });
26
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
+ tools: tools.map((tool) => ({
28
+ name: tool.name,
29
+ description: tool.description,
30
+ inputSchema: z.toJSONSchema(tool.schema),
31
+ })),
32
+ }));
33
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
34
+ const tool = toolsByName.get(request.params.name);
35
+ if (!tool) {
36
+ return {
37
+ content: [{ type: 'text', text: `Error: unknown tool "${request.params.name}"` }],
38
+ isError: true,
39
+ };
40
+ }
41
+ try {
42
+ const result = tool.handler(request.params.arguments ?? {});
43
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
44
+ }
45
+ catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
48
+ }
49
+ });
50
+ const transport = new StdioServerTransport();
51
+ await server.connect(transport);
52
+ }
53
+ async function main() {
54
+ const [, , subcommand, ...rest] = process.argv;
55
+ if (subcommand === 'install-skill') {
56
+ const target = installSkill(rest);
57
+ console.log(`Installed GoalTracker skill to ${target}`);
58
+ return;
59
+ }
60
+ await runServer();
61
+ }
62
+ main().catch((error) => {
63
+ console.error('Fatal error starting GoalTracker MCP server:', error);
64
+ process.exit(1);
65
+ });
@@ -0,0 +1,18 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { SKILL_MD } from './skillContent.js';
5
+ export function resolveSkillTargetDir(args, cwd, homedir) {
6
+ const scopeDir = args.includes('--project') ? cwd : homedir;
7
+ return path.join(scopeDir, '.claude', 'skills', 'goaltracker');
8
+ }
9
+ export function writeSkill(targetDir) {
10
+ mkdirSync(targetDir, { recursive: true });
11
+ const targetPath = path.join(targetDir, 'SKILL.md');
12
+ writeFileSync(targetPath, SKILL_MD);
13
+ return targetPath;
14
+ }
15
+ export function installSkill(args = []) {
16
+ const targetDir = resolveSkillTargetDir(args, process.cwd(), os.homedir());
17
+ return writeSkill(targetDir);
18
+ }
@@ -0,0 +1,196 @@
1
+ import { z } from 'zod';
2
+ // ---------- Enums ----------
3
+ export const GoalStatus = z.enum(['active', 'completed', 'archived']);
4
+ export const MilestoneStatus = z.enum(['pending', 'in_progress', 'completed']);
5
+ export const TaskStatus = z.enum(['pending', 'in_progress', 'completed', 'blocked', 'cancelled']);
6
+ export const TaskPriority = z.enum(['low', 'medium', 'high']);
7
+ export const NoteType = z.enum(['progress', 'blocker', 'decision', 'evidence', 'uncertainty']);
8
+ // ---------- Models ----------
9
+ export const Goal = z.object({
10
+ id: z.string(),
11
+ title: z.string(),
12
+ description: z.string().optional(),
13
+ status: GoalStatus,
14
+ status_note: z.string().optional(),
15
+ created_at: z.string(),
16
+ updated_at: z.string(),
17
+ });
18
+ export const Spec = z.object({
19
+ goal_id: z.string(),
20
+ overview: z.string(),
21
+ acceptance_criteria: z.array(z.string()),
22
+ constraints: z.array(z.string()),
23
+ out_of_scope: z.array(z.string()),
24
+ updated_at: z.string(),
25
+ });
26
+ export const Milestone = z.object({
27
+ id: z.string(),
28
+ goal_id: z.string(),
29
+ title: z.string(),
30
+ description: z.string().optional(),
31
+ order: z.number(),
32
+ status: MilestoneStatus,
33
+ approved_at: z.string().optional(),
34
+ created_at: z.string(),
35
+ });
36
+ export const Task = z.object({
37
+ id: z.string(),
38
+ milestone_id: z.string(),
39
+ goal_id: z.string(),
40
+ title: z.string(),
41
+ description: z.string().optional(),
42
+ status: TaskStatus,
43
+ priority: TaskPriority,
44
+ status_reason: z.string().optional(),
45
+ created_at: z.string(),
46
+ updated_at: z.string(),
47
+ });
48
+ export const Note = z.object({
49
+ id: z.string(),
50
+ task_id: z.string(),
51
+ content: z.string(),
52
+ type: NoteType,
53
+ created_at: z.string(),
54
+ });
55
+ export const Checkpoint = z.object({
56
+ goal_id: z.string(),
57
+ current_task_id: z.string().optional(),
58
+ agent_summary: z.string(),
59
+ next_actions: z.array(z.string()),
60
+ saved_at: z.string(),
61
+ });
62
+ // ---------- Tool input schemas ----------
63
+ export const goalCreateInput = z.object({
64
+ title: z.string().min(1),
65
+ description: z.string().optional(),
66
+ });
67
+ export const goalListInput = z.object({
68
+ status: GoalStatus.optional(),
69
+ });
70
+ export const goalGetContextInput = z.object({
71
+ goal_id: z.string().min(1),
72
+ });
73
+ export const goalUpdateStatusInput = z.object({
74
+ goal_id: z.string().min(1),
75
+ status: GoalStatus,
76
+ note: z.string().optional(),
77
+ });
78
+ export const specSetInput = z.object({
79
+ goal_id: z.string().min(1),
80
+ overview: z.string().min(1),
81
+ acceptance_criteria: z.array(z.string()),
82
+ constraints: z.array(z.string()).optional(),
83
+ out_of_scope: z.array(z.string()).optional(),
84
+ });
85
+ export const milestoneCreateInput = z.object({
86
+ goal_id: z.string().min(1),
87
+ title: z.string().min(1),
88
+ description: z.string().optional(),
89
+ order: z.number().int().optional(),
90
+ });
91
+ export const milestoneApproveInput = z.object({
92
+ milestone_id: z.string().min(1),
93
+ });
94
+ export const taskCreateInput = z.object({
95
+ milestone_id: z.string().min(1),
96
+ title: z.string().min(1),
97
+ description: z.string().optional(),
98
+ priority: TaskPriority.optional(),
99
+ });
100
+ export const taskGetInput = z.object({
101
+ task_id: z.string().min(1),
102
+ });
103
+ export const taskListInput = z.object({
104
+ goal_id: z.string().optional(),
105
+ milestone_id: z.string().optional(),
106
+ status: TaskStatus.optional(),
107
+ priority: TaskPriority.optional(),
108
+ });
109
+ export const taskUpdateStatusInput = z
110
+ .object({
111
+ task_id: z.string().min(1),
112
+ status: TaskStatus,
113
+ reason: z.string().optional(),
114
+ })
115
+ .refine((data) => !(['blocked', 'cancelled'].includes(data.status) && !data.reason), {
116
+ message: 'reason is required when status is "blocked" or "cancelled"',
117
+ path: ['reason'],
118
+ });
119
+ export const taskAddNoteInput = z.object({
120
+ task_id: z.string().min(1),
121
+ content: z.string().min(1),
122
+ type: NoteType,
123
+ });
124
+ export const statusReportInput = z.object({
125
+ goal_id: z.string().min(1),
126
+ });
127
+ export const checkpointSaveInput = z.object({
128
+ goal_id: z.string().min(1),
129
+ current_task_id: z.string().optional(),
130
+ agent_summary: z.string().min(1),
131
+ next_actions: z.array(z.string()),
132
+ });
133
+ export function rowToGoal(row) {
134
+ return {
135
+ id: row.id,
136
+ title: row.title,
137
+ description: row.description ?? undefined,
138
+ status: row.status,
139
+ status_note: row.status_note ?? undefined,
140
+ created_at: row.created_at,
141
+ updated_at: row.updated_at,
142
+ };
143
+ }
144
+ export function rowToSpec(row) {
145
+ return {
146
+ goal_id: row.goal_id,
147
+ overview: row.overview,
148
+ acceptance_criteria: JSON.parse(row.acceptance_criteria),
149
+ constraints: JSON.parse(row.constraints),
150
+ out_of_scope: JSON.parse(row.out_of_scope),
151
+ updated_at: row.updated_at,
152
+ };
153
+ }
154
+ export function rowToMilestoneBase(row) {
155
+ return {
156
+ id: row.id,
157
+ goal_id: row.goal_id,
158
+ title: row.title,
159
+ description: row.description ?? undefined,
160
+ order: row.order,
161
+ approved_at: row.approved_at ?? undefined,
162
+ created_at: row.created_at,
163
+ };
164
+ }
165
+ export function rowToTask(row) {
166
+ return {
167
+ id: row.id,
168
+ milestone_id: row.milestone_id,
169
+ goal_id: row.goal_id,
170
+ title: row.title,
171
+ description: row.description ?? undefined,
172
+ status: row.status,
173
+ priority: row.priority,
174
+ status_reason: row.status_reason ?? undefined,
175
+ created_at: row.created_at,
176
+ updated_at: row.updated_at,
177
+ };
178
+ }
179
+ export function rowToNote(row) {
180
+ return {
181
+ id: row.id,
182
+ task_id: row.task_id,
183
+ content: row.content,
184
+ type: row.type,
185
+ created_at: row.created_at,
186
+ };
187
+ }
188
+ export function rowToCheckpoint(row) {
189
+ return {
190
+ goal_id: row.goal_id,
191
+ current_task_id: row.current_task_id ?? undefined,
192
+ agent_summary: row.agent_summary,
193
+ next_actions: JSON.parse(row.next_actions),
194
+ saved_at: row.saved_at,
195
+ };
196
+ }
@@ -0,0 +1,165 @@
1
+ // Auto-generated by scripts/generate-skill-content.mjs from .claude/skills/goaltracker/SKILL.md — do not edit directly.
2
+ export const SKILL_MD = `---
3
+ name: goaltracker
4
+ description: Use when the GoalTracker MCP tools (goal_create, goal_list, goal_get_context, spec_set, milestone_create, milestone_approve, task_create, task_get, task_list, task_update_status, task_add_note, status_report, goal_update_status, checkpoint_save) are connected and the user wants to track progress on a project, write a spec or acceptance criteria, break work into milestones and tasks, decide what order to tackle work in, resume work after a context reset, or check on progress. Also reach for this the moment you notice a requested feature or task is large enough that you need to ask the user several clarifying questions before you can even start (multiple components, missing architecture/tech context, unclear scope) — that need for clarification is itself the signal to set up tracking, not just a reason to ask and move on. Make sure to use this whenever GoalTracker is available and the conversation involves planning, spec-writing, task breakdown, or any feature/task request that clearly needs more than one sitting to finish — even if the user never mentions GoalTracker, planning, or tracking by name. Teaches the correct call sequence, how to write a spec that's actually checkable, how to choose a milestone breakdown strategy, and the schema's conventions so the agent doesn't reinvent bookkeeping the MCP already handles.
5
+ ---
6
+
7
+ # Using GoalTracker
8
+
9
+ GoalTracker is a data-only MCP: it stores and returns structured state (Goal → Spec → Milestone → Task → Note), but never reasons or suggests — all judgment stays with you, the agent. Follow these conventions so the state you build stays useful across sessions, and so the plans you create are actually good plans, not just a todo dump with extra steps.
10
+
11
+ ## The clarifying-questions moment is the trigger, not just planning requests
12
+
13
+ A request like "handle this feature for me" reads as an instruction to execute, not to plan — so don't wait for the user to explicitly ask for a plan or say "track this." Watch for the moment instead: if a feature or task is big enough that you need to ask the user several things before you can even start — which repo/path, what stack, what already exists, which pieces are actually in scope — that need for clarification is itself the signal, not just a reason to fire off questions and wait for replies.
14
+
15
+ When you notice that moment, create the Goal and a first-pass Spec from whatever you already know before (or alongside) asking your questions: put the confirmed scope in \`overview\`, and put the open questions themselves in \`constraints\` or \`out_of_scope\` until they're answered. That way the answers you get back fill in a spec instead of vanishing into a one-off Q&A, and the next session — yours or someone else's — can see the shape of the ask even before every detail is settled.
16
+
17
+ ## Every session starts with one call
18
+
19
+ Before doing anything else on an existing project, call \`goal_get_context(goal_id)\`. It returns the goal, spec, every milestone with its task counts, a \`milestones_out_of_range\` list, aggregate progress, and the last checkpoint — everything you need to resume, in one call. Don't call \`task_list\` or dig through individual tasks just to "get oriented"; that's what this call replaces.
20
+
21
+ If the returned \`last_checkpoint\` has a \`current_task_id\`, call \`task_get(current_task_id)\` next to see that task's full detail and notes before you resume it — the checkpoint tells you *what* you were doing, \`task_get\` tells you the specifics you'd otherwise have to reconstruct from memory that no longer exists.
22
+
23
+ If you don't know the \`goal_id\`, call \`goal_list\` first.
24
+
25
+ ## Setting up a new project
26
+
27
+ \`\`\`
28
+ goal_create → [draft spec ⇄ confirm with the user] → spec_set
29
+ → milestone_create (× N) → task_create (× M)
30
+ → [present final plan, pick a check-in cadence] → start work
31
+ \`\`\`
32
+
33
+ The step between \`goal_create\` and the point where you actually start breaking milestones is a conversation, not a formality. Treat it like a kickoff discussion you'd have before committing to a plan, not an admin step to clear so you can get to the breakdown.
34
+
35
+ - **Draft before you commit.** Work out a first-pass \`overview\`, \`acceptance_criteria\`, \`constraints\`, and \`out_of_scope\` from what you already know, then present it to the user in the **spec draft** format below — *before* treating \`spec_set\` as final. Don't silently assume what "done" means and move straight to milestones — state it, in a consistent structure, and let them correct it.
36
+ - Write \`acceptance_criteria\` as a real Definition of Done, not a restatement of the goal: each item should be something you could point at task evidence and tick off as true or false. Cover exception paths and edge cases too, not just the happy path — a criteria list that's all happy-path is a plan waiting to be surprised. If you don't have enough information yet to write a reliable criterion, say so and ask, rather than guessing at one and hoping it holds up.
37
+ - **Ask about what would actually change the breakdown**, not everything: what's explicitly out of scope (the thing people under-specify the most), any constraint that isn't obvious from the request itself (a deadline, a system you can't touch, a stack decision already made elsewhere), and whether the acceptance criteria you drafted match what they'd really accept as "done."
38
+ - **\`spec_set\` is create-or-replace, so use it like a checkpoint while the spec is still taking shape**, not a one-shot commit — call it with your draft, revise based on what the user says, call it again. There's no separate "update" tool and no penalty for calling it more than once.
39
+ - **Don't call \`milestone_create\` until the user has actually confirmed the spec** — a real "yes, that's it" or a correction you've since folded back in, not just silence after you stated it once. This is a behavioral habit, not something the MCP enforces — \`milestone_create\` will not stop you from skipping this, which is exactly why it's worth being deliberate about instead of treating the spec step as a rubber stamp on the way to the real work.
40
+ - Once the spec is actually confirmed: choose a milestone breakdown strategy (below) before calling \`milestone_create\` repeatedly — don't just default to whatever order occurred to you first.
41
+ - Each milestone should end up holding roughly 2–5 active (non-cancelled) tasks — enough to be a meaningful phase, small enough to stay focused. The upper end is still just a nudge, not enforced. The **lower** end now has teeth: \`task_update_status\` will refuse to start (\`"in_progress"\`) any task in a milestone with fewer than 2 active tasks until you call \`milestone_approve\` — see "When a milestone is too small to start on" below.
42
+ - Leave \`order\` unset unless you need a specific sequence — it auto-assigns.
43
+ - **Once every milestone and task exists (and any undersized milestone has already been approved — see below), present the whole plan back to the user** in the **final plan** format below, as one last look at the full shape of the work before any of it starts. Don't call \`task_update_status(..., "in_progress")\` on anything until they've seen it.
44
+ - **With the plan confirmed, ask how they want you to check in while you work**: after every task, after every milestone, or straight through with a report only at the end (or when something blocks you). Whatever they pick applies for the rest of the session — see "Doing the work" below for what each option means in practice.
45
+
46
+ ## Templates for presenting to the user
47
+
48
+ Use these formats consistently. This costs nothing extra from the MCP — no tool returns formatted text, formatting is entirely your job using data these tools already gave you — and a structured draft is something a user can actually skim and react to, instead of a paragraph they have to parse.
49
+
50
+ **Spec draft** — after drafting, before calling \`spec_set\` for real:
51
+
52
+ \`\`\`
53
+ ## Spec draft — <goal title>
54
+
55
+ **Overview:** <what "done" means, in 1-2 sentences>
56
+
57
+ **Acceptance criteria:**
58
+ - <criterion 1>
59
+ - <criterion 2>
60
+
61
+ **Constraints:**
62
+ - <constraint, or "none identified yet">
63
+
64
+ **Out of scope:**
65
+ - <item, or "none identified yet">
66
+
67
+ Anything to add, cut, or correct before I lock this in?
68
+ \`\`\`
69
+
70
+ **Final plan** — after milestones and tasks exist and any small-milestone approval is settled, before execution starts:
71
+
72
+ \`\`\`
73
+ ## Plan — <goal title>
74
+
75
+ **M1 — <title>** (<breakdown strategy>: <one-line why this order>)
76
+ - <task 1>
77
+ - <task 2>
78
+
79
+ **M2 — <title>**
80
+ - <task 1>
81
+ ...
82
+
83
+ Ready to start? Want me to check in after every task, after every milestone, or run straight through and report at the end?
84
+ \`\`\`
85
+
86
+ **Completion report** — whenever your check-in cadence says to stop, or when a Goal is closed:
87
+
88
+ \`\`\`
89
+ ## <Task, Milestone, or Goal> complete — <title>
90
+
91
+ **Done:**
92
+ - <task>: <one-line outcome or evidence>
93
+
94
+ **Status:** <e.g. "3 of 5 tasks done in this milestone, 1 blocked">
95
+ <mention any blocked task and why, if there is one>
96
+
97
+ Next: <what you'll do next, or the question you need answered before continuing>
98
+ \`\`\`
99
+
100
+ ## Before you create milestones: choose a breakdown strategy
101
+
102
+ Pick one primary strategy and let it drive the order you create milestones (and, within a milestone, tasks) in:
103
+
104
+ - **dependency-first** — order milestones so nothing blocks on work that comes later.
105
+ - **risk-first** — tackle the riskiest unknown first; if it invalidates the plan, better to find out now than after three milestones of unrelated work.
106
+ - **verify-first** — front-load whichever milestone produces real evidence/proof early, when getting confirmation that the approach works is the actual bottleneck.
107
+ - **unblock-first** — if one thing is already blocking multiple other directions, resolve that before anything else.
108
+ - **handoff-first** — if someone else (a teammate, or a future session with no memory of this conversation) needs to pick this up, structure milestones so the plan reads clearly without you there to explain it.
109
+
110
+ There's no schema field for "strategy" — say which one you picked and why in the milestone's \`description\` (one line is enough). This isn't busywork: it's the difference between a milestone list that's just a todo dump and one that actually reflects a plan, and it costs nothing since \`description\` already exists.
111
+
112
+ ## When a milestone is too small to start on
113
+
114
+ If \`task_update_status(..., "in_progress")\` comes back with an error about the milestone having fewer than 2 active tasks, that's not a bug to route around — it means this milestone is small enough that it might be an accidental under-breakdown rather than a deliberate one-off. Before calling \`milestone_approve(milestone_id)\`, actually check with the user: is a single-task milestone here intentional, or would this task make more sense folded into a neighboring milestone instead? Only call \`milestone_approve\` once you've gotten a real answer — it exists to catch genuine oversights, not to be rubber-stamped past on your own so you can keep moving. Once approved, the restriction is lifted for that milestone permanently, even if its active task count drops back below 2 later (e.g. a task gets cancelled).
115
+
116
+ ## Doing the work
117
+
118
+ \`\`\`
119
+ task_list(status="pending") → task_update_status(id, "in_progress") → do the work
120
+ → task_add_note(type="progress" | "evidence" | "uncertainty") as you go
121
+ → [if something stops you] task_add_note(type="blocker") → task_update_status(id, "blocked", reason=...)
122
+ → task_update_status(id, "completed")
123
+ \`\`\`
124
+
125
+ - If \`task_update_status(id, "in_progress")\` is rejected for being in an unapproved, too-small milestone, see "When a milestone is too small to start on" above — don't just retry or work around it.
126
+ - **Follow the check-in cadence the user picked when you presented the final plan:**
127
+ - *After every task* — once a task is \`completed\` (or \`blocked\`), stop and report before starting the next one.
128
+ - *After every milestone* — keep working through a milestone's tasks uninterrupted, then stop and report once every task in it is \`completed\` or \`cancelled\` (or one is \`blocked\`).
129
+ - *Straight through* — keep going until the whole Goal is done, or until something is \`blocked\` and needs a decision only the user can make.
130
+ Whichever cadence applies, use the **completion report** format below when you stop — don't just finish a tool call silently and wait for the next instruction.
131
+ - \`reason\` is required by the schema when you set status to \`blocked\` or \`cancelled\` — always explain why.
132
+ - The schema has no dedicated fields for a task's dependencies or how to verify it done — that's intentional (see the closing note), not a gap to work around with extra tools. Put that context straight into the task's \`description\` when you create it (e.g. "depends on: <other task title>" / "verify by: run X, check Y") so it's there when you or a future session reads the task back.
133
+ - \`task_create\`'s response includes \`milestone_active_task_count\`. Watch it: if a milestone is trending past 5 active tasks, consider starting a new milestone for the next chunk of work instead of continuing to pile on. If \`goal_get_context\` / \`status_report\` flag a milestone in \`milestones_out_of_range\`, it's advisory — decide whether to split, merge, or just add more tasks; you won't be blocked either way.
134
+ - Titles and descriptions are immutable once created (by design — corrections are rare enough that they don't need a dedicated tool). If a title or description turns out wrong, record the correction with \`task_add_note(type="decision")\` rather than looking for an edit tool.
135
+
136
+ ## Ending a session
137
+
138
+ Always call \`checkpoint_save(goal_id, current_task_id?, agent_summary, next_actions)\` before your context resets — even if you didn't finish anything. \`agent_summary\` and \`next_actions\` are what the *next* session's \`goal_get_context\` call will hand back as \`last_checkpoint\`; write them for a future instance of yourself with zero memory of this conversation.
139
+
140
+ If you changed the plan mid-session — added a milestone that wasn't in the original breakdown, cancelled tasks because an assumption turned out wrong, reprioritized which milestone matters most — say so in \`agent_summary\`. There's no separate "pivot log" in this schema, and no tool reads back checkpoint *history* either — \`goal_get_context\` only ever returns the single latest one. Writing down what changed and why still matters, but think of it as a note for whoever picks this up next, not as building a queryable log: if you need to know what happened three sessions ago, there's no tool call that gets you there.
141
+
142
+ ## Checking status / verifying completion
143
+
144
+ Call \`status_report(goal_id)\` for a periodic review or before closing a goal. It returns the same progress/milestone aggregates as \`goal_get_context\`, plus \`blocked_tasks\` (each with its latest note) and the goal's \`acceptance_criteria\` as a plain list.
145
+
146
+ For any \`blocked_tasks\` entry worth acting on, call \`task_get(task_id)\` to read its full note history before deciding anything — the single \`latest_note\` in \`status_report\` is enough to notice a block, not always enough to understand it. Record what you decide with \`task_add_note(type="decision")\` on that task.
147
+
148
+ There is no per-criterion "checked" flag anywhere in this schema — that was tried and deliberately removed. Verification is your job: cross-check \`acceptance_criteria\` against the actual task evidence yourself, then record what you verified in \`checkpoint_save\`'s \`agent_summary\` / \`next_actions\` (or a \`task_add_note(type="evidence")\` on the relevant task). Don't invent your own tracking field for this.
149
+
150
+ \`completion_pct\` is \`null\` when there are no tasks yet, or every task has been cancelled — treat \`null\` as "not measurable yet," not zero. When you report a progress number to a human, say what it's counting (e.g. "42% — 5 of 12 non-cancelled tasks" rather than a bare "42%"), so a stale or misleading number is easy to catch instead of taken on faith.
151
+
152
+ ## Closing out
153
+
154
+ \`\`\`
155
+ status_report(goal_id) // completion_pct should be 100 (or null if everything was cancelled)
156
+ → [verify acceptance_criteria yourself against task evidence]
157
+ → goal_update_status(goal_id, "completed", note?)
158
+ \`\`\`
159
+
160
+ \`goal_update_status\` also handles archiving (\`"archived"\`) and reactivating an archived goal (\`"active"\`) — there's no separate archive/reactivate tool.
161
+
162
+ ## Why this schema stays this thin
163
+
164
+ GoalTracker deliberately has no fields for task dependencies, verify-methods, breakdown strategy, or a change/pivot log — richer planning practice (a breakdown strategy, dependency ordering, a real Definition of Done, logging why a plan changed) belongs in *how you use* \`description\`, \`task_add_note\`, and \`checkpoint_save\`, not in new columns. Before treating something as a missing feature, try encoding it in an existing free-text field first — the schema has already had several structured fields proposed and deliberately rejected for exactly this reason (see \`docs/design/DESIGN.md\` §9). If something genuinely can't be expressed this way — not just "would be marginally more convenient as a dedicated field" — that's worth raising as a real schema change instead of a workaround.
165
+ `;
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { checkpointSaveInput, rowToCheckpoint } from '../schemas/index.js';
3
+ export function checkpointTools(db) {
4
+ return [
5
+ {
6
+ name: 'checkpoint_save',
7
+ description: "Save the Agent's current context summary before the session ends. Loaded back via goal_get_context as last_checkpoint.",
8
+ schema: checkpointSaveInput,
9
+ handler: (args) => {
10
+ const input = checkpointSaveInput.parse(args);
11
+ const goalExists = db.prepare('SELECT 1 FROM goals WHERE id = ?').get(input.goal_id);
12
+ if (!goalExists)
13
+ throw new Error(`Goal not found: ${input.goal_id}`);
14
+ if (input.current_task_id) {
15
+ const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(input.current_task_id);
16
+ if (!taskExists)
17
+ throw new Error(`Task not found: ${input.current_task_id}`);
18
+ }
19
+ const now = new Date().toISOString();
20
+ const id = randomUUID();
21
+ db.prepare(`INSERT INTO checkpoints (id, goal_id, current_task_id, agent_summary, next_actions, saved_at)
22
+ VALUES (?, ?, ?, ?, ?, ?)`).run(id, input.goal_id, input.current_task_id ?? null, input.agent_summary, JSON.stringify(input.next_actions), now);
23
+ return rowToCheckpoint(db.prepare('SELECT * FROM checkpoints WHERE id = ?').get(id));
24
+ },
25
+ },
26
+ ];
27
+ }
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { goalCreateInput, goalListInput, goalGetContextInput, goalUpdateStatusInput, rowToGoal, rowToSpec, rowToMilestoneBase, rowToCheckpoint, } from '../schemas/index.js';
3
+ import { buildMilestonesSummary, buildProgress } from '../utils/computed.js';
4
+ export function goalTools(db) {
5
+ return [
6
+ {
7
+ name: 'goal_create',
8
+ description: 'Create a new Goal. Called once when starting a new project.',
9
+ schema: goalCreateInput,
10
+ handler: (args) => {
11
+ const input = goalCreateInput.parse(args);
12
+ const now = new Date().toISOString();
13
+ const goal = {
14
+ id: randomUUID(),
15
+ title: input.title,
16
+ description: input.description,
17
+ status: 'active',
18
+ created_at: now,
19
+ updated_at: now,
20
+ };
21
+ db.prepare(`INSERT INTO goals (id, title, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`).run(goal.id, goal.title, goal.description ?? null, goal.status, goal.created_at, goal.updated_at);
22
+ return goal;
23
+ },
24
+ },
25
+ {
26
+ name: 'goal_list',
27
+ description: 'List Goals, optionally filtered by status. Use to find a goal_id or get an overview of all projects.',
28
+ schema: goalListInput,
29
+ handler: (args) => {
30
+ const input = goalListInput.parse(args);
31
+ const rows = input.status
32
+ ? db.prepare('SELECT * FROM goals WHERE status = ? ORDER BY created_at DESC').all(input.status)
33
+ : db.prepare('SELECT * FROM goals ORDER BY created_at DESC').all();
34
+ return rows.map((r) => rowToGoal(r));
35
+ },
36
+ },
37
+ {
38
+ name: 'goal_get_context',
39
+ description: 'The single warm-up call for a context-reset Agent: returns the goal, spec, milestones with task counts, out-of-range milestones, aggregate progress, and the last checkpoint. Call this at the start of every session.',
40
+ schema: goalGetContextInput,
41
+ handler: (args) => {
42
+ const { goal_id } = goalGetContextInput.parse(args);
43
+ const goalRow = db.prepare('SELECT * FROM goals WHERE id = ?').get(goal_id);
44
+ if (!goalRow)
45
+ throw new Error(`Goal not found: ${goal_id}`);
46
+ const goal = rowToGoal(goalRow);
47
+ const specRow = db.prepare('SELECT * FROM specs WHERE goal_id = ?').get(goal_id);
48
+ const spec = specRow ? rowToSpec(specRow) : null;
49
+ const milestoneRows = db
50
+ .prepare('SELECT * FROM milestones WHERE goal_id = ? ORDER BY "order" ASC')
51
+ .all(goal_id);
52
+ const taskStatusRows = db
53
+ .prepare('SELECT milestone_id, status FROM tasks WHERE goal_id = ?')
54
+ .all(goal_id);
55
+ const tasksByMilestone = new Map();
56
+ const allStatuses = [];
57
+ for (const row of taskStatusRows) {
58
+ allStatuses.push(row.status);
59
+ const list = tasksByMilestone.get(row.milestone_id) ?? [];
60
+ list.push(row.status);
61
+ tasksByMilestone.set(row.milestone_id, list);
62
+ }
63
+ const { milestones, outOfRange, pendingApproval } = buildMilestonesSummary(milestoneRows.map((r) => rowToMilestoneBase(r)), tasksByMilestone);
64
+ const progress = buildProgress(allStatuses);
65
+ const checkpointRow = db
66
+ .prepare('SELECT * FROM checkpoints WHERE goal_id = ? ORDER BY saved_at DESC LIMIT 1')
67
+ .get(goal_id);
68
+ const last_checkpoint = checkpointRow ? rowToCheckpoint(checkpointRow) : null;
69
+ return {
70
+ goal,
71
+ spec,
72
+ milestones,
73
+ milestones_out_of_range: outOfRange,
74
+ milestones_pending_approval: pendingApproval,
75
+ progress,
76
+ last_checkpoint,
77
+ };
78
+ },
79
+ },
80
+ {
81
+ name: 'goal_update_status',
82
+ description: 'Close a finished Goal, archive an inactive one, or reactivate an archived Goal. Merges goal_complete + goal_archive + goal_reactivate.',
83
+ schema: goalUpdateStatusInput,
84
+ handler: (args) => {
85
+ const input = goalUpdateStatusInput.parse(args);
86
+ const existing = db.prepare('SELECT 1 FROM goals WHERE id = ?').get(input.goal_id);
87
+ if (!existing)
88
+ throw new Error(`Goal not found: ${input.goal_id}`);
89
+ const now = new Date().toISOString();
90
+ db.prepare(`UPDATE goals SET status = ?, status_note = COALESCE(?, status_note), updated_at = ? WHERE id = ?`).run(input.status, input.note ?? null, now, input.goal_id);
91
+ return rowToGoal(db.prepare('SELECT * FROM goals WHERE id = ?').get(input.goal_id));
92
+ },
93
+ },
94
+ ];
95
+ }
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { milestoneCreateInput, milestoneApproveInput, rowToMilestoneBase, } from '../schemas/index.js';
3
+ import { computeMilestoneStatus } from '../utils/computed.js';
4
+ export function milestoneTools(db) {
5
+ return [
6
+ {
7
+ name: 'milestone_create',
8
+ description: 'Break a Goal into a major phase. Status is auto-computed from its tasks. When order is omitted, it auto-assigns to max(order in this goal) + 1.',
9
+ schema: milestoneCreateInput,
10
+ handler: (args) => {
11
+ const input = milestoneCreateInput.parse(args);
12
+ const goalExists = db.prepare('SELECT 1 FROM goals WHERE id = ?').get(input.goal_id);
13
+ if (!goalExists)
14
+ throw new Error(`Goal not found: ${input.goal_id}`);
15
+ let order = input.order;
16
+ if (order === undefined) {
17
+ const row = db
18
+ .prepare('SELECT MAX("order") as maxOrder FROM milestones WHERE goal_id = ?')
19
+ .get(input.goal_id);
20
+ order = (row.maxOrder ?? -1) + 1;
21
+ }
22
+ const now = new Date().toISOString();
23
+ const id = randomUUID();
24
+ db.prepare(`INSERT INTO milestones (id, goal_id, title, description, "order", created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(id, input.goal_id, input.title, input.description ?? null, order, now);
25
+ const row = db.prepare('SELECT * FROM milestones WHERE id = ?').get(id);
26
+ return { ...rowToMilestoneBase(row), status: computeMilestoneStatus([], undefined) };
27
+ },
28
+ },
29
+ {
30
+ name: 'milestone_approve',
31
+ description: 'Explicitly approve a Milestone that has fewer than 2 active tasks, lifting the restriction that blocks task_update_status from starting work on its tasks. Only call this after actually confirming with the user that a small Milestone is intentional — not as a shortcut to unblock yourself.',
32
+ schema: milestoneApproveInput,
33
+ handler: (args) => {
34
+ const { milestone_id } = milestoneApproveInput.parse(args);
35
+ const existing = db.prepare('SELECT 1 FROM milestones WHERE id = ?').get(milestone_id);
36
+ if (!existing)
37
+ throw new Error(`Milestone not found: ${milestone_id}`);
38
+ db.prepare('UPDATE milestones SET approved_at = ? WHERE id = ?').run(new Date().toISOString(), milestone_id);
39
+ const row = db.prepare('SELECT * FROM milestones WHERE id = ?').get(milestone_id);
40
+ const statuses = db.prepare('SELECT status FROM tasks WHERE milestone_id = ?').all(milestone_id).map((r) => r.status);
41
+ const base = rowToMilestoneBase(row);
42
+ return { ...base, status: computeMilestoneStatus(statuses, base.approved_at) };
43
+ },
44
+ },
45
+ ];
46
+ }
@@ -0,0 +1,33 @@
1
+ import { specSetInput, rowToSpec } from '../schemas/index.js';
2
+ export function specTools(db) {
3
+ return [
4
+ {
5
+ name: 'spec_set',
6
+ description: 'Create or overwrite the Spec for a Goal (overview, acceptance criteria, constraints, out of scope). Create-or-replace semantics — call again any time the spec changes.',
7
+ schema: specSetInput,
8
+ handler: (args) => {
9
+ const input = specSetInput.parse(args);
10
+ const goalExists = db.prepare('SELECT 1 FROM goals WHERE id = ?').get(input.goal_id);
11
+ if (!goalExists)
12
+ throw new Error(`Goal not found: ${input.goal_id}`);
13
+ const now = new Date().toISOString();
14
+ db.prepare(`INSERT INTO specs (goal_id, overview, acceptance_criteria, constraints, out_of_scope, updated_at)
15
+ VALUES (@goal_id, @overview, @acceptance_criteria, @constraints, @out_of_scope, @updated_at)
16
+ ON CONFLICT(goal_id) DO UPDATE SET
17
+ overview = excluded.overview,
18
+ acceptance_criteria = excluded.acceptance_criteria,
19
+ constraints = excluded.constraints,
20
+ out_of_scope = excluded.out_of_scope,
21
+ updated_at = excluded.updated_at`).run({
22
+ goal_id: input.goal_id,
23
+ overview: input.overview,
24
+ acceptance_criteria: JSON.stringify(input.acceptance_criteria),
25
+ constraints: JSON.stringify(input.constraints ?? []),
26
+ out_of_scope: JSON.stringify(input.out_of_scope ?? []),
27
+ updated_at: now,
28
+ });
29
+ return rowToSpec(db.prepare('SELECT * FROM specs WHERE goal_id = ?').get(input.goal_id));
30
+ },
31
+ },
32
+ ];
33
+ }
@@ -0,0 +1,56 @@
1
+ import { statusReportInput, rowToGoal, rowToMilestoneBase, rowToTask, rowToNote, } from '../schemas/index.js';
2
+ import { buildMilestonesSummary, buildProgress } from '../utils/computed.js';
3
+ export function statusTools(db) {
4
+ return [
5
+ {
6
+ name: 'status_report',
7
+ description: 'Periodic review / pre-closure check. Returns aggregate progress, milestones with task counts, blocked tasks, and the acceptance criteria list. Data only — no suggestions.',
8
+ schema: statusReportInput,
9
+ handler: (args) => {
10
+ const { goal_id } = statusReportInput.parse(args);
11
+ const goalRow = db.prepare('SELECT * FROM goals WHERE id = ?').get(goal_id);
12
+ if (!goalRow)
13
+ throw new Error(`Goal not found: ${goal_id}`);
14
+ const goal = rowToGoal(goalRow);
15
+ const milestoneRows = db
16
+ .prepare('SELECT * FROM milestones WHERE goal_id = ? ORDER BY "order" ASC')
17
+ .all(goal_id);
18
+ const taskRows = db
19
+ .prepare('SELECT * FROM tasks WHERE goal_id = ?')
20
+ .all(goal_id)
21
+ .map((r) => rowToTask(r));
22
+ const tasksByMilestone = new Map();
23
+ for (const t of taskRows) {
24
+ const list = tasksByMilestone.get(t.milestone_id) ?? [];
25
+ list.push(t.status);
26
+ tasksByMilestone.set(t.milestone_id, list);
27
+ }
28
+ const { milestones, outOfRange, pendingApproval } = buildMilestonesSummary(milestoneRows.map((r) => rowToMilestoneBase(r)), tasksByMilestone);
29
+ const progress = buildProgress(taskRows.map((t) => t.status));
30
+ const blocked_tasks = taskRows
31
+ .filter((t) => t.status === 'blocked')
32
+ .map((t) => {
33
+ const latestNoteRow = db
34
+ .prepare('SELECT * FROM notes WHERE task_id = ? ORDER BY created_at DESC LIMIT 1')
35
+ .get(t.id);
36
+ return {
37
+ task: t,
38
+ blocked_reason: t.status_reason ?? '',
39
+ latest_note: latestNoteRow ? rowToNote(latestNoteRow) : undefined,
40
+ };
41
+ });
42
+ const specRow = db.prepare('SELECT acceptance_criteria FROM specs WHERE goal_id = ?').get(goal_id);
43
+ const acceptance_criteria = specRow ? JSON.parse(specRow.acceptance_criteria) : [];
44
+ return {
45
+ goal,
46
+ progress,
47
+ milestones,
48
+ milestones_out_of_range: outOfRange,
49
+ milestones_pending_approval: pendingApproval,
50
+ blocked_tasks,
51
+ acceptance_criteria,
52
+ };
53
+ },
54
+ },
55
+ ];
56
+ }
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { taskCreateInput, taskGetInput, taskListInput, taskUpdateStatusInput, taskAddNoteInput, rowToTask, rowToNote, } from '../schemas/index.js';
3
+ import { activeTaskCount, needsApproval } from '../utils/computed.js';
4
+ export function taskTools(db) {
5
+ return [
6
+ {
7
+ name: 'task_create',
8
+ description: 'Create a task within a milestone. No hard cap — the response includes milestone_active_task_count so the Agent can judge against the 2-5 active-task guideline.',
9
+ schema: taskCreateInput,
10
+ handler: (args) => {
11
+ const input = taskCreateInput.parse(args);
12
+ const milestoneRow = db
13
+ .prepare('SELECT goal_id FROM milestones WHERE id = ?')
14
+ .get(input.milestone_id);
15
+ if (!milestoneRow)
16
+ throw new Error(`Milestone not found: ${input.milestone_id}`);
17
+ const now = new Date().toISOString();
18
+ const id = randomUUID();
19
+ db.prepare(`INSERT INTO tasks (id, milestone_id, goal_id, title, description, priority, created_at, updated_at)
20
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.milestone_id, milestoneRow.goal_id, input.title, input.description ?? null, input.priority ?? 'medium', now, now);
21
+ const statuses = db.prepare('SELECT status FROM tasks WHERE milestone_id = ?').all(input.milestone_id).map((r) => r.status);
22
+ const task = rowToTask(db.prepare('SELECT * FROM tasks WHERE id = ?').get(id));
23
+ return { ...task, milestone_active_task_count: activeTaskCount(statuses) };
24
+ },
25
+ },
26
+ {
27
+ name: 'task_get',
28
+ description: "Inspect a task's full detail, including all its notes.",
29
+ schema: taskGetInput,
30
+ handler: (args) => {
31
+ const { task_id } = taskGetInput.parse(args);
32
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task_id);
33
+ if (!row)
34
+ throw new Error(`Task not found: ${task_id}`);
35
+ const notes = db
36
+ .prepare('SELECT * FROM notes WHERE task_id = ? ORDER BY created_at ASC')
37
+ .all(task_id)
38
+ .map((r) => rowToNote(r));
39
+ return { ...rowToTask(row), notes };
40
+ },
41
+ },
42
+ {
43
+ name: 'task_list',
44
+ description: 'Find tasks to work on or review, filtered by goal, milestone, status, and/or priority.',
45
+ schema: taskListInput,
46
+ handler: (args) => {
47
+ const input = taskListInput.parse(args);
48
+ const clauses = [];
49
+ const params = [];
50
+ if (input.goal_id) {
51
+ clauses.push('goal_id = ?');
52
+ params.push(input.goal_id);
53
+ }
54
+ if (input.milestone_id) {
55
+ clauses.push('milestone_id = ?');
56
+ params.push(input.milestone_id);
57
+ }
58
+ if (input.status) {
59
+ clauses.push('status = ?');
60
+ params.push(input.status);
61
+ }
62
+ if (input.priority) {
63
+ clauses.push('priority = ?');
64
+ params.push(input.priority);
65
+ }
66
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
67
+ const rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at ASC`).all(...params);
68
+ return rows.map((r) => rowToTask(r));
69
+ },
70
+ },
71
+ {
72
+ name: 'task_update_status',
73
+ description: "Update a task's status (pending, in_progress, completed, blocked, cancelled). reason is required for blocked and cancelled.",
74
+ schema: taskUpdateStatusInput,
75
+ handler: (args) => {
76
+ const input = taskUpdateStatusInput.parse(args);
77
+ const existing = db.prepare('SELECT milestone_id FROM tasks WHERE id = ?').get(input.task_id);
78
+ if (!existing)
79
+ throw new Error(`Task not found: ${input.task_id}`);
80
+ if (input.status === 'in_progress') {
81
+ const milestoneRow = db
82
+ .prepare('SELECT title, approved_at FROM milestones WHERE id = ?')
83
+ .get(existing.milestone_id);
84
+ const statuses = db.prepare('SELECT status FROM tasks WHERE milestone_id = ?').all(existing.milestone_id).map((r) => r.status);
85
+ if (needsApproval(statuses, milestoneRow.approved_at ?? undefined)) {
86
+ throw new Error(`Milestone "${milestoneRow.title}" has fewer than 2 active tasks and has not been approved yet. ` +
87
+ `Confirm with the user that this small milestone is intentional, then call milestone_approve(milestone_id) before starting work on its tasks.`);
88
+ }
89
+ }
90
+ const now = new Date().toISOString();
91
+ db.prepare(`UPDATE tasks SET status = ?, status_reason = ?, updated_at = ? WHERE id = ?`).run(input.status, input.reason ?? null, now, input.task_id);
92
+ return rowToTask(db.prepare('SELECT * FROM tasks WHERE id = ?').get(input.task_id));
93
+ },
94
+ },
95
+ {
96
+ name: 'task_add_note',
97
+ description: 'Record a progress update, blocker, technical decision, evidence, or uncertainty on a task.',
98
+ schema: taskAddNoteInput,
99
+ handler: (args) => {
100
+ const input = taskAddNoteInput.parse(args);
101
+ const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(input.task_id);
102
+ if (!taskExists)
103
+ throw new Error(`Task not found: ${input.task_id}`);
104
+ const now = new Date().toISOString();
105
+ const id = randomUUID();
106
+ db.prepare(`INSERT INTO notes (id, task_id, content, type, created_at) VALUES (?, ?, ?, ?, ?)`).run(id, input.task_id, input.content, input.type, now);
107
+ return rowToNote(db.prepare('SELECT * FROM notes WHERE id = ?').get(id));
108
+ },
109
+ },
110
+ ];
111
+ }
@@ -0,0 +1,85 @@
1
+ export function emptyTaskStatusCounts() {
2
+ return { pending: 0, in_progress: 0, completed: 0, blocked: 0, cancelled: 0 };
3
+ }
4
+ export function activeTaskCount(taskStatuses) {
5
+ return taskStatuses.filter((s) => s !== 'cancelled').length;
6
+ }
7
+ export function isMilestoneOutOfRange(taskStatuses) {
8
+ const count = activeTaskCount(taskStatuses);
9
+ return count < 2 || count > 5;
10
+ }
11
+ /**
12
+ * The 5-active-task upper bound stays advisory only (see DESIGN.md §9,
13
+ * "Rejected: hard cap on Milestone task count"). The 2-active-task lower
14
+ * bound is enforced instead: a Milestone below it needs an explicit
15
+ * milestone_approve before task_update_status will allow starting work on
16
+ * its tasks. This checks that lower-bound gate.
17
+ */
18
+ export function needsApproval(taskStatuses, approvedAt) {
19
+ return approvedAt === undefined && activeTaskCount(taskStatuses) < 2;
20
+ }
21
+ /**
22
+ * "All tasks completed/cancelled" is checked before the approval-gate rule,
23
+ * so a Milestone whose only task(s) are already done always reads
24
+ * "completed" — even if that leaves it under the 2-active-task minimum.
25
+ * Without this order, a 1-task Milestone whose sole task is completed would
26
+ * be stuck reading "pending" forever, since the <2-active-tasks check would
27
+ * always match first. (This is also why an empty Milestone needs its own
28
+ * check first: `[].every(...)` is vacuously true and would otherwise read
29
+ * as "completed" before it has any tasks at all.)
30
+ *
31
+ * Note "pending" still covers two different situations that this field
32
+ * alone doesn't distinguish: a Milestone below the 2-active-task minimum
33
+ * and not yet approved (in effect "pending approval" — see needsApproval)
34
+ * versus one at/above that minimum, or already approved, where no task has
35
+ * started yet ("pending start"). Callers that need to tell them apart
36
+ * should check approved_at / needsApproval too.
37
+ */
38
+ export function computeMilestoneStatus(taskStatuses, approvedAt) {
39
+ if (taskStatuses.length === 0)
40
+ return 'pending';
41
+ if (taskStatuses.every((s) => s === 'completed' || s === 'cancelled'))
42
+ return 'completed';
43
+ if (needsApproval(taskStatuses, approvedAt))
44
+ return 'pending';
45
+ if (taskStatuses.every((s) => s === 'pending'))
46
+ return 'pending';
47
+ return 'in_progress';
48
+ }
49
+ export function completionPct(counts) {
50
+ const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
51
+ const denom = total - counts.cancelled;
52
+ if (denom === 0)
53
+ return null;
54
+ return (counts.completed / denom) * 100;
55
+ }
56
+ export function buildMilestonesSummary(milestones, tasksByMilestone) {
57
+ const result = [];
58
+ const outOfRange = [];
59
+ const pendingApproval = [];
60
+ for (const m of milestones) {
61
+ const statuses = tasksByMilestone.get(m.id) ?? [];
62
+ const status = computeMilestoneStatus(statuses, m.approved_at);
63
+ const counts = emptyTaskStatusCounts();
64
+ for (const s of statuses)
65
+ counts[s]++;
66
+ result.push({ milestone: { ...m, status }, task_counts: counts });
67
+ if (status !== 'completed' && isMilestoneOutOfRange(statuses)) {
68
+ outOfRange.push(m.id);
69
+ }
70
+ if (status !== 'completed' && needsApproval(statuses, m.approved_at)) {
71
+ pendingApproval.push(m.id);
72
+ }
73
+ }
74
+ return { milestones: result, outOfRange, pendingApproval };
75
+ }
76
+ export function buildProgress(taskStatuses) {
77
+ const counts = emptyTaskStatusCounts();
78
+ for (const s of taskStatuses)
79
+ counts[s]++;
80
+ return {
81
+ total_tasks: taskStatuses.length,
82
+ ...counts,
83
+ completion_pct: completionPct(counts),
84
+ };
85
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "goaltracker",
3
+ "version": "1.0.0",
4
+ "description": "MCP server that helps AI Agents track progress, manage Goals and Tasks in a hierarchical model, and maintain context across sessions.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "goaltracker": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "generate:skill": "node scripts/generate-skill-content.mjs",
15
+ "prebuild": "npm run generate:skill",
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "predev": "npm run generate:skill",
19
+ "dev": "tsx src/index.ts",
20
+ "pretypecheck": "npm run generate:skill",
21
+ "typecheck": "tsc --noEmit",
22
+ "pretest": "npm run generate:skill",
23
+ "test": "vitest run"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Nhat-TheDev",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/Nhat-TheDev/GoalTracker.git"
30
+ },
31
+ "homepage": "https://github.com/Nhat-TheDev/GoalTracker#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/Nhat-TheDev/GoalTracker/issues"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.29.0",
37
+ "better-sqlite3": "^12.11.1",
38
+ "zod": "^4.4.3"
39
+ },
40
+ "devDependencies": {
41
+ "@types/better-sqlite3": "^7.6.13",
42
+ "@types/node": "^26.1.1",
43
+ "tsx": "^4.23.1",
44
+ "typescript": "^7.0.2",
45
+ "vitest": "^4.1.10"
46
+ }
47
+ }