@danypops/papyrus 0.21.4 → 0.21.6
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/extension/src/discuss.ts +33 -8
- package/package.json +1 -1
- package/src/constants.ts +1 -1
- package/src/db.ts +14 -0
- package/src/discussion-service.ts +5 -5
- package/src/domain/discussion.ts +10 -7
- package/src/task-context.ts +36 -3
- package/src/task-service.ts +10 -10
package/extension/src/discuss.ts
CHANGED
|
@@ -25,6 +25,29 @@ import { callService } from "./service-client.ts";
|
|
|
25
25
|
const SOURCE = "discuss-tui";
|
|
26
26
|
const ACTOR = "human";
|
|
27
27
|
|
|
28
|
+
/** Tasks a human could plausibly want to block on -- excludes terminal ones, since blocking already-finished or canceled work is meaningless. */
|
|
29
|
+
export async function openTaskChoices(cwd: string): Promise<Artifact[]> {
|
|
30
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", { project_root: cwd });
|
|
31
|
+
return rows.filter((task) => task.status !== "done" && task.status !== "canceled");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolves the task ids a Discussion currently has a `blocks` edge to, by title -- so "Unblock" only ever offers tasks actually blocked by this one, never the whole task list. */
|
|
35
|
+
export async function blockedTaskChoices(discussionId: string): Promise<Artifact[]> {
|
|
36
|
+
const tree = await callService<Record<string, unknown>, Artifact>("graph.tree", { id: discussionId, depth: 1 });
|
|
37
|
+
const blockedIds = (tree.edges ?? []).filter((edge) => edge.relation === "blocks" && edge.from === discussionId).map((edge) => edge.to);
|
|
38
|
+
const tasks = await Promise.all(blockedIds.map((id) => callService<Record<string, unknown>, Artifact | null>("tasks.show", { id }).catch(() => null)));
|
|
39
|
+
return tasks.filter((task): task is Artifact => task !== null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Picking a task by title, the same ui.select pattern used for Discuss's own single-choice options -- no ecosystem extension (Pi's own docs/examples, pi-tasks) builds a bespoke fuzzy picker for a plain "choose one named thing" list. */
|
|
43
|
+
export async function pickTaskByName(ctx: ExtensionCommandContext, title: string, tasks: Artifact[]): Promise<Artifact | undefined> {
|
|
44
|
+
if (tasks.length === 0) { ctx.ui.notify("No open tasks to choose from.", "info"); return undefined; }
|
|
45
|
+
const label = await ctx.ui.select(title, tasks.map((task) => `${task.title} [${task.status}]`));
|
|
46
|
+
if (!label) return undefined;
|
|
47
|
+
const index = tasks.map((task) => `${task.title} [${task.status}]`).indexOf(label);
|
|
48
|
+
return index === -1 ? undefined : tasks[index];
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
|
|
29
52
|
const state = discussionStateOf(discussion);
|
|
30
53
|
const presentation = DISCUSSION_STATE_PRESENTATION[state];
|
|
@@ -93,17 +116,19 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
|
|
|
93
116
|
return;
|
|
94
117
|
}
|
|
95
118
|
if (choice === "Block a task") {
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
98
|
-
await callService("discuss.block", { id: discussion.id, task_id:
|
|
99
|
-
commandCtx.ui.notify(
|
|
119
|
+
const target = await pickTaskByName(commandCtx, "Block which task?", await openTaskChoices(commandCtx.cwd));
|
|
120
|
+
if (!target) return;
|
|
121
|
+
await callService("discuss.block", { id: discussion.id, task_id: target.id, actor: ACTOR, source: SOURCE });
|
|
122
|
+
commandCtx.ui.notify(`"${discussion.title}" now blocks "${target.title}"`, "info");
|
|
100
123
|
return;
|
|
101
124
|
}
|
|
102
125
|
if (choice === "Unblock a task") {
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
const
|
|
106
|
-
|
|
126
|
+
const blocked = await blockedTaskChoices(discussion.id);
|
|
127
|
+
if (blocked.length === 0) { commandCtx.ui.notify("This discussion isn't blocking any task.", "info"); return; }
|
|
128
|
+
const target = await pickTaskByName(commandCtx, "Unblock which task?", blocked);
|
|
129
|
+
if (!target) return;
|
|
130
|
+
const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", { id: discussion.id, task_id: target.id, actor: ACTOR, source: SOURCE });
|
|
131
|
+
commandCtx.ui.notify(result.unblocked ? `"${discussion.title}" no longer blocks "${target.title}"` : "No such blocking relationship.", "info");
|
|
107
132
|
}
|
|
108
133
|
},
|
|
109
134
|
});
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 17;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
package/src/db.ts
CHANGED
|
@@ -490,6 +490,20 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
490
490
|
}
|
|
491
491
|
},
|
|
492
492
|
},
|
|
493
|
+
{
|
|
494
|
+
version: 17,
|
|
495
|
+
name: "discussion-task-kind",
|
|
496
|
+
// See domain/discussion.ts for why. Status remaps the same loosely-followed way the
|
|
497
|
+
// doc column always worked: active/deferred -> in-progress, archived -> done.
|
|
498
|
+
up: (db) => {
|
|
499
|
+
db.exec(`
|
|
500
|
+
UPDATE artifacts SET kind = 'task', status = CASE status
|
|
501
|
+
WHEN 'archived' THEN 'done'
|
|
502
|
+
ELSE 'in-progress' END
|
|
503
|
+
WHERE kind = 'doc' AND subtype = 'discussion';
|
|
504
|
+
`);
|
|
505
|
+
},
|
|
506
|
+
},
|
|
493
507
|
];
|
|
494
508
|
|
|
495
509
|
/**
|
|
@@ -80,11 +80,11 @@ export class Discussions {
|
|
|
80
80
|
const posed = this.validatePosedOptions(input.options, input.optionsMode);
|
|
81
81
|
return this.artifacts.atomic(() => {
|
|
82
82
|
const discussion = this.artifacts.create({
|
|
83
|
-
kind: "
|
|
83
|
+
kind: "task",
|
|
84
84
|
subtype: DISCUSSION_SUBTYPE,
|
|
85
85
|
title: input.title,
|
|
86
86
|
body: input.body ?? "",
|
|
87
|
-
status: "
|
|
87
|
+
status: "in-progress",
|
|
88
88
|
labels: input.labels,
|
|
89
89
|
extra: {
|
|
90
90
|
discussion: {
|
|
@@ -163,7 +163,7 @@ export class Discussions {
|
|
|
163
163
|
...discussion.extra,
|
|
164
164
|
discussion: { ...state, state: "settled", settlement: validSettlement, settledAt: new Date().toISOString() },
|
|
165
165
|
}, context)!;
|
|
166
|
-
return this.artifacts.setStatus(discussionId, "
|
|
166
|
+
return this.artifacts.setStatus(discussionId, "done", context) ?? updated;
|
|
167
167
|
});
|
|
168
168
|
}
|
|
169
169
|
|
|
@@ -173,7 +173,7 @@ export class Discussions {
|
|
|
173
173
|
if (this.extra(discussion).state === "settled") throw new DiscussionError(`discussion "${discussionId}" is settled; it can no longer block anything`);
|
|
174
174
|
const task = this.artifacts.get(taskId);
|
|
175
175
|
if (!task) throw new DiscussionError(`task "${taskId}" not found`);
|
|
176
|
-
if (task.kind !== "task") throw new DiscussionError(`artifact "${taskId}" is not a task`);
|
|
176
|
+
if (task.kind !== "task" || isDiscussionArtifact(task)) throw new DiscussionError(`artifact "${taskId}" is not a task`);
|
|
177
177
|
this.artifacts.link({ from: discussionId, relation: "blocks", to: taskId }, context);
|
|
178
178
|
}
|
|
179
179
|
|
|
@@ -197,7 +197,7 @@ export class Discussions {
|
|
|
197
197
|
// (limit omitted) can never fall through to queryArtifacts' own unbounded default -- the same
|
|
198
198
|
// class of gap notes.ts's noteListInput comment documents fixing for Notes.
|
|
199
199
|
const limit = Math.min(DISCUSSION_LIST_MAX_LIMIT, Math.max(1, Math.floor(filter.limit ?? DISCUSSION_LIST_DEFAULT_LIMIT)));
|
|
200
|
-
const rows = this.artifacts.query({ kind: "
|
|
200
|
+
const rows = this.artifacts.query({ kind: "task", subtype: DISCUSSION_SUBTYPE, limit });
|
|
201
201
|
if (!filter.state) return rows;
|
|
202
202
|
return rows.filter((row) => {
|
|
203
203
|
try { return this.extra(row).state === filter.state; } catch { return false; }
|
package/src/domain/discussion.ts
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* Discuss: a native Papyrus deliberation with a real lifecycle, distinct from a one-shot
|
|
3
3
|
* "ask" (see the design discussion this implements) and from Discourse's forum (kept fully
|
|
4
4
|
* standalone by design -- no dependency here, Discuss reuses none of its storage or wire
|
|
5
|
-
* shape). A Discussion is a `
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* shape). A Discussion is a `task` artifact with subtype "discussion": not a passive
|
|
6
|
+
* record (see the blocking behavior below), so it takes the kind whose lifecycle, focus,
|
|
7
|
+
* and dependency-graph machinery are already built for unresolved work -- not a fifth
|
|
8
|
+
* enforced artifact kind. Its fine-grained lifecycle lives in
|
|
9
|
+
* extra.discussion rather than the shared task status vocabulary, since Papyrus enforces
|
|
10
|
+
* status per-kind, not per-subtype -- "deferred" has no equivalent among a plain task's
|
|
11
|
+
* todo/in-progress/review/done/rejected/canceled. The task's own status column follows
|
|
12
|
+
* loosely: "in-progress" while extra.discussion.state is active or deferred, "done" once
|
|
13
|
+
* settled.
|
|
11
14
|
*
|
|
12
15
|
* Blocking is the forcing, load-bearing behavior a Discussion adds over a passive record:
|
|
13
16
|
* an "active" Discussion that `blocks` a Task refuses that Task's completion (see
|
|
@@ -126,7 +129,7 @@ export function validateSelectedOptions(selected: string[], pendingOptions: stri
|
|
|
126
129
|
|
|
127
130
|
/** True for any artifact (already fetched) that is a Discussion, regardless of its current lifecycle state. */
|
|
128
131
|
export function isDiscussionArtifact(artifact: { kind: string; subtype: string }): boolean {
|
|
129
|
-
return artifact.kind === "
|
|
132
|
+
return artifact.kind === "task" && artifact.subtype === DISCUSSION_SUBTYPE;
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
/** Reads and defensively validates the extra.discussion shape; throws on a corrupt/foreign shape rather than silently treating it as some default state. */
|
package/src/task-context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Artifact } from "./domain/artifact.ts";
|
|
2
|
+
import { DISCUSSION_SUBTYPE, readDiscussionExtra } from "./domain/discussion.ts";
|
|
2
3
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
3
4
|
import {
|
|
4
5
|
TASK_CONTEXT_CURRENT_LIMIT,
|
|
@@ -34,22 +35,54 @@ function renderCurrent(task: Artifact): string[] {
|
|
|
34
35
|
];
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Same scoping rule taskContext already applies to ordinary tasks, plus the focused task even if scope excludes it. */
|
|
39
|
+
function inScope(taskId: string, activeTaskId: string | undefined, taskIds: Set<string> | undefined): boolean {
|
|
40
|
+
return taskIds === undefined || taskIds.has(taskId) || taskId === activeTaskId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function deferredBlockingDiscussions(artifacts: ArtifactStore, activeTaskId: string | undefined, taskIds: Set<string> | undefined): string[] {
|
|
44
|
+
const discussions = artifacts.query({ kind: "task", subtype: DISCUSSION_SUBTYPE })
|
|
45
|
+
.filter((discussion) => {
|
|
46
|
+
try { return readDiscussionExtra(discussion.extra).state === "deferred"; } catch { return false; }
|
|
47
|
+
});
|
|
48
|
+
if (discussions.length === 0) return [];
|
|
49
|
+
|
|
50
|
+
const blocks = artifacts.relationships({ artifactIds: discussions.map((discussion) => discussion.id) })
|
|
51
|
+
.filter((edge) => edge.relation === "blocks");
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
for (const discussion of discussions) {
|
|
54
|
+
for (const edge of blocks) {
|
|
55
|
+
if (edge.from !== discussion.id) continue;
|
|
56
|
+
if (!inScope(edge.to, activeTaskId, taskIds)) continue;
|
|
57
|
+
const blockedTask = artifacts.get(edge.to);
|
|
58
|
+
if (!blockedTask || blockedTask.status === "done" || blockedTask.status === "canceled") continue;
|
|
59
|
+
lines.push(`${discussion.title} (${discussion.id}) -- blocks "${blockedTask.title}"`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
37
65
|
export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, taskIds?: Set<string>): string | null {
|
|
38
|
-
const tasks = artifacts.query({ kind: "task" })
|
|
66
|
+
const tasks = artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE })
|
|
39
67
|
.filter((task) => taskIds === undefined || taskIds.has(task.id))
|
|
40
68
|
.sort((left, right) => left.updated_at.localeCompare(right.updated_at));
|
|
41
69
|
const open = tasks.filter((task) => task.status !== "done" && task.status !== "canceled");
|
|
42
|
-
|
|
70
|
+
const deferredDiscussions = deferredBlockingDiscussions(artifacts, activeTaskId, taskIds);
|
|
71
|
+
if (open.length === 0 && deferredDiscussions.length === 0) return null;
|
|
43
72
|
|
|
44
73
|
const done = tasks.length - open.length;
|
|
45
74
|
const active = activeTaskId ? open.find((task) => task.id === activeTaskId) : undefined;
|
|
46
75
|
const current = active ? [active] : open.filter((task) => task.status === "in-progress" || task.status === "review").slice(0, TASK_CONTEXT_CURRENT_LIMIT);
|
|
47
76
|
const next = open.find((task) => task.status === "todo");
|
|
48
77
|
const rejected = open.filter((task) => task.status === "rejected").slice(0, TASK_CONTEXT_REJECTED_LIMIT);
|
|
49
|
-
const lines = [`Progress: ${done}/${tasks.length} done`];
|
|
78
|
+
const lines = tasks.length > 0 ? [`Progress: ${done}/${tasks.length} done`] : [];
|
|
50
79
|
for (const task of current) lines.push(...renderCurrent(task));
|
|
51
80
|
if (next) lines.push(`Next: ${next.title} (${next.id})`);
|
|
52
81
|
if (rejected.length > 0) lines.push(`Rejected: ${rejected.map((task) => `${task.title} (${task.id})`).join(", ")}`);
|
|
82
|
+
if (deferredDiscussions.length > 0) {
|
|
83
|
+
lines.push("", "Deferred discussions blocking this scope -- resume and re-surface these, do not leave them dormant:");
|
|
84
|
+
for (const line of deferredDiscussions) lines.push(`• ${line}`);
|
|
85
|
+
}
|
|
53
86
|
lines.push("", TASK_RECONCILIATION_INSTRUCTION);
|
|
54
87
|
return lines.join("\n");
|
|
55
88
|
}
|
package/src/task-service.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./constants.ts";
|
|
12
12
|
import type { Artifact } from "./domain/artifact.ts";
|
|
13
13
|
import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
|
|
14
|
-
import { isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
|
|
14
|
+
import { DISCUSSION_SUBTYPE, isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
|
|
15
15
|
import { validateGates, type Gate, type GateResult } from "./domain/gate.ts";
|
|
16
16
|
import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
|
|
17
17
|
import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
|
|
@@ -225,7 +225,7 @@ export class Tasks {
|
|
|
225
225
|
throw new Error(`task list limit must be between 1 and ${TASK_SCOPE_MAX_TASKS + 1}`);
|
|
226
226
|
}
|
|
227
227
|
if (selection.mode === "all") {
|
|
228
|
-
return this.artifacts.query({ kind: "task", status: filter.status, text: filter.text, limit });
|
|
228
|
+
return this.artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, status: filter.status, text: filter.text, limit });
|
|
229
229
|
}
|
|
230
230
|
const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
|
|
231
231
|
if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
|
|
@@ -233,7 +233,7 @@ export class Tasks {
|
|
|
233
233
|
const text = filter.text?.toLowerCase();
|
|
234
234
|
return [...selectedIds]
|
|
235
235
|
.map((id) => this.artifacts.get(id))
|
|
236
|
-
.filter((task): task is Artifact => task?.kind === "task")
|
|
236
|
+
.filter((task): task is Artifact => task?.kind === "task" && !isDiscussionArtifact(task))
|
|
237
237
|
.filter((task) => filter.status === undefined || task.status === filter.status)
|
|
238
238
|
.filter((task) => text === undefined || task.title.toLowerCase().includes(text) || task.body.toLowerCase().includes(text))
|
|
239
239
|
.sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
|
|
@@ -404,8 +404,8 @@ export class Tasks {
|
|
|
404
404
|
const transition = TASK_TRANSITIONS[action];
|
|
405
405
|
if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
|
|
406
406
|
if (action === "start") {
|
|
407
|
-
const blocking = this.dependencyIds(id).
|
|
408
|
-
if (blocking.length > 0) throw new Error(`task "${
|
|
407
|
+
const blocking = this.dependencyIds(id).map((dependencyId) => this.require(dependencyId)).filter((dependency) => dependency.status !== "done");
|
|
408
|
+
if (blocking.length > 0) throw new Error(`task "${task.title}" is blocked by dependencies: ${blocking.map((dependency) => `"${dependency.title}"`).join(", ")}`);
|
|
409
409
|
this.focusStore.set(id, context.sessionId);
|
|
410
410
|
}
|
|
411
411
|
const updated = this.artifacts.setStatus(id, transition.to)!;
|
|
@@ -420,7 +420,7 @@ export class Tasks {
|
|
|
420
420
|
|
|
421
421
|
complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
|
|
422
422
|
const task = this.requireReview(id);
|
|
423
|
-
this.requireNotBlocked(
|
|
423
|
+
this.requireNotBlocked(task);
|
|
424
424
|
const attemptId = crypto.randomUUID();
|
|
425
425
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
426
426
|
const checklist = this.reviewChecklist(task);
|
|
@@ -430,7 +430,7 @@ export class Tasks {
|
|
|
430
430
|
|
|
431
431
|
async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
|
|
432
432
|
const task = this.requireReview(id);
|
|
433
|
-
this.requireNotBlocked(
|
|
433
|
+
this.requireNotBlocked(task);
|
|
434
434
|
const attemptId = crypto.randomUUID();
|
|
435
435
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
436
436
|
const checklist = this.reviewChecklist(task);
|
|
@@ -712,10 +712,10 @@ export class Tasks {
|
|
|
712
712
|
});
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
-
private requireNotBlocked(
|
|
716
|
-
const blockers = this.blockingDiscussions(id);
|
|
715
|
+
private requireNotBlocked(task: Artifact): void {
|
|
716
|
+
const blockers = this.blockingDiscussions(task.id);
|
|
717
717
|
if (blockers.length > 0) {
|
|
718
|
-
throw new Error(`task "${
|
|
718
|
+
throw new Error(`task "${task.title}" is blocked by ${blockers.length} active Discussion(s): ${blockers.map((discussion) => `"${discussion.title}"`).join(", ")}`);
|
|
719
719
|
}
|
|
720
720
|
}
|
|
721
721
|
}
|