@matthewfl/pi-jtodo 0.0.1
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 +19 -0
- package/README.md +83 -0
- package/package.json +48 -0
- package/src/config.ts +71 -0
- package/src/constants.ts +78 -0
- package/src/gates.ts +771 -0
- package/src/index.ts +873 -0
- package/src/model.ts +155 -0
- package/src/normalize.ts +122 -0
- package/src/schema.ts +101 -0
- package/src/viewer.ts +136 -0
- package/src/watchdog.ts +71 -0
- package/src/widget.ts +378 -0
- package/tests/test-todo.cjs +1040 -0
package/src/model.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data model — types ported from jcode-task-types/src/lib.rs, plus
|
|
3
|
+
* sanitizers used when replaying tool-result `details` from the session
|
|
4
|
+
* branch (which round-trips through JSON, so all fields are optional).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TodoItem {
|
|
8
|
+
id: string;
|
|
9
|
+
content: string;
|
|
10
|
+
status: string;
|
|
11
|
+
priority: string;
|
|
12
|
+
group?: string;
|
|
13
|
+
confidence?: number;
|
|
14
|
+
completion_confidence?: number;
|
|
15
|
+
/** Tool-maintained; model-supplied values are discarded on write. */
|
|
16
|
+
confidence_history: number[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TodoPlan {
|
|
20
|
+
user_intention?: string;
|
|
21
|
+
understands_user_intent?: number;
|
|
22
|
+
/** Tool-maintained; model-supplied values are discarded on write. */
|
|
23
|
+
understands_user_intent_history: number[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TodoGoal {
|
|
27
|
+
/** Group label this goal describes; undefined = the ungrouped flat list. */
|
|
28
|
+
group?: string;
|
|
29
|
+
closed_feedback_loop?: number;
|
|
30
|
+
closed_feedback_loop_history: number[];
|
|
31
|
+
feedback_loop?: string;
|
|
32
|
+
end_to_end_ownership?: number;
|
|
33
|
+
end_to_end_ownership_history: number[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type GateObservationKind = "intent_understanding" | "closed_feedback_loop";
|
|
37
|
+
|
|
38
|
+
/** A point during the turn that a quality check would have interrupted on. */
|
|
39
|
+
export interface GateObservation {
|
|
40
|
+
kind: GateObservationKind;
|
|
41
|
+
group?: string;
|
|
42
|
+
score?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TodoState {
|
|
46
|
+
todos: TodoItem[];
|
|
47
|
+
plan: TodoPlan;
|
|
48
|
+
goals: TodoGoal[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type TodoPlanField = "user_intention" | "understands_user_intent";
|
|
52
|
+
export interface TodoPlanChange {
|
|
53
|
+
before?: TodoPlan;
|
|
54
|
+
after?: TodoPlan;
|
|
55
|
+
fields: TodoPlanField[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type TodoGoalField = "closed_feedback_loop" | "feedback_loop" | "end_to_end_ownership";
|
|
59
|
+
export interface TodoGoalChange {
|
|
60
|
+
before?: TodoGoal;
|
|
61
|
+
after?: TodoGoal;
|
|
62
|
+
fields: TodoGoalField[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TodoDetails extends TodoState {
|
|
66
|
+
operation: "read" | "write" | "rejected";
|
|
67
|
+
/** One-line applied-change digest, set on accepted writes that changed
|
|
68
|
+
* something: "2 updated; 1 new; removed #x; group cleared on #y". */
|
|
69
|
+
item_changes?: string;
|
|
70
|
+
plan_update?: TodoPlanChange;
|
|
71
|
+
goal_updates?: TodoGoalChange[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function defaultState(): TodoState {
|
|
75
|
+
return {
|
|
76
|
+
todos: [],
|
|
77
|
+
plan: { understands_user_intent_history: [] },
|
|
78
|
+
goals: [],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Normalized group label: trimmed, empty/whitespace -> undefined (implicit goal). */
|
|
83
|
+
export function goalGroupKey(group: string | undefined | null): string | undefined {
|
|
84
|
+
if (group === undefined || group === null) return undefined;
|
|
85
|
+
const trimmed = group.trim();
|
|
86
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function planIsDefault(plan: TodoPlan): boolean {
|
|
90
|
+
return (
|
|
91
|
+
plan.user_intention === undefined &&
|
|
92
|
+
plan.understands_user_intent === undefined &&
|
|
93
|
+
plan.understands_user_intent_history.length === 0
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ----------------------------------------------------------------------------
|
|
98
|
+
// Sanitizers (defensive: details replayed from session JSON may be partial)
|
|
99
|
+
// ----------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function numArray(value: unknown): number[] {
|
|
102
|
+
return Array.isArray(value) ? value.filter((v): v is number => typeof v === "number") : [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function sanitizeItem(raw: unknown): TodoItem | undefined {
|
|
106
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
107
|
+
const r = raw as Record<string, unknown>;
|
|
108
|
+
if (typeof r.id !== "string" && typeof r.id !== "number") return undefined;
|
|
109
|
+
const item: TodoItem = {
|
|
110
|
+
id: String(r.id),
|
|
111
|
+
content: typeof r.content === "string" ? r.content : "",
|
|
112
|
+
status: typeof r.status === "string" ? r.status : "pending",
|
|
113
|
+
priority: typeof r.priority === "string" ? r.priority : "medium",
|
|
114
|
+
confidence_history: numArray(r.confidence_history),
|
|
115
|
+
|
|
116
|
+
};
|
|
117
|
+
if (typeof r.group === "string") item.group = r.group;
|
|
118
|
+
if (typeof r.confidence === "number") item.confidence = r.confidence;
|
|
119
|
+
if (typeof r.completion_confidence === "number") item.completion_confidence = r.completion_confidence;
|
|
120
|
+
return item;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function sanitizeState(raw: unknown): TodoState | undefined {
|
|
124
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
125
|
+
const r = raw as Record<string, unknown>;
|
|
126
|
+
if (!Array.isArray(r.todos)) return undefined;
|
|
127
|
+
const todos = r.todos.map(sanitizeItem).filter((t): t is TodoItem => t !== undefined);
|
|
128
|
+
|
|
129
|
+
const plan: TodoPlan = { understands_user_intent_history: [] };
|
|
130
|
+
if (typeof r.plan === "object" && r.plan !== null) {
|
|
131
|
+
const p = r.plan as Record<string, unknown>;
|
|
132
|
+
if (typeof p.user_intention === "string") plan.user_intention = p.user_intention;
|
|
133
|
+
if (typeof p.understands_user_intent === "number")
|
|
134
|
+
plan.understands_user_intent = p.understands_user_intent;
|
|
135
|
+
plan.understands_user_intent_history = numArray(p.understands_user_intent_history);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const goals: TodoGoal[] = [];
|
|
139
|
+
if (Array.isArray(r.goals)) {
|
|
140
|
+
for (const g of r.goals) {
|
|
141
|
+
if (typeof g !== "object" || g === null) continue;
|
|
142
|
+
const gr = g as Record<string, unknown>;
|
|
143
|
+
const goal: TodoGoal = {
|
|
144
|
+
closed_feedback_loop_history: numArray(gr.closed_feedback_loop_history),
|
|
145
|
+
end_to_end_ownership_history: numArray(gr.end_to_end_ownership_history),
|
|
146
|
+
};
|
|
147
|
+
if (typeof gr.group === "string") goal.group = gr.group;
|
|
148
|
+
if (typeof gr.closed_feedback_loop === "number") goal.closed_feedback_loop = gr.closed_feedback_loop;
|
|
149
|
+
if (typeof gr.feedback_loop === "string") goal.feedback_loop = gr.feedback_loop;
|
|
150
|
+
if (typeof gr.end_to_end_ownership === "number") goal.end_to_end_ownership = gr.end_to_end_ownership;
|
|
151
|
+
goals.push(goal);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { todos, plan, goals };
|
|
155
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lenient input normalization — port of jcode-app-core's
|
|
3
|
+
* normalize_todo_input (issue #357). Runs in the tool's prepareArguments
|
|
4
|
+
* hook, before schema validation.
|
|
5
|
+
*
|
|
6
|
+
* Some providers intermittently emit tool arguments as JSON *strings*
|
|
7
|
+
* instead of native types: the whole `todos` array as one stringified JSON
|
|
8
|
+
* blob, individual items as stringified objects, or numeric fields like
|
|
9
|
+
* `confidence` as "90". Strict validation would reject these, failing the
|
|
10
|
+
* entire call. Legacy aliases (hill_climbability, alignment_score,
|
|
11
|
+
* user_intention_alignment) are accepted at runtime but never advertised.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Coerce a numeric string ("90") or whole float (90.0) to an integer, and
|
|
19
|
+
* an empty string to null. Anything else passes through unchanged. */
|
|
20
|
+
function coerceValueToInteger(value: unknown): unknown {
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (trimmed === "") return null;
|
|
24
|
+
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === "number" && !Number.isInteger(value)) {
|
|
28
|
+
if (Number.isFinite(value) && value >= 0 && Number.isSafeInteger(Math.trunc(value))) {
|
|
29
|
+
return Math.trunc(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseJsonIf(value: unknown, shape: "object" | "array"): unknown {
|
|
36
|
+
if (typeof value !== "string") return value;
|
|
37
|
+
const trimmed = value.trim();
|
|
38
|
+
if (trimmed === "") return null;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(trimmed);
|
|
41
|
+
if (parsed === null) return null;
|
|
42
|
+
if (shape === "object" && isPlainObject(parsed)) return parsed;
|
|
43
|
+
if (shape === "array" && Array.isArray(parsed)) return parsed;
|
|
44
|
+
return value;
|
|
45
|
+
} catch {
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PLAN_SCORE_ALIASES = ["alignment_score", "user_intention_alignment"];
|
|
51
|
+
const GOAL_LOOP_ALIASES = ["hill_climbability"];
|
|
52
|
+
|
|
53
|
+
const NUMERIC_ITEM_KEYS = [
|
|
54
|
+
"confidence",
|
|
55
|
+
"completion_confidence",
|
|
56
|
+
"closed_feedback_loop",
|
|
57
|
+
"hill_climbability",
|
|
58
|
+
"alignment_score",
|
|
59
|
+
"user_intention_alignment",
|
|
60
|
+
"end_to_end_ownership",
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function adoptAlias(
|
|
64
|
+
fields: Record<string, unknown>,
|
|
65
|
+
target: string,
|
|
66
|
+
aliases: string[],
|
|
67
|
+
): void {
|
|
68
|
+
if (typeof fields[target] === "number" && Number.isInteger(fields[target])) return;
|
|
69
|
+
for (const alias of aliases) {
|
|
70
|
+
const v = fields[alias];
|
|
71
|
+
if (typeof v === "number" && Number.isInteger(v)) {
|
|
72
|
+
fields[target] = v;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Normalize raw todo-tool arguments; returns a fresh object (never mutates). */
|
|
79
|
+
export function normalizeTodoInput(raw: unknown): unknown {
|
|
80
|
+
if (!isPlainObject(raw)) return raw;
|
|
81
|
+
const input = JSON.parse(JSON.stringify(raw)) as Record<string, unknown>;
|
|
82
|
+
|
|
83
|
+
// plan: stringified object or "" -> absent
|
|
84
|
+
if ("plan" in input) {
|
|
85
|
+
input.plan = parseJsonIf(input.plan, "object");
|
|
86
|
+
if (input.plan === null) {
|
|
87
|
+
delete input.plan;
|
|
88
|
+
} else if (isPlainObject(input.plan)) {
|
|
89
|
+
const plan = input.plan;
|
|
90
|
+
for (const key of [...PLAN_SCORE_ALIASES, "understands_user_intent"]) {
|
|
91
|
+
if (key in plan) plan[key] = coerceValueToInteger(plan[key]);
|
|
92
|
+
}
|
|
93
|
+
adoptAlias(plan, "understands_user_intent", PLAN_SCORE_ALIASES);
|
|
94
|
+
for (const alias of PLAN_SCORE_ALIASES) delete plan[alias];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const key of ["todos", "goals"]) {
|
|
99
|
+
if (!(key in input)) continue;
|
|
100
|
+
const entries = parseJsonIf(input[key], "array");
|
|
101
|
+
if (entries === null) {
|
|
102
|
+
delete input[key];
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(entries)) continue;
|
|
106
|
+
input[key] = entries.map((item) => {
|
|
107
|
+
const parsed = parseJsonIf(item, "object");
|
|
108
|
+
if (!isPlainObject(parsed)) return parsed;
|
|
109
|
+
for (const numKey of NUMERIC_ITEM_KEYS) {
|
|
110
|
+
if (numKey in parsed) parsed[numKey] = coerceValueToInteger(parsed[numKey]);
|
|
111
|
+
}
|
|
112
|
+
if (key === "goals") {
|
|
113
|
+
// serde(alias) equivalent: pre-rename keys still load, but the
|
|
114
|
+
// advertised schema only knows the new names.
|
|
115
|
+
adoptAlias(parsed, "closed_feedback_loop", GOAL_LOOP_ALIASES);
|
|
116
|
+
for (const alias of GOAL_LOOP_ALIASES) delete parsed[alias];
|
|
117
|
+
}
|
|
118
|
+
return parsed;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return input;
|
|
122
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool parameter schema — field names, required arrays, and description
|
|
3
|
+
* texts verbatim from jcode-app-core/src/tool/todo.rs::parameters_schema().
|
|
4
|
+
* The descriptions are handwritten model-visible calibration text: they
|
|
5
|
+
* never mention thresholds, gate wording, scores, or domain hints.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type Static, Type } from "typebox";
|
|
9
|
+
|
|
10
|
+
export const TodoItemSchema = Type.Object({
|
|
11
|
+
content: Type.String({ description: "Task." }),
|
|
12
|
+
status: Type.String({ description: "Status." }),
|
|
13
|
+
priority: Type.String({ description: "Priority." }),
|
|
14
|
+
id: Type.String({ description: "ID." }),
|
|
15
|
+
group: Type.Optional(
|
|
16
|
+
Type.String({
|
|
17
|
+
description:
|
|
18
|
+
"Optional group label, encouraged. Prefer a group whenever the task has more than one step: todos sharing a group render together under one header, so use one group per coherent goal (e.g. 'optimize rendering'), and start a new group instead of renaming the existing one when the user steers into new work. Omit only for a one-off flat list (ungrouped items share one implicit goal and its checks). On update-style writes, omitting this field keeps the item's existing group — send an empty string to clear it.",
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
21
|
+
confidence: Type.Integer({
|
|
22
|
+
minimum: 0,
|
|
23
|
+
maximum: 100,
|
|
24
|
+
description:
|
|
25
|
+
"Self-assessed confidence, 0-100, that this todo can be completed correctly. Reassess it as evidence accumulates while working.",
|
|
26
|
+
}),
|
|
27
|
+
completion_confidence: Type.Optional(
|
|
28
|
+
Type.Integer({
|
|
29
|
+
minimum: 0,
|
|
30
|
+
maximum: 100,
|
|
31
|
+
description:
|
|
32
|
+
"Self-assessed confidence, 0-100, that this todo was completed correctly. Use only for completed items. On later writes, omitting this field keeps the stored value for this id.",
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const TodoPlanInputSchema = Type.Object(
|
|
38
|
+
{
|
|
39
|
+
user_intention: Type.String({
|
|
40
|
+
description:
|
|
41
|
+
"Concise statement of what the user actually wants: their underlying reason and desired end state for this work. Omit on later updates to retain the stored intention.",
|
|
42
|
+
}),
|
|
43
|
+
understands_user_intent: Type.Integer({
|
|
44
|
+
minimum: 0,
|
|
45
|
+
maximum: 100,
|
|
46
|
+
description:
|
|
47
|
+
"Self-assessment, 0-100, of how well you understand what the user actually wants and how faithfully this plan represents it: their underlying goal, what they left implicit, and what outcome would make them consider this done. Before scoring, form a requirement inventory covering outcomes, deliverables, constraints, prohibited actions, integration paths, edge cases, and necessary follow-through, and check that the plan and its feedback loops name an explicit observation or check for each item. A generic instruction to run tests, verify, or review does not establish coverage: tests count only for behaviors they actually enforce, while non-testable requirements such as edit scope, dependency limits, required reporting, branches or commits, and prohibited modifications need separate explicit checks. Score low when interpretations of the request still materially diverge, you are guessing at intent, or any material item is unrepresented. Prefer resolving low understanding by re-reading the request and investigating the conversation and codebase over asking the user, since asking blocks them.",
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
description:
|
|
52
|
+
"Plan-level understanding of the user's request, covering the whole todo list. Send it on the first write and whenever your understanding changes.",
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export const TodoGoalSchema = Type.Object({
|
|
57
|
+
group: Type.Optional(
|
|
58
|
+
Type.String({
|
|
59
|
+
description: "Group label this goal describes. Omit or null for the ungrouped list.",
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
closed_feedback_loop: Type.Integer({
|
|
63
|
+
minimum: 0,
|
|
64
|
+
maximum: 100,
|
|
65
|
+
description:
|
|
66
|
+
"Self-assessment, 0-100: how much of this goal's correctness the `feedback_loop` below can tell you on its own, without your judgment or the user's.",
|
|
67
|
+
}),
|
|
68
|
+
feedback_loop: Type.String({
|
|
69
|
+
description:
|
|
70
|
+
"Concrete requirement-to-check process used to compare progress across iterations and detect whether the user's intention is satisfied or violated. Name an explicit observation or check for every material behavior, deliverable, constraint, prohibited action, integration path, edge case, and necessary follow-through. Generic phrases such as run tests, verify, or review count only for requirements those named checks demonstrably enforce; add separate checks for non-testable prompt requirements.",
|
|
71
|
+
}),
|
|
72
|
+
end_to_end_ownership: Type.Optional(
|
|
73
|
+
Type.Integer({
|
|
74
|
+
minimum: 0,
|
|
75
|
+
maximum: 100,
|
|
76
|
+
description:
|
|
77
|
+
"Completion-time self-assessment, 0-100, of whether the full intended user outcome and its necessary follow-through were delivered, rather than only the immediate implementation. Use only when completing the goal.",
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const TodoParams = Type.Object({
|
|
83
|
+
todos: Type.Optional(
|
|
84
|
+
Type.Array(TodoItemSchema, {
|
|
85
|
+
description:
|
|
86
|
+
"Todo list to save. Replaces the stored list when present: re-list every item (omitting one deletes it), but only re-type fields you are changing — optional fields you omit on an existing id are inherited. To read or keep the current list, omit the field entirely — never send an empty array unless you intend to clear every todo.",
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
plan: Type.Optional(TodoPlanInputSchema),
|
|
90
|
+
goals: Type.Optional(
|
|
91
|
+
Type.Array(TodoGoalSchema, {
|
|
92
|
+
description:
|
|
93
|
+
"Optional goal-level assessments, one per todo group. Use group: null for an ungrouped list. Stored assessments for groups omitted from an update are retained.",
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export type TodoParamsInput = Static<typeof TodoParams>;
|
|
99
|
+
export type TodoItemInput = Static<typeof TodoItemSchema>;
|
|
100
|
+
export type TodoPlanInput = Static<typeof TodoPlanInputSchema>;
|
|
101
|
+
export type TodoGoalInput = Static<typeof TodoGoalSchema>;
|
package/src/viewer.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /todos TUI viewer — a read-only overlay component showing the current
|
|
3
|
+
* branch's todo state. Rendering chrome is pi-local (jcode's own TUI todo
|
|
4
|
+
* view is a different design); the content mirrors the stored state.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { TodoState } from "./model.js";
|
|
10
|
+
|
|
11
|
+
export class TodoListComponent {
|
|
12
|
+
private state: TodoState;
|
|
13
|
+
private armed: boolean;
|
|
14
|
+
private theme: Theme;
|
|
15
|
+
private onClose: () => void;
|
|
16
|
+
private cachedWidth?: number;
|
|
17
|
+
private cachedLines?: string[];
|
|
18
|
+
|
|
19
|
+
constructor(state: TodoState, armed: boolean, theme: Theme, onClose: () => void) {
|
|
20
|
+
this.state = state;
|
|
21
|
+
this.armed = armed;
|
|
22
|
+
this.theme = theme;
|
|
23
|
+
this.onClose = onClose;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
handleInput(data: string): void {
|
|
27
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
28
|
+
this.onClose();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
render(width: number): string[] {
|
|
33
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
34
|
+
const th = this.theme;
|
|
35
|
+
const lines: string[] = [""];
|
|
36
|
+
lines.push(
|
|
37
|
+
truncateToWidth(
|
|
38
|
+
th.fg("borderMuted", "─".repeat(3)) +
|
|
39
|
+
th.fg("accent", " Todos ") +
|
|
40
|
+
th.fg("borderMuted", "─".repeat(Math.max(0, width - 10))),
|
|
41
|
+
width,
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
lines.push("");
|
|
45
|
+
|
|
46
|
+
const total = this.state.todos.length;
|
|
47
|
+
const completed = this.state.todos.filter((t) => t.status === "completed").length;
|
|
48
|
+
const cancelled = this.state.todos.filter((t) => t.status === "cancelled").length;
|
|
49
|
+
const inProgress = this.state.todos.filter((t) => t.status === "in_progress").length;
|
|
50
|
+
const pending = total - completed - cancelled - inProgress;
|
|
51
|
+
const percent = total === 0 ? 0 : Math.round(((completed + cancelled) / total) * 100);
|
|
52
|
+
lines.push(truncateToWidth(` ${completed + cancelled}/${total} settled (${percent}%)`, width));
|
|
53
|
+
if (inProgress) lines.push(truncateToWidth(` ${inProgress} in progress`, width));
|
|
54
|
+
if (pending) lines.push(truncateToWidth(` ${pending} pending`, width));
|
|
55
|
+
if (cancelled) lines.push(truncateToWidth(` ${cancelled} cancelled`, width));
|
|
56
|
+
lines.push(truncateToWidth(` ${th.fg("dim", `auto-poke: ${this.armed ? "on" : "off"}`)}`, width));
|
|
57
|
+
|
|
58
|
+
if (this.state.plan.user_intention) {
|
|
59
|
+
lines.push("");
|
|
60
|
+
lines.push(
|
|
61
|
+
truncateToWidth(` ${th.fg("muted", "Intention:")} ${this.state.plan.user_intention}`, width),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (total === 0) {
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet.")}`, width));
|
|
68
|
+
} else {
|
|
69
|
+
lines.push("");
|
|
70
|
+
for (const todo of this.state.todos) {
|
|
71
|
+
let icon: string;
|
|
72
|
+
let color: "success" | "error" | "accent" | "dim";
|
|
73
|
+
switch (todo.status) {
|
|
74
|
+
case "completed":
|
|
75
|
+
icon = "✓";
|
|
76
|
+
color = "success";
|
|
77
|
+
break;
|
|
78
|
+
case "cancelled":
|
|
79
|
+
icon = "✗";
|
|
80
|
+
color = "error";
|
|
81
|
+
break;
|
|
82
|
+
case "in_progress":
|
|
83
|
+
icon = "▶";
|
|
84
|
+
color = "accent";
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
icon = "○";
|
|
88
|
+
color = "dim";
|
|
89
|
+
}
|
|
90
|
+
let text = todo.content;
|
|
91
|
+
if (todo.status === "completed" || todo.status === "cancelled") {
|
|
92
|
+
text = th.fg("dim", text);
|
|
93
|
+
}
|
|
94
|
+
const group = todo.group ? th.fg("muted", `[${todo.group}] `) : "";
|
|
95
|
+
lines.push(
|
|
96
|
+
truncateToWidth(
|
|
97
|
+
` ${th.fg(color, icon)} ${th.fg("accent", `#${todo.id}`)} ${group}${text}`,
|
|
98
|
+
width,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (this.state.goals.length) {
|
|
105
|
+
lines.push("");
|
|
106
|
+
lines.push(truncateToWidth(` ${th.fg("muted", "Goals:")}`, width));
|
|
107
|
+
for (const goal of this.state.goals) {
|
|
108
|
+
const g = goal.group ?? "(ungrouped)";
|
|
109
|
+
const loop = goal.feedback_loop ? ` – ${goal.feedback_loop}` : "";
|
|
110
|
+
const ownership =
|
|
111
|
+
goal.end_to_end_ownership !== undefined
|
|
112
|
+
? ` [ownership ${goal.end_to_end_ownership}]`
|
|
113
|
+
: "";
|
|
114
|
+
lines.push(
|
|
115
|
+
truncateToWidth(
|
|
116
|
+
` ${th.fg("accent", g)}${th.fg("dim", loop)}${th.fg("dim", ownership)}`,
|
|
117
|
+
width,
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
|
125
|
+
lines.push("");
|
|
126
|
+
|
|
127
|
+
this.cachedWidth = width;
|
|
128
|
+
this.cachedLines = lines;
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
invalidate(): void {
|
|
133
|
+
this.cachedWidth = undefined;
|
|
134
|
+
this.cachedLines = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/watchdog.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle starvation watchdog — the liveness half of pi-simple-goal's watchdog,
|
|
3
|
+
* applied to the todo auto-poke cycle (jcode's queued-followup starvation
|
|
4
|
+
* watchdog, QUEUED_FOLLOWUP_STARVATION_TIMEOUT, is the same idea).
|
|
5
|
+
*
|
|
6
|
+
* Model: while auto-poke is armed and todos remain open, the agent should
|
|
7
|
+
* NEVER sit idle for long — the turn-end machine pokes at every settle, so
|
|
8
|
+
* a long quiet period means the follow-up was lost (dispatch consumed, run
|
|
9
|
+
* never started, provider died silently). A periodic tick detects that
|
|
10
|
+
* state and re-fires the poke, with a small cap so a truly wedged session
|
|
11
|
+
* disarms instead of looping. Any real activity (agent start, turn end,
|
|
12
|
+
* user input, our own successful send) postpones the fire window, and a
|
|
13
|
+
* user abort (Esc) disarms the whole cycle via the normal path.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface WatchdogDeps {
|
|
17
|
+
now: () => number;
|
|
18
|
+
isArmed: () => boolean;
|
|
19
|
+
isIdle: () => boolean;
|
|
20
|
+
incompleteCount: () => number;
|
|
21
|
+
wasAborted: () => boolean;
|
|
22
|
+
idleMs: number;
|
|
23
|
+
maxRePokes: number;
|
|
24
|
+
/** Re-send the auto-poke follow-up (fire-and-forget is fine). */
|
|
25
|
+
onFire: (consecutiveRePokes: number) => void;
|
|
26
|
+
/** Cap reached or abort races in: stop the cycle (`disarm` + notice). */
|
|
27
|
+
onStarve: (reason: "cap" | "aborted") => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Watchdog {
|
|
31
|
+
tick: () => void;
|
|
32
|
+
notifyActivity: () => void;
|
|
33
|
+
/** Consecutive re-pokes with no real activity; exposed for tests. */
|
|
34
|
+
getRePokes: () => number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createWatchdog(deps: WatchdogDeps): Watchdog {
|
|
38
|
+
let lastActivity = deps.now();
|
|
39
|
+
let rePokes = 0;
|
|
40
|
+
return {
|
|
41
|
+
notifyActivity() {
|
|
42
|
+
lastActivity = deps.now();
|
|
43
|
+
rePokes = 0;
|
|
44
|
+
},
|
|
45
|
+
getRePokes: () => rePokes,
|
|
46
|
+
tick() {
|
|
47
|
+
const now = deps.now();
|
|
48
|
+
if (!deps.isArmed() || !deps.isIdle()) {
|
|
49
|
+
lastActivity = now;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (deps.incompleteCount() === 0) {
|
|
53
|
+
lastActivity = now;
|
|
54
|
+
rePokes = 0;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (now - lastActivity < deps.idleMs) return;
|
|
58
|
+
lastActivity = now; // one shot per window regardless of outcome
|
|
59
|
+
if (deps.wasAborted()) {
|
|
60
|
+
deps.onStarve("aborted");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (rePokes >= deps.maxRePokes) {
|
|
64
|
+
deps.onStarve("cap");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
rePokes += 1;
|
|
68
|
+
deps.onFire(rePokes);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|