@bumpyclock/pi-tasque 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -140
- package/package.json +24 -6
- package/src/bridge/bridge-tool.ts +10 -13
- package/src/bridge/types.ts +11 -12
- package/src/durable-tasks/bulk-contract.ts +176 -0
- package/src/durable-tasks/handoff-guard.ts +329 -0
- package/src/durable-tasks/project.ts +71 -0
- package/src/durable-tasks/status.ts +21 -3
- package/src/durable-tasks/task-tool.ts +764 -0
- package/src/durable-tasks/tools-bulk.ts +144 -0
- package/src/durable-tasks/tools-change.ts +107 -51
- package/src/durable-tasks/tools-claim.ts +10 -13
- package/src/durable-tasks/tools-query.ts +57 -25
- package/src/durable-tasks/tools-spec.ts +230 -0
- package/src/durable-tasks/tools-tree-create.ts +227 -0
- package/src/guidelines/internal-tools.ts +33 -0
- package/src/guidelines/task.ts +5 -0
- package/src/guidelines/todo.ts +5 -0
- package/src/index.ts +2 -13
- package/src/session-todos/state/replay.ts +10 -13
- package/src/session-todos/todo-overlay.ts +1 -1
- package/src/session-todos/todo.ts +10 -11
- package/src/session-todos/tool/types.ts +7 -7
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public contract types and validation for `bulk` and `create_tree` task actions.
|
|
3
|
+
*
|
|
4
|
+
* Defines the input shapes, validates them eagerly, and exports result type
|
|
5
|
+
* scaffolds used by the separate bulk and create-tree executors. Dispatch
|
|
6
|
+
* happens in the unified task tool.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── Bulk item contract ─────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export const BULK_ITEM_ACTIONS = [
|
|
12
|
+
"start",
|
|
13
|
+
"finish",
|
|
14
|
+
"reopen",
|
|
15
|
+
"defer",
|
|
16
|
+
"note",
|
|
17
|
+
"mark_planned",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type BulkItemAction = (typeof BULK_ITEM_ACTIONS)[number];
|
|
21
|
+
|
|
22
|
+
/** A single lifecycle/note mutation inside a `bulk` call. */
|
|
23
|
+
export interface BulkItem {
|
|
24
|
+
/** Lifecycle or note action to run on the target task. */
|
|
25
|
+
readonly action: BulkItemAction;
|
|
26
|
+
/** Durable task id (e.g. "tsq-3"). */
|
|
27
|
+
readonly task: string;
|
|
28
|
+
/** Note/reason text — required for `note`, optional for `finish`/`defer`. */
|
|
29
|
+
readonly because?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Result shape bulk executors will produce (tsq-6.2). */
|
|
33
|
+
export interface BulkResult {
|
|
34
|
+
readonly completed: readonly string[];
|
|
35
|
+
readonly failed?: { readonly task: string; readonly error: string };
|
|
36
|
+
readonly skipped: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Create-tree node contract ──────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** A node in the `create_tree` input. Recursive via `children`. */
|
|
42
|
+
export interface CreateTreeNode {
|
|
43
|
+
readonly title: string;
|
|
44
|
+
readonly kind: string;
|
|
45
|
+
readonly priority: number;
|
|
46
|
+
readonly description?: string;
|
|
47
|
+
/** Mark this node as already planned. Contradicts `needsPlan`. */
|
|
48
|
+
readonly planned?: boolean;
|
|
49
|
+
/** Mark this node as needing planning. Contradicts `planned`. */
|
|
50
|
+
readonly needsPlan?: boolean;
|
|
51
|
+
readonly children?: readonly CreateTreeNode[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Result shape tree executors will produce (tsq-6.3). */
|
|
55
|
+
export interface CreateTreeResult {
|
|
56
|
+
readonly created: readonly { readonly id: string; readonly title: string }[];
|
|
57
|
+
readonly failed?: { readonly title: string; readonly error: string };
|
|
58
|
+
readonly skipped: readonly { readonly title: string }[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Validation ─────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
type Ok = { readonly ok: true };
|
|
64
|
+
type Fail = { readonly ok: false; readonly message: string };
|
|
65
|
+
type ValidationResult = Ok | Fail;
|
|
66
|
+
|
|
67
|
+
const OK: Ok = { ok: true } as const;
|
|
68
|
+
|
|
69
|
+
function fail(message: string): Fail {
|
|
70
|
+
return { ok: false, message };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate a `bulk` action's `items` array.
|
|
75
|
+
*
|
|
76
|
+
* Rejects: missing/empty array, missing action/task on any item,
|
|
77
|
+
* unsupported action values, missing `because` when action is `note`.
|
|
78
|
+
*/
|
|
79
|
+
export function validateBulkItems(items: unknown): ValidationResult {
|
|
80
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
81
|
+
return fail("items must be a non-empty array");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < items.length; i++) {
|
|
85
|
+
const item = items[i] as Record<string, unknown>;
|
|
86
|
+
const prefix = `items[${i}]`;
|
|
87
|
+
|
|
88
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
89
|
+
return fail(`${prefix} must be an object`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const action = item.action;
|
|
93
|
+
if (typeof action !== "string" || action.trim().length === 0) {
|
|
94
|
+
return fail(`${prefix}.action is required`);
|
|
95
|
+
}
|
|
96
|
+
if (!(BULK_ITEM_ACTIONS as readonly string[]).includes(action)) {
|
|
97
|
+
return fail(
|
|
98
|
+
`${prefix}.action "${action}" is not supported; use one of: ${BULK_ITEM_ACTIONS.join(", ")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const task = item.task;
|
|
103
|
+
if (typeof task !== "string" || task.trim().length === 0) {
|
|
104
|
+
return fail(`${prefix}.task is required`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// `note` requires `because`
|
|
108
|
+
if (action === "note") {
|
|
109
|
+
const because = item.because;
|
|
110
|
+
if (typeof because !== "string" || because.trim().length === 0) {
|
|
111
|
+
return fail(`${prefix}.because is required when action is "note"`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return OK;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate a `create_tree` action's `root` node, recursively.
|
|
121
|
+
*
|
|
122
|
+
* Rejects: missing title/kind/priority, contradictory planned+needsPlan,
|
|
123
|
+
* empty children arrays.
|
|
124
|
+
*/
|
|
125
|
+
export function validateCreateTreeNode(
|
|
126
|
+
node: unknown,
|
|
127
|
+
path = "root",
|
|
128
|
+
): ValidationResult {
|
|
129
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) {
|
|
130
|
+
return fail(`${path} must be an object`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const n = node as Record<string, unknown>;
|
|
134
|
+
|
|
135
|
+
// Required fields
|
|
136
|
+
if (typeof n.title !== "string" || n.title.trim().length === 0) {
|
|
137
|
+
return fail(`${path}.title is required`);
|
|
138
|
+
}
|
|
139
|
+
if (typeof n.kind !== "string" || n.kind.trim().length === 0) {
|
|
140
|
+
return fail(`${path}.kind is required`);
|
|
141
|
+
}
|
|
142
|
+
if (typeof n.priority !== "number" || !Number.isInteger(n.priority)) {
|
|
143
|
+
return fail(`${path}.priority is required`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (n.description !== undefined && typeof n.description !== "string") {
|
|
147
|
+
return fail(`${path}.description must be a string`);
|
|
148
|
+
}
|
|
149
|
+
if (n.planned !== undefined && typeof n.planned !== "boolean") {
|
|
150
|
+
return fail(`${path}.planned must be a boolean`);
|
|
151
|
+
}
|
|
152
|
+
if (n.needsPlan !== undefined && typeof n.needsPlan !== "boolean") {
|
|
153
|
+
return fail(`${path}.needsPlan must be a boolean`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Contradictory planning flags
|
|
157
|
+
if (n.planned === true && n.needsPlan === true) {
|
|
158
|
+
return fail(`${path}: planned and needsPlan cannot both be true`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Recursive children validation
|
|
162
|
+
if (n.children !== undefined) {
|
|
163
|
+
if (!Array.isArray(n.children) || n.children.length === 0) {
|
|
164
|
+
return fail(`${path}.children must be a non-empty array when provided`);
|
|
165
|
+
}
|
|
166
|
+
for (let i = 0; i < n.children.length; i++) {
|
|
167
|
+
const childResult = validateCreateTreeNode(
|
|
168
|
+
n.children[i],
|
|
169
|
+
`${path}.children[${i}]`,
|
|
170
|
+
);
|
|
171
|
+
if (!childResult.ok) return childResult;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return OK;
|
|
176
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only handoff readiness checker.
|
|
3
|
+
*
|
|
4
|
+
* Collects session todo state and linked durable task statuses to produce a
|
|
5
|
+
* structured ready/not-ready report. Never mutates session todos or durable
|
|
6
|
+
* task state. Uses only read-only `show` CLI calls for linked tasks.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { deriveTaskLinks } from "../bridge/link-store.js";
|
|
13
|
+
import { selectVisibleTasks } from "../session-todos/state/selectors.js";
|
|
14
|
+
import { getState } from "../session-todos/state/store.js";
|
|
15
|
+
import type { Task } from "../session-todos/tool/types.js";
|
|
16
|
+
import { resolveProjectRoot } from "./project.js";
|
|
17
|
+
import { TsqCommandError, TsqProcessError, runTsqJson } from "./runner.js";
|
|
18
|
+
import type { TsqShowData, TsqTaskStatus } from "./types.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Status classification
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Durable task statuses that count as "ready" (work complete). */
|
|
25
|
+
const READY_STATUSES: ReadonlySet<TsqTaskStatus> = new Set(["closed"]);
|
|
26
|
+
|
|
27
|
+
/** Durable task statuses that count as "warning" (not blocking). */
|
|
28
|
+
const WARNING_STATUSES: ReadonlySet<TsqTaskStatus> = new Set(["canceled"]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Classify a durable task status for handoff readiness.
|
|
32
|
+
*
|
|
33
|
+
* - `"ready"` — closed, work done.
|
|
34
|
+
* - `"blocker"` — open/in_progress/blocked/deferred/unknown/missing.
|
|
35
|
+
* - `"warning"` — canceled (notable but not blocking).
|
|
36
|
+
*/
|
|
37
|
+
export function classifyDurableStatus(
|
|
38
|
+
status: string | undefined | null,
|
|
39
|
+
): "ready" | "blocker" | "warning" {
|
|
40
|
+
if (status == null || status.trim().length === 0) return "blocker";
|
|
41
|
+
if (READY_STATUSES.has(status)) return "ready";
|
|
42
|
+
if (WARNING_STATUSES.has(status)) return "warning";
|
|
43
|
+
return "blocker";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Read-error classification
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Error codes from linked `tsq show` that are *actionable* (user can fix them)
|
|
52
|
+
* rather than infrastructure failures. These become `ok:true, ready:false` with
|
|
53
|
+
* structured `readErrors`, not `ok:false`.
|
|
54
|
+
*/
|
|
55
|
+
const ACTIONABLE_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
56
|
+
"not_found",
|
|
57
|
+
"task_not_found",
|
|
58
|
+
"validation_error",
|
|
59
|
+
"read_error",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify a tsq CLI error code from a linked `show` call.
|
|
64
|
+
*
|
|
65
|
+
* - `"actionable"` — not-found, validation, read-envelope → ok:true, ready:false
|
|
66
|
+
* - `"internal"` — process/timeout/abort/invalid-JSON → ok:false
|
|
67
|
+
*/
|
|
68
|
+
export function classifyReadError(code: string): "actionable" | "internal" {
|
|
69
|
+
return ACTIONABLE_ERROR_CODES.has(code.toLowerCase())
|
|
70
|
+
? "actionable"
|
|
71
|
+
: "internal";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Result types
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export interface HandoffTodoBlocker {
|
|
79
|
+
readonly todoId: number;
|
|
80
|
+
readonly subject: string;
|
|
81
|
+
readonly status: string;
|
|
82
|
+
readonly reason: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface HandoffLinkedBlocker {
|
|
86
|
+
readonly todoId: number;
|
|
87
|
+
readonly tsqId: string;
|
|
88
|
+
readonly status: string;
|
|
89
|
+
readonly classification: "blocker" | "warning";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface HandoffReadError {
|
|
93
|
+
readonly tsqId: string;
|
|
94
|
+
readonly code: string;
|
|
95
|
+
readonly message: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface HandoffReadyResult {
|
|
99
|
+
readonly ok: true;
|
|
100
|
+
readonly ready: true;
|
|
101
|
+
readonly projectRoot?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface HandoffNotReadyResult {
|
|
105
|
+
readonly ok: true;
|
|
106
|
+
readonly ready: false;
|
|
107
|
+
readonly projectRoot?: string;
|
|
108
|
+
readonly todoBlockers?: readonly HandoffTodoBlocker[];
|
|
109
|
+
readonly linkedBlockers?: readonly HandoffLinkedBlocker[];
|
|
110
|
+
readonly linkedWarnings?: readonly HandoffLinkedBlocker[];
|
|
111
|
+
readonly readErrors?: readonly HandoffReadError[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface HandoffInternalError {
|
|
115
|
+
readonly ok: false;
|
|
116
|
+
readonly code: string;
|
|
117
|
+
readonly message: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type HandoffCheckResult =
|
|
121
|
+
| HandoffReadyResult
|
|
122
|
+
| HandoffNotReadyResult
|
|
123
|
+
| HandoffInternalError;
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Collector options
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export interface CollectHandoffOptions {
|
|
130
|
+
readonly pi: ExtensionAPI;
|
|
131
|
+
readonly cwd: string;
|
|
132
|
+
readonly signal?: AbortSignal;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Collector implementation
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Collect handoff readiness from session todo state and linked durable tasks.
|
|
141
|
+
*
|
|
142
|
+
* Read-only: never mutates session todos or durable task state.
|
|
143
|
+
* Only uses `tsq show <id>` (read-only) for linked tasks.
|
|
144
|
+
*/
|
|
145
|
+
export async function collectHandoffStatus(
|
|
146
|
+
options: CollectHandoffOptions,
|
|
147
|
+
): Promise<HandoffCheckResult> {
|
|
148
|
+
const { pi, cwd, signal } = options;
|
|
149
|
+
|
|
150
|
+
// 1. Read session todo state (snapshot, no mutation)
|
|
151
|
+
const state = getState();
|
|
152
|
+
const visibleTodos = selectVisibleTasks(state);
|
|
153
|
+
|
|
154
|
+
// 2. Collect todo blockers
|
|
155
|
+
const todoBlockers = collectTodoBlockers(visibleTodos);
|
|
156
|
+
|
|
157
|
+
// 3. Derive todo↔task links
|
|
158
|
+
const links = deriveTaskLinks(state);
|
|
159
|
+
|
|
160
|
+
// 4. If links exist, resolve project root and read linked task statuses
|
|
161
|
+
if (links.length > 0) {
|
|
162
|
+
let projectRoot: string;
|
|
163
|
+
try {
|
|
164
|
+
projectRoot = await resolveProjectRoot(
|
|
165
|
+
pi,
|
|
166
|
+
cwd,
|
|
167
|
+
signal != null ? { signal } : {},
|
|
168
|
+
);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
code: "project_resolution_error",
|
|
173
|
+
message:
|
|
174
|
+
err instanceof Error ? err.message : "Unable to resolve project root",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const linkedBlockers: HandoffLinkedBlocker[] = [];
|
|
179
|
+
const linkedWarnings: HandoffLinkedBlocker[] = [];
|
|
180
|
+
const readErrors: HandoffReadError[] = [];
|
|
181
|
+
|
|
182
|
+
for (const link of links) {
|
|
183
|
+
const result = await readLinkedTaskStatus(
|
|
184
|
+
pi,
|
|
185
|
+
projectRoot,
|
|
186
|
+
link.tsqId,
|
|
187
|
+
link.todoId,
|
|
188
|
+
signal,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (result.type === "internal_error") {
|
|
192
|
+
return { ok: false, code: result.code, message: result.message };
|
|
193
|
+
}
|
|
194
|
+
if (result.type === "read_error") {
|
|
195
|
+
readErrors.push(result.error);
|
|
196
|
+
} else if (result.type === "blocker") {
|
|
197
|
+
linkedBlockers.push(result.entry);
|
|
198
|
+
} else if (result.type === "warning") {
|
|
199
|
+
linkedWarnings.push(result.entry);
|
|
200
|
+
}
|
|
201
|
+
// "ready" → no action needed
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const hasBlockers =
|
|
205
|
+
todoBlockers.length > 0 ||
|
|
206
|
+
linkedBlockers.length > 0 ||
|
|
207
|
+
readErrors.length > 0;
|
|
208
|
+
const hasWarnings = linkedWarnings.length > 0;
|
|
209
|
+
|
|
210
|
+
if (!hasBlockers && !hasWarnings) {
|
|
211
|
+
return { ok: true, ready: true, projectRoot };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
ready: !hasBlockers,
|
|
217
|
+
projectRoot,
|
|
218
|
+
...(todoBlockers.length > 0 ? { todoBlockers } : {}),
|
|
219
|
+
...(linkedBlockers.length > 0 ? { linkedBlockers } : {}),
|
|
220
|
+
...(linkedWarnings.length > 0 ? { linkedWarnings } : {}),
|
|
221
|
+
...(readErrors.length > 0 ? { readErrors } : {}),
|
|
222
|
+
} as HandoffCheckResult;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// No links: todo-only readiness (no git root needed)
|
|
226
|
+
if (todoBlockers.length > 0) {
|
|
227
|
+
return { ok: true, ready: false, todoBlockers };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { ok: true, ready: true };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Internal helpers
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function collectTodoBlockers(todos: readonly Task[]): HandoffTodoBlocker[] {
|
|
238
|
+
const blockers: HandoffTodoBlocker[] = [];
|
|
239
|
+
|
|
240
|
+
for (const todo of todos) {
|
|
241
|
+
if (todo.status === "pending") {
|
|
242
|
+
const hasUnresolvedBlockers =
|
|
243
|
+
todo.blockedBy != null && todo.blockedBy.length > 0;
|
|
244
|
+
blockers.push({
|
|
245
|
+
todoId: todo.id,
|
|
246
|
+
subject: todo.subject,
|
|
247
|
+
status: "pending",
|
|
248
|
+
reason: hasUnresolvedBlockers ? "blocked" : "pending",
|
|
249
|
+
});
|
|
250
|
+
} else if (todo.status === "in_progress") {
|
|
251
|
+
blockers.push({
|
|
252
|
+
todoId: todo.id,
|
|
253
|
+
subject: todo.subject,
|
|
254
|
+
status: "in_progress",
|
|
255
|
+
reason: "in_progress",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return blockers;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type LinkedReadResult =
|
|
264
|
+
| { type: "ready" }
|
|
265
|
+
| { type: "blocker"; entry: HandoffLinkedBlocker }
|
|
266
|
+
| { type: "warning"; entry: HandoffLinkedBlocker }
|
|
267
|
+
| { type: "read_error"; error: HandoffReadError }
|
|
268
|
+
| { type: "internal_error"; code: string; message: string };
|
|
269
|
+
|
|
270
|
+
async function readLinkedTaskStatus(
|
|
271
|
+
pi: ExtensionAPI,
|
|
272
|
+
projectRoot: string,
|
|
273
|
+
tsqId: string,
|
|
274
|
+
todoId: number,
|
|
275
|
+
signal?: AbortSignal,
|
|
276
|
+
): Promise<LinkedReadResult> {
|
|
277
|
+
try {
|
|
278
|
+
const data = await runTsqJson<TsqShowData>(
|
|
279
|
+
pi,
|
|
280
|
+
{ cwd: projectRoot },
|
|
281
|
+
["show", tsqId],
|
|
282
|
+
signal != null ? { signal } : {},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const status = data.task?.status;
|
|
286
|
+
const classification = classifyDurableStatus(status);
|
|
287
|
+
|
|
288
|
+
if (classification === "ready") return { type: "ready" };
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
type: classification,
|
|
292
|
+
entry: {
|
|
293
|
+
todoId,
|
|
294
|
+
tsqId,
|
|
295
|
+
status: status ?? "unknown",
|
|
296
|
+
classification,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (err instanceof TsqCommandError) {
|
|
301
|
+
const errClass = classifyReadError(err.code);
|
|
302
|
+
if (errClass === "actionable") {
|
|
303
|
+
return {
|
|
304
|
+
type: "read_error",
|
|
305
|
+
error: { tsqId, code: err.code, message: err.message },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
type: "internal_error",
|
|
310
|
+
code: err.code,
|
|
311
|
+
message: err.message,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (err instanceof TsqProcessError) {
|
|
316
|
+
return {
|
|
317
|
+
type: "internal_error",
|
|
318
|
+
code: "process_error",
|
|
319
|
+
message: err.message,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
type: "internal_error",
|
|
325
|
+
code: "unknown",
|
|
326
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_GIT_TIMEOUT_MS = 5_000;
|
|
4
|
+
|
|
5
|
+
export interface ProjectResolutionOptions {
|
|
6
|
+
readonly signal?: AbortSignal;
|
|
7
|
+
readonly timeout?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ProjectResolutionError extends Error {
|
|
11
|
+
override readonly name = "ProjectResolutionError";
|
|
12
|
+
readonly cwd: string;
|
|
13
|
+
readonly code: number;
|
|
14
|
+
readonly stderr: string;
|
|
15
|
+
readonly stdout: string;
|
|
16
|
+
readonly killed: boolean;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
cwd: string,
|
|
20
|
+
result: {
|
|
21
|
+
readonly code: number;
|
|
22
|
+
readonly stderr: string;
|
|
23
|
+
readonly stdout: string;
|
|
24
|
+
readonly killed: boolean;
|
|
25
|
+
},
|
|
26
|
+
) {
|
|
27
|
+
super(buildProjectResolutionMessage(cwd, result));
|
|
28
|
+
this.cwd = cwd;
|
|
29
|
+
this.code = result.code;
|
|
30
|
+
this.stderr = result.stderr;
|
|
31
|
+
this.stdout = result.stdout;
|
|
32
|
+
this.killed = result.killed;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function resolveProjectRoot(
|
|
37
|
+
pi: ExtensionAPI,
|
|
38
|
+
cwd: string,
|
|
39
|
+
options: ProjectResolutionOptions = {},
|
|
40
|
+
): Promise<string> {
|
|
41
|
+
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], {
|
|
42
|
+
cwd,
|
|
43
|
+
timeout: options.timeout ?? DEFAULT_GIT_TIMEOUT_MS,
|
|
44
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const projectRoot = result.stdout.trim();
|
|
48
|
+
if (result.code !== 0 || result.killed || projectRoot.length === 0) {
|
|
49
|
+
throw new ProjectResolutionError(cwd, result);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return projectRoot;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildProjectResolutionMessage(
|
|
56
|
+
cwd: string,
|
|
57
|
+
result: {
|
|
58
|
+
readonly code: number;
|
|
59
|
+
readonly stderr: string;
|
|
60
|
+
readonly stdout: string;
|
|
61
|
+
readonly killed: boolean;
|
|
62
|
+
},
|
|
63
|
+
): string {
|
|
64
|
+
if (result.killed) {
|
|
65
|
+
return `Unable to resolve Tasque project root from ${cwd}: git rev-parse timed out`;
|
|
66
|
+
}
|
|
67
|
+
const detail = (result.stderr || result.stdout).replace(/\s+/gu, " ").trim();
|
|
68
|
+
return detail.length === 0
|
|
69
|
+
? `Unable to resolve Tasque project root from ${cwd}`
|
|
70
|
+
: `Unable to resolve Tasque project root from ${cwd}: ${detail}`;
|
|
71
|
+
}
|
|
@@ -8,10 +8,11 @@ import {
|
|
|
8
8
|
refreshTasqueStatusCache,
|
|
9
9
|
type TasqueStatusCache,
|
|
10
10
|
} from "./cache.js";
|
|
11
|
+
import { resolveProjectRoot } from "./project.js";
|
|
11
12
|
|
|
12
13
|
export const TASQUE_STATUS_KEY = "pi-tasque";
|
|
13
14
|
|
|
14
|
-
const MUTATING_TOOL_NAMES = new Set(["
|
|
15
|
+
const MUTATING_TOOL_NAMES = new Set(["task"]);
|
|
15
16
|
const DEFAULT_INTERVAL_MS = 60_000;
|
|
16
17
|
|
|
17
18
|
export interface TasqueStatusLifecycleOptions {
|
|
@@ -56,12 +57,25 @@ export function registerTasqueStatusLifecycle(
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
let refreshPromise: Promise<void>;
|
|
59
|
-
refreshPromise =
|
|
60
|
-
...(options.now === undefined ? {} : { now: options.now }),
|
|
60
|
+
refreshPromise = resolveProjectRoot(pi, ctx.cwd, {
|
|
61
61
|
...(options.refreshTimeoutMs === undefined
|
|
62
62
|
? {}
|
|
63
63
|
: { timeout: options.refreshTimeoutMs }),
|
|
64
64
|
})
|
|
65
|
+
.then((projectRoot) =>
|
|
66
|
+
refreshTasqueStatusCache(pi, { cwd: projectRoot }, cache, {
|
|
67
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
68
|
+
...(options.refreshTimeoutMs === undefined
|
|
69
|
+
? {}
|
|
70
|
+
: { timeout: options.refreshTimeoutMs }),
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
.catch((error) =>
|
|
74
|
+
createTasqueStatusCache({
|
|
75
|
+
...cache.state,
|
|
76
|
+
error: getErrorMessage(error),
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
65
79
|
.then((nextCache) => {
|
|
66
80
|
if (!statusActive || generation !== lifecycleGeneration) {
|
|
67
81
|
return;
|
|
@@ -179,6 +193,10 @@ function hasStatusUi(ctx: ExtensionContext): boolean {
|
|
|
179
193
|
);
|
|
180
194
|
}
|
|
181
195
|
|
|
196
|
+
function getErrorMessage(error: unknown): string {
|
|
197
|
+
return error instanceof Error ? error.message : String(error);
|
|
198
|
+
}
|
|
199
|
+
|
|
182
200
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
183
201
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
184
202
|
}
|