mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 17:01:35 +08:00
simplify proof logs and keep tasks repo-local
This commit is contained in:
@@ -13,7 +13,7 @@ A [pi](https://pi.dev) extension that adds proof-gated top-level tasks to task t
|
|||||||
|
|
||||||
The core idea: subtasks are normal checklist items, but top-level tasks are goals. Agents cannot mark top-level tasks complete directly. They must call `TaskClaimDone` with auditable evidence, UAT hints, and explicit failure-mode analysis. A fresh judge then accepts or rejects the claim. Accepted review completes the task; rejected review leaves it open with suggestions.
|
The core idea: subtasks are normal checklist items, but top-level tasks are goals. Agents cannot mark top-level tasks complete directly. They must call `TaskClaimDone` with auditable evidence, UAT hints, and explicit failure-mode analysis. A fresh judge then accepts or rejects the claim. Accepted review completes the task; rejected review leaves it open with suggestions.
|
||||||
|
|
||||||
Humans can use `/lgtm` to view the proof log and sanity-check the reviewer notes later. `/lgtm` is intentionally thin: proof viewing lives there, task management stays in `/tasks`.
|
Humans can use `/lgtm` to view the proof log and sanity-check the reviewer notes later. `/lgtm` is intentionally thin: proof viewing lives there, task management stays in `/tasks`. Long submitted-evidence blocks are previewed inline and truncated after about 16 lines, with the full artifact path shown in the proof log.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -44,9 +44,8 @@ Stripped: `TaskExecute`, `TaskOutput`, `TaskStop`, `process-tracker.ts`, subagen
|
|||||||
## Widget
|
## Widget
|
||||||
|
|
||||||
```
|
```
|
||||||
● 3 tasks (1 done, 1 in progress, 1 open)
|
● 3 goals (1 done hidden, 1 in progress, 1 open)
|
||||||
✔ #1 Design schema
|
✳ #2 Implementing cache layer… (2m 49s, ↑ 4.1k ↓ 1.2k)
|
||||||
✳ #2 Implementing cache layer… (2m 49s · ↑ 4.1k ↓ 1.2k)
|
|
||||||
◻ #3 Load test
|
◻ #3 Load test
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,9 +141,9 @@ Interactive task-management menu: view tasks, create task, delete a selected tas
|
|||||||
```text
|
```text
|
||||||
Top-level task:
|
Top-level task:
|
||||||
pending -> in_progress -> TaskClaimDone
|
pending -> in_progress -> TaskClaimDone
|
||||||
-> current evidence iteration N 🛠
|
-> current evidence iteration N
|
||||||
-> robot review iteration(s) 🤖
|
-> robot review iteration(s)
|
||||||
-> completed ✓ if latest robot review accepts
|
-> completed if latest robot review accepts
|
||||||
-> remains open if reviewer rejects
|
-> remains open if reviewer rejects
|
||||||
-> completed if reviewer infrastructure fails (fail-open, note logged)
|
-> completed if reviewer infrastructure fails (fail-open, note logged)
|
||||||
-> lgtm_supersede or newer TaskClaimDone -> superseded history + fresh current evidence
|
-> lgtm_supersede or newer TaskClaimDone -> superseded history + fresh current evidence
|
||||||
@@ -168,7 +167,7 @@ Override via env:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
PI_TASKS=off # in-memory (CI)
|
PI_TASKS=off # in-memory (CI)
|
||||||
PI_TASKS=sprint-1 # named shared list at ~/.pi/tasks/sprint-1.json
|
PI_TASKS=sprint-1 # named project-local list at .pi/tasks/sprint-1.json
|
||||||
PI_TASKS=/abs/path # explicit path
|
PI_TASKS=/abs/path # explicit path
|
||||||
PI_TASKS_DEBUG=1 # trace to stderr
|
PI_TASKS_DEBUG=1 # trace to stderr
|
||||||
```
|
```
|
||||||
@@ -178,7 +177,7 @@ PI_TASKS_DEBUG=1 # trace to stderr
|
|||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── index.ts # tools + /tasks + /lgtm evidence viewer + widget + event handlers
|
├── index.ts # tools + /tasks + /lgtm evidence viewer + widget + event handlers
|
||||||
├── review-badges.ts # Review badge helpers for evidence/review/completion lanes
|
├── review-badges.ts # Review state helpers for proof/completion lanes
|
||||||
├── robot-review.ts # Robot review iteration storage + compatibility helpers
|
├── robot-review.ts # Robot review iteration storage + compatibility helpers
|
||||||
├── types.ts # Task, TaskStatus types
|
├── types.ts # Task, TaskStatus types
|
||||||
├── task-store.ts # File-backed store with CRUD, locking, complete() method
|
├── task-store.ts # File-backed store with CRUD, locking, complete() method
|
||||||
|
|||||||
+7
-3
@@ -40,8 +40,9 @@ export class AutoClearManager {
|
|||||||
/** Check if all tasks are completed and start/reset the batch countdown. */
|
/** Check if all tasks are completed and start/reset the batch countdown. */
|
||||||
private checkAllCompleted(currentTurn: number): void {
|
private checkAllCompleted(currentTurn: number): void {
|
||||||
const tasks = this.getStore().list();
|
const tasks = this.getStore().list();
|
||||||
if (tasks.length > 0 && tasks.every(t => t.status === "completed")) {
|
if (tasks.length > 0 && tasks.every((t) => t.status === "completed")) {
|
||||||
if (this.allCompletedAtTurn === null) this.allCompletedAtTurn = currentTurn;
|
if (this.allCompletedAtTurn === null)
|
||||||
|
this.allCompletedAtTurn = currentTurn;
|
||||||
} else {
|
} else {
|
||||||
this.allCompletedAtTurn = null;
|
this.allCompletedAtTurn = null;
|
||||||
}
|
}
|
||||||
@@ -78,7 +79,10 @@ export class AutoClearManager {
|
|||||||
cleared = true;
|
cleared = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (mode === "on_list_complete" && this.allCompletedAtTurn !== null) {
|
} else if (
|
||||||
|
mode === "on_list_complete" &&
|
||||||
|
this.allCompletedAtTurn !== null
|
||||||
|
) {
|
||||||
if (currentTurn - this.allCompletedAtTurn >= this.clearDelayTurns) {
|
if (currentTurn - this.allCompletedAtTurn >= this.clearDelayTurns) {
|
||||||
this.getStore().clearCompleted();
|
this.getStore().clearCompleted();
|
||||||
this.allCompletedAtTurn = null;
|
this.allCompletedAtTurn = null;
|
||||||
|
|||||||
+1178
-328
File diff suppressed because it is too large
Load Diff
+34
-47
@@ -1,34 +1,20 @@
|
|||||||
import { getLatestRobotReview, getRobotReviews } from "./robot-review.js";
|
import { getLatestRobotReview } from "./robot-review.js";
|
||||||
import type { Task } from "./types.js";
|
import type { Task } from "./types.js";
|
||||||
|
|
||||||
const STAGES = ["🛠", "🤖", "✓"] as const;
|
|
||||||
|
|
||||||
function hasCurrentEvidence(task: Task): boolean {
|
function hasCurrentEvidence(task: Task): boolean {
|
||||||
return typeof task.metadata?.lgtm_evidence === "string" && task.metadata.lgtm_evidence.length > 0;
|
return (
|
||||||
|
typeof task.metadata?.lgtm_evidence === "string" &&
|
||||||
|
task.metadata.lgtm_evidence.length > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasEvidenceHistory(task: Task): boolean {
|
function hasEvidenceHistory(task: Task): boolean {
|
||||||
return Array.isArray(task.metadata?.lgtm_history) && task.metadata.lgtm_history.length > 0;
|
return (
|
||||||
|
Array.isArray(task.metadata?.lgtm_history) &&
|
||||||
|
task.metadata.lgtm_history.length > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pipeline stages: `[🛠·🤖·✓]` fills left-to-right as evidence→review→completed progresses. */
|
|
||||||
export function getReviewBadges(task: Task): string {
|
|
||||||
const filled = [
|
|
||||||
!!task.metadata?.lgtm_evidence,
|
|
||||||
getRobotReviews(task).length > 0,
|
|
||||||
task.status === "completed",
|
|
||||||
];
|
|
||||||
const slots = STAGES.map((emoji, i) => filled[i] ? emoji : "·");
|
|
||||||
return `[${slots.join("")}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const REVIEW_BADGES = {
|
|
||||||
evidence: STAGES[0],
|
|
||||||
robot: STAGES[1],
|
|
||||||
complete: STAGES[2],
|
|
||||||
pipeline: STAGES,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DisplayStatus = "in_progress" | "pending" | "completed";
|
export type DisplayStatus = "in_progress" | "pending" | "completed";
|
||||||
|
|
||||||
export function getDisplayStatus(task: Task): DisplayStatus {
|
export function getDisplayStatus(task: Task): DisplayStatus {
|
||||||
@@ -44,8 +30,6 @@ export type ReviewState =
|
|||||||
| "reviewer_accepted"
|
| "reviewer_accepted"
|
||||||
| "superseded"
|
| "superseded"
|
||||||
| "completed";
|
| "completed";
|
||||||
export type StateTag = "ACTIVE" | "PENDING" | "DONE";
|
|
||||||
|
|
||||||
export function getCompletionMode(task: Task): CompletionMode {
|
export function getCompletionMode(task: Task): CompletionMode {
|
||||||
return task.parentId ? "direct" : "proof";
|
return task.parentId ? "direct" : "proof";
|
||||||
}
|
}
|
||||||
@@ -55,45 +39,48 @@ export function getReviewState(task: Task): ReviewState {
|
|||||||
const latest = getLatestRobotReview(task);
|
const latest = getLatestRobotReview(task);
|
||||||
if (latest && !latest.accepted) return "reviewer_rejected";
|
if (latest && !latest.accepted) return "reviewer_rejected";
|
||||||
if (latest?.accepted) return "reviewer_accepted";
|
if (latest?.accepted) return "reviewer_accepted";
|
||||||
if (typeof task.metadata?.robot_review_last_error === "string") return "reviewer_failed_to_run";
|
if (typeof task.metadata?.robot_review_last_error === "string")
|
||||||
|
return "reviewer_failed_to_run";
|
||||||
if (hasCurrentEvidence(task)) return "claim_submitted";
|
if (hasCurrentEvidence(task)) return "claim_submitted";
|
||||||
if (hasEvidenceHistory(task)) return "superseded";
|
if (hasEvidenceHistory(task)) return "superseded";
|
||||||
return "no_claim";
|
return "no_claim";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function needsProofAttention(task: Task): boolean {
|
||||||
|
if (task.parentId || task.status === "completed") return false;
|
||||||
|
const state = getReviewState(task);
|
||||||
|
return (
|
||||||
|
state === "reviewer_rejected" ||
|
||||||
|
state === "reviewer_accepted" ||
|
||||||
|
state === "reviewer_failed_to_run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getGateStatus(task: Task): string {
|
export function getGateStatus(task: Task): string {
|
||||||
const state = getReviewState(task);
|
const state = getReviewState(task);
|
||||||
if (task.parentId) {
|
if (task.parentId) {
|
||||||
return task.status === "completed" ? "completed directly as subtask" : "subtask: direct completion allowed";
|
return task.status === "completed"
|
||||||
|
? "completed directly as subtask"
|
||||||
|
: "subtask: direct completion allowed";
|
||||||
}
|
}
|
||||||
if (task.status === "completed") {
|
if (task.status === "completed") {
|
||||||
if (typeof task.metadata?.robot_review_last_error === "string") {
|
if (typeof task.metadata?.robot_review_last_error === "string") {
|
||||||
return `completed with reviewer unavailable: ${task.metadata.robot_review_last_error}`;
|
return `completed with reviewer unavailable: ${task.metadata.robot_review_last_error}`;
|
||||||
}
|
}
|
||||||
if (getLatestRobotReview(task)?.accepted) return "completed after accepted proof review";
|
if (getLatestRobotReview(task)?.accepted)
|
||||||
|
return "completed after accepted proof review";
|
||||||
return "completed";
|
return "completed";
|
||||||
}
|
}
|
||||||
if (state === "no_claim") return "top-level task requires TaskClaimDone evidence before completion";
|
if (state === "no_claim")
|
||||||
if (state === "reviewer_accepted") return "review accepted; task should be completed";
|
return "top-level task requires TaskClaimDone evidence before completion";
|
||||||
|
if (state === "reviewer_accepted")
|
||||||
|
return "review accepted; task should be completed";
|
||||||
if (state === "reviewer_failed_to_run") {
|
if (state === "reviewer_failed_to_run") {
|
||||||
return `review unavailable; autonomy continues: ${task.metadata.robot_review_last_error}`;
|
return `review unavailable; autonomy continues: ${task.metadata.robot_review_last_error}`;
|
||||||
}
|
}
|
||||||
if (state === "reviewer_rejected") return "latest proof review rejected the evidence; strengthen the proof and try again";
|
if (state === "reviewer_rejected")
|
||||||
if (state === "superseded") return "current evidence superseded, waiting for a new proof claim";
|
return "latest proof review rejected the evidence; strengthen the proof and try again";
|
||||||
|
if (state === "superseded")
|
||||||
|
return "current evidence superseded, waiting for a new proof claim";
|
||||||
return "proof claim submitted, automatic review still required";
|
return "proof claim submitted, automatic review still required";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Short uppercase tag for compact task-list display. */
|
|
||||||
export function getStateTag(task: Task): StateTag {
|
|
||||||
const s = getDisplayStatus(task);
|
|
||||||
if (s === "completed") return "DONE";
|
|
||||||
if (s === "in_progress") return "ACTIVE";
|
|
||||||
return "PENDING";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Theme colour key for each state tag (only theme colours present in pi-tui are used). */
|
|
||||||
export function getStateTagColor(tag: StateTag): "accent" | "dim" | undefined {
|
|
||||||
if (tag === "ACTIVE") return "accent";
|
|
||||||
if (tag === "DONE") return "dim";
|
|
||||||
return undefined; // PENDING — default fg
|
|
||||||
}
|
|
||||||
|
|||||||
+162
-46
@@ -21,46 +21,81 @@ export interface RobotReviewRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toStringArray(value: unknown): string[] {
|
function toStringArray(value: unknown): string[] {
|
||||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
return Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === "string")
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRubric(value: unknown): Record<string, { reason: string; pass: boolean }> | undefined {
|
function extractRubric(
|
||||||
|
value: unknown,
|
||||||
|
): Record<string, { reason: string; pass: boolean }> | undefined {
|
||||||
if (!value || typeof value !== "object") return undefined;
|
if (!value || typeof value !== "object") return undefined;
|
||||||
const r: Record<string, { reason: string; pass: boolean }> = {};
|
const r: Record<string, { reason: string; pass: boolean }> = {};
|
||||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||||
if (val && typeof val === "object" && "reason" in (val as any) && "pass" in (val as any)) {
|
if (
|
||||||
|
val &&
|
||||||
|
typeof val === "object" &&
|
||||||
|
"reason" in (val as any) &&
|
||||||
|
"pass" in (val as any)
|
||||||
|
) {
|
||||||
const v = val as { reason: unknown; pass: unknown };
|
const v = val as { reason: unknown; pass: unknown };
|
||||||
r[key] = { reason: typeof v.reason === "string" ? v.reason : "", pass: v.pass === true };
|
r[key] = {
|
||||||
|
reason: typeof v.reason === "string" ? v.reason : "",
|
||||||
|
pass: v.pass === true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Object.keys(r).length > 0 ? r : undefined;
|
return Object.keys(r).length > 0 ? r : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeReview(value: unknown, index: number): RobotReviewRecord | undefined {
|
function normalizeReview(
|
||||||
|
value: unknown,
|
||||||
|
index: number,
|
||||||
|
): RobotReviewRecord | undefined {
|
||||||
if (!value || typeof value !== "object") return undefined;
|
if (!value || typeof value !== "object") return undefined;
|
||||||
const review = value as Record<string, unknown>;
|
const review = value as Record<string, unknown>;
|
||||||
const reviewer = typeof review.reviewer === "string" ? review.reviewer : "unknown";
|
const reviewer =
|
||||||
|
typeof review.reviewer === "string" ? review.reviewer : "unknown";
|
||||||
const scope = typeof review.scope === "string" ? review.scope : "unknown";
|
const scope = typeof review.scope === "string" ? review.scope : "unknown";
|
||||||
const observations = toStringArray(review.observations);
|
const observations = toStringArray(review.observations);
|
||||||
if (observations.length === 0) return undefined;
|
if (observations.length === 0) return undefined;
|
||||||
return {
|
return {
|
||||||
iteration: typeof review.iteration === "number" ? review.iteration : index + 1,
|
iteration:
|
||||||
|
typeof review.iteration === "number" ? review.iteration : index + 1,
|
||||||
reviewer,
|
reviewer,
|
||||||
scope,
|
scope,
|
||||||
observations,
|
observations,
|
||||||
concerns: toStringArray(review.concerns),
|
concerns: toStringArray(review.concerns),
|
||||||
suggestions: toStringArray(review.suggestions),
|
suggestions: toStringArray(review.suggestions),
|
||||||
blind_spots: typeof review.blind_spots === "string" ? review.blind_spots : "not recorded",
|
blind_spots:
|
||||||
accepted: typeof review.accepted === "boolean"
|
typeof review.blind_spots === "string"
|
||||||
|
? review.blind_spots
|
||||||
|
: "not recorded",
|
||||||
|
accepted:
|
||||||
|
typeof review.accepted === "boolean"
|
||||||
? review.accepted
|
? review.accepted
|
||||||
: (typeof review.evidence_complete === "boolean" ? review.evidence_complete : true)
|
: (typeof review.evidence_complete === "boolean"
|
||||||
&& (typeof review.evidence_convincing === "boolean" ? review.evidence_convincing : true),
|
? review.evidence_complete
|
||||||
evidence_complete: typeof review.evidence_complete === "boolean" ? review.evidence_complete : true,
|
: true) &&
|
||||||
evidence_convincing: typeof review.evidence_convincing === "boolean" ? review.evidence_convincing : true,
|
(typeof review.evidence_convincing === "boolean"
|
||||||
|
? review.evidence_convincing
|
||||||
|
: true),
|
||||||
|
evidence_complete:
|
||||||
|
typeof review.evidence_complete === "boolean"
|
||||||
|
? review.evidence_complete
|
||||||
|
: true,
|
||||||
|
evidence_convincing:
|
||||||
|
typeof review.evidence_convincing === "boolean"
|
||||||
|
? review.evidence_convincing
|
||||||
|
: true,
|
||||||
missing_evidence: toStringArray(review.missing_evidence),
|
missing_evidence: toStringArray(review.missing_evidence),
|
||||||
submitted_at: typeof review.submitted_at === "string" ? review.submitted_at : new Date(0).toISOString(),
|
submitted_at:
|
||||||
|
typeof review.submitted_at === "string"
|
||||||
|
? review.submitted_at
|
||||||
|
: new Date(0).toISOString(),
|
||||||
mode: review.mode === "auto" ? "auto" : "manual",
|
mode: review.mode === "auto" ? "auto" : "manual",
|
||||||
raw_output: typeof review.raw_output === "string" ? review.raw_output : undefined,
|
raw_output:
|
||||||
|
typeof review.raw_output === "string" ? review.raw_output : undefined,
|
||||||
rubric: extractRubric(review.rubric),
|
rubric: extractRubric(review.rubric),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -70,22 +105,50 @@ function getLegacyRobotReview(task: Task): RobotReviewRecord | undefined {
|
|||||||
if (observations.length === 0) return undefined;
|
if (observations.length === 0) return undefined;
|
||||||
return {
|
return {
|
||||||
iteration: 1,
|
iteration: 1,
|
||||||
reviewer: typeof task.metadata?.robot_review_reviewer === "string" ? task.metadata.robot_review_reviewer : "unknown",
|
reviewer:
|
||||||
scope: typeof task.metadata?.robot_review_scope === "string" ? task.metadata.robot_review_scope : "unknown",
|
typeof task.metadata?.robot_review_reviewer === "string"
|
||||||
|
? task.metadata.robot_review_reviewer
|
||||||
|
: "unknown",
|
||||||
|
scope:
|
||||||
|
typeof task.metadata?.robot_review_scope === "string"
|
||||||
|
? task.metadata.robot_review_scope
|
||||||
|
: "unknown",
|
||||||
observations,
|
observations,
|
||||||
concerns: toStringArray(task.metadata?.robot_review_concerns),
|
concerns: toStringArray(task.metadata?.robot_review_concerns),
|
||||||
suggestions: toStringArray(task.metadata?.robot_review_suggestions),
|
suggestions: toStringArray(task.metadata?.robot_review_suggestions),
|
||||||
blind_spots: typeof task.metadata?.robot_review_blind_spots === "string" ? task.metadata.robot_review_blind_spots : "not recorded",
|
blind_spots:
|
||||||
accepted: typeof task.metadata?.robot_review_accepted === "boolean"
|
typeof task.metadata?.robot_review_blind_spots === "string"
|
||||||
|
? task.metadata.robot_review_blind_spots
|
||||||
|
: "not recorded",
|
||||||
|
accepted:
|
||||||
|
typeof task.metadata?.robot_review_accepted === "boolean"
|
||||||
? task.metadata.robot_review_accepted
|
? task.metadata.robot_review_accepted
|
||||||
: (typeof task.metadata?.robot_review_evidence_complete === "boolean" ? task.metadata.robot_review_evidence_complete : true)
|
: (typeof task.metadata?.robot_review_evidence_complete === "boolean"
|
||||||
&& (typeof task.metadata?.robot_review_evidence_convincing === "boolean" ? task.metadata.robot_review_evidence_convincing : true),
|
? task.metadata.robot_review_evidence_complete
|
||||||
evidence_complete: typeof task.metadata?.robot_review_evidence_complete === "boolean" ? task.metadata.robot_review_evidence_complete : true,
|
: true) &&
|
||||||
evidence_convincing: typeof task.metadata?.robot_review_evidence_convincing === "boolean" ? task.metadata.robot_review_evidence_convincing : true,
|
(typeof task.metadata?.robot_review_evidence_convincing === "boolean"
|
||||||
missing_evidence: toStringArray(task.metadata?.robot_review_missing_evidence),
|
? task.metadata.robot_review_evidence_convincing
|
||||||
submitted_at: typeof task.metadata?.robot_review_submitted_at === "string" ? task.metadata.robot_review_submitted_at : new Date(0).toISOString(),
|
: true),
|
||||||
|
evidence_complete:
|
||||||
|
typeof task.metadata?.robot_review_evidence_complete === "boolean"
|
||||||
|
? task.metadata.robot_review_evidence_complete
|
||||||
|
: true,
|
||||||
|
evidence_convincing:
|
||||||
|
typeof task.metadata?.robot_review_evidence_convincing === "boolean"
|
||||||
|
? task.metadata.robot_review_evidence_convincing
|
||||||
|
: true,
|
||||||
|
missing_evidence: toStringArray(
|
||||||
|
task.metadata?.robot_review_missing_evidence,
|
||||||
|
),
|
||||||
|
submitted_at:
|
||||||
|
typeof task.metadata?.robot_review_submitted_at === "string"
|
||||||
|
? task.metadata.robot_review_submitted_at
|
||||||
|
: new Date(0).toISOString(),
|
||||||
mode: task.metadata?.robot_review_mode === "auto" ? "auto" : "manual",
|
mode: task.metadata?.robot_review_mode === "auto" ? "auto" : "manual",
|
||||||
raw_output: typeof task.metadata?.robot_review_raw_output === "string" ? task.metadata.robot_review_raw_output : undefined,
|
raw_output:
|
||||||
|
typeof task.metadata?.robot_review_raw_output === "string"
|
||||||
|
? task.metadata.robot_review_raw_output
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,13 +159,18 @@ export function getRobotReviews(task: Task): RobotReviewRecord[] {
|
|||||||
.filter((review): review is RobotReviewRecord => review !== undefined)
|
.filter((review): review is RobotReviewRecord => review !== undefined)
|
||||||
: [];
|
: [];
|
||||||
if (reviews.length > 0) {
|
if (reviews.length > 0) {
|
||||||
return reviews.map((review, index) => ({ ...review, iteration: index + 1 }));
|
return reviews.map((review, index) => ({
|
||||||
|
...review,
|
||||||
|
iteration: index + 1,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
const legacy = getLegacyRobotReview(task);
|
const legacy = getLegacyRobotReview(task);
|
||||||
return legacy ? [legacy] : [];
|
return legacy ? [legacy] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLatestRobotReview(task: Task): RobotReviewRecord | undefined {
|
export function getLatestRobotReview(
|
||||||
|
task: Task,
|
||||||
|
): RobotReviewRecord | undefined {
|
||||||
const reviews = getRobotReviews(task);
|
const reviews = getRobotReviews(task);
|
||||||
return reviews.length > 0 ? reviews[reviews.length - 1] : undefined;
|
return reviews.length > 0 ? reviews[reviews.length - 1] : undefined;
|
||||||
}
|
}
|
||||||
@@ -113,7 +181,8 @@ function hasNonEmptyString(value: unknown): boolean {
|
|||||||
|
|
||||||
export function hasCompleteProofClaim(task: Task): boolean {
|
export function hasCompleteProofClaim(task: Task): boolean {
|
||||||
const metadata = task.metadata ?? {};
|
const metadata = task.metadata ?? {};
|
||||||
return [
|
return (
|
||||||
|
[
|
||||||
metadata.lgtm_evidence,
|
metadata.lgtm_evidence,
|
||||||
metadata.lgtm_failure_likely,
|
metadata.lgtm_failure_likely,
|
||||||
metadata.lgtm_failure_sneaky,
|
metadata.lgtm_failure_sneaky,
|
||||||
@@ -121,40 +190,86 @@ export function hasCompleteProofClaim(task: Task): boolean {
|
|||||||
metadata.lgtm_falsification_test,
|
metadata.lgtm_falsification_test,
|
||||||
metadata.lgtm_evidence_reasoning,
|
metadata.lgtm_evidence_reasoning,
|
||||||
metadata.lgtm_remaining_uncertainty,
|
metadata.lgtm_remaining_uncertainty,
|
||||||
].every(hasNonEmptyString)
|
].every(hasNonEmptyString) &&
|
||||||
&& Array.isArray(metadata.lgtm_verification_hints)
|
Array.isArray(metadata.lgtm_verification_hints) &&
|
||||||
&& metadata.lgtm_verification_hints.some(hasNonEmptyString);
|
metadata.lgtm_verification_hints.some(hasNonEmptyString)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldCompleteAfterAcceptedReview(task: Task, reviewAccepted: boolean): boolean {
|
export function shouldCompleteAfterAcceptedReview(
|
||||||
|
task: Task,
|
||||||
|
reviewAccepted: boolean,
|
||||||
|
): boolean {
|
||||||
return reviewAccepted && hasCompleteProofClaim(task);
|
return reviewAccepted && hasCompleteProofClaim(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function relaxAdvisoryVerificationHints(review: Omit<RobotReviewRecord, "iteration">): Omit<RobotReviewRecord, "iteration"> {
|
export function relaxAdvisoryVerificationHints(
|
||||||
|
review: Omit<RobotReviewRecord, "iteration">,
|
||||||
|
): Omit<RobotReviewRecord, "iteration"> {
|
||||||
const rubric = review.rubric;
|
const rubric = review.rubric;
|
||||||
if (!rubric || review.evidence_complete !== true) return review;
|
if (!rubric || review.evidence_complete !== true) return review;
|
||||||
const requiredCoreKeys = ["evidence_covers_done_criterion", "falsification_test_runnable", "failure_modes_addressed", "evidence_distinguishes_success"];
|
const requiredCoreKeys = [
|
||||||
if (!requiredCoreKeys.every((key) => rubric[key]?.pass === true)) return review;
|
"evidence_covers_done_criterion",
|
||||||
|
"falsification_test_runnable",
|
||||||
|
];
|
||||||
|
if (!requiredCoreKeys.every((key) => rubric[key]?.pass === true))
|
||||||
|
return review;
|
||||||
const failedKeys = Object.entries(rubric)
|
const failedKeys = Object.entries(rubric)
|
||||||
.filter(([, item]) => item.pass !== true)
|
.filter(([, item]) => item.pass !== true)
|
||||||
.map(([key]) => key);
|
.map(([key]) => key);
|
||||||
if (failedKeys.length !== 1 || failedKeys[0] !== "verification_hints_actionable") return review;
|
const advisoryKeys = [
|
||||||
|
"failure_modes_addressed",
|
||||||
|
"evidence_distinguishes_success",
|
||||||
|
"verification_hints_actionable",
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
failedKeys.length === 0 ||
|
||||||
|
!failedKeys.every((key) => advisoryKeys.includes(key))
|
||||||
|
)
|
||||||
|
return review;
|
||||||
|
|
||||||
|
const advisoryNotes: string[] = [];
|
||||||
|
if (failedKeys.includes("failure_modes_addressed")) {
|
||||||
|
advisoryNotes.push(
|
||||||
|
"Failure-mode writeup was weak, but treated as advisory because the verbatim evidence already covered the done criterion.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (failedKeys.includes("evidence_distinguishes_success")) {
|
||||||
|
advisoryNotes.push(
|
||||||
|
"Why-this-proves-it reasoning was weak, but treated as advisory because the packet already contained direct success evidence.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (failedKeys.includes("verification_hints_actionable")) {
|
||||||
|
advisoryNotes.push(
|
||||||
|
"Verification hints were weak, but treated as advisory because the verbatim evidence already covered the done criterion.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...review,
|
...review,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
evidence_convincing: true,
|
evidence_convincing: true,
|
||||||
observations: [
|
observations: [...review.observations, ...advisoryNotes],
|
||||||
...review.observations,
|
|
||||||
"Verification hints were weak, but treated as advisory because the verbatim evidence already covered the done criterion.",
|
|
||||||
],
|
|
||||||
concerns: review.concerns,
|
concerns: review.concerns,
|
||||||
suggestions: review.suggestions,
|
suggestions: review.suggestions,
|
||||||
missing_evidence: review.missing_evidence.filter((item) => item !== "verification_hints_actionable" && !/verification hint/i.test(item)),
|
missing_evidence: review.missing_evidence.filter(
|
||||||
|
(item) =>
|
||||||
|
!advisoryKeys.includes(item) &&
|
||||||
|
!/verification hint/i.test(item) &&
|
||||||
|
!/failure[- ]?mode/i.test(item) &&
|
||||||
|
!/distinguish/i.test(item),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendRobotReviewMetadata(task: Task, review: Omit<RobotReviewRecord, "iteration">): Record<string, unknown> {
|
export function appendRobotReviewMetadata(
|
||||||
const robot_reviews = [...getRobotReviews(task), { ...review, iteration: 0 }].map((entry, index) => ({
|
task: Task,
|
||||||
|
review: Omit<RobotReviewRecord, "iteration">,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const robot_reviews = [
|
||||||
|
...getRobotReviews(task),
|
||||||
|
{ ...review, iteration: 0 },
|
||||||
|
].map((entry, index) => ({
|
||||||
...entry,
|
...entry,
|
||||||
accepted: entry.accepted,
|
accepted: entry.accepted,
|
||||||
iteration: index + 1,
|
iteration: index + 1,
|
||||||
@@ -175,7 +290,9 @@ export function appendRobotReviewMetadata(task: Task, review: Omit<RobotReviewRe
|
|||||||
robot_review_submitted_at: latest.submitted_at,
|
robot_review_submitted_at: latest.submitted_at,
|
||||||
robot_review_mode: latest.mode,
|
robot_review_mode: latest.mode,
|
||||||
robot_review_raw_output: latest.raw_output ?? null,
|
robot_review_raw_output: latest.raw_output ?? null,
|
||||||
robot_review_requires_followup: !(latest.evidence_complete && latest.evidence_convincing),
|
robot_review_requires_followup: !(
|
||||||
|
latest.evidence_complete && latest.evidence_convincing
|
||||||
|
),
|
||||||
robot_review_iteration_count: robot_reviews.length,
|
robot_review_iteration_count: robot_reviews.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -184,4 +301,3 @@ export function latestRobotReviewPasses(task: Task): boolean {
|
|||||||
const latest = getLatestRobotReview(task);
|
const latest = getLatestRobotReview(task);
|
||||||
return latest ? latest.accepted : false;
|
return latest ? latest.accepted : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+136
-43
@@ -2,15 +2,21 @@
|
|||||||
* task-store.ts — File-backed task store with CRUD, dependency management, and file locking.
|
* task-store.ts — File-backed task store with CRUD, dependency management, and file locking.
|
||||||
*
|
*
|
||||||
* Session-scoped (default): in-memory Map — no disk I/O.
|
* Session-scoped (default): in-memory Map — no disk I/O.
|
||||||
* Shared (PI_TASK_LIST_ID set): ~/.pi/tasks/<listId>.json with file locking.
|
* Named or project stores live under <cwd>/.pi/tasks/ unless an absolute path is given.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
import {
|
||||||
import { homedir } from "node:os";
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { dirname, isAbsolute, join } from "node:path";
|
import { dirname, isAbsolute, join } from "node:path";
|
||||||
import type { Task, TaskStatus, TaskStoreData } from "./types.js";
|
import type { Task, TaskStatus, TaskStoreData } from "./types.js";
|
||||||
|
|
||||||
const TASKS_DIR = join(homedir(), ".pi", "tasks");
|
const TASKS_DIR = join(process.cwd(), ".pi", "tasks");
|
||||||
const LOCK_RETRY_MS = 50;
|
const LOCK_RETRY_MS = 50;
|
||||||
const LOCK_MAX_RETRIES = 100; // 5s max
|
const LOCK_MAX_RETRIES = 100; // 5s max
|
||||||
|
|
||||||
@@ -23,10 +29,17 @@ function acquireLock(lockPath: string): void {
|
|||||||
if (e.code === "EEXIST") {
|
if (e.code === "EEXIST") {
|
||||||
try {
|
try {
|
||||||
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
||||||
if (pid && !isProcessRunning(pid)) { unlinkSync(lockPath); continue; }
|
if (pid && !isProcessRunning(pid)) {
|
||||||
} catch { /* ignore */ }
|
unlinkSync(lockPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
while (Date.now() - start < LOCK_RETRY_MS) {
|
||||||
|
/* busy wait */
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
@@ -36,11 +49,20 @@ function acquireLock(lockPath: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function releaseLock(lockPath: string): void {
|
function releaseLock(lockPath: string): void {
|
||||||
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
try {
|
||||||
|
unlinkSync(lockPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProcessRunning(pid: number): boolean {
|
function isProcessRunning(pid: number): boolean {
|
||||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TaskStore {
|
export class TaskStore {
|
||||||
@@ -52,7 +74,9 @@ export class TaskStore {
|
|||||||
constructor(listIdOrPath?: string) {
|
constructor(listIdOrPath?: string) {
|
||||||
if (!listIdOrPath) return;
|
if (!listIdOrPath) return;
|
||||||
const isAbsPath = isAbsolute(listIdOrPath);
|
const isAbsPath = isAbsolute(listIdOrPath);
|
||||||
const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
|
const filePath = isAbsPath
|
||||||
|
? listIdOrPath
|
||||||
|
: join(TASKS_DIR, `${listIdOrPath}.json`);
|
||||||
mkdirSync(dirname(filePath), { recursive: true });
|
mkdirSync(dirname(filePath), { recursive: true });
|
||||||
this.filePath = filePath;
|
this.filePath = filePath;
|
||||||
this.lockPath = filePath + ".lock";
|
this.lockPath = filePath + ".lock";
|
||||||
@@ -62,40 +86,69 @@ export class TaskStore {
|
|||||||
private load(): void {
|
private load(): void {
|
||||||
if (!this.filePath || !existsSync(this.filePath)) return;
|
if (!this.filePath || !existsSync(this.filePath)) return;
|
||||||
try {
|
try {
|
||||||
const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
const data: TaskStoreData = JSON.parse(
|
||||||
|
readFileSync(this.filePath, "utf-8"),
|
||||||
|
);
|
||||||
this.nextId = data.nextId;
|
this.nextId = data.nextId;
|
||||||
this.tasks.clear();
|
this.tasks.clear();
|
||||||
for (const t of data.tasks) this.tasks.set(t.id, t);
|
for (const t of data.tasks) this.tasks.set(t.id, t);
|
||||||
} catch { /* corrupt file — start fresh */ }
|
} catch {
|
||||||
|
/* corrupt file — start fresh */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private save(): void {
|
private save(): void {
|
||||||
if (!this.filePath) return;
|
if (!this.filePath) return;
|
||||||
const tmpPath = this.filePath + ".tmp";
|
const tmpPath = this.filePath + ".tmp";
|
||||||
writeFileSync(tmpPath, JSON.stringify({ nextId: this.nextId, tasks: Array.from(this.tasks.values()) }, null, 2));
|
writeFileSync(
|
||||||
|
tmpPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{ nextId: this.nextId, tasks: Array.from(this.tasks.values()) },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
renameSync(tmpPath, this.filePath);
|
renameSync(tmpPath, this.filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private withLock<T>(fn: () => T): T {
|
private withLock<T>(fn: () => T): T {
|
||||||
if (!this.lockPath) return fn();
|
if (!this.lockPath) return fn();
|
||||||
acquireLock(this.lockPath);
|
acquireLock(this.lockPath);
|
||||||
try { this.load(); const result = fn(); this.save(); return result; }
|
try {
|
||||||
finally { releaseLock(this.lockPath); }
|
this.load();
|
||||||
|
const result = fn();
|
||||||
|
this.save();
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
releaseLock(this.lockPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
create(subject: string, description: string, done_criterion: string, progress_label?: string, metadata?: Record<string, any>, parentId?: string): Task {
|
create(
|
||||||
|
subject: string,
|
||||||
|
description: string,
|
||||||
|
done_criterion: string,
|
||||||
|
progress_label?: string,
|
||||||
|
metadata?: Record<string, any>,
|
||||||
|
parentId?: string,
|
||||||
|
): Task {
|
||||||
return this.withLock(() => {
|
return this.withLock(() => {
|
||||||
if (parentId && !this.tasks.has(parentId)) throw new Error(`Parent task #${parentId} not found`);
|
if (parentId && !this.tasks.has(parentId))
|
||||||
|
throw new Error(`Parent task #${parentId} not found`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id: String(this.nextId++),
|
id: String(this.nextId++),
|
||||||
subject, description, done_criterion,
|
subject,
|
||||||
|
description,
|
||||||
|
done_criterion,
|
||||||
parentId,
|
parentId,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
progress_label,
|
progress_label,
|
||||||
metadata: metadata ?? {},
|
metadata: metadata ?? {},
|
||||||
blocks: [], blockedBy: [],
|
blocks: [],
|
||||||
createdAt: now, updatedAt: now,
|
blockedBy: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
this.tasks.set(task.id, task);
|
this.tasks.set(task.id, task);
|
||||||
return task;
|
return task;
|
||||||
@@ -109,10 +162,14 @@ export class TaskStore {
|
|||||||
|
|
||||||
list(): Task[] {
|
list(): Task[] {
|
||||||
if (this.filePath) this.load();
|
if (this.filePath) this.load();
|
||||||
return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
return Array.from(this.tasks.values()).sort(
|
||||||
|
(a, b) => Number(a.id) - Number(b.id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, fields: {
|
update(
|
||||||
|
id: string,
|
||||||
|
fields: {
|
||||||
status?: TaskStatus | "deleted";
|
status?: TaskStatus | "deleted";
|
||||||
subject?: string;
|
subject?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -122,7 +179,8 @@ export class TaskStore {
|
|||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
add_blocks?: string[];
|
add_blocks?: string[];
|
||||||
add_blocked_by?: string[];
|
add_blocked_by?: string[];
|
||||||
}): { task: Task | undefined; changedFields: string[]; warnings: string[] } {
|
},
|
||||||
|
): { task: Task | undefined; changedFields: string[]; warnings: string[] } {
|
||||||
return this.withLock(() => {
|
return this.withLock(() => {
|
||||||
const task = this.tasks.get(id);
|
const task = this.tasks.get(id);
|
||||||
if (!task) return { task: undefined, changedFields: [], warnings: [] };
|
if (!task) return { task: undefined, changedFields: [], warnings: [] };
|
||||||
@@ -133,23 +191,40 @@ export class TaskStore {
|
|||||||
// Subtasks are normal checklist items. Top-level tasks are goals and need a proof
|
// Subtasks are normal checklist items. Top-level tasks are goals and need a proof
|
||||||
// claim plus automatic review; TaskClaimDone is the only agent path that completes them.
|
// claim plus automatic review; TaskClaimDone is the only agent path that completes them.
|
||||||
if (fields.status === "completed" && !task.parentId) {
|
if (fields.status === "completed" && !task.parentId) {
|
||||||
throw new Error(`Top-level task #${id} requires proof. Use TaskClaimDone with evidence and failure modes; subtasks can be completed directly.`);
|
throw new Error(
|
||||||
|
`Top-level task #${id} requires proof. Use TaskClaimDone with evidence and failure modes; subtasks can be completed directly.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.status === "deleted") {
|
if (fields.status === "deleted") {
|
||||||
this.tasks.delete(id);
|
this.tasks.delete(id);
|
||||||
for (const t of this.tasks.values()) {
|
for (const t of this.tasks.values()) {
|
||||||
t.blocks = t.blocks.filter(bid => bid !== id);
|
t.blocks = t.blocks.filter((bid) => bid !== id);
|
||||||
t.blockedBy = t.blockedBy.filter(bid => bid !== id);
|
t.blockedBy = t.blockedBy.filter((bid) => bid !== id);
|
||||||
}
|
}
|
||||||
return { task: undefined, changedFields: ["deleted"], warnings: [] };
|
return { task: undefined, changedFields: ["deleted"], warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.status !== undefined) { task.status = fields.status as TaskStatus; changedFields.push("status"); }
|
if (fields.status !== undefined) {
|
||||||
if (fields.subject !== undefined) { task.subject = fields.subject; changedFields.push("subject"); }
|
task.status = fields.status as TaskStatus;
|
||||||
if (fields.description !== undefined) { task.description = fields.description; changedFields.push("description"); }
|
changedFields.push("status");
|
||||||
if (fields.done_criterion !== undefined) { task.done_criterion = fields.done_criterion; changedFields.push("done_criterion"); }
|
}
|
||||||
if (fields.progress_label !== undefined) { task.progress_label = fields.progress_label; changedFields.push("progress_label"); }
|
if (fields.subject !== undefined) {
|
||||||
|
task.subject = fields.subject;
|
||||||
|
changedFields.push("subject");
|
||||||
|
}
|
||||||
|
if (fields.description !== undefined) {
|
||||||
|
task.description = fields.description;
|
||||||
|
changedFields.push("description");
|
||||||
|
}
|
||||||
|
if (fields.done_criterion !== undefined) {
|
||||||
|
task.done_criterion = fields.done_criterion;
|
||||||
|
changedFields.push("done_criterion");
|
||||||
|
}
|
||||||
|
if (fields.progress_label !== undefined) {
|
||||||
|
task.progress_label = fields.progress_label;
|
||||||
|
changedFields.push("progress_label");
|
||||||
|
}
|
||||||
|
|
||||||
if (fields.metadata !== undefined) {
|
if (fields.metadata !== undefined) {
|
||||||
for (const [key, value] of Object.entries(fields.metadata)) {
|
for (const [key, value] of Object.entries(fields.metadata)) {
|
||||||
@@ -160,17 +235,23 @@ export class TaskStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fields.parentId !== undefined) {
|
if (fields.parentId !== undefined) {
|
||||||
throw new Error("parentId is creation-only. Create subtasks with TaskCreate(parentId); do not downgrade top-level proof goals.");
|
throw new Error(
|
||||||
|
"parentId is creation-only. Create subtasks with TaskCreate(parentId); do not downgrade top-level proof goals.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.add_blocks?.length) {
|
if (fields.add_blocks?.length) {
|
||||||
for (const targetId of fields.add_blocks) {
|
for (const targetId of fields.add_blocks) {
|
||||||
if (!task.blocks.includes(targetId)) task.blocks.push(targetId);
|
if (!task.blocks.includes(targetId)) task.blocks.push(targetId);
|
||||||
const target = this.tasks.get(targetId);
|
const target = this.tasks.get(targetId);
|
||||||
if (target && !target.blockedBy.includes(id)) { target.blockedBy.push(id); target.updatedAt = Date.now(); }
|
if (target && !target.blockedBy.includes(id)) {
|
||||||
|
target.blockedBy.push(id);
|
||||||
|
target.updatedAt = Date.now();
|
||||||
|
}
|
||||||
if (targetId === id) warnings.push(`#${id} blocks itself`);
|
if (targetId === id) warnings.push(`#${id} blocks itself`);
|
||||||
else if (!target) warnings.push(`#${targetId} does not exist`);
|
else if (!target) warnings.push(`#${targetId} does not exist`);
|
||||||
else if (target.blocks.includes(id)) warnings.push(`cycle: #${id} and #${targetId} block each other`);
|
else if (target.blocks.includes(id))
|
||||||
|
warnings.push(`cycle: #${id} and #${targetId} block each other`);
|
||||||
}
|
}
|
||||||
changedFields.push("blocks");
|
changedFields.push("blocks");
|
||||||
}
|
}
|
||||||
@@ -179,10 +260,14 @@ export class TaskStore {
|
|||||||
for (const targetId of fields.add_blocked_by) {
|
for (const targetId of fields.add_blocked_by) {
|
||||||
if (!task.blockedBy.includes(targetId)) task.blockedBy.push(targetId);
|
if (!task.blockedBy.includes(targetId)) task.blockedBy.push(targetId);
|
||||||
const target = this.tasks.get(targetId);
|
const target = this.tasks.get(targetId);
|
||||||
if (target && !target.blocks.includes(id)) { target.blocks.push(id); target.updatedAt = Date.now(); }
|
if (target && !target.blocks.includes(id)) {
|
||||||
|
target.blocks.push(id);
|
||||||
|
target.updatedAt = Date.now();
|
||||||
|
}
|
||||||
if (targetId === id) warnings.push(`#${id} blocks itself`);
|
if (targetId === id) warnings.push(`#${id} blocks itself`);
|
||||||
else if (!target) warnings.push(`#${targetId} does not exist`);
|
else if (!target) warnings.push(`#${targetId} does not exist`);
|
||||||
else if (task.blocks.includes(targetId)) warnings.push(`cycle: #${id} and #${targetId} block each other`);
|
else if (task.blocks.includes(targetId))
|
||||||
|
warnings.push(`cycle: #${id} and #${targetId} block each other`);
|
||||||
}
|
}
|
||||||
changedFields.push("blockedBy");
|
changedFields.push("blockedBy");
|
||||||
}
|
}
|
||||||
@@ -197,7 +282,8 @@ export class TaskStore {
|
|||||||
return this.withLock(() => {
|
return this.withLock(() => {
|
||||||
const task = this.tasks.get(id);
|
const task = this.tasks.get(id);
|
||||||
if (!task) throw new Error(`Task #${id} not found`);
|
if (!task) throw new Error(`Task #${id} not found`);
|
||||||
if (task.status === "completed") throw new Error(`Task #${id} already completed`);
|
if (task.status === "completed")
|
||||||
|
throw new Error(`Task #${id} already completed`);
|
||||||
task.status = "completed";
|
task.status = "completed";
|
||||||
task.updatedAt = Date.now();
|
task.updatedAt = Date.now();
|
||||||
return task;
|
return task;
|
||||||
@@ -209,8 +295,8 @@ export class TaskStore {
|
|||||||
if (!this.tasks.has(id)) return false;
|
if (!this.tasks.has(id)) return false;
|
||||||
this.tasks.delete(id);
|
this.tasks.delete(id);
|
||||||
for (const t of this.tasks.values()) {
|
for (const t of this.tasks.values()) {
|
||||||
t.blocks = t.blocks.filter(bid => bid !== id);
|
t.blocks = t.blocks.filter((bid) => bid !== id);
|
||||||
t.blockedBy = t.blockedBy.filter(bid => bid !== id);
|
t.blockedBy = t.blockedBy.filter((bid) => bid !== id);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -226,7 +312,11 @@ export class TaskStore {
|
|||||||
|
|
||||||
deleteFileIfEmpty(): boolean {
|
deleteFileIfEmpty(): boolean {
|
||||||
if (!this.filePath || this.tasks.size > 0) return false;
|
if (!this.filePath || this.tasks.size > 0) return false;
|
||||||
try { unlinkSync(this.filePath); } catch { /* ignore */ }
|
try {
|
||||||
|
unlinkSync(this.filePath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,13 +324,16 @@ export class TaskStore {
|
|||||||
return this.withLock(() => {
|
return this.withLock(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [id, task] of this.tasks) {
|
for (const [id, task] of this.tasks) {
|
||||||
if (task.status === "completed") { this.tasks.delete(id); count++; }
|
if (task.status === "completed") {
|
||||||
|
this.tasks.delete(id);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
const validIds = new Set(this.tasks.keys());
|
const validIds = new Set(this.tasks.keys());
|
||||||
for (const t of this.tasks.values()) {
|
for (const t of this.tasks.values()) {
|
||||||
t.blocks = t.blocks.filter(bid => validIds.has(bid));
|
t.blocks = t.blocks.filter((bid) => validIds.has(bid));
|
||||||
t.blockedBy = t.blockedBy.filter(bid => validIds.has(bid));
|
t.blockedBy = t.blockedBy.filter((bid) => validIds.has(bid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
|
|||||||
+4
-2
@@ -6,7 +6,7 @@ import { dirname, join } from "node:path";
|
|||||||
export interface TasksConfig {
|
export interface TasksConfig {
|
||||||
taskScope?: "memory" | "session" | "project"; // default: "session"
|
taskScope?: "memory" | "session" | "project"; // default: "session"
|
||||||
autoCascade?: boolean; // default: false
|
autoCascade?: boolean; // default: false
|
||||||
autoClearCompleted?: "never" | "on_list_complete" | "on_task_complete"; // default: "on_list_complete"
|
autoClearCompleted?: "never" | "on_list_complete" | "on_task_complete"; // default: "never"
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_PATH = join(process.cwd(), ".pi", "tasks-config.json");
|
const CONFIG_PATH = join(process.cwd(), ".pi", "tasks-config.json");
|
||||||
@@ -14,7 +14,9 @@ const CONFIG_PATH = join(process.cwd(), ".pi", "tasks-config.json");
|
|||||||
export function loadTasksConfig(): TasksConfig {
|
export function loadTasksConfig(): TasksConfig {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
||||||
} catch { return {}; }
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveTasksConfig(config: TasksConfig): void {
|
export function saveTasksConfig(config: TasksConfig): void {
|
||||||
|
|||||||
+61
-27
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* task-widget.ts — Persistent widget showing task list with status icons and progress.
|
* task-widget.ts — Persistent widget showing open goals with simple status icons and progress.
|
||||||
*
|
*
|
||||||
* Display style matches Claude Code's task list:
|
* Display style:
|
||||||
* ✔ completed tasks (strikethrough + dim)
|
|
||||||
* ◼ in_progress tasks
|
* ◼ in_progress tasks
|
||||||
* ◻ pending tasks
|
* ◻ pending tasks
|
||||||
* ✳/✽ actively executing task (star spinner with progress_label text)
|
* ✳/✽ actively executing task (star spinner with progress_label text)
|
||||||
|
* Completed tasks stay in storage but are hidden from the collapsed widget.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { truncateToWidth } from "@mariozechner/pi-tui";
|
import { truncateToWidth } from "@mariozechner/pi-tui";
|
||||||
@@ -24,7 +24,12 @@ export type UICtx = {
|
|||||||
setStatus(key: string, text: string | undefined): void;
|
setStatus(key: string, text: string | undefined): void;
|
||||||
setWidget(
|
setWidget(
|
||||||
key: string,
|
key: string,
|
||||||
content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
|
content:
|
||||||
|
| undefined
|
||||||
|
| ((
|
||||||
|
tui: any,
|
||||||
|
theme: Theme,
|
||||||
|
) => { render(): string[]; invalidate(): void }),
|
||||||
options?: { placement?: "aboveEditor" | "belowEditor" },
|
options?: { placement?: "aboveEditor" | "belowEditor" },
|
||||||
): void;
|
): void;
|
||||||
};
|
};
|
||||||
@@ -89,7 +94,11 @@ export class TaskWidget {
|
|||||||
if (taskId && active) {
|
if (taskId && active) {
|
||||||
this.activeTaskIds.add(taskId);
|
this.activeTaskIds.add(taskId);
|
||||||
if (!this.metrics.has(taskId)) {
|
if (!this.metrics.has(taskId)) {
|
||||||
this.metrics.set(taskId, { startedAt: Date.now(), inputTokens: 0, outputTokens: 0 });
|
this.metrics.set(taskId, {
|
||||||
|
startedAt: Date.now(),
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.ensureTimer();
|
this.ensureTimer();
|
||||||
} else if (taskId) {
|
} else if (taskId) {
|
||||||
@@ -128,25 +137,29 @@ export class TaskWidget {
|
|||||||
const counts = { completed: 0, in_progress: 0, pending: 0 };
|
const counts = { completed: 0, in_progress: 0, pending: 0 };
|
||||||
for (const t of tasks) counts[getDisplayStatus(t)]++;
|
for (const t of tasks) counts[getDisplayStatus(t)]++;
|
||||||
|
|
||||||
|
const visibleTasks = tasks.filter((task) => task.status !== "completed");
|
||||||
|
if (visibleTasks.length === 0) return [];
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (counts.completed > 0) parts.push(`${counts.completed} done`);
|
if (counts.completed > 0) parts.push(`${counts.completed} done hidden`);
|
||||||
if (counts.in_progress > 0) parts.push(`${counts.in_progress} in progress`);
|
if (counts.in_progress > 0) parts.push(`${counts.in_progress} in progress`);
|
||||||
if (counts.pending > 0) parts.push(`${counts.pending} open`);
|
if (counts.pending > 0) parts.push(`${counts.pending} open`);
|
||||||
const statusText = `${tasks.length} tasks (${parts.join(", ")})`;
|
const statusText = `${tasks.length} goals (${parts.join(", ")})`;
|
||||||
|
|
||||||
const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
|
const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
|
||||||
const lines: string[] = [truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText))];
|
const lines: string[] = [
|
||||||
|
truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText)),
|
||||||
|
];
|
||||||
|
|
||||||
const visible = tasks.slice(0, MAX_VISIBLE_TASKS);
|
const visible = visibleTasks.slice(0, MAX_VISIBLE_TASKS);
|
||||||
for (let i = 0; i < visible.length; i++) {
|
for (let i = 0; i < visible.length; i++) {
|
||||||
const task = visible[i];
|
const task = visible[i];
|
||||||
const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
|
const isActive =
|
||||||
|
this.activeTaskIds.has(task.id) && task.status === "in_progress";
|
||||||
|
|
||||||
let icon: string;
|
let icon: string;
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
icon = theme.fg("accent", spinnerChar);
|
icon = theme.fg("accent", spinnerChar);
|
||||||
} else if (task.status === "completed") {
|
|
||||||
icon = theme.fg("success", "✔");
|
|
||||||
} else if (task.status === "in_progress") {
|
} else if (task.status === "in_progress") {
|
||||||
icon = theme.fg("accent", "◼");
|
icon = theme.fg("accent", "◼");
|
||||||
} else {
|
} else {
|
||||||
@@ -155,12 +168,15 @@ export class TaskWidget {
|
|||||||
|
|
||||||
let suffix = "";
|
let suffix = "";
|
||||||
if (task.status === "pending" && task.blockedBy.length > 0) {
|
if (task.status === "pending" && task.blockedBy.length > 0) {
|
||||||
const openBlockers = task.blockedBy.filter(bid => {
|
const openBlockers = task.blockedBy.filter((bid) => {
|
||||||
const blocker = this.store.get(bid);
|
const blocker = this.store.get(bid);
|
||||||
return blocker && blocker.status !== "completed";
|
return blocker && blocker.status !== "completed";
|
||||||
});
|
});
|
||||||
if (openBlockers.length > 0) {
|
if (openBlockers.length > 0) {
|
||||||
suffix = theme.fg("dim", ` › blocked by ${openBlockers.map(id => "#" + id).join(", ")}`);
|
suffix = theme.fg(
|
||||||
|
"dim",
|
||||||
|
` › blocked by ${openBlockers.map((id) => "#" + id).join(", ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,15 +188,16 @@ export class TaskWidget {
|
|||||||
if (m) {
|
if (m) {
|
||||||
const elapsed = formatDuration(Date.now() - m.startedAt);
|
const elapsed = formatDuration(Date.now() - m.startedAt);
|
||||||
const tokenParts: string[] = [];
|
const tokenParts: string[] = [];
|
||||||
if (m.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`);
|
if (m.inputTokens > 0)
|
||||||
if (m.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`);
|
tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`);
|
||||||
stats = tokenParts.length > 0
|
if (m.outputTokens > 0)
|
||||||
? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
|
tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`);
|
||||||
|
stats =
|
||||||
|
tokenParts.length > 0
|
||||||
|
? ` ${theme.fg("dim", `(${elapsed}, ${tokenParts.join(" ")})`)}`
|
||||||
: ` ${theme.fg("dim", `(${elapsed})`)}`;
|
: ` ${theme.fg("dim", `(${elapsed})`)}`;
|
||||||
}
|
}
|
||||||
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${theme.fg("accent", form + "…")}${stats}`;
|
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${theme.fg("accent", form + "…")}${stats}`;
|
||||||
} else if (task.status === "completed") {
|
|
||||||
text = ` ${icon} ${theme.fg("dim", theme.strikethrough("#" + task.id + " " + task.subject))}`;
|
|
||||||
} else {
|
} else {
|
||||||
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}`;
|
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}`;
|
||||||
}
|
}
|
||||||
@@ -188,8 +205,15 @@ export class TaskWidget {
|
|||||||
lines.push(truncate(text + suffix));
|
lines.push(truncate(text + suffix));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.length > MAX_VISIBLE_TASKS) {
|
if (visibleTasks.length > MAX_VISIBLE_TASKS) {
|
||||||
lines.push(truncate(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`)));
|
lines.push(
|
||||||
|
truncate(
|
||||||
|
theme.fg(
|
||||||
|
"dim",
|
||||||
|
` … and ${visibleTasks.length - MAX_VISIBLE_TASKS} more open`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
@@ -199,9 +223,10 @@ export class TaskWidget {
|
|||||||
update() {
|
update() {
|
||||||
if (!this.uiCtx) return;
|
if (!this.uiCtx) return;
|
||||||
const tasks = this.store.list();
|
const tasks = this.store.list();
|
||||||
|
const visibleTasks = tasks.filter((task) => task.status !== "completed");
|
||||||
|
|
||||||
// Transition: visible → hidden
|
// Transition: visible → hidden
|
||||||
if (tasks.length === 0) {
|
if (visibleTasks.length === 0) {
|
||||||
if (this.widgetRegistered) {
|
if (this.widgetRegistered) {
|
||||||
this.uiCtx.setWidget("tasks", undefined);
|
this.uiCtx.setWidget("tasks", undefined);
|
||||||
this.widgetRegistered = false;
|
this.widgetRegistered = false;
|
||||||
@@ -223,7 +248,9 @@ export class TaskWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if any task needs animation
|
// Check if any task needs animation
|
||||||
const hasActiveSpinner = tasks.some(t => this.activeTaskIds.has(t.id) && t.status === "in_progress");
|
const hasActiveSpinner = tasks.some(
|
||||||
|
(t) => this.activeTaskIds.has(t.id) && t.status === "in_progress",
|
||||||
|
);
|
||||||
if (hasActiveSpinner) {
|
if (hasActiveSpinner) {
|
||||||
this.ensureTimer();
|
this.ensureTimer();
|
||||||
} else if (!hasActiveSpinner && this.widgetInterval) {
|
} else if (!hasActiveSpinner && this.widgetInterval) {
|
||||||
@@ -235,10 +262,17 @@ export class TaskWidget {
|
|||||||
|
|
||||||
// Transition: hidden → visible — register widget callback once
|
// Transition: hidden → visible — register widget callback once
|
||||||
if (!this.widgetRegistered) {
|
if (!this.widgetRegistered) {
|
||||||
this.uiCtx.setWidget("tasks", (tui, theme) => {
|
this.uiCtx.setWidget(
|
||||||
|
"tasks",
|
||||||
|
(tui, theme) => {
|
||||||
this.tui = tui;
|
this.tui = tui;
|
||||||
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} };
|
return {
|
||||||
}, { placement: "aboveEditor" });
|
render: () => this.renderWidget(tui, theme),
|
||||||
|
invalidate: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ placement: "aboveEditor" },
|
||||||
|
);
|
||||||
this.widgetRegistered = true;
|
this.widgetRegistered = true;
|
||||||
} else if (this.tui) {
|
} else if (this.tui) {
|
||||||
// Widget already registered — just request a re-render
|
// Widget already registered — just request a re-render
|
||||||
|
|||||||
+36
-9
@@ -9,7 +9,10 @@ describe("auto-clear: on_task_complete mode", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = new TaskStore();
|
store = new TaskStore();
|
||||||
manager = new AutoClearManager(() => store, () => "on_task_complete");
|
manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => "on_task_complete",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not clear completed task before REMINDER_INTERVAL turns", () => {
|
it("does not clear completed task before REMINDER_INTERVAL turns", () => {
|
||||||
@@ -98,7 +101,10 @@ describe("auto-clear: on_list_complete mode", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = new TaskStore();
|
store = new TaskStore();
|
||||||
manager = new AutoClearManager(() => store, () => "on_list_complete");
|
manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => "on_list_complete",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not clear when some tasks are still pending", () => {
|
it("does not clear when some tasks are still pending", () => {
|
||||||
@@ -187,7 +193,10 @@ describe("auto-clear: never mode", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = new TaskStore();
|
store = new TaskStore();
|
||||||
manager = new AutoClearManager(() => store, () => "never");
|
manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => "never",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never clears completed tasks regardless of turns", () => {
|
it("never clears completed tasks regardless of turns", () => {
|
||||||
@@ -218,7 +227,10 @@ describe("auto-clear: dynamic mode switching", () => {
|
|||||||
it("respects mode changes via getMode callback", () => {
|
it("respects mode changes via getMode callback", () => {
|
||||||
const store = new TaskStore();
|
const store = new TaskStore();
|
||||||
let mode: AutoClearMode = "never";
|
let mode: AutoClearMode = "never";
|
||||||
const manager = new AutoClearManager(() => store, () => mode);
|
const manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => mode,
|
||||||
|
);
|
||||||
|
|
||||||
store.create("Task", "Desc", "done");
|
store.create("Task", "Desc", "done");
|
||||||
store.complete("1");
|
store.complete("1");
|
||||||
@@ -239,7 +251,10 @@ describe("auto-clear: dynamic mode switching", () => {
|
|||||||
describe("auto-clear: store getter (session switch)", () => {
|
describe("auto-clear: store getter (session switch)", () => {
|
||||||
it("operates on the current store after swap", () => {
|
it("operates on the current store after swap", () => {
|
||||||
let store = new TaskStore();
|
let store = new TaskStore();
|
||||||
const manager = new AutoClearManager(() => store, () => "on_task_complete");
|
const manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => "on_task_complete",
|
||||||
|
);
|
||||||
|
|
||||||
store.create("Old task", "Desc", "done");
|
store.create("Old task", "Desc", "done");
|
||||||
store.complete("1");
|
store.complete("1");
|
||||||
@@ -258,7 +273,10 @@ describe("auto-clear: store getter (session switch)", () => {
|
|||||||
|
|
||||||
it("clears from new store, not old store", () => {
|
it("clears from new store, not old store", () => {
|
||||||
let store = new TaskStore();
|
let store = new TaskStore();
|
||||||
const manager = new AutoClearManager(() => store, () => "on_task_complete");
|
const manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => "on_task_complete",
|
||||||
|
);
|
||||||
|
|
||||||
// Swap to new store with a completed task
|
// Swap to new store with a completed task
|
||||||
store = new TaskStore();
|
store = new TaskStore();
|
||||||
@@ -274,7 +292,10 @@ describe("auto-clear: store getter (session switch)", () => {
|
|||||||
describe("auto-clear: reset (new session)", () => {
|
describe("auto-clear: reset (new session)", () => {
|
||||||
it("reset clears per-task tracking so old completions don't fire", () => {
|
it("reset clears per-task tracking so old completions don't fire", () => {
|
||||||
const store = new TaskStore();
|
const store = new TaskStore();
|
||||||
const manager = new AutoClearManager(() => store, () => "on_task_complete");
|
const manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => "on_task_complete",
|
||||||
|
);
|
||||||
|
|
||||||
store.create("Task", "Desc", "done");
|
store.create("Task", "Desc", "done");
|
||||||
store.complete("1");
|
store.complete("1");
|
||||||
@@ -290,7 +311,10 @@ describe("auto-clear: reset (new session)", () => {
|
|||||||
|
|
||||||
it("reset clears batch countdown so old all-completed state doesn't fire", () => {
|
it("reset clears batch countdown so old all-completed state doesn't fire", () => {
|
||||||
const store = new TaskStore();
|
const store = new TaskStore();
|
||||||
const manager = new AutoClearManager(() => store, () => "on_list_complete");
|
const manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => "on_list_complete",
|
||||||
|
);
|
||||||
|
|
||||||
store.create("Task", "Desc", "done");
|
store.create("Task", "Desc", "done");
|
||||||
store.complete("1");
|
store.complete("1");
|
||||||
@@ -306,7 +330,10 @@ describe("auto-clear: reset (new session)", () => {
|
|||||||
|
|
||||||
it("tracking works normally after reset", () => {
|
it("tracking works normally after reset", () => {
|
||||||
const store = new TaskStore();
|
const store = new TaskStore();
|
||||||
const manager = new AutoClearManager(() => store, () => "on_task_complete");
|
const manager = new AutoClearManager(
|
||||||
|
() => store,
|
||||||
|
() => "on_task_complete",
|
||||||
|
);
|
||||||
|
|
||||||
store.create("Task", "Desc", "done");
|
store.create("Task", "Desc", "done");
|
||||||
store.complete("1");
|
store.complete("1");
|
||||||
|
|||||||
+56
-14
@@ -19,7 +19,9 @@ function makeHarness() {
|
|||||||
const pi = {
|
const pi = {
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
registerTool: vi.fn((tool: RegisteredTool) => tools.set(tool.name, tool)),
|
registerTool: vi.fn((tool: RegisteredTool) => tools.set(tool.name, tool)),
|
||||||
registerCommand: vi.fn((name: string, command: RegisteredCommand) => commands.set(name, command)),
|
registerCommand: vi.fn((name: string, command: RegisteredCommand) =>
|
||||||
|
commands.set(name, command),
|
||||||
|
),
|
||||||
sendMessage: vi.fn((message: any) => sentMessages.push(message)),
|
sendMessage: vi.fn((message: any) => sentMessages.push(message)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,10 +33,12 @@ function makeHarness() {
|
|||||||
return tool.execute("tool-call", params, undefined, undefined, {});
|
return tool.execute("tool-call", params, undefined, undefined, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeUi(overrides: {
|
function makeUi(
|
||||||
|
overrides: {
|
||||||
select?: Array<string | undefined>;
|
select?: Array<string | undefined>;
|
||||||
confirm?: Array<boolean>;
|
confirm?: Array<boolean>;
|
||||||
} = {}) {
|
} = {},
|
||||||
|
) {
|
||||||
const selectQueue = [...(overrides.select ?? [])];
|
const selectQueue = [...(overrides.select ?? [])];
|
||||||
const confirmQueue = [...(overrides.confirm ?? [])];
|
const confirmQueue = [...(overrides.confirm ?? [])];
|
||||||
return {
|
return {
|
||||||
@@ -55,18 +59,38 @@ describe("parseLgtmArgs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects task-management forms", () => {
|
it("rejects task-management forms", () => {
|
||||||
expect(parseLgtmArgs("clear")).toEqual({ kind: "error", message: "Task management lives in /tasks now. /lgtm is viewer-only." });
|
expect(parseLgtmArgs("clear")).toEqual({
|
||||||
expect(parseLgtmArgs("clear *")).toEqual({ kind: "error", message: "Task management lives in /tasks now. /lgtm is viewer-only." });
|
kind: "error",
|
||||||
expect(parseLgtmArgs("clear #7")).toEqual({ kind: "error", message: "Task management lives in /tasks now. /lgtm is viewer-only." });
|
message: "Task management lives in /tasks now. /lgtm is viewer-only.",
|
||||||
expect(parseLgtmArgs("delete #7")).toEqual({ kind: "error", message: "Task management lives in /tasks now. /lgtm is viewer-only." });
|
});
|
||||||
|
expect(parseLgtmArgs("clear *")).toEqual({
|
||||||
|
kind: "error",
|
||||||
|
message: "Task management lives in /tasks now. /lgtm is viewer-only.",
|
||||||
|
});
|
||||||
|
expect(parseLgtmArgs("clear #7")).toEqual({
|
||||||
|
kind: "error",
|
||||||
|
message: "Task management lives in /tasks now. /lgtm is viewer-only.",
|
||||||
|
});
|
||||||
|
expect(parseLgtmArgs("delete #7")).toEqual({
|
||||||
|
kind: "error",
|
||||||
|
message: "Task management lives in /tasks now. /lgtm is viewer-only.",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/lgtm command", () => {
|
describe("/lgtm command", () => {
|
||||||
it("shows all open proof logs from the picker", async () => {
|
it("shows all open proof logs from the picker", async () => {
|
||||||
const harness = makeHarness();
|
const harness = makeHarness();
|
||||||
await harness.execTool("TaskCreate", { subject: "Task A", description: "Desc", done_criterion: "done" });
|
await harness.execTool("TaskCreate", {
|
||||||
await harness.execTool("TaskCreate", { subject: "Task B", description: "Desc", done_criterion: "done" });
|
subject: "Task A",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Task B",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
|
||||||
const ui = harness.makeUi({ select: ["View all open proof logs"] });
|
const ui = harness.makeUi({ select: ["View all open proof logs"] });
|
||||||
const command = harness.commands.get("lgtm");
|
const command = harness.commands.get("lgtm");
|
||||||
@@ -82,7 +106,11 @@ describe("/lgtm command", () => {
|
|||||||
|
|
||||||
it("shows one proof log from the picker", async () => {
|
it("shows one proof log from the picker", async () => {
|
||||||
const harness = makeHarness();
|
const harness = makeHarness();
|
||||||
await harness.execTool("TaskCreate", { subject: "Task A", description: "Desc", done_criterion: "done" });
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Task A",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
|
||||||
const ui = harness.makeUi({ select: ["[PENDING] #1 Task A"] });
|
const ui = harness.makeUi({ select: ["[PENDING] #1 Task A"] });
|
||||||
const command = harness.commands.get("lgtm");
|
const command = harness.commands.get("lgtm");
|
||||||
@@ -96,7 +124,11 @@ describe("/lgtm command", () => {
|
|||||||
|
|
||||||
it("rejects /lgtm clear and points task management back to /tasks", async () => {
|
it("rejects /lgtm clear and points task management back to /tasks", async () => {
|
||||||
const harness = makeHarness();
|
const harness = makeHarness();
|
||||||
await harness.execTool("TaskCreate", { subject: "Task A", description: "Desc", done_criterion: "done" });
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Task A",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
|
||||||
const ui = harness.makeUi();
|
const ui = harness.makeUi();
|
||||||
const command = harness.commands.get("lgtm");
|
const command = harness.commands.get("lgtm");
|
||||||
@@ -105,12 +137,19 @@ describe("/lgtm command", () => {
|
|||||||
await command.handler("clear 1", { ui });
|
await command.handler("clear 1", { ui });
|
||||||
|
|
||||||
expect(harness.sentMessages).toHaveLength(0);
|
expect(harness.sentMessages).toHaveLength(0);
|
||||||
expect(ui.notify).toHaveBeenCalledWith("Task management lives in /tasks now. /lgtm is viewer-only.", "error");
|
expect(ui.notify).toHaveBeenCalledWith(
|
||||||
|
"Task management lives in /tasks now. /lgtm is viewer-only.",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects /lgtm delete and points task management back to /tasks", async () => {
|
it("rejects /lgtm delete and points task management back to /tasks", async () => {
|
||||||
const harness = makeHarness();
|
const harness = makeHarness();
|
||||||
await harness.execTool("TaskCreate", { subject: "Task A", description: "Desc", done_criterion: "done" });
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Task A",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
|
||||||
const ui = harness.makeUi();
|
const ui = harness.makeUi();
|
||||||
const command = harness.commands.get("lgtm");
|
const command = harness.commands.get("lgtm");
|
||||||
@@ -119,6 +158,9 @@ describe("/lgtm command", () => {
|
|||||||
await command.handler("delete 1", { ui });
|
await command.handler("delete 1", { ui });
|
||||||
|
|
||||||
expect(harness.sentMessages).toHaveLength(0);
|
expect(harness.sentMessages).toHaveLength(0);
|
||||||
expect(ui.notify).toHaveBeenCalledWith("Task management lives in /tasks now. /lgtm is viewer-only.", "error");
|
expect(ui.notify).toHaveBeenCalledWith(
|
||||||
|
"Task management lives in /tasks now. /lgtm is viewer-only.",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+50
-55
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getCompletionMode, getDisplayStatus, getGateStatus, getReviewBadges, getReviewState } from "../src/review-badges.js";
|
import {
|
||||||
|
getCompletionMode,
|
||||||
|
getDisplayStatus,
|
||||||
|
getGateStatus,
|
||||||
|
getReviewState,
|
||||||
|
} from "../src/review-badges.js";
|
||||||
import type { Task } from "../src/types.js";
|
import type { Task } from "../src/types.js";
|
||||||
|
|
||||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
@@ -19,46 +24,6 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("getReviewBadges", () => {
|
|
||||||
it("renders all dots when no artifacts exist", () => {
|
|
||||||
expect(getReviewBadges(makeTask())).toBe("[···]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fills evidence/review/completed slots independently", () => {
|
|
||||||
const task = makeTask({
|
|
||||||
metadata: {
|
|
||||||
lgtm_evidence: "npm test",
|
|
||||||
robot_reviews: [{
|
|
||||||
iteration: 1,
|
|
||||||
reviewer: "opencode",
|
|
||||||
scope: "task evidence",
|
|
||||||
observations: ["Observed one unchecked edge case"],
|
|
||||||
concerns: ["Evidence does not cover prod traffic."],
|
|
||||||
suggestions: ["Inspect one prod traffic sample."],
|
|
||||||
blind_spots: "Did not inspect prod traffic",
|
|
||||||
accepted: false,
|
|
||||||
evidence_complete: false,
|
|
||||||
evidence_convincing: false,
|
|
||||||
missing_evidence: ["Prod traffic sample"],
|
|
||||||
submitted_at: "2026-04-17T00:00:00.000Z",
|
|
||||||
mode: "manual",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getReviewBadges(task)).toBe("[🛠🤖·]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fills the completed badge once the task is completed", () => {
|
|
||||||
const task = makeTask({
|
|
||||||
status: "completed",
|
|
||||||
metadata: { lgtm_evidence: "ok" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getReviewBadges(task)).toBe("[🛠·✓]");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("review state helpers", () => {
|
describe("review state helpers", () => {
|
||||||
it("reports completion mode as proof for top-level tasks", () => {
|
it("reports completion mode as proof for top-level tasks", () => {
|
||||||
expect(getCompletionMode(makeTask())).toBe("proof");
|
expect(getCompletionMode(makeTask())).toBe("proof");
|
||||||
@@ -69,29 +34,42 @@ describe("review state helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reports superseded when only history remains", () => {
|
it("reports superseded when only history remains", () => {
|
||||||
expect(getReviewState(makeTask({ metadata: { lgtm_history: [{ iteration: 1 }] } }))).toBe("superseded");
|
expect(
|
||||||
|
getReviewState(
|
||||||
|
makeTask({ metadata: { lgtm_history: [{ iteration: 1 }] } }),
|
||||||
|
),
|
||||||
|
).toBe("superseded");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getGateStatus", () => {
|
describe("getGateStatus", () => {
|
||||||
it("reports top-level proof requirement before evidence", () => {
|
it("reports top-level proof requirement before evidence", () => {
|
||||||
expect(getGateStatus(makeTask())).toBe("top-level task requires TaskClaimDone evidence before completion");
|
expect(getGateStatus(makeTask())).toBe(
|
||||||
|
"top-level task requires TaskClaimDone evidence before completion",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports non-blocking reviewer failure", () => {
|
it("reports non-blocking reviewer failure", () => {
|
||||||
expect(getGateStatus(makeTask({
|
expect(
|
||||||
|
getGateStatus(
|
||||||
|
makeTask({
|
||||||
metadata: {
|
metadata: {
|
||||||
lgtm_evidence: "ok",
|
lgtm_evidence: "ok",
|
||||||
robot_review_last_error: "Unexpected token 'a'",
|
robot_review_last_error: "Unexpected token 'a'",
|
||||||
},
|
},
|
||||||
}))).toContain("review unavailable; autonomy continues");
|
}),
|
||||||
|
),
|
||||||
|
).toContain("review unavailable; autonomy continues");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports rejected robot review when latest review does not accept", () => {
|
it("reports rejected robot review when latest review does not accept", () => {
|
||||||
expect(getGateStatus(makeTask({
|
expect(
|
||||||
|
getGateStatus(
|
||||||
|
makeTask({
|
||||||
metadata: {
|
metadata: {
|
||||||
lgtm_evidence: "ok",
|
lgtm_evidence: "ok",
|
||||||
robot_reviews: [{
|
robot_reviews: [
|
||||||
|
{
|
||||||
iteration: 1,
|
iteration: 1,
|
||||||
reviewer: "opencode",
|
reviewer: "opencode",
|
||||||
scope: "task evidence",
|
scope: "task evidence",
|
||||||
@@ -105,17 +83,25 @@ describe("getGateStatus", () => {
|
|||||||
missing_evidence: ["literal output"],
|
missing_evidence: ["literal output"],
|
||||||
submitted_at: "2026-04-17T00:00:00.000Z",
|
submitted_at: "2026-04-17T00:00:00.000Z",
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
}))).toBe("latest proof review rejected the evidence; strengthen the proof and try again");
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
"latest proof review rejected the evidence; strengthen the proof and try again",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps rejection higher priority than a later reviewer warning", () => {
|
it("keeps rejection higher priority than a later reviewer warning", () => {
|
||||||
expect(getGateStatus(makeTask({
|
expect(
|
||||||
|
getGateStatus(
|
||||||
|
makeTask({
|
||||||
metadata: {
|
metadata: {
|
||||||
lgtm_evidence: "ok",
|
lgtm_evidence: "ok",
|
||||||
robot_review_last_error: "timeout",
|
robot_review_last_error: "timeout",
|
||||||
robot_reviews: [{
|
robot_reviews: [
|
||||||
|
{
|
||||||
iteration: 1,
|
iteration: 1,
|
||||||
reviewer: "opencode",
|
reviewer: "opencode",
|
||||||
scope: "task evidence",
|
scope: "task evidence",
|
||||||
@@ -129,9 +115,14 @@ describe("getGateStatus", () => {
|
|||||||
missing_evidence: ["literal output"],
|
missing_evidence: ["literal output"],
|
||||||
submitted_at: "2026-04-17T00:00:00.000Z",
|
submitted_at: "2026-04-17T00:00:00.000Z",
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
}))).toBe("latest proof review rejected the evidence; strengthen the proof and try again");
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
"latest proof review rejected the evidence; strengthen the proof and try again",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,10 +132,14 @@ describe("getDisplayStatus", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns in_progress for active tasks not yet escalated", () => {
|
it("returns in_progress for active tasks not yet escalated", () => {
|
||||||
expect(getDisplayStatus(makeTask({ status: "in_progress" }))).toBe("in_progress");
|
expect(getDisplayStatus(makeTask({ status: "in_progress" }))).toBe(
|
||||||
|
"in_progress",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns completed for completed tasks", () => {
|
it("returns completed for completed tasks", () => {
|
||||||
expect(getDisplayStatus(makeTask({ status: "completed" }))).toBe("completed");
|
expect(getDisplayStatus(makeTask({ status: "completed" }))).toBe(
|
||||||
|
"completed",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ import {
|
|||||||
|
|
||||||
describe("robot review runner helpers", () => {
|
describe("robot review runner helpers", () => {
|
||||||
it("uses plain pi by default and allows override", () => {
|
it("uses plain pi by default and allows override", () => {
|
||||||
expect(getPiInvocation(["--mode", "json"], {} as NodeJS.ProcessEnv)).toEqual({
|
expect(
|
||||||
|
getPiInvocation(["--mode", "json"], {} as NodeJS.ProcessEnv),
|
||||||
|
).toEqual({
|
||||||
command: "pi",
|
command: "pi",
|
||||||
args: ["--mode", "json"],
|
args: ["--mode", "json"],
|
||||||
});
|
});
|
||||||
expect(getPiInvocation(["-p"], { PI_PROOF_TASKS_PI_BIN: "/custom/pi" } as NodeJS.ProcessEnv)).toEqual({
|
expect(
|
||||||
|
getPiInvocation(["-p"], {
|
||||||
|
PI_PROOF_TASKS_PI_BIN: "/custom/pi",
|
||||||
|
} as NodeJS.ProcessEnv),
|
||||||
|
).toEqual({
|
||||||
command: "/custom/pi",
|
command: "/custom/pi",
|
||||||
args: ["-p"],
|
args: ["-p"],
|
||||||
});
|
});
|
||||||
@@ -23,10 +29,12 @@ describe("robot review runner helpers", () => {
|
|||||||
|
|
||||||
it("parses the final assistant text from pi jsonl", () => {
|
it("parses the final assistant text from pi jsonl", () => {
|
||||||
const output = [
|
const output = [
|
||||||
"{\"type\":\"message_update\"}",
|
'{"type":"message_update"}',
|
||||||
"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"ROBOT_REVIEW_JSON_START {\\\"accepted\\\":true} ROBOT_REVIEW_JSON_END\"}]}}",
|
'{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ROBOT_REVIEW_JSON_START {\\"accepted\\":true} ROBOT_REVIEW_JSON_END"}]}}',
|
||||||
].join("\n");
|
].join("\n");
|
||||||
expect(extractFinalAssistantTextFromPiJsonl(output)).toContain("ROBOT_REVIEW_JSON_START");
|
expect(extractFinalAssistantTextFromPiJsonl(output)).toContain(
|
||||||
|
"ROBOT_REVIEW_JSON_START",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses noisy JSON wrapped in review markers", () => {
|
it("parses noisy JSON wrapped in review markers", () => {
|
||||||
@@ -38,29 +46,54 @@ describe("robot review runner helpers", () => {
|
|||||||
"```",
|
"```",
|
||||||
"ROBOT_REVIEW_JSON_END",
|
"ROBOT_REVIEW_JSON_END",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
expect(extractRobotReviewJson(output)).toEqual({ accepted: true, observations: ["ok"] });
|
expect(extractRobotReviewJson(output)).toEqual({
|
||||||
|
accepted: true,
|
||||||
|
observations: ["ok"],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes raw output context on parse failure", () => {
|
it("includes raw output context on parse failure", () => {
|
||||||
expect(() => extractRobotReviewJson("ROBOT_REVIEW_JSON_START and nope ROBOT_REVIEW_JSON_END")).toThrow(/Raw output:/);
|
expect(() =>
|
||||||
|
extractRobotReviewJson(
|
||||||
|
"ROBOT_REVIEW_JSON_START and nope ROBOT_REVIEW_JSON_END",
|
||||||
|
),
|
||||||
|
).toThrow(/Raw output:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses configured timeout or falls back to default", () => {
|
it("uses configured timeout or falls back to default", () => {
|
||||||
expect(getRobotReviewTimeoutMs({ PI_PROOF_TASKS_ROBOT_REVIEW_TIMEOUT_MS: "2500" } as NodeJS.ProcessEnv)).toBe(2500);
|
expect(
|
||||||
expect(getRobotReviewTimeoutMs({ PI_PROOF_TASKS_ROBOT_REVIEW_TIMEOUT_MS: "bad" } as NodeJS.ProcessEnv)).toBe(DEFAULT_ROBOT_REVIEW_TIMEOUT_MS);
|
getRobotReviewTimeoutMs({
|
||||||
|
PI_PROOF_TASKS_ROBOT_REVIEW_TIMEOUT_MS: "2500",
|
||||||
|
} as NodeJS.ProcessEnv),
|
||||||
|
).toBe(2500);
|
||||||
|
expect(
|
||||||
|
getRobotReviewTimeoutMs({
|
||||||
|
PI_PROOF_TASKS_ROBOT_REVIEW_TIMEOUT_MS: "bad",
|
||||||
|
} as NodeJS.ProcessEnv),
|
||||||
|
).toBe(DEFAULT_ROBOT_REVIEW_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats the current model as the reviewer model ref", () => {
|
it("formats the current model as the reviewer model ref", () => {
|
||||||
expect(getCurrentModelRef({ provider: "openai", id: "gpt-5" })).toBe("openai/gpt-5");
|
expect(getCurrentModelRef({ provider: "openai", id: "gpt-5" })).toBe(
|
||||||
expect(getCurrentModelRef({ providerId: "anthropic", modelId: "claude-haiku" })).toBe("anthropic/claude-haiku");
|
"openai/gpt-5",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getCurrentModelRef({ providerId: "anthropic", modelId: "claude-haiku" }),
|
||||||
|
).toBe("anthropic/claude-haiku");
|
||||||
expect(getCurrentModelRef({ provider: "openai" })).toBeUndefined();
|
expect(getCurrentModelRef({ provider: "openai" })).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("times out bounded child commands", async () => {
|
it("times out bounded child commands", async () => {
|
||||||
await expect(runRobotReviewCommand({
|
await expect(
|
||||||
|
runRobotReviewCommand(
|
||||||
|
{
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: ["-e", "setTimeout(() => {}, 1000)"],
|
args: ["-e", "setTimeout(() => {}, 1000)"],
|
||||||
}, undefined, 25)).rejects.toThrow(/timed out/i);
|
},
|
||||||
|
undefined,
|
||||||
|
25,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/timed out/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts assistant text from a child jsonl process", async () => {
|
it("extracts assistant text from a child jsonl process", async () => {
|
||||||
@@ -68,10 +101,14 @@ describe("robot review runner helpers", () => {
|
|||||||
"process.stdout.write(JSON.stringify({type:'message_update'}) + '\\n');",
|
"process.stdout.write(JSON.stringify({type:'message_update'}) + '\\n');",
|
||||||
"process.stdout.write(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'ROBOT_REVIEW_JSON_START {\\\"accepted\\\":true,\\\"observations\\\":[\\\"ok\\\"]} ROBOT_REVIEW_JSON_END'}]}}) + '\\n');",
|
"process.stdout.write(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'ROBOT_REVIEW_JSON_START {\\\"accepted\\\":true,\\\"observations\\\":[\\\"ok\\\"]} ROBOT_REVIEW_JSON_END'}]}}) + '\\n');",
|
||||||
].join("");
|
].join("");
|
||||||
const result = await runRobotReviewCommand({
|
const result = await runRobotReviewCommand(
|
||||||
|
{
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: ["-e", script],
|
args: ["-e", script],
|
||||||
}, undefined, 500);
|
},
|
||||||
|
undefined,
|
||||||
|
500,
|
||||||
|
);
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
expect(result.stdout).toContain("ROBOT_REVIEW_JSON_END");
|
expect(result.stdout).toContain("ROBOT_REVIEW_JSON_END");
|
||||||
});
|
});
|
||||||
|
|||||||
+191
-33
@@ -2,8 +2,23 @@ import { mkdtempSync, writeFileSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { archiveCurrentEvidence, buildArtifactRecords, buildRobotReviewPrompt, getCurrentEvidenceIteration, getEvidenceHistory, renderEvidencePacket, renderProofLog } from "../src/index.js";
|
import {
|
||||||
import { appendRobotReviewMetadata, getLatestRobotReview, getRobotReviews, hasCompleteProofClaim, relaxAdvisoryVerificationHints, shouldCompleteAfterAcceptedReview } from "../src/robot-review.js";
|
archiveCurrentEvidence,
|
||||||
|
buildArtifactRecords,
|
||||||
|
buildRobotReviewPrompt,
|
||||||
|
getCurrentEvidenceIteration,
|
||||||
|
getEvidenceHistory,
|
||||||
|
renderEvidencePacket,
|
||||||
|
renderProofLog,
|
||||||
|
} from "../src/index.js";
|
||||||
|
import {
|
||||||
|
appendRobotReviewMetadata,
|
||||||
|
getLatestRobotReview,
|
||||||
|
getRobotReviews,
|
||||||
|
hasCompleteProofClaim,
|
||||||
|
relaxAdvisoryVerificationHints,
|
||||||
|
shouldCompleteAfterAcceptedReview,
|
||||||
|
} from "../src/robot-review.js";
|
||||||
import type { Task } from "../src/types.js";
|
import type { Task } from "../src/types.js";
|
||||||
|
|
||||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
@@ -32,15 +47,23 @@ describe("robot review helpers", () => {
|
|||||||
lgtm_failure_sneaky: "right output for wrong reason",
|
lgtm_failure_sneaky: "right output for wrong reason",
|
||||||
lgtm_failure_unknown: "untested platform",
|
lgtm_failure_unknown: "untested platform",
|
||||||
lgtm_falsification_test: "npm test\npass",
|
lgtm_falsification_test: "npm test\npass",
|
||||||
lgtm_evidence_reasoning: "the test output rules out the named failures for this scope",
|
lgtm_evidence_reasoning:
|
||||||
lgtm_verification_hints: ["test/robot-review.test.ts shows the expectation"],
|
"the test output rules out the named failures for this scope",
|
||||||
|
lgtm_verification_hints: [
|
||||||
|
"test/robot-review.test.ts shows the expectation",
|
||||||
|
],
|
||||||
lgtm_remaining_uncertainty: "does not test prod install",
|
lgtm_remaining_uncertainty: "does not test prod install",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(hasCompleteProofClaim(task)).toBe(true);
|
expect(hasCompleteProofClaim(task)).toBe(true);
|
||||||
expect(shouldCompleteAfterAcceptedReview(task, true)).toBe(true);
|
expect(shouldCompleteAfterAcceptedReview(task, true)).toBe(true);
|
||||||
expect(shouldCompleteAfterAcceptedReview(task, false)).toBe(false);
|
expect(shouldCompleteAfterAcceptedReview(task, false)).toBe(false);
|
||||||
expect(shouldCompleteAfterAcceptedReview(makeTask({ metadata: { lgtm_evidence: "literal output" } }), true)).toBe(false);
|
expect(
|
||||||
|
shouldCompleteAfterAcceptedReview(
|
||||||
|
makeTask({ metadata: { lgtm_evidence: "literal output" } }),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads legacy single-review metadata", () => {
|
it("reads legacy single-review metadata", () => {
|
||||||
@@ -48,7 +71,9 @@ describe("robot review helpers", () => {
|
|||||||
metadata: {
|
metadata: {
|
||||||
robot_review_reviewer: "opencode",
|
robot_review_reviewer: "opencode",
|
||||||
robot_review_scope: "task evidence",
|
robot_review_scope: "task evidence",
|
||||||
robot_review_observations: ["Observed no command output for the core claim"],
|
robot_review_observations: [
|
||||||
|
"Observed no command output for the core claim",
|
||||||
|
],
|
||||||
robot_review_blind_spots: "Did not rerun tests",
|
robot_review_blind_spots: "Did not rerun tests",
|
||||||
robot_review_submitted_at: "2026-04-17T00:00:00.000Z",
|
robot_review_submitted_at: "2026-04-17T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
@@ -80,7 +105,8 @@ describe("robot review helpers", () => {
|
|||||||
lgtm_failure_sneaky: "wrong threshold",
|
lgtm_failure_sneaky: "wrong threshold",
|
||||||
lgtm_failure_unknown: "untested environment",
|
lgtm_failure_unknown: "untested environment",
|
||||||
lgtm_falsification_test: "pytest -k check",
|
lgtm_falsification_test: "pytest -k check",
|
||||||
lgtm_evidence_reasoning: "pytest output distinguishes the expected passing path from the named failures",
|
lgtm_evidence_reasoning:
|
||||||
|
"pytest output distinguishes the expected passing path from the named failures",
|
||||||
lgtm_verification_hints: ["see line 5"],
|
lgtm_verification_hints: ["see line 5"],
|
||||||
lgtm_remaining_uncertainty: "not load tested",
|
lgtm_remaining_uncertainty: "not load tested",
|
||||||
lgtm_submitted_at: "2026-06-07T00:00:00.000Z",
|
lgtm_submitted_at: "2026-06-07T00:00:00.000Z",
|
||||||
@@ -92,10 +118,12 @@ describe("robot review helpers", () => {
|
|||||||
const taskWithHistory = makeTask({ metadata: archived });
|
const taskWithHistory = makeTask({ metadata: archived });
|
||||||
expect(getCurrentEvidenceIteration(task)?.iteration).toBe(1);
|
expect(getCurrentEvidenceIteration(task)?.iteration).toBe(1);
|
||||||
expect(getEvidenceHistory(taskWithHistory)).toHaveLength(1);
|
expect(getEvidenceHistory(taskWithHistory)).toHaveLength(1);
|
||||||
expect(getEvidenceHistory(taskWithHistory)[0].supersede_reason).toBe("threshold changed");
|
expect(getEvidenceHistory(taskWithHistory)[0].supersede_reason).toBe(
|
||||||
|
"threshold changed",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats verification hints as advisory when core evidence already passes", () => {
|
it("treats advisory rubric failures as non-blocking when core evidence already passes", () => {
|
||||||
const review = relaxAdvisoryVerificationHints({
|
const review = relaxAdvisoryVerificationHints({
|
||||||
reviewer: "auto",
|
reviewer: "auto",
|
||||||
scope: "task evidence",
|
scope: "task evidence",
|
||||||
@@ -106,21 +134,41 @@ describe("robot review helpers", () => {
|
|||||||
accepted: false,
|
accepted: false,
|
||||||
evidence_complete: true,
|
evidence_complete: true,
|
||||||
evidence_convincing: false,
|
evidence_convincing: false,
|
||||||
missing_evidence: ["verification_hints_actionable"],
|
missing_evidence: [
|
||||||
|
"verification_hints_actionable",
|
||||||
|
"evidence_distinguishes_success",
|
||||||
|
],
|
||||||
submitted_at: "2026-06-13T00:00:00.000Z",
|
submitted_at: "2026-06-13T00:00:00.000Z",
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
rubric: {
|
rubric: {
|
||||||
evidence_covers_done_criterion: { reason: "verbatim logs match", pass: true },
|
evidence_covers_done_criterion: {
|
||||||
falsification_test_runnable: { reason: "command and output shown", pass: true },
|
reason: "verbatim logs match",
|
||||||
failure_modes_addressed: { reason: "plausible top risks named", pass: true },
|
pass: true,
|
||||||
evidence_distinguishes_success: { reason: "evidence rules out named failures", pass: true },
|
},
|
||||||
verification_hints_actionable: { reason: "paths are vague", pass: false },
|
falsification_test_runnable: {
|
||||||
|
reason: "command and output shown",
|
||||||
|
pass: true,
|
||||||
|
},
|
||||||
|
failure_modes_addressed: {
|
||||||
|
reason: "plausible top risks named",
|
||||||
|
pass: true,
|
||||||
|
},
|
||||||
|
evidence_distinguishes_success: {
|
||||||
|
reason: "reasoning writeup is thin",
|
||||||
|
pass: false,
|
||||||
|
},
|
||||||
|
verification_hints_actionable: {
|
||||||
|
reason: "paths are vague",
|
||||||
|
pass: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(review.accepted).toBe(true);
|
expect(review.accepted).toBe(true);
|
||||||
expect(review.evidence_convincing).toBe(true);
|
expect(review.evidence_convincing).toBe(true);
|
||||||
expect(review.observations.at(-1)).toContain("treated as advisory");
|
expect(
|
||||||
|
review.observations.some((item) => item.includes("treated as advisory")),
|
||||||
|
).toBe(true);
|
||||||
expect(review.missing_evidence).toEqual([]);
|
expect(review.missing_evidence).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,10 +188,22 @@ describe("robot review helpers", () => {
|
|||||||
mode: "auto",
|
mode: "auto",
|
||||||
rubric: {
|
rubric: {
|
||||||
evidence_covers_done_criterion: { reason: "summary only", pass: false },
|
evidence_covers_done_criterion: { reason: "summary only", pass: false },
|
||||||
falsification_test_runnable: { reason: "command and output shown", pass: true },
|
falsification_test_runnable: {
|
||||||
failure_modes_addressed: { reason: "plausible top risks named", pass: true },
|
reason: "command and output shown",
|
||||||
evidence_distinguishes_success: { reason: "evidence does not rule out summary-only failure", pass: false },
|
pass: true,
|
||||||
verification_hints_actionable: { reason: "paths are vague", pass: false },
|
},
|
||||||
|
failure_modes_addressed: {
|
||||||
|
reason: "plausible top risks named",
|
||||||
|
pass: true,
|
||||||
|
},
|
||||||
|
evidence_distinguishes_success: {
|
||||||
|
reason: "evidence does not rule out summary-only failure",
|
||||||
|
pass: false,
|
||||||
|
},
|
||||||
|
verification_hints_actionable: {
|
||||||
|
reason: "paths are vague",
|
||||||
|
pass: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,22 +219,72 @@ describe("robot review helpers", () => {
|
|||||||
lgtm_failure_sneaky: "wrong threshold",
|
lgtm_failure_sneaky: "wrong threshold",
|
||||||
lgtm_failure_unknown: "does not test UI rendering",
|
lgtm_failure_unknown: "does not test UI rendering",
|
||||||
lgtm_falsification_test: "pytest -k check\nPASSED",
|
lgtm_falsification_test: "pytest -k check\nPASSED",
|
||||||
lgtm_evidence_reasoning: "The passing pytest transcript distinguishes success from wrong-threshold and wrong-seed failures for this test scope.",
|
lgtm_evidence_reasoning:
|
||||||
lgtm_verification_hints: ["test/robot-review.test.ts contains the new guard test"],
|
"The passing pytest transcript distinguishes success from wrong-threshold and wrong-seed failures for this test scope.",
|
||||||
|
lgtm_verification_hints: [
|
||||||
|
"test/robot-review.test.ts contains the new guard test",
|
||||||
|
],
|
||||||
lgtm_remaining_uncertainty: "not load tested",
|
lgtm_remaining_uncertainty: "not load tested",
|
||||||
lgtm_submitted_at: "2026-06-14T00:00:00.000Z",
|
lgtm_submitted_at: "2026-06-14T00:00:00.000Z",
|
||||||
lgtm_commands: [{ cmd: "npm test", exit_code: 0, stdout_path: "/tmp/test.log" }],
|
lgtm_commands: [
|
||||||
lgtm_evidence_artifacts: [{ path: "/tmp/test.log", sha256: "abc", bytes: 123 }],
|
{ cmd: "npm test", exit_code: 0, stdout_path: "/tmp/test.log" },
|
||||||
|
],
|
||||||
|
lgtm_evidence_artifacts: [
|
||||||
|
{ path: "/tmp/test.log", sha256: "abc", bytes: 123 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const packet = renderEvidencePacket(task);
|
const packet = renderEvidencePacket(task);
|
||||||
const prompt = buildRobotReviewPrompt(task);
|
const prompt = buildRobotReviewPrompt(task);
|
||||||
expect(packet).toContain("## Goal");
|
expect(packet).toContain("## Goal");
|
||||||
expect(packet).toContain("## Planned evidence / UAT");
|
|
||||||
expect(packet).toContain("## Attempt 1");
|
expect(packet).toContain("## Attempt 1");
|
||||||
|
expect(packet).toContain("### Evidence");
|
||||||
|
expect(packet).toContain("### Verify");
|
||||||
expect(prompt).toContain(packet);
|
expect(prompt).toContain(packet);
|
||||||
expect(prompt).toContain("does this evidence prove success for the stated goal");
|
expect(prompt).toContain(
|
||||||
|
"does this packet prove the exact user-visible success condition",
|
||||||
|
);
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"Do not reject solely because items 3, 4, or 5 are weak",
|
||||||
|
);
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"concrete missing artifacts, command outputs, written-file checks",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long submitted evidence in the rendered proof log and points to the full artifact", () => {
|
||||||
|
const longEvidence = Array.from(
|
||||||
|
{ length: 35 },
|
||||||
|
(_, i) => `line ${i + 1}`,
|
||||||
|
).join("\n");
|
||||||
|
const task = makeTask({
|
||||||
|
metadata: {
|
||||||
|
lgtm_evidence: longEvidence,
|
||||||
|
lgtm_failure_likely: "wrong seed",
|
||||||
|
lgtm_failure_sneaky: "wrong threshold",
|
||||||
|
lgtm_failure_unknown: "untested environment",
|
||||||
|
lgtm_falsification_test: "pytest -k check\nPASSED",
|
||||||
|
lgtm_evidence_reasoning:
|
||||||
|
"The transcript rules out the named failures for this scope.",
|
||||||
|
lgtm_verification_hints: ["see /tmp/test.log"],
|
||||||
|
lgtm_remaining_uncertainty: "not load tested",
|
||||||
|
lgtm_submitted_at: "2026-06-14T00:00:00.000Z",
|
||||||
|
lgtm_evidence_artifacts: [
|
||||||
|
{ path: "/tmp/test.log", sha256: "abc", bytes: 123 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = renderProofLog(task);
|
||||||
|
expect(log).toContain("line 1");
|
||||||
|
expect(log).toContain("line 8");
|
||||||
|
expect(log).toContain("line 35");
|
||||||
|
expect(log).not.toContain("line 9");
|
||||||
|
expect(log).toContain("[... 19 middle lines omitted ...]");
|
||||||
|
expect(log).toContain(
|
||||||
|
"[truncated at 16 lines from 35; showing first 8 and last 8; full text: /tmp/test.log]",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends robot reviews as iterations", () => {
|
it("appends robot reviews as iterations", () => {
|
||||||
@@ -226,8 +336,11 @@ describe("robot review helpers", () => {
|
|||||||
lgtm_failure_sneaky: "top-level direct completion still slips through",
|
lgtm_failure_sneaky: "top-level direct completion still slips through",
|
||||||
lgtm_failure_unknown: "fresh judge command fails in a real session",
|
lgtm_failure_unknown: "fresh judge command fails in a real session",
|
||||||
lgtm_falsification_test: "npm test\n125 passed",
|
lgtm_falsification_test: "npm test\n125 passed",
|
||||||
lgtm_evidence_reasoning: "The test transcript and grep distinguish the intended behavior from stale workflow regressions.",
|
lgtm_evidence_reasoning:
|
||||||
lgtm_verification_hints: ["README.md install block shows pi-proof-tasks"],
|
"The test transcript and grep distinguish the intended behavior from stale workflow regressions.",
|
||||||
|
lgtm_verification_hints: [
|
||||||
|
"README.md install block shows pi-proof-tasks",
|
||||||
|
],
|
||||||
lgtm_remaining_uncertainty: "Did not exercise every model provider.",
|
lgtm_remaining_uncertainty: "Did not exercise every model provider.",
|
||||||
lgtm_submitted_at: "2026-06-14T00:00:00.000Z",
|
lgtm_submitted_at: "2026-06-14T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
@@ -255,14 +368,53 @@ describe("robot review helpers", () => {
|
|||||||
const log = renderProofLog(task);
|
const log = renderProofLog(task);
|
||||||
expect(log).toContain("# Task #1: Test");
|
expect(log).toContain("# Task #1: Test");
|
||||||
expect(log).toContain("## Goal");
|
expect(log).toContain("## Goal");
|
||||||
expect(log).toContain("## Planned evidence / UAT");
|
|
||||||
expect(log).toContain("## Attempt 1");
|
expect(log).toContain("## Attempt 1");
|
||||||
expect(log).toContain("### Submitted evidence");
|
expect(log).toContain("### Evidence");
|
||||||
|
expect(log).toContain("### Verify");
|
||||||
expect(log).toContain("### Judgement");
|
expect(log).toContain("### Judgement");
|
||||||
expect(log).toContain("Refused by auto");
|
expect(log).toContain("Refused by auto");
|
||||||
|
expect(log).toContain("### Observations");
|
||||||
|
expect(log).toContain("### Concerns");
|
||||||
|
expect(log).toContain("### Missing evidence");
|
||||||
|
expect(log).toContain("### Suggestions");
|
||||||
expect(log).toContain("Run one self-hosted TaskClaimDone UAT.");
|
expect(log).toContain("Run one self-hosted TaskClaimDone UAT.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps full submitted evidence in the automatic review packet even when proof logs truncate it", () => {
|
||||||
|
const artifactPath = join(tmpdir(), "proof-packet-long-evidence.log");
|
||||||
|
const longEvidence = Array.from(
|
||||||
|
{ length: 35 },
|
||||||
|
(_, i) => `line ${i + 1}`,
|
||||||
|
).join("\n");
|
||||||
|
writeFileSync(artifactPath, longEvidence);
|
||||||
|
const task = makeTask({
|
||||||
|
metadata: {
|
||||||
|
lgtm_evidence: longEvidence,
|
||||||
|
lgtm_failure_likely: "missing artifact",
|
||||||
|
lgtm_failure_sneaky: "wrong slice shown",
|
||||||
|
lgtm_failure_unknown: "untested provider path",
|
||||||
|
lgtm_falsification_test: "npm test\npass",
|
||||||
|
lgtm_evidence_reasoning:
|
||||||
|
"The full evidence must stay visible to the judge even if humans see a shortened preview.",
|
||||||
|
lgtm_verification_hints: [
|
||||||
|
"Open the artifact if the inline preview truncates.",
|
||||||
|
],
|
||||||
|
lgtm_remaining_uncertainty: "Did not inspect live TUI.",
|
||||||
|
lgtm_evidence_artifacts: buildArtifactRecords([artifactPath]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const proofLog = renderProofLog(task);
|
||||||
|
const reviewPacket = renderEvidencePacket(task, {
|
||||||
|
truncateEvidence: false,
|
||||||
|
});
|
||||||
|
expect(proofLog).toContain("line 8");
|
||||||
|
expect(proofLog).toContain("line 35");
|
||||||
|
expect(proofLog).not.toContain("line 9");
|
||||||
|
expect(reviewPacket).toContain("line 35");
|
||||||
|
expect(reviewPacket).not.toContain("[truncated at 16 lines");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders reviewer-unavailable proof logs for fail-open completion notes", () => {
|
it("renders reviewer-unavailable proof logs for fail-open completion notes", () => {
|
||||||
const task = makeTask({
|
const task = makeTask({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -272,8 +424,11 @@ describe("robot review helpers", () => {
|
|||||||
lgtm_failure_sneaky: "top-level direct completion still slips through",
|
lgtm_failure_sneaky: "top-level direct completion still slips through",
|
||||||
lgtm_failure_unknown: "fresh judge command fails in a real session",
|
lgtm_failure_unknown: "fresh judge command fails in a real session",
|
||||||
lgtm_falsification_test: "npm test\n125 passed",
|
lgtm_falsification_test: "npm test\n125 passed",
|
||||||
lgtm_evidence_reasoning: "The test transcript and grep distinguish the intended behavior from stale workflow regressions.",
|
lgtm_evidence_reasoning:
|
||||||
lgtm_verification_hints: ["README.md install block shows pi-proof-tasks"],
|
"The test transcript and grep distinguish the intended behavior from stale workflow regressions.",
|
||||||
|
lgtm_verification_hints: [
|
||||||
|
"README.md install block shows pi-proof-tasks",
|
||||||
|
],
|
||||||
lgtm_remaining_uncertainty: "Did not exercise every model provider.",
|
lgtm_remaining_uncertainty: "Did not exercise every model provider.",
|
||||||
robot_review_last_error: "judge auth failed",
|
robot_review_last_error: "judge auth failed",
|
||||||
},
|
},
|
||||||
@@ -283,7 +438,10 @@ describe("robot review helpers", () => {
|
|||||||
expect(log).toContain("completed with reviewer unavailable");
|
expect(log).toContain("completed with reviewer unavailable");
|
||||||
expect(log).toContain("### Judgement");
|
expect(log).toContain("### Judgement");
|
||||||
expect(log).toContain("judge auth failed");
|
expect(log).toContain("judge auth failed");
|
||||||
|
expect(log).toContain("### Suggestions");
|
||||||
|
expect(log).not.toContain("### Missing evidence");
|
||||||
|
expect(log).not.toContain("### Observations");
|
||||||
|
expect(log).not.toContain("### Concerns");
|
||||||
expect(log).toContain("Autonomy continued without blocking completion.");
|
expect(log).toContain("Autonomy continued without blocking completion.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import proofTasksExtension from "../src/index.js";
|
||||||
|
|
||||||
|
type RegisteredTool = {
|
||||||
|
name: string;
|
||||||
|
execute: (...args: any[]) => Promise<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeHarness() {
|
||||||
|
const tools = new Map<string, RegisteredTool>();
|
||||||
|
const pi = {
|
||||||
|
on: vi.fn(),
|
||||||
|
registerTool: vi.fn((tool: RegisteredTool) => tools.set(tool.name, tool)),
|
||||||
|
registerCommand: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
proofTasksExtension(pi as any);
|
||||||
|
|
||||||
|
async function execTool(
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
ctx: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const tool = tools.get(name);
|
||||||
|
if (!tool) throw new Error(`Tool ${name} not registered`);
|
||||||
|
return tool.execute("tool-call", params, undefined, undefined, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { execTool };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeReviewerScript(source: string): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-proof-reviewer-"));
|
||||||
|
const path = join(dir, "reviewer.js");
|
||||||
|
writeFileSync(path, `#!/usr/bin/env node\n${source}\n`);
|
||||||
|
chmodSync(path, 0o755);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORIGINAL_PI_BIN = process.env.PI_PROOF_TASKS_PI_BIN;
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL_PI_BIN === undefined) delete process.env.PI_PROOF_TASKS_PI_BIN;
|
||||||
|
else process.env.PI_PROOF_TASKS_PI_BIN = ORIGINAL_PI_BIN;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TaskClaimDone end-to-end proof flow", () => {
|
||||||
|
it("keeps the task open on rejected review and /lgtm-style TaskGet shows truncated evidence", async () => {
|
||||||
|
const reviewer = writeReviewerScript(`
|
||||||
|
const review = {
|
||||||
|
reviewer: "fake-judge",
|
||||||
|
scope: "task evidence",
|
||||||
|
rubric: {
|
||||||
|
evidence_covers_done_criterion: { reason: "missing one artifact", pass: false },
|
||||||
|
falsification_test_runnable: { reason: "ok", pass: true },
|
||||||
|
failure_modes_addressed: { reason: "ok", pass: true },
|
||||||
|
evidence_distinguishes_success: { reason: "not enough", pass: false },
|
||||||
|
verification_hints_actionable: { reason: "ok", pass: true }
|
||||||
|
},
|
||||||
|
observations: ["Observed truncated proof packet"],
|
||||||
|
concerns: ["Need stronger evidence"],
|
||||||
|
suggestions: ["Add one more artifact"],
|
||||||
|
blind_spots: "Did not inspect live TUI",
|
||||||
|
missing_evidence: ["evidence_covers_done_criterion", "evidence_distinguishes_success"],
|
||||||
|
evidence_complete: false,
|
||||||
|
evidence_convincing: false,
|
||||||
|
accepted: false
|
||||||
|
};
|
||||||
|
console.log("ROBOT_REVIEW_JSON_START");
|
||||||
|
console.log(JSON.stringify(review));
|
||||||
|
console.log("ROBOT_REVIEW_JSON_END");
|
||||||
|
`);
|
||||||
|
process.env.PI_PROOF_TASKS_PI_BIN = reviewer;
|
||||||
|
|
||||||
|
const harness = makeHarness();
|
||||||
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Proof task",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifactPath = join(tmpdir(), "proof-long-evidence.log");
|
||||||
|
const longEvidence = Array.from(
|
||||||
|
{ length: 35 },
|
||||||
|
(_, i) => `line ${i + 1}`,
|
||||||
|
).join("\n");
|
||||||
|
writeFileSync(artifactPath, longEvidence);
|
||||||
|
|
||||||
|
const claim = await harness.execTool(
|
||||||
|
"TaskClaimDone",
|
||||||
|
{
|
||||||
|
taskId: "1",
|
||||||
|
evidence: longEvidence,
|
||||||
|
failure_likely: "missing artifact",
|
||||||
|
failure_sneaky: "right shape for wrong reason",
|
||||||
|
failure_unknown: "untested provider path",
|
||||||
|
falsification_test: "npm test\npass",
|
||||||
|
evidence_reasoning:
|
||||||
|
"The packet distinguishes the named failures for this test scope.",
|
||||||
|
verification_hints: ["look at the proof log"],
|
||||||
|
remaining_uncertainty: "Did not inspect live TUI",
|
||||||
|
evidence_paths: [artifactPath],
|
||||||
|
},
|
||||||
|
{ model: { provider: "openai", id: "gpt-5" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const claimText = claim.content[0].text;
|
||||||
|
|
||||||
|
const taskGet = await harness.execTool("TaskGet", { taskId: "1" });
|
||||||
|
const text = taskGet.content[0].text;
|
||||||
|
|
||||||
|
expect(claimText).toContain("## TaskClaimDone -> Task #1: Proof task");
|
||||||
|
expect(claimText).toContain("### Metadata");
|
||||||
|
expect(claimText).toContain("- Proof iterations: 1");
|
||||||
|
expect(claimText).toContain("- Robot reviews: 1");
|
||||||
|
expect(text).toContain("Status: pending");
|
||||||
|
expect(text).toContain(
|
||||||
|
"Gate status: latest proof review rejected the evidence; strengthen the proof and try again",
|
||||||
|
);
|
||||||
|
expect(text).toContain("line 1");
|
||||||
|
expect(text).toContain("line 8");
|
||||||
|
expect(text).toContain("line 35");
|
||||||
|
expect(text).not.toContain("line 9");
|
||||||
|
expect(text).toContain("[... 19 middle lines omitted ...]");
|
||||||
|
expect(text).toContain(
|
||||||
|
`[truncated at 16 lines from 35; showing first 8 and last 8; full text: ${artifactPath}]`,
|
||||||
|
);
|
||||||
|
expect(text).toContain("### Judgement");
|
||||||
|
expect(text).toContain("Refused");
|
||||||
|
expect(text).toContain("### Missing evidence");
|
||||||
|
expect(text).toContain("### Suggestions");
|
||||||
|
expect(text).toContain("Add one more artifact");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes the task fail-open on parse failure and preserves the failure note", async () => {
|
||||||
|
const reviewer = writeReviewerScript(`
|
||||||
|
console.log("ROBOT_REVIEW_JSON_START and nope ROBOT_REVIEW_JSON_END");
|
||||||
|
`);
|
||||||
|
process.env.PI_PROOF_TASKS_PI_BIN = reviewer;
|
||||||
|
|
||||||
|
const harness = makeHarness();
|
||||||
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Proof task",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
|
||||||
|
const claim = await harness.execTool(
|
||||||
|
"TaskClaimDone",
|
||||||
|
{
|
||||||
|
taskId: "1",
|
||||||
|
evidence: "short evidence",
|
||||||
|
failure_likely: "missing artifact",
|
||||||
|
failure_sneaky: "right shape for wrong reason",
|
||||||
|
failure_unknown: "untested provider path",
|
||||||
|
falsification_test: "npm test\npass",
|
||||||
|
evidence_reasoning:
|
||||||
|
"The packet distinguishes the named failures for this test scope.",
|
||||||
|
verification_hints: ["look at the proof log"],
|
||||||
|
remaining_uncertainty: "Did not inspect live TUI",
|
||||||
|
},
|
||||||
|
{ model: { provider: "openai", id: "gpt-5" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const claimText = claim.content[0].text;
|
||||||
|
|
||||||
|
const taskGet = await harness.execTool("TaskGet", { taskId: "1" });
|
||||||
|
const text = taskGet.content[0].text;
|
||||||
|
|
||||||
|
expect(claimText).toContain("## TaskClaimDone -> Task #1: Proof task");
|
||||||
|
expect(claimText).toContain("### Metadata");
|
||||||
|
expect(claimText).toContain(
|
||||||
|
"- Gate status: completed with reviewer unavailable",
|
||||||
|
);
|
||||||
|
expect(text).toContain("Status: completed");
|
||||||
|
expect(text).toContain("completed with reviewer unavailable");
|
||||||
|
expect(text).toContain("Raw output:");
|
||||||
|
expect(text).toContain("### Suggestions");
|
||||||
|
expect(text).not.toContain("### Missing evidence\n- (none)");
|
||||||
|
expect(text).not.toContain("### Observations\n- (none)");
|
||||||
|
expect(text).not.toContain("### Concerns\n- (none)");
|
||||||
|
expect(text).toContain(
|
||||||
|
"ROBOT_REVIEW_JSON_START and nope ROBOT_REVIEW_JSON_END",
|
||||||
|
);
|
||||||
|
expect(text).toContain("Autonomy continued without blocking completion.");
|
||||||
|
});
|
||||||
|
});
|
||||||
+227
-14
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import proofTasksExtension from "../src/index.js";
|
import proofTasksExtension from "../src/index.js";
|
||||||
|
import { TaskStore } from "../src/task-store.js";
|
||||||
|
|
||||||
type RegisteredTool = {
|
type RegisteredTool = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,8 +12,13 @@ type RegisteredTool = {
|
|||||||
|
|
||||||
function makeHarness() {
|
function makeHarness() {
|
||||||
const tools = new Map<string, RegisteredTool>();
|
const tools = new Map<string, RegisteredTool>();
|
||||||
|
const handlers = new Map<string, Array<(...args: any[]) => any>>();
|
||||||
const pi = {
|
const pi = {
|
||||||
on: vi.fn(),
|
on: vi.fn((event: string, handler: (...args: any[]) => any) => {
|
||||||
|
const existing = handlers.get(event) ?? [];
|
||||||
|
existing.push(handler);
|
||||||
|
handlers.set(event, existing);
|
||||||
|
}),
|
||||||
registerTool: vi.fn((tool: RegisteredTool) => tools.set(tool.name, tool)),
|
registerTool: vi.fn((tool: RegisteredTool) => tools.set(tool.name, tool)),
|
||||||
registerCommand: vi.fn(),
|
registerCommand: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
@@ -23,10 +32,24 @@ function makeHarness() {
|
|||||||
return tool.execute("tool-call", params, undefined, undefined, {});
|
return tool.execute("tool-call", params, undefined, undefined, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { execTool };
|
async function trigger(event: string, payload: any = {}, ctx: any = {}) {
|
||||||
|
for (const handler of handlers.get(event) ?? []) {
|
||||||
|
await handler(payload, ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TaskList", () => {
|
return { execTool, trigger };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.PI_TASKS;
|
||||||
|
while (tempDirs.length > 0)
|
||||||
|
rmSync(tempDirs.pop()!, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Task tools", () => {
|
||||||
it("renders a compact one-line-per-task summary", async () => {
|
it("renders a compact one-line-per-task summary", async () => {
|
||||||
const harness = makeHarness();
|
const harness = makeHarness();
|
||||||
await harness.execTool("TaskCreate", {
|
await harness.execTool("TaskCreate", {
|
||||||
@@ -53,25 +76,83 @@ describe("TaskList", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await harness.execTool("TaskUpdate", { taskId: "1", status: "completed" });
|
await harness.execTool("TaskUpdate", { taskId: "1", status: "completed" });
|
||||||
await harness.execTool("TaskUpdate", { taskId: "2", status: "in_progress" });
|
await harness.execTool("TaskUpdate", {
|
||||||
await harness.execTool("TaskUpdate", { taskId: "3", add_blocked_by: ["1"] });
|
taskId: "2",
|
||||||
await harness.execTool("TaskUpdate", { taskId: "4", add_blocked_by: ["2", "3"] });
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
await harness.execTool("TaskUpdate", {
|
||||||
|
taskId: "3",
|
||||||
|
add_blocked_by: ["1"],
|
||||||
|
});
|
||||||
|
await harness.execTool("TaskUpdate", {
|
||||||
|
taskId: "4",
|
||||||
|
add_blocked_by: ["2", "3"],
|
||||||
|
});
|
||||||
|
|
||||||
const result = await harness.execTool("TaskList", {});
|
const result = await harness.execTool("TaskList", {});
|
||||||
const text = result.content[0].text;
|
const text = result.content[0].text;
|
||||||
|
|
||||||
expect(text).toContain("● 4 tasks (1 in progress, 3 open)");
|
expect(text).toContain("● 4 goals (1 in progress, 3 open)");
|
||||||
expect(text).toContain("◻ #1 Design the flux capacitor");
|
expect(text).toContain("◻ #1 Design the flux capacitor");
|
||||||
expect(text).toContain("◼ #2 Acquiring plutonium");
|
expect(text).toContain("◼ #2 Acquiring plutonium");
|
||||||
expect(text).toContain("◻ #3 Install flux capacitor in DeLorean › subtask of #1 › blocked by #1");
|
expect(text).toContain(
|
||||||
expect(text).toContain("◻ #4 Test time travel at 88 mph › blocked by #2, #3");
|
"◻ #3 Install flux capacitor in DeLorean › subtask of #1 › blocked by #1",
|
||||||
|
);
|
||||||
|
expect(text).toContain(
|
||||||
|
"◻ #4 Test time travel at 88 mph › blocked by #2, #3",
|
||||||
|
);
|
||||||
expect(text).not.toContain("[ACTIVE]");
|
expect(text).not.toContain("[ACTIVE]");
|
||||||
expect(text).not.toContain("[PENDING]");
|
expect(text).not.toContain("[PENDING]");
|
||||||
expect(text).not.toContain("[DONE");
|
expect(text).not.toContain("[DONE");
|
||||||
expect(text).not.toContain("🛠");
|
expect(text).not.toContain("proof claim submitted");
|
||||||
expect(text).not.toContain("test:");
|
expect(text).not.toContain("test:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows TaskCreate output with metadata and compact previews", async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
const result = await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Top-level goal",
|
||||||
|
description: "Line 1\nLine 2\nLine 3",
|
||||||
|
done_criterion: "observe line a\nobserve line b",
|
||||||
|
progress_label: "Running check",
|
||||||
|
metadata: { owner: "pi", note: "short" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = result.content[0].text;
|
||||||
|
expect(text).toContain("## TaskCreate -> Task #1: Top-level goal");
|
||||||
|
expect(text).toContain("### Metadata");
|
||||||
|
expect(text).toContain("- Metadata keys: 2");
|
||||||
|
expect(text).toContain("### Done criterion");
|
||||||
|
expect(text).toContain("### Description");
|
||||||
|
expect(text).toContain("### Progress label");
|
||||||
|
expect(text).toContain("### Metadata preview");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows TaskUpdate output with changed fields and previews", async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Top-level goal",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await harness.execTool("TaskUpdate", {
|
||||||
|
taskId: "1",
|
||||||
|
status: "in_progress",
|
||||||
|
progress_label: "Running check",
|
||||||
|
metadata: { owner: "pi" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = result.content[0].text;
|
||||||
|
expect(text).toContain("## TaskUpdate -> Task #1: Top-level goal");
|
||||||
|
expect(text).toContain(
|
||||||
|
"- Updated fields: status, progress_label, metadata",
|
||||||
|
);
|
||||||
|
expect(text).toContain("- status: pending -> in_progress");
|
||||||
|
expect(text).toContain("- progress_label: (missing) -> Running check");
|
||||||
|
expect(text).toContain("### Metadata patch");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows completed subtasks without proof-lane clutter", async () => {
|
it("shows completed subtasks without proof-lane clutter", async () => {
|
||||||
const harness = makeHarness();
|
const harness = makeHarness();
|
||||||
await harness.execTool("TaskCreate", {
|
await harness.execTool("TaskCreate", {
|
||||||
@@ -91,9 +172,141 @@ describe("TaskList", () => {
|
|||||||
const result = await harness.execTool("TaskList", {});
|
const result = await harness.execTool("TaskList", {});
|
||||||
const text = result.content[0].text;
|
const text = result.content[0].text;
|
||||||
|
|
||||||
expect(text).toContain("● 2 tasks (1 done, 1 open)");
|
expect(text).toContain("● 2 goals (1 done hidden, 1 open)");
|
||||||
expect(text).toContain("✔ #2 Finished checklist item › subtask of #1");
|
expect(text).toContain("◻ #1 Top-level goal");
|
||||||
|
expect(text).not.toContain("#2 Finished checklist item");
|
||||||
expect(text).not.toContain("[DONE");
|
expect(text).not.toContain("[DONE");
|
||||||
expect(text).not.toContain("🛠");
|
expect(text).not.toContain("proof claim submitted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps persisted completed tasks on startup but hides them from the collapsed list", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-proof-tasks-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const taskPath = join(dir, "tasks.json");
|
||||||
|
process.env.PI_TASKS = taskPath;
|
||||||
|
|
||||||
|
const seeded = new TaskStore(taskPath);
|
||||||
|
seeded.create("Finished work", "Desc", "done");
|
||||||
|
seeded.complete("1");
|
||||||
|
|
||||||
|
const harness = makeHarness();
|
||||||
|
await harness.trigger(
|
||||||
|
"before_agent_start",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
ui: { setWidget() {}, setStatus() {} },
|
||||||
|
sessionManager: { getSessionId: () => "session-test" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await harness.execTool("TaskList", {});
|
||||||
|
expect(result.content[0].text).toContain("● 1 goals (1 done hidden)");
|
||||||
|
expect(result.content[0].text).toContain(
|
||||||
|
"No open tasks. Completed tasks are hidden by default.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const reloaded = new TaskStore(taskPath);
|
||||||
|
expect(reloaded.get("1")?.status).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps persisted completed tasks on startup even when one open goal remains", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-proof-tasks-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const taskPath = join(dir, "tasks.json");
|
||||||
|
process.env.PI_TASKS = taskPath;
|
||||||
|
|
||||||
|
const seeded = new TaskStore(taskPath);
|
||||||
|
seeded.create("Open goal", "Desc", "done");
|
||||||
|
seeded.create("Finished work", "Desc", "done", undefined, undefined, "1");
|
||||||
|
seeded.complete("2");
|
||||||
|
|
||||||
|
const harness = makeHarness();
|
||||||
|
await harness.trigger(
|
||||||
|
"before_agent_start",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
ui: { setWidget() {}, setStatus() {} },
|
||||||
|
sessionManager: { getSessionId: () => "session-test" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await harness.execTool("TaskList", {});
|
||||||
|
const text = result.content[0].text;
|
||||||
|
expect(text).toContain("● 2 goals (1 done hidden, 1 open)");
|
||||||
|
expect(text).toContain("◻ #1 Open goal");
|
||||||
|
expect(text).not.toContain("Finished work");
|
||||||
|
|
||||||
|
const reloaded = new TaskStore(taskPath);
|
||||||
|
expect(reloaded.get("2")?.status).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps completed tasks persisted by default across later turns", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-proof-tasks-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const taskPath = join(dir, "tasks.json");
|
||||||
|
process.env.PI_TASKS = taskPath;
|
||||||
|
|
||||||
|
const harness = makeHarness();
|
||||||
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Persistent completed goal",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Checklist item",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
parentId: "1",
|
||||||
|
});
|
||||||
|
await harness.execTool("TaskUpdate", { taskId: "2", status: "completed" });
|
||||||
|
|
||||||
|
for (let turn = 0; turn < 8; turn++) {
|
||||||
|
await harness.trigger("turn_start", {}, {
|
||||||
|
ui: { setWidget() {}, setStatus() {} },
|
||||||
|
sessionManager: { getSessionId: () => "session-test" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloaded = new TaskStore(taskPath);
|
||||||
|
expect(reloaded.get("2")?.status).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores named PI_TASKS lists inside the repo .pi/tasks directory", async () => {
|
||||||
|
process.env.PI_TASKS = `named-${Date.now()}`;
|
||||||
|
const expectedPath = join(
|
||||||
|
process.cwd(),
|
||||||
|
".pi",
|
||||||
|
"tasks",
|
||||||
|
`${process.env.PI_TASKS}.json`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
rmSync(expectedPath);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
rmSync(expectedPath + ".lock");
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
rmSync(expectedPath + ".tmp");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const harness = makeHarness();
|
||||||
|
await harness.execTool("TaskCreate", {
|
||||||
|
subject: "Repo local task",
|
||||||
|
description: "Desc",
|
||||||
|
done_criterion: "done",
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloaded = new TaskStore(expectedPath);
|
||||||
|
expect(reloaded.get("1")?.subject).toBe("Repo local task");
|
||||||
|
|
||||||
|
try {
|
||||||
|
rmSync(expectedPath);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
rmSync(expectedPath + ".lock");
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
rmSync(expectedPath + ".tmp");
|
||||||
|
} catch {}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+91
-28
@@ -1,5 +1,5 @@
|
|||||||
import { readFileSync, rmSync } from "node:fs";
|
import { readFileSync, rmSync } from "node:fs";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { TaskStore } from "../src/task-store.js";
|
import { TaskStore } from "../src/task-store.js";
|
||||||
@@ -7,7 +7,14 @@ import { TaskStore } from "../src/task-store.js";
|
|||||||
// Helper: create a subtask, which can be ticked off directly.
|
// Helper: create a subtask, which can be ticked off directly.
|
||||||
function createSubtask(store: TaskStore, subject: string) {
|
function createSubtask(store: TaskStore, subject: string) {
|
||||||
const parent = store.create(`${subject} parent`, "Desc", "done criterion");
|
const parent = store.create(`${subject} parent`, "Desc", "done criterion");
|
||||||
return store.create(subject, "Desc", "done criterion", undefined, undefined, parent.id);
|
return store.create(
|
||||||
|
subject,
|
||||||
|
"Desc",
|
||||||
|
"done criterion",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
parent.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TaskStore (in-memory)", () => {
|
describe("TaskStore (in-memory)", () => {
|
||||||
@@ -30,7 +37,9 @@ describe("TaskStore (in-memory)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates tasks with optional fields", () => {
|
it("creates tasks with optional fields", () => {
|
||||||
const t = store.create("Task", "Desc", "done criterion", "Running task", { key: "value" });
|
const t = store.create("Task", "Desc", "done criterion", "Running task", {
|
||||||
|
key: "value",
|
||||||
|
});
|
||||||
|
|
||||||
expect(t.progress_label).toBe("Running task");
|
expect(t.progress_label).toBe("Running task");
|
||||||
expect(t.metadata).toEqual({ key: "value" });
|
expect(t.metadata).toEqual({ key: "value" });
|
||||||
@@ -54,12 +63,14 @@ describe("TaskStore (in-memory)", () => {
|
|||||||
store.create("Task 2", "Desc", "done");
|
store.create("Task 2", "Desc", "done");
|
||||||
|
|
||||||
const tasks = store.list();
|
const tasks = store.list();
|
||||||
expect(tasks.map(t => t.id)).toEqual(["1", "2", "3"]);
|
expect(tasks.map((t) => t.id)).toEqual(["1", "2", "3"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates task status", () => {
|
it("updates task status", () => {
|
||||||
store.create("Test", "Desc", "done");
|
store.create("Test", "Desc", "done");
|
||||||
const { task, changedFields } = store.update("1", { status: "in_progress" });
|
const { task, changedFields } = store.update("1", {
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
|
||||||
expect(task!.status).toBe("in_progress");
|
expect(task!.status).toBe("in_progress");
|
||||||
expect(changedFields).toEqual(["status"]);
|
expect(changedFields).toEqual(["status"]);
|
||||||
@@ -140,7 +151,7 @@ describe("TaskStore (in-memory)", () => {
|
|||||||
store.update("1", { add_blocks: ["2"] }); // duplicate
|
store.update("1", { add_blocks: ["2"] }); // duplicate
|
||||||
|
|
||||||
const t1 = store.get("1")!;
|
const t1 = store.get("1")!;
|
||||||
expect(t1.blocks.filter(id => id === "2")).toHaveLength(1);
|
expect(t1.blocks.filter((id) => id === "2")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up dependency edges on deletion", () => {
|
it("cleans up dependency edges on deletion", () => {
|
||||||
@@ -175,23 +186,31 @@ describe("TaskStore (in-memory)", () => {
|
|||||||
|
|
||||||
it("blocks TaskUpdate(status=completed) for top-level tasks", () => {
|
it("blocks TaskUpdate(status=completed) for top-level tasks", () => {
|
||||||
store.create("Goal", "Desc", "done");
|
store.create("Goal", "Desc", "done");
|
||||||
expect(() => store.update("1", { status: "completed" })).toThrow("Top-level task #1 requires proof");
|
expect(() => store.update("1", { status: "completed" })).toThrow(
|
||||||
|
"Top-level task #1 requires proof",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps top-level completion gated even after proof evidence exists", () => {
|
it("keeps top-level completion gated even after proof evidence exists", () => {
|
||||||
store.create("Escalated", "Desc", "done");
|
store.create("Escalated", "Desc", "done");
|
||||||
store.update("1", { metadata: { lgtm_evidence: "literal output" } });
|
store.update("1", { metadata: { lgtm_evidence: "literal output" } });
|
||||||
expect(() => store.update("1", { status: "completed" })).toThrow("TaskClaimDone");
|
expect(() => store.update("1", { status: "completed" })).toThrow(
|
||||||
|
"TaskClaimDone",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects changing parentId after creation", () => {
|
it("rejects changing parentId after creation", () => {
|
||||||
store.create("Parent", "Desc", "done");
|
store.create("Parent", "Desc", "done");
|
||||||
store.create("Child", "Desc", "done");
|
store.create("Child", "Desc", "done");
|
||||||
expect(() => store.update("2", { parentId: "1" })).toThrow("parentId is creation-only");
|
expect(() => store.update("2", { parentId: "1" })).toThrow(
|
||||||
|
"parentId is creation-only",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns not found for update on non-existent task", () => {
|
it("returns not found for update on non-existent task", () => {
|
||||||
const { task, changedFields } = store.update("999", { status: "in_progress" });
|
const { task, changedFields } = store.update("999", {
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
expect(task).toBeUndefined();
|
expect(task).toBeUndefined();
|
||||||
expect(changedFields).toEqual([]);
|
expect(changedFields).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -220,7 +239,10 @@ describe("TaskStore (in-memory)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates tasks with metadata via TaskCreate", () => {
|
it("creates tasks with metadata via TaskCreate", () => {
|
||||||
const t = store.create("With meta", "Desc", "done", undefined, { pr: "123", reviewer: "alice" });
|
const t = store.create("With meta", "Desc", "done", undefined, {
|
||||||
|
pr: "123",
|
||||||
|
reviewer: "alice",
|
||||||
|
});
|
||||||
expect(t.metadata).toEqual({ pr: "123", reviewer: "alice" });
|
expect(t.metadata).toEqual({ pr: "123", reviewer: "alice" });
|
||||||
|
|
||||||
const retrieved = store.get("1")!;
|
const retrieved = store.get("1")!;
|
||||||
@@ -266,27 +288,35 @@ describe("TaskStore (in-memory)", () => {
|
|||||||
|
|
||||||
it("updates progress_label field", () => {
|
it("updates progress_label field", () => {
|
||||||
store.create("Test", "Desc", "done");
|
store.create("Test", "Desc", "done");
|
||||||
const { changedFields } = store.update("1", { progress_label: "Running tests" });
|
const { changedFields } = store.update("1", {
|
||||||
|
progress_label: "Running tests",
|
||||||
|
});
|
||||||
expect(changedFields).toContain("progress_label");
|
expect(changedFields).toContain("progress_label");
|
||||||
expect(store.get("1")!.progress_label).toBe("Running tests");
|
expect(store.get("1")!.progress_label).toBe("Running tests");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates description field", () => {
|
it("updates description field", () => {
|
||||||
store.create("Test", "Original desc", "done");
|
store.create("Test", "Original desc", "done");
|
||||||
const { changedFields } = store.update("1", { description: "Updated desc" });
|
const { changedFields } = store.update("1", {
|
||||||
|
description: "Updated desc",
|
||||||
|
});
|
||||||
expect(changedFields).toContain("description");
|
expect(changedFields).toContain("description");
|
||||||
expect(store.get("1")!.description).toBe("Updated desc");
|
expect(store.get("1")!.description).toBe("Updated desc");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates done_criterion field", () => {
|
it("updates done_criterion field", () => {
|
||||||
store.create("Test", "Desc", "original criterion");
|
store.create("Test", "Desc", "original criterion");
|
||||||
const { changedFields } = store.update("1", { done_criterion: "updated criterion" });
|
const { changedFields } = store.update("1", {
|
||||||
|
done_criterion: "updated criterion",
|
||||||
|
});
|
||||||
expect(changedFields).toContain("done_criterion");
|
expect(changedFields).toContain("done_criterion");
|
||||||
expect(store.get("1")!.done_criterion).toBe("updated criterion");
|
expect(store.get("1")!.done_criterion).toBe("updated criterion");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty changedFields when updating non-existent task", () => {
|
it("returns empty changedFields when updating non-existent task", () => {
|
||||||
const { task, changedFields, warnings } = store.update("999", { status: "in_progress" });
|
const { task, changedFields, warnings } = store.update("999", {
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
expect(task).toBeUndefined();
|
expect(task).toBeUndefined();
|
||||||
expect(changedFields).toEqual([]);
|
expect(changedFields).toEqual([]);
|
||||||
expect(warnings).toEqual([]);
|
expect(warnings).toEqual([]);
|
||||||
@@ -354,27 +384,48 @@ describe("TaskStore (in-memory)", () => {
|
|||||||
store.update("3", { status: "in_progress" });
|
store.update("3", { status: "in_progress" });
|
||||||
|
|
||||||
const tasks = store.list();
|
const tasks = store.list();
|
||||||
const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
|
const statusOrder: Record<string, number> = {
|
||||||
|
pending: 0,
|
||||||
|
in_progress: 1,
|
||||||
|
completed: 2,
|
||||||
|
};
|
||||||
const sorted = [...tasks].sort((a, b) => {
|
const sorted = [...tasks].sort((a, b) => {
|
||||||
const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
|
const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
|
||||||
if (so !== 0) return so;
|
if (so !== 0) return so;
|
||||||
return Number(a.id) - Number(b.id);
|
return Number(a.id) - Number(b.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sorted.map(t => t.id)).toEqual(["1", "4", "3", "2"]);
|
expect(sorted.map((t) => t.id)).toEqual(["1", "4", "3", "2"]);
|
||||||
expect(sorted.map(t => t.status)).toEqual(["pending", "pending", "in_progress", "completed"]);
|
expect(sorted.map((t) => t.status)).toEqual([
|
||||||
|
"pending",
|
||||||
|
"pending",
|
||||||
|
"in_progress",
|
||||||
|
"completed",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskStore (file-backed)", () => {
|
describe("TaskStore (file-backed)", () => {
|
||||||
const testListId = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const testListId = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const tasksDir = join(homedir(), ".pi", "tasks");
|
const tasksDir = join(process.cwd(), ".pi", "tasks");
|
||||||
const filePath = join(tasksDir, `${testListId}.json`);
|
const filePath = join(tasksDir, `${testListId}.json`);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
try { rmSync(filePath); } catch { /* */ }
|
try {
|
||||||
try { rmSync(filePath + ".lock"); } catch { /* */ }
|
rmSync(filePath);
|
||||||
try { rmSync(filePath + ".tmp"); } catch { /* */ }
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rmSync(filePath + ".lock");
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rmSync(filePath + ".tmp");
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists tasks to disk", () => {
|
it("persists tasks to disk", () => {
|
||||||
@@ -421,9 +472,9 @@ describe("TaskStore (file-backed)", () => {
|
|||||||
const store2 = new TaskStore(testListId);
|
const store2 = new TaskStore(testListId);
|
||||||
const tasks = store2.list();
|
const tasks = store2.list();
|
||||||
expect(tasks).toHaveLength(3);
|
expect(tasks).toHaveLength(3);
|
||||||
expect(tasks.map(t => t.id)).toContain("1");
|
expect(tasks.map((t) => t.id)).toContain("1");
|
||||||
expect(tasks.map(t => t.id)).toContain("2");
|
expect(tasks.map((t) => t.id)).toContain("2");
|
||||||
expect(tasks.map(t => t.id)).toContain("3");
|
expect(tasks.map((t) => t.id)).toContain("3");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists ID counter across instances", () => {
|
it("persists ID counter across instances", () => {
|
||||||
@@ -441,9 +492,21 @@ describe("TaskStore (absolute path)", () => {
|
|||||||
const absFilePath = join(tmpdir(), `pi-tasks-test-${Date.now()}.json`);
|
const absFilePath = join(tmpdir(), `pi-tasks-test-${Date.now()}.json`);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
try { rmSync(absFilePath); } catch { /* */ }
|
try {
|
||||||
try { rmSync(absFilePath + ".lock"); } catch { /* */ }
|
rmSync(absFilePath);
|
||||||
try { rmSync(absFilePath + ".tmp"); } catch { /* */ }
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rmSync(absFilePath + ".lock");
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rmSync(absFilePath + ".tmp");
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts absolute path and persists tasks", () => {
|
it("accepts absolute path and persists tasks", () => {
|
||||||
|
|||||||
+23
-20
@@ -73,7 +73,7 @@ describe("TaskWidget", () => {
|
|||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
expect(lines).toHaveLength(2); // header + 1 task
|
expect(lines).toHaveLength(2); // header + 1 task
|
||||||
expect(lines[0]).toContain("1 tasks");
|
expect(lines[0]).toContain("1 goals");
|
||||||
expect(lines[0]).toContain("1 open");
|
expect(lines[0]).toContain("1 open");
|
||||||
expect(lines[1]).toContain("◻");
|
expect(lines[1]).toContain("◻");
|
||||||
expect(lines[1]).toContain("Do something");
|
expect(lines[1]).toContain("Do something");
|
||||||
@@ -90,28 +90,32 @@ describe("TaskWidget", () => {
|
|||||||
expect(lines[1]).toContain("Working on it");
|
expect(lines[1]).toContain("Working on it");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders completed tasks with ✔ icon and strikethrough", () => {
|
it("hides the widget when only completed tasks remain", () => {
|
||||||
store.create("Done task", "Desc", "done");
|
store.create("Done task", "Desc", "done");
|
||||||
store.complete("1");
|
store.complete("1");
|
||||||
widget.update();
|
widget.update();
|
||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
expect(lines[1]).toContain("✔");
|
expect(lines).toEqual([]);
|
||||||
expect(lines[1]).toContain("~~#1 Done task~~");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render proof badges on collapsed rows", () => {
|
it("does not render proof badges on collapsed rows", () => {
|
||||||
|
store.create("Open task", "Desc", "done");
|
||||||
store.create("Done task", "Desc", "done");
|
store.create("Done task", "Desc", "done");
|
||||||
store.update("1", {
|
store.update("2", {
|
||||||
metadata: { robot_review_observations: ["Observed output drift on seed 2"], lgtm_evidence: "verbatim output" },
|
metadata: {
|
||||||
|
robot_review_observations: ["Observed output drift on seed 2"],
|
||||||
|
lgtm_evidence: "verbatim output",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
store.complete("1");
|
store.complete("2");
|
||||||
widget.update();
|
widget.update();
|
||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
|
expect(lines[1]).toContain("Open task");
|
||||||
expect(lines[1]).not.toContain("[");
|
expect(lines[1]).not.toContain("[");
|
||||||
expect(lines[1]).not.toContain("🛠");
|
expect(lines[1]).not.toContain("robot_review_observations");
|
||||||
expect(lines[1]).not.toContain("🤖");
|
expect(lines[1]).not.toContain("lgtm_evidence");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders active tasks with spinner icon", () => {
|
it("renders active tasks with spinner icon", () => {
|
||||||
@@ -133,7 +137,7 @@ describe("TaskWidget", () => {
|
|||||||
widget.update();
|
widget.update();
|
||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
const blockedLine = lines.find(l => l.includes("Blocked"));
|
const blockedLine = lines.find((l) => l.includes("Blocked"));
|
||||||
// blocked-by suffix is only added via dim theme helper, which in mock is identity
|
// blocked-by suffix is only added via dim theme helper, which in mock is identity
|
||||||
// So we should see the raw text. Check for the relevant subject line having blocked-by info
|
// So we should see the raw text. Check for the relevant subject line having blocked-by info
|
||||||
expect(blockedLine).toContain("blocked by #1");
|
expect(blockedLine).toContain("blocked by #1");
|
||||||
@@ -147,7 +151,7 @@ describe("TaskWidget", () => {
|
|||||||
widget.update();
|
widget.update();
|
||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
const blockedLine = lines.find(l => l.includes("Blocked"));
|
const blockedLine = lines.find((l) => l.includes("Blocked"));
|
||||||
expect(blockedLine).not.toContain("blocked by");
|
expect(blockedLine).not.toContain("blocked by");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,8 +164,8 @@ describe("TaskWidget", () => {
|
|||||||
widget.update();
|
widget.update();
|
||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
expect(lines[0]).toContain("3 tasks");
|
expect(lines[0]).toContain("3 goals");
|
||||||
expect(lines[0]).toContain("1 done");
|
expect(lines[0]).toContain("1 done hidden");
|
||||||
expect(lines[0]).toContain("1 in progress");
|
expect(lines[0]).toContain("1 in progress");
|
||||||
expect(lines[0]).toContain("1 open");
|
expect(lines[0]).toContain("1 open");
|
||||||
});
|
});
|
||||||
@@ -183,9 +187,9 @@ describe("TaskWidget", () => {
|
|||||||
widget.update();
|
widget.update();
|
||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
// header + 5 visible tasks + "...and 10 more"
|
// header + 5 visible tasks + "...and 10 more open"
|
||||||
expect(lines).toHaveLength(7);
|
expect(lines).toHaveLength(7);
|
||||||
expect(lines[6]).toContain("10 more");
|
expect(lines[6]).toContain("10 more open");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks token usage for active tasks", () => {
|
it("tracks token usage for active tasks", () => {
|
||||||
@@ -197,7 +201,7 @@ describe("TaskWidget", () => {
|
|||||||
widget.addTokenUsage(500, 300);
|
widget.addTokenUsage(500, 300);
|
||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
const activeLine = lines.find(l => l.includes("Running…"));
|
const activeLine = lines.find((l) => l.includes("Running…"));
|
||||||
expect(activeLine).toContain("↑ 1.5k");
|
expect(activeLine).toContain("↑ 1.5k");
|
||||||
expect(activeLine).toContain("↓ 800");
|
expect(activeLine).toContain("↓ 800");
|
||||||
});
|
});
|
||||||
@@ -227,10 +231,9 @@ describe("TaskWidget", () => {
|
|||||||
store.complete("1");
|
store.complete("1");
|
||||||
widget.update();
|
widget.update();
|
||||||
|
|
||||||
// Should render as completed, not active
|
// Completed tasks are hidden from the default widget
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
expect(lines[1]).toContain("✔");
|
expect(lines).toEqual([]);
|
||||||
expect(lines[1]).toContain("~~#1 Task~~");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports multiple active tasks simultaneously", () => {
|
it("supports multiple active tasks simultaneously", () => {
|
||||||
@@ -290,7 +293,7 @@ describe("TaskWidget", () => {
|
|||||||
widget.update();
|
widget.update();
|
||||||
|
|
||||||
const lines = renderWidget(ui.state);
|
const lines = renderWidget(ui.state);
|
||||||
const activeLine = lines.find(l => l.includes("Working…"));
|
const activeLine = lines.find((l) => l.includes("Working…"));
|
||||||
expect(activeLine).toContain("5s");
|
expect(activeLine).toContain("5s");
|
||||||
expect(activeLine).not.toContain("↑");
|
expect(activeLine).not.toContain("↑");
|
||||||
expect(activeLine).not.toContain("↓");
|
expect(activeLine).not.toContain("↓");
|
||||||
|
|||||||
Reference in New Issue
Block a user