@getpipher/armory-todo 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -14
- package/docs/superpowers/plans/2026-07-20-spec-1-store-layer.md +1691 -0
- package/docs/superpowers/plans/2026-07-20-spec-2-health-hard-prune.md +762 -0
- package/docs/superpowers/plans/2026-07-20-spec-3-interactive-panel.md +651 -0
- package/docs/superpowers/plans/2026-07-21-title-notes-split.md +1586 -0
- package/docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md +323 -0
- package/docs/superpowers/specs/2026-07-21-title-notes-split-design.md +411 -0
- package/docs/todo-SPEC.md +5 -0
- package/extensions/todo.ts +273 -36
- package/package.json +2 -2
- package/src/archive.ts +214 -0
- package/src/config.ts +101 -0
- package/src/hard-prune.ts +89 -0
- package/src/health.ts +97 -0
- package/src/migrate.ts +154 -0
- package/src/panel-data.ts +63 -0
- package/src/panel.ts +382 -0
- package/src/paths.ts +36 -0
- package/src/todo-store.ts +86 -37
package/src/config.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Prune + health configuration for armory-todo.
|
|
2
|
+
//
|
|
3
|
+
// Stored at <TODO_DIR>/todo.config.json. Missing or corrupt → defaults are
|
|
4
|
+
// rewritten (the bad file is backed up to todo.config.json.bad-<ts>). All
|
|
5
|
+
// values are editable (later, via the SPEC-3 /todo Config panel).
|
|
6
|
+
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { getConfigPath } from "./paths.ts";
|
|
10
|
+
|
|
11
|
+
export interface PruneConfig {
|
|
12
|
+
/** Closed todos older than this (by closedAt) are moved to archive on `prune`. */
|
|
13
|
+
defaultAgeDays: number;
|
|
14
|
+
/** Archive items older than this are flagged for hard-prune suggestion. */
|
|
15
|
+
hardAgeDays: number;
|
|
16
|
+
/** Which terminal statuses get pruned. */
|
|
17
|
+
statuses: ("done" | "cancelled")[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HealthConfig {
|
|
21
|
+
activeMaxOpen: number;
|
|
22
|
+
activeStaleDays: number;
|
|
23
|
+
parkedMax: number;
|
|
24
|
+
parkedStaleDays: number;
|
|
25
|
+
archiveMax: number;
|
|
26
|
+
archiveOldDays: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TodoConfig {
|
|
30
|
+
version: 1;
|
|
31
|
+
prune: PruneConfig;
|
|
32
|
+
health: HealthConfig;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_CONFIG: TodoConfig = {
|
|
36
|
+
version: 1,
|
|
37
|
+
prune: {
|
|
38
|
+
defaultAgeDays: 7,
|
|
39
|
+
hardAgeDays: 180,
|
|
40
|
+
statuses: ["done", "cancelled"],
|
|
41
|
+
},
|
|
42
|
+
health: {
|
|
43
|
+
activeMaxOpen: 15,
|
|
44
|
+
activeStaleDays: 30,
|
|
45
|
+
parkedMax: 10,
|
|
46
|
+
parkedStaleDays: 60,
|
|
47
|
+
archiveMax: 200,
|
|
48
|
+
archiveOldDays: 180,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Deep clone of DEFAULT_CONFIG (so callers can't mutate the constant). */
|
|
53
|
+
function freshDefaults(): TodoConfig {
|
|
54
|
+
return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as TodoConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function loadConfig(): TodoConfig {
|
|
58
|
+
const path = getConfigPath();
|
|
59
|
+
if (!existsSync(path)) {
|
|
60
|
+
const cfg = freshDefaults();
|
|
61
|
+
saveConfig(cfg);
|
|
62
|
+
return cfg;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(path, "utf8");
|
|
66
|
+
const parsed = JSON.parse(raw) as TodoConfig;
|
|
67
|
+
if (!parsed || typeof parsed !== "object" || !parsed.prune || !parsed.health) {
|
|
68
|
+
throw new Error("invalid config shape");
|
|
69
|
+
}
|
|
70
|
+
// Merge with defaults so new fields get filled in on upgrade.
|
|
71
|
+
return {
|
|
72
|
+
version: 1,
|
|
73
|
+
prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
|
|
74
|
+
health: { ...DEFAULT_CONFIG.health, ...parsed.health },
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
try {
|
|
78
|
+
renameSync(path, `${path}.bad-${Date.now()}`);
|
|
79
|
+
} catch {
|
|
80
|
+
// best-effort backup
|
|
81
|
+
}
|
|
82
|
+
const cfg = freshDefaults();
|
|
83
|
+
saveConfig(cfg);
|
|
84
|
+
return cfg;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Atomic, 0600 write. */
|
|
89
|
+
export function saveConfig(config: TodoConfig): void {
|
|
90
|
+
const path = getConfigPath();
|
|
91
|
+
const dir = dirname(path);
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
const tmp = `${path}.tmp`;
|
|
94
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
95
|
+
try {
|
|
96
|
+
chmodSync(tmp, 0o600);
|
|
97
|
+
} catch {
|
|
98
|
+
// some filesystems ignore mode bits
|
|
99
|
+
}
|
|
100
|
+
renameSync(tmp, path);
|
|
101
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The ONLY irreversible deletion path in armory-todo. Everything else is
|
|
2
|
+
// reversible (park = status flip, prune = archive move, restore = move back).
|
|
3
|
+
// hardPrune permanently deletes todos from the targeted box.
|
|
4
|
+
//
|
|
5
|
+
// Structural gate: refuses to execute unless `confirm: true` is passed. Even
|
|
6
|
+
// if the agent hallucinates intent, the tool demands the flag. The prompt
|
|
7
|
+
// guidelines (extensions/todo.ts) instruct the agent to always surface the
|
|
8
|
+
// `health` report + the exact proposed command and wait for an explicit user
|
|
9
|
+
// "yes" before passing confirm. The slash path uses ctx.ui.confirm.
|
|
10
|
+
|
|
11
|
+
import { loadStore, saveStore } from "./todo-store.ts";
|
|
12
|
+
import { loadArchive, saveArchive } from "./archive.ts";
|
|
13
|
+
import type { Todo } from "./todo-store.ts";
|
|
14
|
+
|
|
15
|
+
export type HardPruneBox = "archive" | "active" | "parked";
|
|
16
|
+
|
|
17
|
+
export interface HardPruneInput {
|
|
18
|
+
confirm: boolean; // REQUIRED — must be true to execute
|
|
19
|
+
box?: HardPruneBox; // default: "archive"
|
|
20
|
+
olderThan?: number; // days; filters by updatedAt (active/parked) or closedAt (archive)
|
|
21
|
+
project?: string;
|
|
22
|
+
tag?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HardPruneResult {
|
|
26
|
+
refused: boolean;
|
|
27
|
+
deleted: number;
|
|
28
|
+
ids: string[];
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function daysAgo(iso: string): number {
|
|
33
|
+
return (Date.now() - Date.parse(iso)) / 86400_000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Permanently delete todos from a box. The only irreversible action.
|
|
38
|
+
* Returns `{ refused: true, deleted: 0, ... }` unless `confirm: true`.
|
|
39
|
+
*/
|
|
40
|
+
export function hardPrune(opts: HardPruneInput): HardPruneResult {
|
|
41
|
+
if (!opts.confirm) {
|
|
42
|
+
return {
|
|
43
|
+
refused: true,
|
|
44
|
+
deleted: 0,
|
|
45
|
+
ids: [],
|
|
46
|
+
message: "Refused: pass confirm:true to execute hard-prune (this permanently deletes).",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const box: HardPruneBox = opts.box ?? "archive";
|
|
50
|
+
const cutoff = opts.olderThan ? Date.now() - opts.olderThan * 86400_000 : null;
|
|
51
|
+
|
|
52
|
+
const matches = (t: Todo): boolean => {
|
|
53
|
+
if (opts.project && t.project !== opts.project) return false;
|
|
54
|
+
if (opts.tag && !t.tags.includes(opts.tag)) return false;
|
|
55
|
+
if (cutoff !== null) {
|
|
56
|
+
const dateField = box === "archive" ? (t.closedAt ?? t.updatedAt) : t.updatedAt;
|
|
57
|
+
if (Date.parse(dateField) > cutoff) return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (box === "archive") {
|
|
63
|
+
const archive = loadArchive();
|
|
64
|
+
const kept: Todo[] = [];
|
|
65
|
+
const deleted: Todo[] = [];
|
|
66
|
+
for (const t of archive.todos) (matches(t) ? deleted : kept).push(t);
|
|
67
|
+
if (deleted.length === 0) return { refused: false, deleted: 0, ids: [], message: "No archived todos matched the criteria." };
|
|
68
|
+
archive.todos = kept;
|
|
69
|
+
saveArchive(archive);
|
|
70
|
+
return { refused: false, deleted: deleted.length, ids: deleted.map((t) => t.id), message: `Permanently deleted ${deleted.length} archived todo${deleted.length === 1 ? "" : "s"}.` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// active or parked box → live store
|
|
74
|
+
const live = loadStore();
|
|
75
|
+
const targetStatuses = box === "parked" ? ["parked"] : ["open", "in_progress"];
|
|
76
|
+
const kept: Todo[] = [];
|
|
77
|
+
const deleted: Todo[] = [];
|
|
78
|
+
for (const t of live.todos) {
|
|
79
|
+
if (targetStatuses.includes(t.status) && matches(t)) {
|
|
80
|
+
deleted.push(t);
|
|
81
|
+
} else {
|
|
82
|
+
kept.push(t);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (deleted.length === 0) return { refused: false, deleted: 0, ids: [], message: `No ${box} todos matched the criteria.` };
|
|
86
|
+
live.todos = kept;
|
|
87
|
+
saveStore(live);
|
|
88
|
+
return { refused: false, deleted: deleted.length, ids: deleted.map((t) => t.id), message: `Permanently deleted ${deleted.length} ${box} todo${deleted.length === 1 ? "" : "s"}.` };
|
|
89
|
+
}
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Bloat diagnostics for armory-todo — a pure-read report across all three
|
|
2
|
+
// lifecycle boxes (active / parked / archive), driven by the heuristics in
|
|
3
|
+
// todo.config.json. No side effects. The agent surfaces this + suggestions,
|
|
4
|
+
// then waits for user confirmation before any `prune --hard` (SPEC-2).
|
|
5
|
+
|
|
6
|
+
import { loadStore } from "./todo-store.ts";
|
|
7
|
+
import { loadArchive } from "./archive.ts";
|
|
8
|
+
import { loadConfig } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
export interface ActiveHealth {
|
|
11
|
+
open: number;
|
|
12
|
+
in_progress: number;
|
|
13
|
+
stale_30d: number; // open todos with updatedAt older than activeStaleDays
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ParkedHealth {
|
|
17
|
+
count: number;
|
|
18
|
+
stale_60d: number; // parked with updatedAt older than parkedStaleDays
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ArchiveHealth {
|
|
22
|
+
count: number;
|
|
23
|
+
older_180d: number; // closedAt older than archiveOldDays
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface NotesBytes {
|
|
27
|
+
total: number;
|
|
28
|
+
max: number;
|
|
29
|
+
avg: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type HealthFlag =
|
|
33
|
+
| "ACTIVE_LARGE" | "ACTIVE_STALE"
|
|
34
|
+
| "PARKED_LARGE" | "PARKED_STALE"
|
|
35
|
+
| "ARCHIVE_LARGE" | "ARCHIVE_OLD";
|
|
36
|
+
|
|
37
|
+
export interface HealthReport {
|
|
38
|
+
active: ActiveHealth;
|
|
39
|
+
parked: ParkedHealth;
|
|
40
|
+
archive: ArchiveHealth;
|
|
41
|
+
notesBytes: NotesBytes;
|
|
42
|
+
flags: HealthFlag[];
|
|
43
|
+
suggestions: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function daysAgo(iso: string): number {
|
|
47
|
+
return (Date.now() - Date.parse(iso)) / 86400_000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function healthReport(): HealthReport {
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
const h = config.health;
|
|
53
|
+
const live = loadStore();
|
|
54
|
+
const archive = loadArchive();
|
|
55
|
+
|
|
56
|
+
const openTodos = live.todos.filter((t) => t.status === "open");
|
|
57
|
+
const ipTodos = live.todos.filter((t) => t.status === "in_progress");
|
|
58
|
+
const parkedTodos = live.todos.filter((t) => t.status === "parked");
|
|
59
|
+
const actionable = [...openTodos, ...ipTodos];
|
|
60
|
+
|
|
61
|
+
const activeStale = openTodos.filter((t) => daysAgo(t.updatedAt) > h.activeStaleDays).length;
|
|
62
|
+
const parkedStale = parkedTodos.filter((t) => daysAgo(t.updatedAt) > h.parkedStaleDays).length;
|
|
63
|
+
const archiveOld = archive.todos.filter((t) => t.closedAt && daysAgo(t.closedAt) > h.archiveOldDays).length;
|
|
64
|
+
|
|
65
|
+
// notes bytes across active + parked (archived excluded — sealed history).
|
|
66
|
+
const apTodos = [...openTodos, ...ipTodos, ...parkedTodos];
|
|
67
|
+
const notesSizes = apTodos.map((t) => Buffer.byteLength(t.notes, "utf8"));
|
|
68
|
+
const notesBytes: NotesBytes = {
|
|
69
|
+
total: notesSizes.reduce((a, b) => a + b, 0),
|
|
70
|
+
max: notesSizes.length ? Math.max(...notesSizes) : 0,
|
|
71
|
+
avg: notesSizes.length ? Math.round(notesSizes.reduce((a, b) => a + b, 0) / notesSizes.length) : 0,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const active: ActiveHealth = {
|
|
75
|
+
open: openTodos.length,
|
|
76
|
+
in_progress: ipTodos.length,
|
|
77
|
+
stale_30d: activeStale,
|
|
78
|
+
};
|
|
79
|
+
const parked: ParkedHealth = { count: parkedTodos.length, stale_60d: parkedStale };
|
|
80
|
+
const arch: ArchiveHealth = { count: archive.todos.length, older_180d: archiveOld };
|
|
81
|
+
|
|
82
|
+
const flags: HealthFlag[] = [];
|
|
83
|
+
if (actionable.length > h.activeMaxOpen) flags.push("ACTIVE_LARGE");
|
|
84
|
+
if (activeStale > 0) flags.push("ACTIVE_STALE");
|
|
85
|
+
if (parkedTodos.length > h.parkedMax) flags.push("PARKED_LARGE");
|
|
86
|
+
if (parkedStale > 0) flags.push("PARKED_STALE");
|
|
87
|
+
if (archive.todos.length > h.archiveMax) flags.push("ARCHIVE_LARGE");
|
|
88
|
+
if (archiveOld > 0) flags.push("ARCHIVE_OLD");
|
|
89
|
+
|
|
90
|
+
const suggestions: string[] = [];
|
|
91
|
+
if (archiveOld > 0) suggestions.push(`archive: ${archiveOld} items older than ${h.archiveOldDays}d → consider \`prune --hard --box archive --older-than ${h.archiveOldDays} --confirm\``);
|
|
92
|
+
if (activeStale > 0) suggestions.push(`active: ${activeStale} open TODOs untouched for ${h.activeStaleDays}d → park or close them`);
|
|
93
|
+
if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
|
|
94
|
+
if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
|
|
95
|
+
|
|
96
|
+
return { active, parked, archive: arch, notesBytes, flags, suggestions };
|
|
97
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// One-time v1 → v2 migration: move the legacy single-file store
|
|
2
|
+
// (~/.pi/agent/todo.json) into the v2 folder layout (~/.pi/agent/todo/todo.json).
|
|
3
|
+
//
|
|
4
|
+
// Pure + testable: takes explicit paths rather than reading env, so tests can
|
|
5
|
+
// point at temp dirs without touching the real home directory.
|
|
6
|
+
//
|
|
7
|
+
// Idempotent: if the target todo.json already exists, do nothing (the user
|
|
8
|
+
// already migrated, or started fresh on v2).
|
|
9
|
+
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
// v2 → v3 schema migration helpers. splitTextFallback is used by loadStore's
|
|
14
|
+
// inline derivation (Task 1) and by migrateV2ToV3 (Task 2, with the curated
|
|
15
|
+
// map). TITLE_MAX here must match the constant in todo-store.ts.
|
|
16
|
+
const TITLE_MAX = 120;
|
|
17
|
+
|
|
18
|
+
/** Truncate at the last word boundary ≤ TITLE_MAX (hard cut if none). No "…"
|
|
19
|
+
* suffix — the cap is a hard rule, not a display truncation. */
|
|
20
|
+
function truncateWordBoundary(s: string): string {
|
|
21
|
+
if (s.length <= TITLE_MAX) return s;
|
|
22
|
+
const slice = s.slice(0, TITLE_MAX);
|
|
23
|
+
const sp = slice.lastIndexOf(" ");
|
|
24
|
+
return sp > 0 ? slice.slice(0, sp) : slice;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Derive { title, notes } from a v2 `text` string (the fallback for any v2
|
|
28
|
+
* todo not in the curated map). Deterministic + idempotent. */
|
|
29
|
+
export function splitTextFallback(text: string): { title: string; notes: string } {
|
|
30
|
+
const raw = (text ?? "").trim();
|
|
31
|
+
if (!raw) return { title: "(untitled)", notes: "" };
|
|
32
|
+
const nl = raw.indexOf("\n");
|
|
33
|
+
if (nl < 0) {
|
|
34
|
+
if (raw.length <= TITLE_MAX) return { title: raw, notes: "" };
|
|
35
|
+
return { title: truncateWordBoundary(raw), notes: raw };
|
|
36
|
+
}
|
|
37
|
+
const firstLine = raw.slice(0, nl).trim();
|
|
38
|
+
const rest = raw.slice(nl + 1).trim();
|
|
39
|
+
if (firstLine.length <= TITLE_MAX) return { title: firstLine, notes: rest };
|
|
40
|
+
return { title: truncateWordBoundary(firstLine), notes: `${firstLine}\n${rest}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MigrateInput {
|
|
44
|
+
/** The v2 folder (e.g. ~/.pi/agent/todo/). */
|
|
45
|
+
todoDir: string;
|
|
46
|
+
/** The pre-v2 single file (e.g. ~/.pi/agent/todo.json). */
|
|
47
|
+
legacyPath: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* If `<todoDir>/todo.json` does not exist but `legacyPath` does, create the
|
|
52
|
+
* folder and move the legacy file in. Atomic-ish: the legacy file is copied
|
|
53
|
+
* first (as a .bak), then moved, so a mid-move failure leaves the legacy file
|
|
54
|
+
* intact. Safe to call on every load.
|
|
55
|
+
*/
|
|
56
|
+
export function migrateIfNeeded(input: MigrateInput): void {
|
|
57
|
+
const target = join(input.todoDir, "todo.json");
|
|
58
|
+
if (existsSync(target)) return; // already v2
|
|
59
|
+
if (!existsSync(input.legacyPath)) return; // nothing to migrate
|
|
60
|
+
|
|
61
|
+
mkdirSync(input.todoDir, { recursive: true });
|
|
62
|
+
// Copy-then-rename so the legacy file survives a crash between copy + rename.
|
|
63
|
+
const backup = `${input.legacyPath}.migrate-bak-${Date.now()}`;
|
|
64
|
+
copyFileSync(input.legacyPath, backup);
|
|
65
|
+
try {
|
|
66
|
+
renameSync(input.legacyPath, target);
|
|
67
|
+
// success → remove the backup
|
|
68
|
+
try { unlinkSync(backup); } catch { /* best-effort */ }
|
|
69
|
+
} catch {
|
|
70
|
+
// rename failed → restore from backup (legacy file may have been moved
|
|
71
|
+
// on some filesystems; copy it back to be safe)
|
|
72
|
+
try { copyFileSync(backup, input.legacyPath); } catch { /* best-effort */ }
|
|
73
|
+
throw new Error(`migration failed: could not move ${input.legacyPath} → ${target}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// v2 → v3 schema migration: each todo gains title + notes (curated for the
|
|
77
|
+
// 2 ids known at migration time; splitTextFallback for the rest), drops text.
|
|
78
|
+
// Pure — does not touch disk. Deterministic + idempotent on v2 input.
|
|
79
|
+
// (splitTextFallback + TITLE_MAX are defined above, alongside migrateIfNeeded.)
|
|
80
|
+
|
|
81
|
+
/** A v2 todo (has `text`, no `title`/`notes`). */
|
|
82
|
+
export interface V2Todo {
|
|
83
|
+
id: string;
|
|
84
|
+
text: string;
|
|
85
|
+
project: string;
|
|
86
|
+
tags: string[];
|
|
87
|
+
priority: string;
|
|
88
|
+
status: string;
|
|
89
|
+
source: string;
|
|
90
|
+
createdAt: string;
|
|
91
|
+
updatedAt: string;
|
|
92
|
+
closedAt: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** v2 store shape (input to migrateV2ToV3). */
|
|
96
|
+
export interface V2Store {
|
|
97
|
+
version: 2;
|
|
98
|
+
updatedAt: string;
|
|
99
|
+
todos: V2Todo[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Hand-curated title + notes for the 2 todos known at v2→v3 migration time
|
|
103
|
+
// (the only survivors of the v0.2.0 incident). Any other v2 todo uses
|
|
104
|
+
// splitTextFallback. Curated notes are reformatted for clarity, not a
|
|
105
|
+
// mechanical split.
|
|
106
|
+
const CURATED_V2_TO_V3: Record<string, { title: string; notes: string }> = {
|
|
107
|
+
"td-mrt3zp9fcnug3p": {
|
|
108
|
+
title: "ZeroClaw×Solana bounty — Phase 4-5: demo video (score bottleneck, unstarted)",
|
|
109
|
+
notes: `superteam.fun/earn/listing/zeroclaw · Superteam Brasil · 5,000 USDG pool / 1st=1,800 · winner Aug 21 2026 · TARGET #1.
|
|
110
|
+
|
|
111
|
+
PHASE 0-2 DONE ✅. PHASE 3 (RESEARCH+SPEC+PLAN + impl alerts+custody+docs) DONE ✅ — slices A-F+H, 45 tests, committed 8fd7483→80614c8, PUSHED, PR #76 retitled "Palinurus — depin-attest + depin-rewards", 17 commits.
|
|
112
|
+
|
|
113
|
+
claim_tx (G) DEFERRED — Helium hotspots are cNFTs → claim needs distribute_compression_rewards_v0 + DAS get_asset_proof (merkle proof), multi-session; PDAs verified, design in README.
|
|
114
|
+
|
|
115
|
+
Decision (score-max): ship alerts core complete, pivot to DEMO track.
|
|
116
|
+
|
|
117
|
+
NEXT (★ Phase 4-5, the score bottleneck — submission REQUIRES a demo video, currently unstarted):
|
|
118
|
+
(1) ASYNC: RECTOR's free Relay Community key → real Helium fixtures + live smoke test;
|
|
119
|
+
(2) Phase 4: wiring SVG (docs/wiring-diagram.svg, dark-mode, NOT ASCII) + marketing site (palinurus.rectorspace.com, Next.js+Tailwind+shadcn) + demo recording guide;
|
|
120
|
+
(3) Phase 5: record demo ≤3min (real ZeroClaw+Telegram, terminal+phone) → ElevenLabs voiceover → ffmpeg → submit on Superteam Earn + engage #solana-bounty Discord.
|
|
121
|
+
|
|
122
|
+
Test totals: 184 (71 palinurus-core + 68 depin-attest + 45 depin-rewards), all clippy+wasm clean.
|
|
123
|
+
HANDOFF: ~/Documents/secret/strategy/zeroclaw-solana/session-handoff-2026-07-21.md
|
|
124
|
+
Docs: {RESEARCH-3,SPEC-3,PLAN-3}-depin-rewards.md (SPEC-3 §4 + PLAN-3 G corrected for cNFT)
|
|
125
|
+
Cwd: ~/local-dev/RECTOR-LABS/zeroclaw-plugins/plugins/depin-rewards
|
|
126
|
+
PR: https://github.com/zeroclaw-labs/zeroclaw-plugins/pull/76`,
|
|
127
|
+
},
|
|
128
|
+
"td-mrt4e1qi9td6jz": {
|
|
129
|
+
title: "armory-todo v0.2.0 — Workstream A shipped (lifecycle boxes + prune + health + TUI)",
|
|
130
|
+
notes: `ALL 3 SPECS DONE ✅. SPEC-1 (store: parked+prune+archive+restore, 12 tasks), SPEC-2 (health+hard-prune, 6 tasks), SPEC-3 (interactive /todo TUI panel, 4 tasks). 147/147 tests across 7 suites. 24 commits on feat/spec-1-lifecycle-boxes, PR #3 retitled to full v0.2.0 scope. Auto-publish CI (release.yml, org NPM_TOKEN).
|
|
131
|
+
|
|
132
|
+
INCIDENT (SPEC-1 Task 9): migration bug destroyed real 52KB/47-todo store (35 done + ~10 open lost, no backup). FIXED (c034509): migration guarded to only run when TODO_DIR is default. RECOVERED: 2 todos.
|
|
133
|
+
|
|
134
|
+
Shipped: merge PR #3 → tag v0.2.0 → CI auto-publish → npm:@getpipher/armory-todo@0.2.0.
|
|
135
|
+
Out of scope: B (title+notes split), C (preventive caps+project registry).`,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Transform a v2 store into a v3 store: each todo gains title + notes
|
|
140
|
+
* (curated for the 2 known ids, splitTextFallback for the rest), drops text.
|
|
141
|
+
* Pure — does not touch disk. Deterministic + idempotent on v2 input. */
|
|
142
|
+
export function migrateV2ToV3(store: V2Store): { version: 3; updatedAt: string; todos: any[] } {
|
|
143
|
+
const todos = store.todos.map((t) => {
|
|
144
|
+
const curated = CURATED_V2_TO_V3[t.id];
|
|
145
|
+
if (curated) {
|
|
146
|
+
const { text: _drop, ...rest } = t;
|
|
147
|
+
return { ...rest, title: curated.title, notes: curated.notes };
|
|
148
|
+
}
|
|
149
|
+
const { title, notes } = splitTextFallback(t.text ?? "");
|
|
150
|
+
const { text: _drop, ...rest } = t;
|
|
151
|
+
return { ...rest, title, notes };
|
|
152
|
+
});
|
|
153
|
+
return { version: 3, updatedAt: store.updatedAt, todos };
|
|
154
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Pure data helpers for the /todo TUI panel (SPEC-3). Kept separate from
|
|
2
|
+
// panel.ts so they're unit-testable without a terminal — the panel component
|
|
3
|
+
// itself is manual-gate only.
|
|
4
|
+
|
|
5
|
+
import type { SelectItem, SettingItem } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { Todo } from "./todo-store.ts";
|
|
7
|
+
import type { ArchiveSummary } from "./archive.ts";
|
|
8
|
+
import type { TodoConfig } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
/** Format a todo as a SelectList item: "[id] (prio)⏵ (project) • title".
|
|
11
|
+
* title is already ≤120 chars (enforced at write time), so no truncation is
|
|
12
|
+
* needed. The • marker shows when notes is non-empty (signals "open the
|
|
13
|
+
* detail view / use `todo get` for context"). */
|
|
14
|
+
export function todoToItem(t: Todo): SelectItem {
|
|
15
|
+
const pin = t.status === "in_progress" ? " ⏵" : "";
|
|
16
|
+
const proj = t.project ? ` (${t.project})` : "";
|
|
17
|
+
const dot = t.notes.trim() ? " •" : "";
|
|
18
|
+
return {
|
|
19
|
+
value: t.id,
|
|
20
|
+
label: `[${t.id}] (${t.priority})${pin}${proj}${dot} ${t.title}`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Format an archive summary into SelectList items (project + month buckets). */
|
|
25
|
+
export function archiveSummaryToItems(s: ArchiveSummary): SelectItem[] {
|
|
26
|
+
const items: SelectItem[] = [{ value: "total", label: `Total: ${s.total}` }];
|
|
27
|
+
for (const [p, n] of Object.entries(s.byProject)) items.push({ value: `project:${p}`, label: ` project ${p}: ${n}` });
|
|
28
|
+
for (const [m, n] of Object.entries(s.byMonth)) items.push({ value: `month:${m}`, label: ` ${m}: ${n}` });
|
|
29
|
+
return items;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Available actions for a todo, depending on its status. */
|
|
33
|
+
export function actionsForTodo(t: Todo): { label: string; action: string }[] {
|
|
34
|
+
const actions: { label: string; action: string }[] = [];
|
|
35
|
+
if (t.status === "open" || t.status === "in_progress") {
|
|
36
|
+
actions.push({ label: "Complete", action: "complete" });
|
|
37
|
+
actions.push({ label: "Park (defer)", action: "park" });
|
|
38
|
+
}
|
|
39
|
+
if (t.status === "parked") {
|
|
40
|
+
actions.push({ label: "Re-activate (open)", action: "open" });
|
|
41
|
+
actions.push({ label: "Complete", action: "complete" });
|
|
42
|
+
}
|
|
43
|
+
if (t.status === "done" || t.status === "cancelled") {
|
|
44
|
+
actions.push({ label: "Restore (from archive)", action: "restore" });
|
|
45
|
+
}
|
|
46
|
+
actions.push({ label: "Edit title", action: "edit" });
|
|
47
|
+
actions.push({ label: "Delete (cancel)", action: "delete" });
|
|
48
|
+
return actions;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Config → SettingsList rows (editable, live-persist). */
|
|
52
|
+
export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
|
|
53
|
+
return [
|
|
54
|
+
{ id: "defaultAgeDays", label: "Prune age (days)", currentValue: String(cfg.prune.defaultAgeDays), values: ["3", "7", "14", "30"], description: "Done/cancelled older than this → archive on prune." },
|
|
55
|
+
{ id: "hardAgeDays", label: "Hard-prune age (days)", currentValue: String(cfg.prune.hardAgeDays), values: ["90", "180", "365"], description: "Archive items older than this → suggested for hard-prune." },
|
|
56
|
+
{ id: "activeMaxOpen", label: "Active max open", currentValue: String(cfg.health.activeMaxOpen), values: ["10", "15", "20", "25"], description: "Bloat flag when open+in_progress exceeds this." },
|
|
57
|
+
{ id: "activeStaleDays", label: "Active stale (days)", currentValue: String(cfg.health.activeStaleDays), values: ["14", "30", "60"], description: "Bloat flag when open todos untouched longer than this." },
|
|
58
|
+
{ id: "parkedMax", label: "Parked max", currentValue: String(cfg.health.parkedMax), values: ["5", "10", "15"], description: "Bloat flag when parked exceeds this." },
|
|
59
|
+
{ id: "parkedStaleDays", label: "Parked stale (days)", currentValue: String(cfg.health.parkedStaleDays), values: ["30", "60", "90"], description: "Bloat flag when parked longer than this." },
|
|
60
|
+
{ id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
|
|
61
|
+
{ id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
|
|
62
|
+
];
|
|
63
|
+
}
|