@uplift-beyond/upgo 0.0.0-stage → 0.4.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.
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ import { itemSchema, pointersSchema, sectionSchema, slug, STATUSES } from "./schema.js";
3
+ import * as m from "./mutate.js";
4
+ // The one write vocabulary the dashboard speaks. The server validates a request body against `opSchema`
5
+ // and runs it through `applyOp`; the CLI calls the same mutate functions.
6
+ const s = itemSchema.shape;
7
+ const clearable = (t) => t.nullable().optional();
8
+ export const itemInputSchema = itemSchema.omit({ id: true, status: true, created: true, updated: true }).extend({
9
+ id: slug.optional(),
10
+ status: z.enum(STATUSES).optional(),
11
+ // Undoing a delete keeps the original date.
12
+ created: s.created.optional(),
13
+ });
14
+ export const itemPatchSchema = z.strictObject({
15
+ section: s.section.optional(),
16
+ title: s.title.optional(),
17
+ phase: s.phase.optional(),
18
+ status: s.status.optional(),
19
+ body: clearable(z.string()),
20
+ size: clearable(s.size.unwrap()),
21
+ scope: clearable(s.scope.unwrap()),
22
+ pointers: clearable(pointersSchema),
23
+ detail: clearable(s.detail.unwrap()),
24
+ related: clearable(s.related.unwrap()),
25
+ note: clearable(z.string()),
26
+ });
27
+ const n = sectionSchema.shape.n;
28
+ const title = sectionSchema.shape.title;
29
+ export const opSchema = z.discriminatedUnion("op", [
30
+ z.strictObject({ op: z.literal("add"), item: itemInputSchema }),
31
+ z.strictObject({ op: z.literal("edit"), id: slug, patch: itemPatchSchema }),
32
+ z.strictObject({ op: z.literal("done"), id: slug, note: z.string().optional() }),
33
+ z.strictObject({ op: z.literal("drop"), id: slug, reason: z.string().trim().min(1) }),
34
+ z.strictObject({ op: z.literal("reopen"), id: slug }),
35
+ z.strictObject({ op: z.literal("delete"), id: slug }),
36
+ z.strictObject({ op: z.literal("move"), id: slug, section: n.optional(), before: slug.optional(), after: slug.optional() }),
37
+ z.strictObject({ op: z.literal("addSection"), title, n: n.optional() }),
38
+ z.strictObject({ op: z.literal("renameSection"), n, title }),
39
+ z.strictObject({ op: z.literal("moveSection"), n, before: n.nullable() }),
40
+ z.strictObject({ op: z.literal("deleteSection"), n }),
41
+ ]);
42
+ export function applyOp(file, op, day) {
43
+ switch (op.op) {
44
+ case "add":
45
+ return m.addItem(file, op.item, day).file;
46
+ case "edit":
47
+ return m.editItem(file, op.id, op.patch, day);
48
+ case "done":
49
+ return m.setStatus(file, op.id, "done", op.note, day);
50
+ case "drop":
51
+ return m.setStatus(file, op.id, "dropped", op.reason, day);
52
+ case "reopen":
53
+ return m.setStatus(file, op.id, "planned", undefined, day);
54
+ case "delete":
55
+ return m.deleteItem(file, op.id);
56
+ case "move":
57
+ return m.moveItem(file, op.id, { section: op.section, before: op.before, after: op.after }, day);
58
+ case "addSection":
59
+ return m.addSection(file, op.title, op.n).file;
60
+ case "renameSection":
61
+ return m.renameSection(file, op.n, op.title);
62
+ case "moveSection":
63
+ return m.moveSection(file, op.n, op.before);
64
+ case "deleteSection":
65
+ return m.deleteSection(file, op.n);
66
+ default:
67
+ throw new Error(`unknown op ${JSON.stringify(op)}`);
68
+ }
69
+ }
@@ -0,0 +1,92 @@
1
+ import { constants, copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { z } from "zod";
6
+ import { formatIssues, slug } from "./schema.js";
7
+ import { withLock, writeAtomic } from "./store.js";
8
+ import { UpgoError } from "./util.js";
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ // Package root: src/core in the repo, dist/src/core when built.
11
+ export const UPGO_DIR = resolve(HERE, existsSync(resolve(HERE, "../../package.json")) ? "../.." : "../../..");
12
+ const entrySchema = z.strictObject({ id: slug, path: z.string().min(1) });
13
+ const registrySchema = z.array(entrySchema);
14
+ const DATA_DIR = join(homedir(), ".upgo");
15
+ let ready = false;
16
+ // Tests point this at a temp file so they never touch the real registry.
17
+ export function registryPath() {
18
+ if (process.env.UPGO_REGISTRY)
19
+ return process.env.UPGO_REGISTRY;
20
+ const path = join(DATA_DIR, "registry.json");
21
+ if (!ready) {
22
+ ready = true;
23
+ mkdirSync(DATA_DIR, { recursive: true });
24
+ // Older versions kept it in the install folder, which an npm update wipes.
25
+ const legacy = join(UPGO_DIR, "registry.json");
26
+ try {
27
+ // EXCL: a parallel first run may already have written the new one.
28
+ if (!existsSync(path) && existsSync(legacy))
29
+ copyFileSync(legacy, path, constants.COPYFILE_EXCL);
30
+ }
31
+ catch (e) {
32
+ if (e.code !== "EEXIST")
33
+ throw e;
34
+ }
35
+ }
36
+ return path;
37
+ }
38
+ const samePath = (a, b) => process.platform === "win32" ? resolve(a).toLowerCase() === resolve(b).toLowerCase() : resolve(a) === resolve(b);
39
+ export function readRegistry() {
40
+ const path = registryPath();
41
+ if (!existsSync(path))
42
+ return [];
43
+ let raw;
44
+ try {
45
+ raw = JSON.parse(readFileSync(path, "utf8"));
46
+ }
47
+ catch (e) {
48
+ throw new UpgoError(`registry ${path} is not valid JSON: ${e.message}`);
49
+ }
50
+ const parsed = registrySchema.safeParse(raw);
51
+ if (!parsed.success)
52
+ throw new UpgoError(`registry ${path} is invalid:`, formatIssues(parsed.error));
53
+ return parsed.data;
54
+ }
55
+ async function writeRegistry(entries) {
56
+ await writeAtomic(registryPath(), JSON.stringify(entries, null, 2) + "\n");
57
+ }
58
+ export function findEntry(id) {
59
+ return readRegistry().find((e) => e.id === id);
60
+ }
61
+ export function findEntryByPath(path) {
62
+ return readRegistry().find((e) => samePath(e.path, path));
63
+ }
64
+ // Throws if `id` at `path` clashes with another registration; returns the entry when it is this one already.
65
+ export function checkRegistrable(id, path, entries = readRegistry()) {
66
+ const byId = entries.find((e) => e.id === id);
67
+ if (byId && !samePath(byId.path, path))
68
+ throw new UpgoError(`project id \`${id}\` is already registered for ${byId.path}`);
69
+ const byPath = entries.find((e) => samePath(e.path, path));
70
+ if (byPath && byPath.id !== id)
71
+ throw new UpgoError(`${path} is already registered as \`${byPath.id}\``);
72
+ return byId;
73
+ }
74
+ export async function register(id, path) {
75
+ return withLock(registryPath(), async () => {
76
+ const entries = readRegistry();
77
+ const byId = checkRegistrable(id, path, entries);
78
+ if (byId)
79
+ return byId;
80
+ const entry = { id, path: resolve(path) };
81
+ await writeRegistry([...entries, entry]);
82
+ return entry;
83
+ });
84
+ }
85
+ export async function forget(id) {
86
+ await withLock(registryPath(), async () => {
87
+ const entries = readRegistry();
88
+ if (!entries.some((e) => e.id === id))
89
+ throw new UpgoError(`no registered project \`${id}\``);
90
+ await writeRegistry(entries.filter((e) => e.id !== id));
91
+ });
92
+ }
@@ -0,0 +1,40 @@
1
+ import { isOpen, PHASE_LABEL } from "./schema.js";
2
+ const COLLAPSED = ["wayLater", "maybe"];
3
+ export const GENERATED_MARK = "<!-- Generated by upgo from goals.json. Never edit this file or goals.json by hand: use the upgo CLI. -->";
4
+ const indent = (s) => s.trim().replace(/\r?\n/g, "\n ");
5
+ function itemLine(item) {
6
+ const tags = [PHASE_LABEL[item.phase], item.status === "partial" ? "partial" : ""].filter(Boolean).join(", ");
7
+ return `- \`${item.id}\` ${item.title} (${tags})`;
8
+ }
9
+ // goals.json -> the slim goals.md agents auto-load. Open items only, one line each.
10
+ export function render(file) {
11
+ const open = file.items.filter(isOpen);
12
+ const live = file.sections.filter((s) => open.some((i) => i.section === s.n));
13
+ const closed = file.sections.filter((s) => !live.includes(s));
14
+ const out = [`# ${file.project}: goals`, "", GENERATED_MARK, ""];
15
+ // LF only: `upgo check` compares against goals.md read back with CRLF normalized.
16
+ if (file.purpose?.trim())
17
+ out.push(file.purpose.trim().replace(/\r\n?/g, "\n"), "");
18
+ out.push("Open work only. Full item: `upgo show <id>`. Any slice: `upgo list --phase later,wayLater,maybe`, `--status done,dropped`, `--section <n>`, `--search <text>`.", "");
19
+ if (closed.length)
20
+ out.push(`Closed sections: ${closed.map((s) => `§${s.n} ${s.title}`).join(" · ")}`, "");
21
+ if (file.traps.length) {
22
+ out.push("## Traps", "");
23
+ for (const t of file.traps)
24
+ out.push(`- **${t.title}**${t.body?.trim() ? ` ${indent(t.body)}` : ""}`);
25
+ out.push("");
26
+ }
27
+ for (const s of live) {
28
+ out.push(`## ${s.n}. ${s.title}`, "");
29
+ const mine = open.filter((i) => i.section === s.n);
30
+ for (const item of mine.filter((i) => !COLLAPSED.includes(i.phase)))
31
+ out.push(itemLine(item));
32
+ const counts = COLLAPSED.map((p) => [p, mine.filter((i) => i.phase === p).length]).filter(([, c]) => c > 0);
33
+ if (counts.length) {
34
+ const what = counts.map(([p, c]) => `${c} ${PHASE_LABEL[p]}`).join(", ");
35
+ out.push(`- Plus ${what}: \`upgo list --section ${s.n} --phase ${counts.map(([p]) => p).join(",")}\``);
36
+ }
37
+ out.push("");
38
+ }
39
+ return out.join("\n").replace(/\n+$/, "\n");
40
+ }
@@ -0,0 +1,35 @@
1
+ export const EM_DASH = "\u2014";
2
+ function dupes(values) {
3
+ const seen = new Set();
4
+ return [...new Set(values.filter((v) => seen.size === seen.add(v).size))];
5
+ }
6
+ // Rules a single file must satisfy on its own; every save runs them.
7
+ export function localIssues(file) {
8
+ const issues = [];
9
+ for (const id of dupes(file.items.map((i) => i.id)))
10
+ issues.push(`duplicate item id \`${id}\``);
11
+ for (const id of dupes(file.traps.map((t) => t.id)))
12
+ issues.push(`duplicate trap id \`${id}\``);
13
+ for (const n of dupes(file.sections.map((s) => s.n)))
14
+ issues.push(`duplicate section number ${n}`);
15
+ const sections = new Set(file.sections.map((s) => s.n));
16
+ for (const item of file.items) {
17
+ if (!sections.has(item.section))
18
+ issues.push(`item \`${item.id}\` is in section ${item.section}, which does not exist`);
19
+ if (item.status === "dropped" && !item.note?.trim())
20
+ issues.push(`dropped item \`${item.id}\` needs a reason in \`note\``);
21
+ for (const ref of item.related ?? []) {
22
+ if (ref === `${file.project}:${item.id}`)
23
+ issues.push(`item \`${item.id}\` relates to itself`);
24
+ }
25
+ }
26
+ const where = [
27
+ ...file.items.filter((i) => JSON.stringify(i).includes(EM_DASH)).map((i) => `item \`${i.id}\``),
28
+ ...file.traps.filter((t) => JSON.stringify(t).includes(EM_DASH)).map((t) => `trap \`${t.id}\``),
29
+ ...file.sections.filter((s) => s.title.includes(EM_DASH)).map((s) => `section ${s.n}`),
30
+ ...(file.purpose?.includes(EM_DASH) ? ["purpose"] : []),
31
+ ];
32
+ for (const w of where)
33
+ issues.push(`${w} contains an em dash (U+2014); use a comma, colon or hyphen`);
34
+ return issues;
35
+ }
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { PHASES, POINTER_KEYS, SIZES, STATUSES, isOpenStatus } from "./vocab.js";
3
+ export * from "./vocab.js";
4
+ export const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ export const DATE = /^\d{4}-\d{2}-\d{2}$/;
6
+ export const slug = z.string().max(60).regex(SLUG, "must be a lowercase slug like `random-logouts`");
7
+ const date = z.string().regex(DATE, "must be YYYY-MM-DD");
8
+ const text = z.string().trim().min(1);
9
+ const list = z.array(text).min(1);
10
+ const line = (max) => text.max(max).regex(/^[^\r\n]*$/, "must be one line");
11
+ // `projectId:itemId`, the only way one project points at another.
12
+ export const ref = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be `projectId:itemId`");
13
+ export const pointersSchema = z.strictObject(Object.fromEntries(POINTER_KEYS.map((k) => [k, list.optional()])));
14
+ export const itemSchema = z.strictObject({
15
+ id: slug,
16
+ section: z.number().int().nonnegative(),
17
+ title: line(200),
18
+ body: z.string().optional(),
19
+ phase: z.enum(PHASES),
20
+ status: z.enum(STATUSES),
21
+ size: z.enum(SIZES).optional(),
22
+ scope: list.optional(),
23
+ pointers: pointersSchema.optional(),
24
+ detail: text.optional(),
25
+ related: z.array(ref).min(1).optional(),
26
+ created: date,
27
+ updated: date,
28
+ note: z.string().optional(),
29
+ });
30
+ export const sectionSchema = z.strictObject({
31
+ n: z.number().int().nonnegative(),
32
+ title: line(120),
33
+ });
34
+ export const trapSchema = z.strictObject({
35
+ id: slug,
36
+ title: line(200),
37
+ body: z.string().optional(),
38
+ });
39
+ // Ideas an agent left for the user, kept in suggestions.json beside goals.json.
40
+ export const suggestionSchema = z.strictObject({
41
+ id: slug,
42
+ title: line(200),
43
+ body: z.string().optional(),
44
+ created: date,
45
+ });
46
+ export const suggestionsFileSchema = z.strictObject({
47
+ version: z.literal(1),
48
+ suggestions: z.array(suggestionSchema),
49
+ });
50
+ export const goalsFileSchema = z.strictObject({
51
+ $schema: z.string().optional(),
52
+ version: z.literal(1),
53
+ project: slug,
54
+ purpose: z.string().optional(),
55
+ sections: z.array(sectionSchema),
56
+ traps: z.array(trapSchema),
57
+ items: z.array(itemSchema),
58
+ });
59
+ export const isOpen = (item) => isOpenStatus(item.status);
60
+ export const SCHEMA_URL = "https://raw.githubusercontent.com/JennyTheGem/upgo/main/goals.schema.json";
61
+ // Also matches files written before the URL moved to GitHub.
62
+ export const isManaged = (goalsJson) => /"\$schema"\s*:\s*"[^"]*\/upgo\/(?:main\/)?goals\.schema\.json"/.test(goalsJson);
63
+ export function jsonSchema() {
64
+ return z.toJSONSchema(goalsFileSchema, { target: "draft-2020-12" });
65
+ }
66
+ export function formatIssues(error) {
67
+ return error.issues.map((i) => `${i.path.length ? i.path.join(".") : "(root)"}: ${i.message}`);
68
+ }
@@ -0,0 +1,122 @@
1
+ import fsp from "node:fs/promises";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { formatIssues, goalsFileSchema, SCHEMA_URL } from "./schema.js";
5
+ import { localIssues } from "./rules.js";
6
+ import { render } from "./render.js";
7
+ import { UpgoError } from "./util.js";
8
+ export const GOALS = "goals.json";
9
+ export const MD = "goals.md";
10
+ const LOCK_STALE_MS = 10_000;
11
+ const LOCK_WAIT_MS = 3_000;
12
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
+ // Nearest folder at or above `start` holding a goals.json.
14
+ export function findProjectDir(start) {
15
+ for (let dir = resolve(start);; dir = dirname(dir)) {
16
+ if (existsSync(join(dir, GOALS)))
17
+ return dir;
18
+ if (dirname(dir) === dir)
19
+ return null;
20
+ }
21
+ }
22
+ export function parseGoals(raw) {
23
+ const parsed = goalsFileSchema.safeParse(raw);
24
+ if (!parsed.success)
25
+ throw new UpgoError("goals.json does not match the schema:", formatIssues(parsed.error));
26
+ return parsed.data;
27
+ }
28
+ export function readGoals(dir) {
29
+ const path = join(dir, GOALS);
30
+ if (!existsSync(path))
31
+ throw new UpgoError(`no goals.json in ${dir} (run \`upgo init\` there first)`);
32
+ let raw;
33
+ try {
34
+ raw = JSON.parse(readFileSync(path, "utf8"));
35
+ }
36
+ catch (e) {
37
+ throw new UpgoError(`goals.json is not valid JSON: ${e.message}`);
38
+ }
39
+ return parseGoals(raw);
40
+ }
41
+ export function serialize(file) {
42
+ // Parsing again puts every key in schema order, so diffs stay stable whoever wrote the file.
43
+ const { $schema: _drop, ...rest } = parseGoals(file);
44
+ return JSON.stringify({ $schema: SCHEMA_URL, ...rest }, null, 2) + "\n";
45
+ }
46
+ // Write a temp file and rename it over the target, so a reader never sees half a file.
47
+ export async function writeAtomic(path, content) {
48
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
49
+ try {
50
+ await fsp.writeFile(tmp, content, "utf8");
51
+ for (let attempt = 0;; attempt++) {
52
+ try {
53
+ await fsp.rename(tmp, path);
54
+ return;
55
+ }
56
+ catch (e) {
57
+ // Windows refuses a rename while a watcher or editor briefly holds the target.
58
+ const code = e.code;
59
+ if (attempt >= 5 || (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES"))
60
+ throw e;
61
+ await sleep(30);
62
+ }
63
+ }
64
+ }
65
+ catch (e) {
66
+ await fsp.rm(tmp, { force: true });
67
+ throw e;
68
+ }
69
+ }
70
+ // Exclusive lockfile next to `target`; a lock older than 10s is a crashed writer and gets taken over.
71
+ export async function withLock(target, fn) {
72
+ const lock = `${target}.lock`;
73
+ const deadline = Date.now() + LOCK_WAIT_MS;
74
+ for (;;) {
75
+ try {
76
+ const handle = await fsp.open(lock, "wx");
77
+ await handle.writeFile(`${process.pid} ${new Date().toISOString()}`);
78
+ await handle.close();
79
+ break;
80
+ }
81
+ catch (e) {
82
+ if (e.code !== "EEXIST")
83
+ throw e;
84
+ const stat = await fsp.stat(lock).catch(() => null);
85
+ if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
86
+ await fsp.rm(lock, { force: true });
87
+ continue;
88
+ }
89
+ if (Date.now() > deadline)
90
+ throw new UpgoError(`${target} is locked by another writer (${lock})`);
91
+ await sleep(50);
92
+ }
93
+ }
94
+ try {
95
+ return await fn();
96
+ }
97
+ finally {
98
+ // A throw here would report a finished write as failed; a leftover lock just goes stale.
99
+ await fsp.rm(lock, { force: true }).catch(() => { });
100
+ }
101
+ }
102
+ // Validates, then writes goals.json and the generated goals.md. Nothing is written if validation fails.
103
+ export async function saveGoals(dir, file) {
104
+ const json = serialize(file);
105
+ const clean = parseGoals(JSON.parse(json));
106
+ const issues = localIssues(clean);
107
+ if (issues.length)
108
+ throw new UpgoError("refusing to save an invalid goals.json:", issues);
109
+ const md = render(clean);
110
+ await writeAtomic(join(dir, GOALS), json);
111
+ try {
112
+ await writeAtomic(join(dir, MD), md);
113
+ }
114
+ catch (e) {
115
+ // The change is in; a caller that retried it would apply it twice.
116
+ throw new UpgoError(`goals.json was saved, but ${MD} could not be written (${e.message}): run \`upgo render\``);
117
+ }
118
+ return clean;
119
+ }
120
+ export async function updateGoals(dir, mutate) {
121
+ return withLock(join(dir, GOALS), async () => saveGoals(dir, mutate(readGoals(dir))));
122
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { EM_DASH } from "./rules.js";
4
+ import { formatIssues, suggestionsFileSchema } from "./schema.js";
5
+ import { withLock, writeAtomic } from "./store.js";
6
+ import { slugify, uniqueSlug, UpgoError } from "./util.js";
7
+ export const SUGGESTIONS = "suggestions.json";
8
+ function parse(raw, problem = `${SUGGESTIONS} does not match the schema:`) {
9
+ const parsed = suggestionsFileSchema.safeParse(raw);
10
+ if (!parsed.success)
11
+ throw new UpgoError(problem, formatIssues(parsed.error));
12
+ return parsed.data.suggestions;
13
+ }
14
+ // No file means no suggestions.
15
+ export function readSuggestions(dir) {
16
+ const path = join(dir, SUGGESTIONS);
17
+ if (!existsSync(path))
18
+ return [];
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(readFileSync(path, "utf8"));
22
+ }
23
+ catch (e) {
24
+ throw new UpgoError(`${SUGGESTIONS} is not valid JSON: ${e.message}`);
25
+ }
26
+ return parse(raw);
27
+ }
28
+ // For readers that carry on past a broken file: the list, or what is wrong with it.
29
+ export function trySuggestions(dir) {
30
+ try {
31
+ return { suggestions: readSuggestions(dir) };
32
+ }
33
+ catch (e) {
34
+ if (!(e instanceof UpgoError))
35
+ throw e;
36
+ // One line: the message carries the details as a list too.
37
+ return { suggestions: [], suggestionsError: [e.message.split("\n")[0], e.details.join("; ")].filter(Boolean).join(" ") };
38
+ }
39
+ }
40
+ export async function updateSuggestions(dir, mutate) {
41
+ const path = join(dir, SUGGESTIONS);
42
+ return withLock(path, async () => {
43
+ // Bad input, not a broken file, so the agent does not go looking for one.
44
+ const list = parse({ version: 1, suggestions: mutate(readSuggestions(dir)) }, `refusing to save an invalid ${SUGGESTIONS}:`);
45
+ await writeAtomic(path, JSON.stringify({ version: 1, suggestions: list }, null, 2) + "\n");
46
+ return list;
47
+ });
48
+ }
49
+ export function addSuggestion(list, input, day) {
50
+ if (JSON.stringify(input).includes(EM_DASH))
51
+ throw new UpgoError("a suggestion contains an em dash (U+2014); use a comma, colon or hyphen");
52
+ const id = uniqueSlug(slugify(input.title), new Set(list.map((s) => s.id)));
53
+ return { list: [...list, { id, title: input.title, ...(input.body?.trim() ? { body: input.body } : {}), created: day }], id };
54
+ }
55
+ export function removeSuggestions(list, ids) {
56
+ const missing = ids.filter((id) => !list.some((s) => s.id === id));
57
+ if (missing.length)
58
+ throw new UpgoError(`no suggestion ${missing.map((id) => `\`${id}\``).join(", ")}`);
59
+ return list.filter((s) => !ids.includes(s.id));
60
+ }
@@ -0,0 +1,27 @@
1
+ import { PHASES, isOpenStatus } from "./vocab.js";
2
+ // Done items per project the dashboard gets, newest first.
3
+ export const RECENT = 5;
4
+ // How much an item counts toward progress. Unsized counts as M.
5
+ export const SIZE_WEIGHT = { S: 1, M: 2, L: 4 };
6
+ // Done share of everything not dropped, weighted by size. Open maybes are not committed work, so they don't count.
7
+ export function percentDone(items) {
8
+ const weigh = (pick) => items.filter(pick).reduce((n, i) => n + SIZE_WEIGHT[i.size ?? "M"], 0);
9
+ const done = weigh((i) => i.status === "done");
10
+ const total = done + weigh((i) => isOpenStatus(i.status) && i.phase !== "maybe");
11
+ return total ? Math.round((done / total) * 100) : 0;
12
+ }
13
+ // What the dashboard shows per project: counts, open items per phase, the open `now` and `next` items, the last
14
+ // change and the latest done items. Zod-free for the web bundle.
15
+ export function summarize(file) {
16
+ const open = file.items.filter((i) => isOpenStatus(i.status));
17
+ const count = (s) => file.items.filter((i) => i.status === s).length;
18
+ const phases = Object.fromEntries(PHASES.map((p) => [p, open.filter((i) => i.phase === p).length]));
19
+ const inPhase = (p) => open.filter((i) => i.phase === p).map(({ id, title, status, size }) => ({ id, title, status, size }));
20
+ const updated = file.items.reduce((d, i) => (i.updated > d ? i.updated : d), "") || undefined;
21
+ const recent = file.items
22
+ .filter((i) => i.status === "done")
23
+ .sort((a, b) => b.updated.localeCompare(a.updated))
24
+ .slice(0, RECENT)
25
+ .map(({ id, title, note, updated }) => ({ id, title, note, updated }));
26
+ return { purpose: file.purpose, open: open.length, done: count("done"), dropped: count("dropped"), percent: percentDone(file.items), phases, now: inPhase("now"), next: inPhase("next"), updated, recent };
27
+ }
@@ -0,0 +1,36 @@
1
+ // Every failure a user or agent can cause is an UpgoError: the CLI prints its message, the server sends it as a 400.
2
+ export class UpgoError extends Error {
3
+ details;
4
+ constructor(message, details = []) {
5
+ super(details.length ? `${message}\n${details.map((d) => ` - ${d}`).join("\n")}` : message);
6
+ this.name = "UpgoError";
7
+ this.details = details;
8
+ }
9
+ }
10
+ export function today(now = new Date()) {
11
+ const p = (n) => String(n).padStart(2, "0");
12
+ return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
13
+ }
14
+ export function slugify(text, max = 48) {
15
+ const s = text
16
+ .normalize("NFKD")
17
+ .replace(/[̀-ͯ]/g, "")
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, "-")
20
+ .replace(/^-+|-+$/g, "");
21
+ if (s.length <= max)
22
+ return s || "item";
23
+ const cut = s.slice(0, max);
24
+ const at = cut.lastIndexOf("-");
25
+ return (at > max / 2 ? cut.slice(0, at) : cut).replace(/-+$/, "");
26
+ }
27
+ // First free slug in `taken`, suffixing -2, -3... on a clash.
28
+ export function uniqueSlug(base, taken) {
29
+ if (!taken.has(base))
30
+ return base;
31
+ for (let i = 2;; i++) {
32
+ const next = `${base.slice(0, 56)}-${i}`;
33
+ if (!taken.has(next))
34
+ return next;
35
+ }
36
+ }
@@ -0,0 +1,14 @@
1
+ // Zod-free vocabulary, so the dashboard can import it without bundling the schema.
2
+ export const PHASES = ["now", "next", "later", "wayLater", "maybe"];
3
+ export const STATUSES = ["planned", "partial", "done", "dropped"];
4
+ export const OPEN_STATUSES = ["planned", "partial"];
5
+ export const SIZES = ["S", "M", "L"];
6
+ export const POINTER_KEYS = ["files", "seam", "depends", "trap", "how", "check", "options", "where"];
7
+ export const PHASE_LABEL = {
8
+ now: "now",
9
+ next: "next",
10
+ later: "later",
11
+ wayLater: "way later",
12
+ maybe: "maybe",
13
+ };
14
+ export const isOpenStatus = (status) => OPEN_STATUSES.includes(status);
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@uplift-beyond/upgo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "Structured goals for every project: a validating CLI for agents, a slim generated goals.md, and a local dashboard.",
7
+ "author": "uplift-beyond.de",
8
+ "homepage": "https://uplift-beyond.de/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/JennyTheGem/upgo.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/JennyTheGem/upgo/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "bin": {
20
+ "upgo": "dist/cli.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "web/dist",
25
+ "skill"
26
+ ],
27
+ "scripts": {
28
+ "ui": "node cli.ts ui",
29
+ "dev": "vite",
30
+ "build": "vite build",
31
+ "schema": "node scripts/emit-schema.ts",
32
+ "typecheck": "tsc --noEmit",
33
+ "lint": "eslint .",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest",
36
+ "build:cli": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
37
+ "prepack": "npm run build && npm run build:cli"
38
+ },
39
+ "dependencies": {
40
+ "zod": "^4.1.12"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "^10.0.1",
44
+ "@testing-library/dom": "^10.4.1",
45
+ "@testing-library/jest-dom": "^7.0.0",
46
+ "@testing-library/react": "^16.3.2",
47
+ "@testing-library/user-event": "^14.6.1",
48
+ "@types/node": "^24.12.3",
49
+ "@types/react": "^19.2.14",
50
+ "@types/react-dom": "^19.2.3",
51
+ "@vitejs/plugin-react": "^6.0.1",
52
+ "eslint": "^10.3.0",
53
+ "eslint-plugin-react-hooks": "^7.1.1",
54
+ "eslint-plugin-react-refresh": "^0.5.2",
55
+ "globals": "^17.6.0",
56
+ "jsdom": "^29.1.1",
57
+ "react": "^19.2.6",
58
+ "react-dom": "^19.2.6",
59
+ "typescript": "~6.0.2",
60
+ "typescript-eslint": "^8.59.2",
61
+ "vite": "^8.0.12",
62
+ "vitest": "^4.1.10"
63
+ },
64
+ "overrides": {
65
+ "minimatch": "^11.0.1"
66
+ }
67
+ }