indus-swarms 0.1.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/AGENTS.md +14 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/claude-parity.md +151 -0
- package/docs/field-notes-teams-setup.md +107 -0
- package/docs/smoke-test-plan.md +146 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +87 -0
- package/extensions/teams/hooks.ts +363 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader-attach-commands.ts +221 -0
- package/extensions/teams/leader-inbox.ts +214 -0
- package/extensions/teams/leader-info-commands.ts +140 -0
- package/extensions/teams/leader-lifecycle-commands.ts +559 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +95 -0
- package/extensions/teams/leader-spawn-command.ts +149 -0
- package/extensions/teams/leader-task-commands.ts +435 -0
- package/extensions/teams/leader-team-command.ts +382 -0
- package/extensions/teams/leader-teams-tool.ts +1075 -0
- package/extensions/teams/leader.ts +925 -0
- package/extensions/teams/mailbox.ts +131 -0
- package/extensions/teams/model-policy.ts +142 -0
- package/extensions/teams/names.ts +121 -0
- package/extensions/teams/paths.ts +37 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +36 -0
- package/extensions/teams/task-store.ts +544 -0
- package/extensions/teams/team-attach-claim.ts +205 -0
- package/extensions/teams/team-config.ts +335 -0
- package/extensions/teams/team-discovery.ts +59 -0
- package/extensions/teams/teammate-rpc.ts +261 -0
- package/extensions/teams/teams-panel.ts +1186 -0
- package/extensions/teams/teams-style.ts +322 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +212 -0
- package/extensions/teams/worker.ts +605 -0
- package/extensions/teams/worktree.ts +103 -0
- package/package.json +53 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +398 -0
- package/scripts/integration-todo-test.mts +533 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +764 -0
- package/scripts/start-tmux-team.sh +91 -0
- package/skills/agent-teams/SKILL.md +180 -0
- package/tsconfig.strict.json +22 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "indusagi-coding-agent";
|
|
2
|
+
import { writeToMailbox } from "./mailbox.js";
|
|
3
|
+
import { sanitizeName } from "./names.js";
|
|
4
|
+
import { getTeamDir } from "./paths.js";
|
|
5
|
+
import { TEAM_MAILBOX_NS } from "./protocol.js";
|
|
6
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
7
|
+
import { formatMemberDisplayName } from "./teams-style.js";
|
|
8
|
+
|
|
9
|
+
export async function handleTeamPlanCommand(opts: {
|
|
10
|
+
ctx: ExtensionCommandContext;
|
|
11
|
+
rest: string[];
|
|
12
|
+
teamId: string;
|
|
13
|
+
leadName: string;
|
|
14
|
+
style: TeamsStyle;
|
|
15
|
+
pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
|
|
16
|
+
}): Promise<void> {
|
|
17
|
+
const { ctx, rest, teamId, leadName, style, pendingPlanApprovals } = opts;
|
|
18
|
+
|
|
19
|
+
const [planSub, ...planRest] = rest;
|
|
20
|
+
if (!planSub || planSub === "help") {
|
|
21
|
+
ctx.ui.notify(
|
|
22
|
+
[
|
|
23
|
+
"Usage:",
|
|
24
|
+
" /team plan approve <name>",
|
|
25
|
+
" /team plan reject <name> [feedback...]",
|
|
26
|
+
].join("\n"),
|
|
27
|
+
"info",
|
|
28
|
+
);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (planSub === "approve") {
|
|
33
|
+
const nameRaw = planRest[0];
|
|
34
|
+
if (!nameRaw) {
|
|
35
|
+
ctx.ui.notify("Usage: /team plan approve <name>", "error");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const name = sanitizeName(nameRaw);
|
|
39
|
+
const pending = pendingPlanApprovals.get(name);
|
|
40
|
+
if (!pending) {
|
|
41
|
+
ctx.ui.notify(`No pending plan approval for ${name}`, "error");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const teamDir = getTeamDir(teamId);
|
|
46
|
+
const ts = new Date().toISOString();
|
|
47
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
|
|
48
|
+
from: leadName,
|
|
49
|
+
text: JSON.stringify({
|
|
50
|
+
type: "plan_approved",
|
|
51
|
+
requestId: pending.requestId,
|
|
52
|
+
from: leadName,
|
|
53
|
+
timestamp: ts,
|
|
54
|
+
}),
|
|
55
|
+
timestamp: ts,
|
|
56
|
+
});
|
|
57
|
+
pendingPlanApprovals.delete(name);
|
|
58
|
+
ctx.ui.notify(`Approved plan for ${formatMemberDisplayName(style, name)}`, "info");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (planSub === "reject") {
|
|
63
|
+
const nameRaw = planRest[0];
|
|
64
|
+
if (!nameRaw) {
|
|
65
|
+
ctx.ui.notify("Usage: /team plan reject <name> [feedback...]", "error");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const name = sanitizeName(nameRaw);
|
|
69
|
+
const pending = pendingPlanApprovals.get(name);
|
|
70
|
+
if (!pending) {
|
|
71
|
+
ctx.ui.notify(`No pending plan approval for ${name}`, "error");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const feedback = planRest.slice(1).join(" ").trim() || "Plan rejected";
|
|
76
|
+
const teamDir = getTeamDir(teamId);
|
|
77
|
+
const ts = new Date().toISOString();
|
|
78
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, name, {
|
|
79
|
+
from: leadName,
|
|
80
|
+
text: JSON.stringify({
|
|
81
|
+
type: "plan_rejected",
|
|
82
|
+
requestId: pending.requestId,
|
|
83
|
+
from: leadName,
|
|
84
|
+
feedback,
|
|
85
|
+
timestamp: ts,
|
|
86
|
+
}),
|
|
87
|
+
timestamp: ts,
|
|
88
|
+
});
|
|
89
|
+
pendingPlanApprovals.delete(name);
|
|
90
|
+
ctx.ui.notify(`Rejected plan for ${formatMemberDisplayName(style, name)}: ${feedback}`, "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ctx.ui.notify(`Unknown plan subcommand: ${planSub}`, "error");
|
|
95
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "indusagi/agent";
|
|
2
|
+
import type { ExtensionCommandContext } from "indusagi-coding-agent";
|
|
3
|
+
import { pickAgentNames, pickNamesFromPool } from "./names.js";
|
|
4
|
+
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
5
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
6
|
+
import { formatMemberDisplayName, getTeamsNamingRules, getTeamsStrings } from "./teams-style.js";
|
|
7
|
+
import type { ContextMode, WorkspaceMode, SpawnTeammateFn } from "./spawn-types.js";
|
|
8
|
+
|
|
9
|
+
function isThinkingLevel(v: string): v is ThinkingLevel {
|
|
10
|
+
switch (v) {
|
|
11
|
+
case "off":
|
|
12
|
+
case "minimal":
|
|
13
|
+
case "low":
|
|
14
|
+
case "medium":
|
|
15
|
+
case "high":
|
|
16
|
+
case "xhigh":
|
|
17
|
+
return true;
|
|
18
|
+
default:
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function handleTeamSpawnCommand(opts: {
|
|
24
|
+
ctx: ExtensionCommandContext;
|
|
25
|
+
rest: string[];
|
|
26
|
+
teammates: Map<string, TeammateRpc>;
|
|
27
|
+
style: TeamsStyle;
|
|
28
|
+
spawnTeammate: SpawnTeammateFn;
|
|
29
|
+
}): Promise<void> {
|
|
30
|
+
const { ctx, rest, teammates, style, spawnTeammate } = opts;
|
|
31
|
+
const strings = getTeamsStrings(style);
|
|
32
|
+
|
|
33
|
+
// Parse flags from any position
|
|
34
|
+
let nameRaw: string | undefined;
|
|
35
|
+
let mode: ContextMode = "fresh";
|
|
36
|
+
let workspaceMode: WorkspaceMode = "shared";
|
|
37
|
+
let planRequired = false;
|
|
38
|
+
let model: string | undefined;
|
|
39
|
+
let thinking: ThinkingLevel | undefined;
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < rest.length; i++) {
|
|
42
|
+
const a = rest[i];
|
|
43
|
+
if (!a) continue;
|
|
44
|
+
|
|
45
|
+
if (a === "fresh" || a === "branch") {
|
|
46
|
+
mode = a;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (a === "shared" || a === "worktree") {
|
|
50
|
+
workspaceMode = a;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (a === "plan") {
|
|
54
|
+
planRequired = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (a === "--model") {
|
|
59
|
+
const next = rest[i + 1];
|
|
60
|
+
if (!next) {
|
|
61
|
+
ctx.ui.notify("Usage: /team spawn <name> [fresh|branch] [shared|worktree] [plan] [--model <provider>/<modelId>] [--thinking <level>]", "error");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
model = next;
|
|
65
|
+
i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (a.startsWith("--model=")) {
|
|
69
|
+
model = a.slice("--model=".length);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (a === "--thinking") {
|
|
74
|
+
const next = rest[i + 1];
|
|
75
|
+
if (!next) {
|
|
76
|
+
ctx.ui.notify("Usage: /team spawn <name> [fresh|branch] [shared|worktree] [plan] [--model <provider>/<modelId>] [--thinking <level>]", "error");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!isThinkingLevel(next)) {
|
|
80
|
+
ctx.ui.notify(
|
|
81
|
+
`Invalid thinking level '${next}'. Valid values: off, minimal, low, medium, high, xhigh`,
|
|
82
|
+
"error",
|
|
83
|
+
);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
thinking = next;
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (a.startsWith("--thinking=")) {
|
|
91
|
+
const next = a.slice("--thinking=".length);
|
|
92
|
+
if (!isThinkingLevel(next)) {
|
|
93
|
+
ctx.ui.notify(
|
|
94
|
+
`Invalid thinking level '${next}'. Valid values: off, minimal, low, medium, high, xhigh`,
|
|
95
|
+
"error",
|
|
96
|
+
);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
thinking = next;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!nameRaw && !a.startsWith("--")) nameRaw = a;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
model = model?.trim();
|
|
107
|
+
if (model === "") model = undefined;
|
|
108
|
+
|
|
109
|
+
// Auto-pick a name when the current style allows it.
|
|
110
|
+
if (!nameRaw) {
|
|
111
|
+
const naming = getTeamsNamingRules(style);
|
|
112
|
+
if (naming.requireExplicitSpawnName) {
|
|
113
|
+
ctx.ui.notify(
|
|
114
|
+
"Usage: /team spawn <name> [fresh|branch] [shared|worktree] [plan] [--model <provider>/<modelId>] [--thinking <level>]",
|
|
115
|
+
"error",
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const taken = new Set(teammates.keys());
|
|
121
|
+
const picked = (() => {
|
|
122
|
+
if (naming.autoNameStrategy.kind === "agent") return pickAgentNames(1, taken).at(0);
|
|
123
|
+
return pickNamesFromPool({
|
|
124
|
+
pool: naming.autoNameStrategy.pool,
|
|
125
|
+
count: 1,
|
|
126
|
+
taken,
|
|
127
|
+
fallbackBase: naming.autoNameStrategy.fallbackBase,
|
|
128
|
+
}).at(0);
|
|
129
|
+
})();
|
|
130
|
+
if (!picked) {
|
|
131
|
+
ctx.ui.notify(`Failed to pick a ${strings.memberTitle.toLowerCase()} name`, "error");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
nameRaw = picked;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const res = await spawnTeammate(ctx, { name: nameRaw, mode, workspaceMode, planRequired, model, thinking });
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
ctx.ui.notify(res.error, "error");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const w of res.warnings) ctx.ui.notify(w, "warning");
|
|
144
|
+
const displayName = formatMemberDisplayName(style, res.name);
|
|
145
|
+
ctx.ui.notify(
|
|
146
|
+
`${displayName} ${strings.joinedVerb} (${res.mode}${res.note ? ", " + res.note : ""} \u00b7 ${res.workspaceMode})`,
|
|
147
|
+
"info",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { ExtensionCommandContext } from "indusagi-coding-agent";
|
|
3
|
+
import { writeToMailbox } from "./mailbox.js";
|
|
4
|
+
import { sanitizeName } from "./names.js";
|
|
5
|
+
import { getTeamDir } from "./paths.js";
|
|
6
|
+
import { taskAssignmentPayload } from "./protocol.js";
|
|
7
|
+
import {
|
|
8
|
+
addTaskDependency,
|
|
9
|
+
clearTasks,
|
|
10
|
+
createTask,
|
|
11
|
+
formatTaskLine,
|
|
12
|
+
getTask,
|
|
13
|
+
isTaskBlocked,
|
|
14
|
+
removeTaskDependency,
|
|
15
|
+
updateTask,
|
|
16
|
+
type TeamTask,
|
|
17
|
+
} from "./task-store.js";
|
|
18
|
+
import { ensureTeamConfig } from "./team-config.js";
|
|
19
|
+
import type { TeamsStyle } from "./teams-style.js";
|
|
20
|
+
import { formatMemberDisplayName } from "./teams-style.js";
|
|
21
|
+
|
|
22
|
+
function parseAssigneePrefix(text: string): { assignee?: string; text: string } {
|
|
23
|
+
const m = text.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/);
|
|
24
|
+
if (!m) return { text };
|
|
25
|
+
const assignee = m[1];
|
|
26
|
+
const rest = m[2];
|
|
27
|
+
if (!assignee || !rest) return { text };
|
|
28
|
+
return { assignee, text: rest };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function handleTeamTaskCommand(opts: {
|
|
32
|
+
ctx: ExtensionCommandContext;
|
|
33
|
+
rest: string[];
|
|
34
|
+
teamId: string;
|
|
35
|
+
leadName: string;
|
|
36
|
+
style: TeamsStyle;
|
|
37
|
+
getTaskListId: () => string | null;
|
|
38
|
+
setTaskListId: (id: string) => void;
|
|
39
|
+
getTasks: () => TeamTask[];
|
|
40
|
+
refreshTasks: () => Promise<void>;
|
|
41
|
+
renderWidget: () => void;
|
|
42
|
+
}): Promise<void> {
|
|
43
|
+
const {
|
|
44
|
+
ctx,
|
|
45
|
+
rest,
|
|
46
|
+
teamId,
|
|
47
|
+
leadName,
|
|
48
|
+
style,
|
|
49
|
+
getTaskListId,
|
|
50
|
+
setTaskListId,
|
|
51
|
+
getTasks,
|
|
52
|
+
refreshTasks,
|
|
53
|
+
renderWidget,
|
|
54
|
+
} = opts;
|
|
55
|
+
|
|
56
|
+
const [taskSub, ...taskRest] = rest;
|
|
57
|
+
const teamDir = getTeamDir(teamId);
|
|
58
|
+
const effectiveTlId = getTaskListId() ?? teamId;
|
|
59
|
+
|
|
60
|
+
if (!taskSub || taskSub === "help") {
|
|
61
|
+
ctx.ui.notify(
|
|
62
|
+
[
|
|
63
|
+
"Usage:",
|
|
64
|
+
" /team task add <text...>",
|
|
65
|
+
" /team task assign <id> <agent>",
|
|
66
|
+
" /team task unassign <id>",
|
|
67
|
+
" /team task list",
|
|
68
|
+
" /team task clear [completed|all] [--force]",
|
|
69
|
+
" /team task show <id>",
|
|
70
|
+
" /team task dep add <id> <depId>",
|
|
71
|
+
" /team task dep rm <id> <depId>",
|
|
72
|
+
" /team task dep ls <id>",
|
|
73
|
+
" /team task use <taskListId>",
|
|
74
|
+
"Tip: prefix with assignee, e.g. 'alice: review the API surface'",
|
|
75
|
+
].join("\n"),
|
|
76
|
+
"info",
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
switch (taskSub) {
|
|
82
|
+
case "add": {
|
|
83
|
+
const raw = taskRest.join(" ").trim();
|
|
84
|
+
if (!raw) {
|
|
85
|
+
ctx.ui.notify("Usage: /team task add <text...>", "error");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parsed = parseAssigneePrefix(raw);
|
|
90
|
+
const owner = parsed.assignee ? sanitizeName(parsed.assignee) : undefined;
|
|
91
|
+
const description = parsed.text.trim();
|
|
92
|
+
const firstLine = description.split("\n").at(0) ?? "";
|
|
93
|
+
const subject = firstLine.slice(0, 120);
|
|
94
|
+
|
|
95
|
+
const task = await createTask(teamDir, effectiveTlId, { subject, description, owner });
|
|
96
|
+
|
|
97
|
+
if (owner) {
|
|
98
|
+
const payload = taskAssignmentPayload(task, leadName);
|
|
99
|
+
await writeToMailbox(teamDir, effectiveTlId, owner, {
|
|
100
|
+
from: leadName,
|
|
101
|
+
text: JSON.stringify(payload),
|
|
102
|
+
timestamp: new Date().toISOString(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
`Created task #${task.id}${owner ? ` (assigned to ${formatMemberDisplayName(style, owner)})` : ""}`,
|
|
108
|
+
"info",
|
|
109
|
+
);
|
|
110
|
+
await refreshTasks();
|
|
111
|
+
renderWidget();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case "assign": {
|
|
116
|
+
const taskId = taskRest[0];
|
|
117
|
+
const agent = taskRest[1];
|
|
118
|
+
if (!taskId || !agent) {
|
|
119
|
+
ctx.ui.notify("Usage: /team task assign <id> <agent>", "error");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const owner = sanitizeName(agent);
|
|
124
|
+
const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
|
|
125
|
+
if (cur.status !== "completed") {
|
|
126
|
+
return { ...cur, owner, status: "pending" };
|
|
127
|
+
}
|
|
128
|
+
return { ...cur, owner };
|
|
129
|
+
});
|
|
130
|
+
if (!updated) {
|
|
131
|
+
ctx.ui.notify(`Task not found: ${taskId}`, "error");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await writeToMailbox(teamDir, effectiveTlId, owner, {
|
|
136
|
+
from: leadName,
|
|
137
|
+
text: JSON.stringify(taskAssignmentPayload(updated, leadName)),
|
|
138
|
+
timestamp: new Date().toISOString(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
ctx.ui.notify(`Assigned task #${updated.id} to ${formatMemberDisplayName(style, owner)}`, "info");
|
|
142
|
+
await refreshTasks();
|
|
143
|
+
renderWidget();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case "unassign": {
|
|
148
|
+
const taskId = taskRest[0];
|
|
149
|
+
if (!taskId) {
|
|
150
|
+
ctx.ui.notify("Usage: /team task unassign <id>", "error");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const updated = await updateTask(teamDir, effectiveTlId, taskId, (cur) => {
|
|
155
|
+
if (cur.status !== "completed") {
|
|
156
|
+
return { ...cur, owner: undefined, status: "pending" };
|
|
157
|
+
}
|
|
158
|
+
return { ...cur, owner: undefined };
|
|
159
|
+
});
|
|
160
|
+
if (!updated) {
|
|
161
|
+
ctx.ui.notify(`Task not found: ${taskId}`, "error");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ctx.ui.notify(`Unassigned task #${updated.id}`, "info");
|
|
166
|
+
await refreshTasks();
|
|
167
|
+
renderWidget();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "show": {
|
|
172
|
+
const taskId = taskRest[0];
|
|
173
|
+
if (!taskId) {
|
|
174
|
+
ctx.ui.notify("Usage: /team task show <id>", "error");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const task = await getTask(teamDir, effectiveTlId, taskId);
|
|
179
|
+
if (!task) {
|
|
180
|
+
ctx.ui.notify(`Task not found: ${taskId}`, "error");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const blocked = task.status !== "completed" && (await isTaskBlocked(teamDir, effectiveTlId, task));
|
|
185
|
+
|
|
186
|
+
const lines: string[] = [];
|
|
187
|
+
lines.push(`#${task.id} ${task.subject}`);
|
|
188
|
+
lines.push(
|
|
189
|
+
`status: ${task.status}${blocked ? " (blocked)" : ""}${task.owner ? ` • owner: ${task.owner}` : ""}`,
|
|
190
|
+
);
|
|
191
|
+
if (task.blockedBy.length) lines.push(`deps: ${task.blockedBy.join(", ")}`);
|
|
192
|
+
if (task.blocks.length) lines.push(`blocks: ${task.blocks.join(", ")}`);
|
|
193
|
+
lines.push("");
|
|
194
|
+
lines.push(task.description);
|
|
195
|
+
|
|
196
|
+
const result = typeof task.metadata?.result === "string" ? task.metadata.result : undefined;
|
|
197
|
+
if (result) {
|
|
198
|
+
lines.push("");
|
|
199
|
+
lines.push("result:");
|
|
200
|
+
lines.push(result);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const qualityGateStatusRaw = task.metadata?.["qualityGateStatus"];
|
|
204
|
+
const qualityGateStatus =
|
|
205
|
+
qualityGateStatusRaw === "failed" || qualityGateStatusRaw === "passed" ? qualityGateStatusRaw : null;
|
|
206
|
+
const qualityGateSummaryRaw = task.metadata?.["qualityGateSummary"];
|
|
207
|
+
const qualityGateSummary =
|
|
208
|
+
typeof qualityGateSummaryRaw === "string" && qualityGateSummaryRaw.trim().length > 0
|
|
209
|
+
? qualityGateSummaryRaw.trim()
|
|
210
|
+
: null;
|
|
211
|
+
if (qualityGateStatus) {
|
|
212
|
+
lines.push("");
|
|
213
|
+
lines.push(`quality gate: ${qualityGateStatus}${qualityGateSummary ? ` • ${qualityGateSummary}` : ""}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case "dep": {
|
|
221
|
+
const [depSub, ...depRest] = taskRest;
|
|
222
|
+
if (!depSub || depSub === "help") {
|
|
223
|
+
ctx.ui.notify(
|
|
224
|
+
[
|
|
225
|
+
"Usage:",
|
|
226
|
+
" /team task dep add <id> <depId>",
|
|
227
|
+
" /team task dep rm <id> <depId>",
|
|
228
|
+
" /team task dep ls <id>",
|
|
229
|
+
].join("\n"),
|
|
230
|
+
"info",
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
switch (depSub) {
|
|
236
|
+
case "add": {
|
|
237
|
+
const taskId = depRest[0];
|
|
238
|
+
const depId = depRest[1];
|
|
239
|
+
if (!taskId || !depId) {
|
|
240
|
+
ctx.ui.notify("Usage: /team task dep add <id> <depId>", "error");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const res = await addTaskDependency(teamDir, effectiveTlId, taskId, depId);
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
ctx.ui.notify(res.error, "error");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ctx.ui.notify(`Added dependency: #${taskId} depends on #${depId}`, "info");
|
|
251
|
+
await refreshTasks();
|
|
252
|
+
renderWidget();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case "rm": {
|
|
257
|
+
const taskId = depRest[0];
|
|
258
|
+
const depId = depRest[1];
|
|
259
|
+
if (!taskId || !depId) {
|
|
260
|
+
ctx.ui.notify("Usage: /team task dep rm <id> <depId>", "error");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const res = await removeTaskDependency(teamDir, effectiveTlId, taskId, depId);
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
ctx.ui.notify(res.error, "error");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
ctx.ui.notify(`Removed dependency: #${taskId} no longer depends on #${depId}`, "info");
|
|
271
|
+
await refreshTasks();
|
|
272
|
+
renderWidget();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case "ls": {
|
|
277
|
+
const taskId = depRest[0];
|
|
278
|
+
if (!taskId) {
|
|
279
|
+
ctx.ui.notify("Usage: /team task dep ls <id>", "error");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await refreshTasks();
|
|
284
|
+
const tasks = getTasks();
|
|
285
|
+
const task = tasks.find((t) => t.id === taskId) ?? (await getTask(teamDir, effectiveTlId, taskId));
|
|
286
|
+
if (!task) {
|
|
287
|
+
ctx.ui.notify(`Task not found: ${taskId}`, "error");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const blocked = task.status !== "completed" && (await isTaskBlocked(teamDir, effectiveTlId, task));
|
|
292
|
+
|
|
293
|
+
const lines: string[] = [];
|
|
294
|
+
lines.push(`#${task.id} ${task.subject}`);
|
|
295
|
+
lines.push(`${blocked ? "blocked" : "unblocked"} • deps:${task.blockedBy.length} • blocks:${task.blocks.length}`);
|
|
296
|
+
|
|
297
|
+
lines.push("");
|
|
298
|
+
lines.push("blockedBy:");
|
|
299
|
+
if (!task.blockedBy.length) {
|
|
300
|
+
lines.push(" (none)");
|
|
301
|
+
} else {
|
|
302
|
+
for (const id of task.blockedBy) {
|
|
303
|
+
const dep = tasks.find((t) => t.id === id) ?? (await getTask(teamDir, effectiveTlId, id));
|
|
304
|
+
lines.push(dep ? ` - #${id} ${dep.status} ${dep.subject}` : ` - #${id} (missing)`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
lines.push("");
|
|
309
|
+
lines.push("blocks:");
|
|
310
|
+
if (!task.blocks.length) {
|
|
311
|
+
lines.push(" (none)");
|
|
312
|
+
} else {
|
|
313
|
+
for (const id of task.blocks) {
|
|
314
|
+
const child = tasks.find((t) => t.id === id) ?? (await getTask(teamDir, effectiveTlId, id));
|
|
315
|
+
lines.push(child ? ` - #${id} ${child.status} ${child.subject}` : ` - #${id} (missing)`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
default: {
|
|
324
|
+
ctx.ui.notify(`Unknown dep subcommand: ${depSub}`, "error");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case "clear": {
|
|
331
|
+
const flags = taskRest.filter((a) => a.startsWith("--"));
|
|
332
|
+
const argsOnly = taskRest.filter((a) => !a.startsWith("--"));
|
|
333
|
+
const force = flags.includes("--force");
|
|
334
|
+
|
|
335
|
+
const unknownFlags = flags.filter((f) => f !== "--force");
|
|
336
|
+
if (unknownFlags.length) {
|
|
337
|
+
ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (argsOnly.length > 1) {
|
|
342
|
+
ctx.ui.notify("Usage: /team task clear [completed|all] [--force]", "error");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const modeArg = argsOnly[0];
|
|
347
|
+
if (modeArg && modeArg !== "completed" && modeArg !== "all") {
|
|
348
|
+
ctx.ui.notify("Usage: /team task clear [completed|all] [--force]", "error");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const mode = modeArg === "all" ? "all" : "completed";
|
|
352
|
+
|
|
353
|
+
await refreshTasks();
|
|
354
|
+
const tasks = getTasks();
|
|
355
|
+
const toDelete = mode === "all" ? tasks.length : tasks.filter((t) => t.status === "completed").length;
|
|
356
|
+
|
|
357
|
+
if (!force) {
|
|
358
|
+
// Only prompt in interactive TTY mode. In RPC mode, confirm() would require
|
|
359
|
+
// the host to send extension_ui_response messages.
|
|
360
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
361
|
+
const title = mode === "all" ? "Clear task list" : "Clear completed tasks";
|
|
362
|
+
const body =
|
|
363
|
+
mode === "all"
|
|
364
|
+
? `Delete ALL ${toDelete} task(s) from the task list? This cannot be undone.`
|
|
365
|
+
: `Delete ${toDelete} completed task(s) from the task list? This cannot be undone.`;
|
|
366
|
+
const ok = await ctx.ui.confirm(title, body);
|
|
367
|
+
if (!ok) return;
|
|
368
|
+
} else {
|
|
369
|
+
ctx.ui.notify("Refusing to clear tasks in non-interactive mode without --force", "error");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const res = await clearTasks(teamDir, effectiveTlId, mode);
|
|
375
|
+
const deleted = res.deletedTaskIds.length;
|
|
376
|
+
|
|
377
|
+
if (res.errors.length) {
|
|
378
|
+
ctx.ui.notify(`Cleared ${deleted} task(s) (${mode}) with ${res.errors.length} error(s)`, "warning");
|
|
379
|
+
const preview = res.errors
|
|
380
|
+
.slice(0, 8)
|
|
381
|
+
.map((e) => `- ${path.basename(e.file)}: ${e.error}`)
|
|
382
|
+
.join("\n");
|
|
383
|
+
ctx.ui.notify(
|
|
384
|
+
`Errors:\n${preview}${res.errors.length > 8 ? `\n... +${res.errors.length - 8} more` : ""}`,
|
|
385
|
+
"warning",
|
|
386
|
+
);
|
|
387
|
+
} else if (deleted === 0) {
|
|
388
|
+
ctx.ui.notify(`No task(s) cleared (${mode})`, "info");
|
|
389
|
+
} else {
|
|
390
|
+
ctx.ui.notify(`Cleared ${deleted} task(s) (${mode})`, "warning");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
await refreshTasks();
|
|
394
|
+
renderWidget();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
case "list": {
|
|
399
|
+
await refreshTasks();
|
|
400
|
+
const tasks = getTasks();
|
|
401
|
+
if (!tasks.length) {
|
|
402
|
+
ctx.ui.notify("No tasks", "info");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const slice = tasks.slice(-30);
|
|
407
|
+
const blocked = await Promise.all(
|
|
408
|
+
slice.map(async (t) => (t.status === "completed" ? false : await isTaskBlocked(teamDir, effectiveTlId, t))),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const preview = slice.map((t, i) => formatTaskLine(t, { blocked: blocked[i] })).join("\n");
|
|
412
|
+
ctx.ui.notify(preview, "info");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
case "use": {
|
|
417
|
+
const newId = taskRest[0];
|
|
418
|
+
if (!newId) {
|
|
419
|
+
ctx.ui.notify("Usage: /team task use <taskListId>", "error");
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
setTaskListId(newId);
|
|
423
|
+
await ensureTeamConfig(teamDir, { teamId, taskListId: newId, leadName, style });
|
|
424
|
+
ctx.ui.notify(`Task list ID set to: ${newId}`, "info");
|
|
425
|
+
await refreshTasks();
|
|
426
|
+
renderWidget();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
default: {
|
|
431
|
+
ctx.ui.notify(`Unknown task subcommand: ${taskSub}`, "error");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|