@staix/agent-hub 0.29.0 → 0.31.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/CHANGELOG.md +51 -0
- package/README.md +15 -7
- package/docs/agent-notes/README.md +13 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +10 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +10 -4
- package/docs/agent-notes/tests.md +8 -4
- package/docs/design/adapters.md +108 -0
- package/docs/design/agent-hub-architecture.md +58 -0
- package/docs/design/bus.md +147 -1
- package/docs/design/task-board.md +100 -0
- package/docs/dogfood.md +29 -8
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +410 -86
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +25 -5
- package/docs/sdlc.md +1 -1
- package/docs/security.md +182 -62
- package/docs/smoke.md +13 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/specs/2026-10-12-office-view-design.md +86 -3
- package/docs/verification/2026-11-03-0.30.0.md +54 -0
- package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
- package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
- package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
- package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
- package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
- package/docs/verification/2026-11-03-pi-title-api.md +26 -0
- package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
- package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verification/dogfood/2026-11-03.md +3095 -0
- package/docs/verification/pi-150-model-free.ts +57 -0
- package/docs/verification/pi-150-native.mjs +21 -0
- package/docs/verification/pi-150-title-api.mjs +31 -0
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4565 -3617
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +13 -13
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +82 -23
- package/src/adapters/pi.ts +140 -16
- package/src/adapters/registry-types.ts +16 -0
- package/src/adapters/registry.ts +4 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/autonomy.ts +66 -1
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +82 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +10 -5
- package/src/cli/identity.ts +21 -4
- package/src/cli/main.ts +167 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/cli/worktree-tidy.ts +158 -0
- package/src/cli/worktrees-output.ts +43 -0
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +396 -20
- package/src/hub/bus.ts +497 -57
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/conductor.ts +4 -3
- package/src/hub/config-apply.ts +8 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1795 -142
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/dogfood-read.ts +62 -9
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +66 -11
- package/src/hub/manager.ts +67 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peer-doing.ts +33 -3
- package/src/hub/peers.ts +32 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/settings.ts +3 -0
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +607 -71
- package/src/hub/ui.ts +2 -0
- package/src/hub/worktree-prs.ts +100 -0
- package/src/hub/worktree-shelf.ts +246 -0
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/pi/extension.ts +43 -3
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +271 -17
- package/src/ui/needs.ts +10 -4
- package/src/ui/office-model.ts +64 -8
- package/src/ui/office-space.ts +1 -0
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +219 -28
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
|
@@ -33,6 +33,9 @@ export function supersededDeliveryReason(
|
|
|
33
33
|
if (!Number.isSafeInteger(id)) return undefined;
|
|
34
34
|
const task = board.get(id);
|
|
35
35
|
if (!task) return undefined;
|
|
36
|
+
// Owner review packages and verdicts carry outcomes, not a reviewer assignment.
|
|
37
|
+
// A task/role move cannot prove that their recipient consumed them.
|
|
38
|
+
if (env.kind === "review" && env.refs.taskRecipient === "owner") return undefined;
|
|
36
39
|
if (task.state === "cancelled") {
|
|
37
40
|
if (typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision) return undefined;
|
|
38
41
|
reasons.add(`task #${task.id} cancelled`);
|
|
@@ -63,7 +66,7 @@ export function deliveryTaskContext(
|
|
|
63
66
|
if (task.state === "cancelled") context.add(`task #${task.id} cancelled`);
|
|
64
67
|
else if (task.state === "approved") context.add(`task #${task.id} approved`);
|
|
65
68
|
else if (task.owner !== recipient && task.reviewer !== recipient) context.add(`task #${task.id} moved`);
|
|
66
|
-
else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) context.add(`task #${task.id} review moved`);
|
|
69
|
+
else if ((env.kind === "review" && env.refs.taskRecipient !== "owner" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) context.add(`task #${task.id} review moved`);
|
|
67
70
|
}
|
|
68
71
|
return context.size ? [...context].join("; ") : undefined;
|
|
69
72
|
}
|
package/src/hub/dogfood-read.ts
CHANGED
|
@@ -12,7 +12,8 @@ import type { ProfileName } from "./profiles.ts";
|
|
|
12
12
|
import type { StampedEvent } from "./events.ts";
|
|
13
13
|
|
|
14
14
|
export const DOGFOOD_BYTE_CAP = 16 * 1024 * 1024;
|
|
15
|
-
|
|
15
|
+
/** MAX_PII bounds the PII inventory (ids and PR refs only), separately from the windowed task rows (#1147). */
|
|
16
|
+
const MAX_TASKS = 500, MAX_PII = 2000, MAX_PRS = 16, MAX_CI_HEADS = 8, MAX_CI_READS = 32;
|
|
16
17
|
const date = (value: unknown): number | undefined => typeof value === "string" && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;
|
|
17
18
|
const id = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0;
|
|
18
19
|
const repoName = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
@@ -51,13 +52,59 @@ export function dogfoodEvents(lines: Source<string[]>, since: number, until: num
|
|
|
51
52
|
}
|
|
52
53
|
return { data: out, available: lines.available, complete };
|
|
53
54
|
}
|
|
54
|
-
/**
|
|
55
|
-
export
|
|
55
|
+
/** One PII task as the privacy inventory knows it: an id and its PR ref, never any text. */
|
|
56
|
+
export interface PiiRef { id: number; repo?: string; pr?: number }
|
|
57
|
+
/**
|
|
58
|
+
* #1147: every PII task of the board, read completely and independently of the scorecard window. Privacy depends only on a task's
|
|
59
|
+
* signals and its sdlc receipt, so only those are read. A row whose signals cannot be read (null, oversized, not a JSON array)
|
|
60
|
+
* could be a PII task, and a PII receipt that cannot be parsed hides its PR: both make the inventory incomplete, as does overflowing MAX_PII.
|
|
61
|
+
*/
|
|
62
|
+
export function dogfoodPiiInventory(file: string): Source<PiiRef[]> {
|
|
63
|
+
let db: Database | undefined;
|
|
64
|
+
try {
|
|
65
|
+
db = new Database(file, { readonly: true });
|
|
66
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
67
|
+
const rows = db.query(`SELECT id, unreadable, receipt_cut, sdlc FROM (
|
|
68
|
+
SELECT id,
|
|
69
|
+
CASE WHEN signals IS NULL OR length(signals) > 65536 THEN 1 WHEN NOT json_valid(signals) THEN 1 WHEN json_type(signals) <> 'array' THEN 1 ELSE 0 END AS unreadable,
|
|
70
|
+
CASE WHEN signals IS NOT NULL AND length(signals) <= 65536 AND json_valid(signals) AND json_type(signals) = 'array'
|
|
71
|
+
THEN EXISTS (SELECT 1 FROM json_each(signals) WHERE value IN ('pii','pii_path')) ELSE 0 END AS pii,
|
|
72
|
+
(sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut,
|
|
73
|
+
CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc FROM tasks)
|
|
74
|
+
WHERE unreadable = 1 OR pii = 1 ORDER BY id DESC LIMIT ?`).all(MAX_PII + 1) as { id: number; unreadable: number; receipt_cut: number; sdlc: string | null }[];
|
|
75
|
+
let complete = rows.length <= MAX_PII;
|
|
76
|
+
const refs: PiiRef[] = [];
|
|
77
|
+
for (const row of rows.slice(0, MAX_PII)) {
|
|
78
|
+
if (row.unreadable || row.receipt_cut || !id(row.id)) { complete = false; continue; }
|
|
79
|
+
try {
|
|
80
|
+
const receipt = JSON.parse(row.sdlc ?? "{}");
|
|
81
|
+
refs.push({ id: row.id, ...(repoName(receipt?.repo) ? { repo: receipt.repo } : {}), ...(id(receipt?.pr) ? { pr: receipt.pr } : {}) });
|
|
82
|
+
} catch { complete = false; }
|
|
83
|
+
}
|
|
84
|
+
return source(refs, complete);
|
|
85
|
+
} catch { return missing([]); }
|
|
86
|
+
finally { db?.close(); }
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Bounded read-only board snapshot. Select no task title/detail or history note into the result.
|
|
90
|
+
* With a window (#1147) only tasks that overlap [since, until] are read (updated >= since and created <= until), so a board over
|
|
91
|
+
* MAX_TASKS rows still reads completely when the window holds few of them. A board without those columns reads its newest MAX_TASKS rows.
|
|
92
|
+
* `linkedPrs` adds every task whose receipt links one of those PR numbers, window or not: a PR's lead time, review rounds and findings
|
|
93
|
+
* need all its tasks, so a sibling last updated before `since` must not be dropped. Their overflow shares the MAX_TASKS bound and
|
|
94
|
+
* makes the source incomplete, which turns every board-derived PR metric unknown.
|
|
95
|
+
*/
|
|
96
|
+
export function dogfoodTasks(file: string, window?: { since: number; until: number }, linkedPrs: number[] = []): Source<DogfoodTask[]> {
|
|
56
97
|
let db: Database | undefined;
|
|
57
98
|
try {
|
|
58
99
|
db = new Database(file, { readonly: true });
|
|
59
100
|
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
60
101
|
const columns = new Set((db.query("PRAGMA table_info(tasks)").all() as {name:string}[]).map(c=>c.name));
|
|
102
|
+
const windowed = window !== undefined && Number.isFinite(window.since) && Number.isFinite(window.until) && columns.has("created") && columns.has("updated");
|
|
103
|
+
const linked = windowed && columns.has("sdlc") ? [...new Set(linkedPrs.filter(n => Number.isSafeInteger(n) && n > 0))].slice(0, MAX_PRS) : [];
|
|
104
|
+
// json_extract raises on malformed JSON, so a row is only asked when its receipt is valid JSON.
|
|
105
|
+
const where = !windowed ? "" : linked.length
|
|
106
|
+
? `WHERE (updated >= ? AND created <= ?) OR (CASE WHEN json_valid(sdlc) THEN json_extract(sdlc,'$.pr') END) IN (${linked.map(() => "?").join(",")})`
|
|
107
|
+
: "WHERE updated >= ? AND created <= ?";
|
|
61
108
|
const metadata = columns.has("class") ? "CASE WHEN length(class) <= 48 THEN class END" : "NULL";
|
|
62
109
|
const profile = columns.has("project_profile") ? "CASE WHEN length(project_profile) <= 65536 THEN project_profile END" : "NULL";
|
|
63
110
|
const plan = columns.has("plan") ? "CASE WHEN length(plan) <= 65536 THEN plan END" : "NULL";
|
|
@@ -67,7 +114,7 @@ export function dogfoodTasks(file: string): Source<DogfoodTask[]> {
|
|
|
67
114
|
const profileCut = columns.has("project_profile") ? "(project_profile IS NOT NULL AND length(project_profile) > 65536)" : "0";
|
|
68
115
|
const rows = db.query(`SELECT (${planCut}) AS plan_cut, ${plan} AS plan, (${metadataCut}) AS metadata_cut, ${state} AS current_state, ${metadata} AS base_class, ${profileCut} AS profile_cut, ${profile} AS project_profile, id, (sdlc IS NOT NULL AND length(sdlc) > 65536) AS receipt_cut, CASE WHEN length(sdlc) <= 65536 THEN sdlc END AS sdlc,
|
|
69
116
|
CASE WHEN length(signals) <= 65536 THEN signals END AS signals,
|
|
70
|
-
CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ORDER BY id DESC LIMIT ?`).all(MAX_TASKS + 1) as { plan_cut: number; plan: string | null; metadata_cut: number; current_state: string | null; profile_cut: number; base_class: string | null; project_profile: string | null; id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
|
|
117
|
+
CASE WHEN length(history) <= 131072 THEN history END AS history FROM tasks ${where} ORDER BY id DESC LIMIT ?`).all(...(windowed ? [window!.since, window!.until, ...linked] : []), MAX_TASKS + 1) as { plan_cut: number; plan: string | null; metadata_cut: number; current_state: string | null; profile_cut: number; base_class: string | null; project_profile: string | null; id: number; receipt_cut: number; sdlc: string | null; signals: string | null; history: string | null }[];
|
|
71
118
|
let complete = rows.length <= MAX_TASKS, bytes = 0;
|
|
72
119
|
const tasks: DogfoodTask[] = [];
|
|
73
120
|
for (const row of rows.slice(0,MAX_TASKS)) {
|
|
@@ -144,15 +191,18 @@ export function dogfoodJournal(file: string, since: number, until: number): Sour
|
|
|
144
191
|
}
|
|
145
192
|
const connection = (value: any): any[] | undefined => value?.pageInfo?.hasNextPage === false && Array.isArray(value.nodes) && value.nodes.length <= 100 ? value.nodes : undefined;
|
|
146
193
|
/** Reuse S10 cost readback; supplementary reads carry timestamps/ids only, never comment or issue bodies. */
|
|
147
|
-
export async function dogfoodPrs(repo: string, tasks: Source<DogfoodTask[]>, since: number, until: number, run: SdlcGh = sdlcGh): Promise<Source<DogfoodPr[]>> {
|
|
148
|
-
|
|
194
|
+
export async function dogfoodPrs(repo: string, tasks: Source<DogfoodTask[]>, since: number, until: number, run: SdlcGh = sdlcGh, pii?: Source<PiiRef[]>): Promise<Source<DogfoodPr[]>> {
|
|
195
|
+
// An unknown privacy inventory forbids remote per-PR reads. With the PII inventory (#1147) only it decides privacy, so an incomplete
|
|
196
|
+
// windowed task list no longer blocks them (its links stay unknown elsewhere); without it the task list is the inventory, as before.
|
|
197
|
+
const inventory: Source<PiiRef[]> = pii ?? { ...tasks, data: tasks.data.filter(t => t.pii).map(t => ({ id: t.id, ...(t.repo ? { repo: t.repo } : {}), ...(t.pr ? { pr: t.pr } : {}) })) };
|
|
198
|
+
if (!repoName(repo) || !inventory.available || !inventory.complete) return missing([]);
|
|
149
199
|
const deadline = performance.now() + 20000;
|
|
150
200
|
const gh: SdlcGh = (args, timeout, cwd, cap) => performance.now() < deadline
|
|
151
201
|
? run(args, Math.max(1,Math.min(timeout ?? 5000,deadline-performance.now())),cwd,Math.min(cap ?? 128*1024,128*1024)) : Promise.resolve({code:1,stdout:""});
|
|
152
202
|
const parse = async (args: string[]) => { try { const r = await gh(args); return r.code === 0 ? JSON.parse(r.stdout) : undefined; } catch { return undefined; } };
|
|
153
203
|
const list = await parse(["pr","list","--repo",repo,"--state","merged","--search",`merged:>=${new Date(since).toISOString()}`,"--limit","100","--json","number,mergedAt,createdAt"]);
|
|
154
204
|
if (!Array.isArray(list)) return missing([]);
|
|
155
|
-
const hidden = new Set(
|
|
205
|
+
const hidden = new Set(inventory.data.filter(t => !t.repo || t.repo.toLowerCase() === repo.toLowerCase()).map(t => t.pr));
|
|
156
206
|
const visible = list.filter(p => id(p?.number) && date(p.mergedAt) !== undefined && date(p.mergedAt)! <= until && !hidden.has(p.number));
|
|
157
207
|
let complete = list.length < 100 && list.every(p => id(p?.number) && date(p.mergedAt) !== undefined) && visible.length <= MAX_PRS;
|
|
158
208
|
// Read recent PRs first: their complete recorded head sets usually fit before
|
|
@@ -252,15 +302,18 @@ export function dogfoodProjectProfile(root: string): Source<ProfileName | null>
|
|
|
252
302
|
return source(selected);
|
|
253
303
|
}
|
|
254
304
|
export async function readDogfood(root: string, stateDir: string, since: number, until = Date.now(), run: SdlcGh = sdlcGh) {
|
|
255
|
-
const tasks = dogfoodTasks(join(stateDir,"hub.db"));
|
|
305
|
+
const tasks = dogfoodTasks(join(stateDir,"hub.db"),{since,until}), pii = dogfoodPiiInventory(join(stateDir,"hub.db"));
|
|
256
306
|
let repo = "";
|
|
257
307
|
try { const r = await run(["repo","view","--json","nameWithOwner"],5000,root,8192); if (r.code === 0) repo = JSON.parse(r.stdout).nameWithOwner; } catch { /* unknown */ }
|
|
258
308
|
const events = dogfoodEvents(dogfoodLines(join(stateDir,"events.jsonl")),since,until);
|
|
259
309
|
const log = dogfoodLines(join(stateDir,"hub.log"));
|
|
310
|
+
const prs = await dogfoodPrs(repo,tasks,since,until,run,pii);
|
|
260
311
|
log.data = log.data.filter(line => {
|
|
261
312
|
const at=date(line.slice(0,24));
|
|
262
313
|
if (at === undefined) { log.complete = false; return false; }
|
|
263
314
|
return at >= since && at <= until;
|
|
264
315
|
});
|
|
265
|
-
|
|
316
|
+
const merged = prs.available ? prs.data.map(p => p.pr) : [];
|
|
317
|
+
const board = merged.length ? dogfoodTasks(join(stateDir,"hub.db"),{since,until},merged) : tasks;
|
|
318
|
+
return dogfoodScorecard({since,until,tasks:board,events,log,projectProfile:dogfoodProjectProfile(root),journal:dogfoodJournal(join(stateDir,"hub.db"),since,until),prs,features:dogfoodFeatures(join(root,"docs/dogfood.md"))});
|
|
266
319
|
}
|
package/src/hub/envelope.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
export type PeerId = string;
|
|
4
4
|
export type PeerState = "idle" | "busy" | "paused" | "offline";
|
|
5
5
|
export type Priority = "important" | "status" | "fyi";
|
|
6
|
-
/** Every envelope kind, once: the journal, the restart reader, the deferred-notice check and the held-delivery hints all read this list. `invocation` and `invocation_result` are #445 C2. */
|
|
7
|
-
export const KINDS = ["chat", "task", "review", "status", "budget", "presence", "invocation", "invocation_result"] as const;
|
|
6
|
+
/** Every envelope kind, once: the journal, the restart reader, the deferred-notice check and the held-delivery hints all read this list. `invocation` and `invocation_result` are #445 C2, `answer` is #1053: a hub-built late answer (or expiry notice) to a question, `refs.question` names the set. */
|
|
7
|
+
export const KINDS = ["chat", "task", "review", "status", "budget", "presence", "invocation", "invocation_result", "answer"] as const;
|
|
8
8
|
export type Kind = (typeof KINDS)[number];
|
|
9
9
|
|
|
10
10
|
export interface Envelope {
|
|
@@ -16,7 +16,7 @@ export interface Envelope {
|
|
|
16
16
|
kind: Kind;
|
|
17
17
|
priority: Priority;
|
|
18
18
|
body: string; // agent conclusions only, never tool noise
|
|
19
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[]; invocation?: string; invocationRemote?: true };
|
|
19
|
+
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; reviewPackage?: string; reviewResponse?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[]; invocation?: string; invocationRemote?: true; question?: string; handoff?: { project: string; task: number } };
|
|
20
20
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
21
21
|
private?: boolean;
|
|
22
22
|
/** Sent by a distinct session (`<peer>~<origin>`, agent-hub#1036): a connection that named a peer id but is not that peer's current session. It grants nothing; it marks the sender for the log. */
|
package/src/hub/events.ts
CHANGED
|
@@ -54,8 +54,14 @@ export type HubEvent =
|
|
|
54
54
|
| { type: "conduct"; peer: string; action: string; task?: number; target?: string }
|
|
55
55
|
| { type: "agent_cli"; peer: string; command: string; refused: boolean; delegated?: true }
|
|
56
56
|
| { type: "permission"; id: string; peer: string; event: "requested" | "answered" | "expired" | "cancelled"; latencyMs?: number; surface?: "console" | "dashboard" | "terminal"; option?: "allow_once" | "allow_always" | "reject_once" | "reject_always" }
|
|
57
|
+
/** #1053: one question a peer asked the person, and how it ended. Counts, ids and a closed reason only: never a prompt, an option, an answer or other text. */
|
|
58
|
+
| { type: "question"; set: string; peer: string; event: "asked" | "answered" | "pending" | "superseded" | "cancelled" | "expired" | "unavailable"; latencyMs?: number; surface?: "terminal" | "console" | "dashboard" | "acon"; person?: string; reason?: "superseded" | "stopped" | "turn_aborted" | "session_changed" | "expired" | "unattended"; questions?: number; private?: boolean }
|
|
57
59
|
/** Completed native turns that received supervision, not feed enqueues or inferred costs. */
|
|
58
60
|
| { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
|
|
61
|
+
/** #1054: a run a person started settled (or a restart cut it short). Ids, a closed outcome and a duration only: never the prompt or the reply. */
|
|
62
|
+
/** #1054: a person (or the hub on a person's command) marked a result read. A receipt only: never read from a snapshot. */
|
|
63
|
+
| { type: "completion_seen"; id: string; surface: "terminal" | "console" | "dashboard" | "acon" }
|
|
64
|
+
| { type: "completion"; id: string; peer: string; outcome: "done" | "failed" | "limit" | "interrupted"; ms: number }
|
|
59
65
|
| { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
|
|
60
66
|
| { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
|
|
61
67
|
| ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
|
package/src/hub/handoff.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// Cross-project task handoff (#604, agent-hub#1030). Pure: values in, values out, no clock, no file system, no socket. The daemon, the registry and the control client are
|
|
2
2
|
// passed in as data or as small ports, so every refusal here is testable without a second daemon and the policy cannot drift from what the daemon enforces.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { CLASSES, type TaskClass } from "./board.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* What crosses projects, and nothing else: no memory, evidence, turn content or task history (#1030 design 5). The per-field limits alone allow about 13 KB, so `bodyBytes` is the real
|
|
7
8
|
* ceiling on the whole request: a body that fills several fields is refused whole, and no partial task is built from it.
|
|
8
9
|
*/
|
|
9
|
-
export const HANDOFF_LIMITS = Object.freeze({ title: 300, detail: 4000, paths: 16, path: 512, issue: 2_000_000_000, bodyBytes: 8 * 1024 });
|
|
10
|
-
export const HANDOFF_FIELDS = Object.freeze(["project", "title", "class", "detail", "paths", "issue", "task"] as const);
|
|
10
|
+
export const HANDOFF_LIMITS = Object.freeze({ title: 300, detail: 4000, paths: 16, path: 512, issue: 2_000_000_000, bodyBytes: 8 * 1024, duplicateOk: 300 });
|
|
11
|
+
export const HANDOFF_FIELDS = Object.freeze(["project", "title", "class", "detail", "paths", "issue", "task", "duplicate_ok", "handoffKey"] as const);
|
|
12
|
+
/** #731 (agent-hub#1203): the origin's idempotency key for one handoff request, a lowercase UUID v4 (what `randomUUID` writes). */
|
|
13
|
+
export const HANDOFF_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
11
14
|
|
|
12
15
|
export type HandoffRefusalCode =
|
|
13
|
-
| "invalid" | "too_large" | "unregistered" | "same_project" | "not_running" | "older_target" | "pii_turn" | "pii_task" | "no_authority";
|
|
16
|
+
| "invalid" | "too_large" | "unregistered" | "same_project" | "not_running" | "older_target" | "pii_turn" | "pii_task" | "no_authority" | "duplicate" | "outcome_unknown" | "mismatch";
|
|
14
17
|
|
|
15
18
|
/** A refusal always names its reason, and for the person-fixable ones the action. The code is for tests and logs, the message for the caller. */
|
|
16
19
|
export class HandoffRefusal extends Error {
|
|
@@ -18,7 +21,11 @@ export class HandoffRefusal extends Error {
|
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** `task` is the caller's OWN task this handoff comes from, so a `[pii]` one is refused and the target can name it in the follow-back. It never crosses as text. */
|
|
21
|
-
export interface HandoffRequest { title: string; class: TaskClass; detail: string; paths: string[]; issue?: number; task?: number
|
|
24
|
+
export interface HandoffRequest { title: string; class: TaskClass; detail: string; paths: string[]; issue?: number; task?: number;
|
|
25
|
+
/** #1175: the sender's reason for filing a second task on a spec issue that already has an open one on the target's board. Absent means the target refuses a duplicate. */
|
|
26
|
+
duplicate_ok?: string;
|
|
27
|
+
/** #731: the key of this request. Absent means the origin mints one when the target understands keys; it crosses inside the request body only to a target that advertises `task_handoff_key`. */
|
|
28
|
+
handoffKey?: string }
|
|
22
29
|
|
|
23
30
|
const hasControl = (text: string): boolean => /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text);
|
|
24
31
|
|
|
@@ -71,11 +78,32 @@ export function parseHandoff(input: unknown): HandoffRequest & { project: string
|
|
|
71
78
|
task = typeof rawTask === "string" && /^[1-9]\d{0,9}$/.test(rawTask) ? Number(rawTask) : rawTask as number;
|
|
72
79
|
if (typeof task !== "number" || !Number.isSafeInteger(task) || task < 1 || task > HANDOFF_LIMITS.issue) throw new HandoffRefusal("invalid", "handoff task must be a positive task id of this project");
|
|
73
80
|
}
|
|
74
|
-
const
|
|
81
|
+
const duplicateOk = text(field("duplicate_ok"), "duplicate_ok", HANDOFF_LIMITS.duplicateOk, false).replace(/\s+/g, " ").trim();
|
|
82
|
+
if (field("duplicate_ok") !== undefined && field("duplicate_ok") !== null && field("duplicate_ok") !== "" && !duplicateOk) throw new HandoffRefusal("invalid", "handoff duplicate_ok needs a reason");
|
|
83
|
+
const rawKey = field("handoffKey");
|
|
84
|
+
if (rawKey !== undefined && rawKey !== null && rawKey !== "" && (typeof rawKey !== "string" || !HANDOFF_KEY.test(rawKey))) throw new HandoffRefusal("invalid", "handoff handoffKey must be a lowercase UUID v4");
|
|
85
|
+
const handoffKey = typeof rawKey === "string" && rawKey ? rawKey : undefined;
|
|
86
|
+
const request = { project, title, class: cls as TaskClass, detail, paths, ...(issue === undefined ? {} : { issue }), ...(task === undefined ? {} : { task }), ...(duplicateOk ? { duplicate_ok: duplicateOk } : {}), ...(handoffKey === undefined ? {} : { handoffKey }) };
|
|
75
87
|
if (Buffer.byteLength(JSON.stringify(request), "utf8") > HANDOFF_LIMITS.bodyBytes) throw new HandoffRefusal("too_large", `handoff is over ${HANDOFF_LIMITS.bodyBytes} bytes`);
|
|
76
88
|
return request;
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
/**
|
|
92
|
+
* #731: what a key stands for. The target keeps this with the key, so a replay of the same key answers with the existing task, and the same key with a different request is refused, never
|
|
93
|
+
* silently answered with a task the caller did not ask for. The key itself and the target are not part of it; the origin's own task id is.
|
|
94
|
+
*/
|
|
95
|
+
export function handoffRequestDigest(request: Pick<HandoffRequest, "title" | "class" | "detail" | "paths" | "issue" | "duplicate_ok">, originTask?: number): string {
|
|
96
|
+
return createHash("sha256").update(JSON.stringify([request.title, request.class, request.detail, request.paths, request.issue ?? null, request.duplicate_ok ?? null, originTask ?? null])).digest("hex");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* #731 (H1): whether a control reply says the request may have reached the target without an answer coming back: the client's own timeout, or a connection that closed while the request was out.
|
|
101
|
+
* A refusal the target wrote, and a request that was never sent, are definite and are not this. Reads the client's fixed wording (`control-client.ts`), never the target's text.
|
|
102
|
+
*/
|
|
103
|
+
export function handoffOutcomeUnknown(reply: { ok?: unknown; error?: unknown }): boolean {
|
|
104
|
+
return reply.ok !== true && typeof reply.error === "string" && /^(no answer from the hub within \d+ s|hub connection closed)/.test(reply.error);
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
export interface RegisteredProject { id: string; root: string; stateDir: string }
|
|
80
108
|
export interface TargetDescriptor { features?: readonly string[]; instanceId?: string; projectId?: string; cwd?: string }
|
|
81
109
|
|
|
@@ -93,6 +121,56 @@ export function resolveTarget(name: string, projects: readonly RegisteredProject
|
|
|
93
121
|
return match;
|
|
94
122
|
}
|
|
95
123
|
|
|
124
|
+
/**
|
|
125
|
+
* #734 (agent-hub#1203 H8): a failed connection to a target hub, named by its cause. The control client throws an `Error` with a `code`: 4426 (the wire versions share no
|
|
126
|
+
* protocol), 4404 (the hub or instance answering is not the one asked for), or a connect code (`AHUB_CONNECT_TIMEOUT`, `AHUB_CONNECT_UNREACHABLE`) or a socket close code. Each cause has
|
|
127
|
+
* its own next step, and none is "run `ahub up`" unless the hub really is not running: a version skew is fixed by upgrading, an instance mismatch by asking the right hub.
|
|
128
|
+
* Pure: the error's `code` and nothing from its message is read, so a target's text never decides a branch.
|
|
129
|
+
*/
|
|
130
|
+
export function handoffConnectFailure(error: unknown, target: { id: string; root: string }): { code: HandoffRefusalCode; reach: "unreachable"; cause: string; message: string } {
|
|
131
|
+
const code = typeof (error as { code?: unknown })?.code === "number" || typeof (error as { code?: unknown })?.code === "string" ? (error as { code: number | string }).code : undefined;
|
|
132
|
+
if (code === 4426) return { code: "older_target", reach: "unreachable", cause: "wire version differs",
|
|
133
|
+
message: `the hub of ${target.id} speaks another wire version than this one: upgrade whichever is older (\`ahub upgrade\` in ${target.root}, and the global CLI with \`bun add -g @staix/agent-hub\`), then hand the task off again` };
|
|
134
|
+
if (code === 4404) return { code: "mismatch", reach: "unreachable", cause: "another hub or instance answered",
|
|
135
|
+
message: `the hub answering for ${target.root} is not the instance this project's registry names (it was restarted, or another project took its port): run \`ahub projects\` to see what is registered, and \`ahub up\` in ${target.root} if it is not running` };
|
|
136
|
+
if (code === "AHUB_CONNECT_TIMEOUT") return { code: "not_running", reach: "unreachable", cause: "no answer in time",
|
|
137
|
+
message: `the hub of ${target.id} did not answer in time: it may be busy or stuck; check \`ahub status\` in ${target.root}, then hand the task off again (handoffs are not queued)` };
|
|
138
|
+
return { code: "not_running", reach: "unreachable", cause: "not running",
|
|
139
|
+
message: `the hub of ${target.id} did not answer; run \`ahub up\` in ${target.root}, then hand the task off again (handoffs are not queued)` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One word for where a handoff row's target stands, shown by `ahub task handoff --list`. `moved` is a project id the registry no longer holds: its root changed or it was removed, and the id is a hash of the root. */
|
|
143
|
+
export type HandoffReach = { reach: "reachable" } | { reach: "unreachable"; cause: string } | { reach: "moved"; cause: string };
|
|
144
|
+
export function handoffReach(input: { registered: boolean; descriptor?: TargetDescriptor | undefined; lastFailure?: string | undefined }): HandoffReach {
|
|
145
|
+
if (!input.registered) return { reach: "moved", cause: "project not registered (moved?): run `ahub projects`, and `ahub up` in its new root" };
|
|
146
|
+
if (!input.descriptor) return { reach: "unreachable", cause: input.lastFailure ?? "not running: run `ahub up` in its root" };
|
|
147
|
+
if (!input.descriptor.features?.includes("task_handoff")) return { reach: "unreachable", cause: "too old for handoffs: upgrade it with `ahub upgrade`" };
|
|
148
|
+
return input.lastFailure ? { reach: "unreachable", cause: input.lastFailure } : { reach: "reachable" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A person-readable age ("45s", "12m", "3h", "5d") for a row last updated `ms` ago. A negative or non-finite age reads "-": a clock that moved is not a duration. */
|
|
152
|
+
export function handoffAge(ms: number): string {
|
|
153
|
+
if (!Number.isFinite(ms) || ms < 0) return "-";
|
|
154
|
+
const s = Math.floor(ms / 1000);
|
|
155
|
+
if (s < 90) return `${s}s`;
|
|
156
|
+
const m = Math.floor(s / 60);
|
|
157
|
+
if (m < 90) return `${m}m`;
|
|
158
|
+
const h = Math.floor(m / 60);
|
|
159
|
+
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* #734 (H7): at most one log line per target and cause in each window, so a target that stays down for a day writes a line per window and not a line per notify or catch-up. The state is a
|
|
164
|
+
* plain map the caller owns (one per daemon), bounded by `max` keys: past it the oldest key is forgotten, which can only let one extra line through, never suppress a new cause.
|
|
165
|
+
*/
|
|
166
|
+
export function handoffLogGate(seen: Map<string, number>, key: string, now: number, windowMs: number, max = 256): boolean {
|
|
167
|
+
const last = seen.get(key);
|
|
168
|
+
if (last !== undefined && now - last < windowMs && now >= last) return false;
|
|
169
|
+
seen.delete(key); seen.set(key, now);
|
|
170
|
+
while (seen.size > max) seen.delete(seen.keys().next().value as string);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
96
174
|
/** What the target's own status descriptor says. A daemon that is not running, or does not know the handoff, is refused with the person's action. */
|
|
97
175
|
export function checkTarget(project: RegisteredProject, descriptor: TargetDescriptor | undefined): void {
|
|
98
176
|
if (!descriptor) throw new HandoffRefusal("not_running", `the hub of ${project.id} is not running; run \`ahub up\` in ${project.root}, then hand the task off again (handoffs are not queued)`);
|
|
@@ -101,7 +179,7 @@ export function checkTarget(project: RegisteredProject, descriptor: TargetDescri
|
|
|
101
179
|
}
|
|
102
180
|
|
|
103
181
|
/** The states a target reports back, in the order a task can reach them. `declined` is the owner passing; the task then returns to the target's routing, so it is information, not an end. */
|
|
104
|
-
export const HANDOFF_EVENTS = Object.freeze(["accepted", "in_review", "approved", "declined", "cancelled"] as const);
|
|
182
|
+
export const HANDOFF_EVENTS = Object.freeze(["accepted", "in_review", "approved", "declined", "cancelled", "changes_requested", "reopened"] as const);
|
|
105
183
|
export type HandoffEvent = (typeof HANDOFF_EVENTS)[number];
|
|
106
184
|
|
|
107
185
|
/** Map a target's task history entry to the event the origin is told about, or undefined for every other entry. Reads only the event name and the task state, never a note. */
|
|
@@ -112,11 +190,33 @@ export function handoffEventFor(entryEvent: string, state: string): HandoffEvent
|
|
|
112
190
|
if (entryEvent === "cancelled" && state === "cancelled") return "cancelled";
|
|
113
191
|
if (entryEvent === "done" && state === "in_review") return "in_review";
|
|
114
192
|
if (entryEvent === "approved" && state === "approved") return "approved";
|
|
193
|
+
// #733 (agent-hub#1203 H10): a reviewer's changes_requested, and the hub's return of that task to its owner, are states the origin used to hear nothing about. A withdrawn approval is the same
|
|
194
|
+
// verdict on an approved task: the row it reopens is picked up through the sequence.
|
|
195
|
+
if (entryEvent === "changes_requested" && state === "changes_requested") return "changes_requested";
|
|
196
|
+
if (entryEvent === "reopened" && state === "in_progress") return "reopened";
|
|
115
197
|
// A class with no reviewer: the done itself approves (`in_progress -> approved`), so the origin hears "approved" for that entry, not "in review".
|
|
116
198
|
if (entryEvent === "done" && state === "approved") return "approved";
|
|
117
199
|
return undefined;
|
|
118
200
|
}
|
|
119
201
|
|
|
202
|
+
/**
|
|
203
|
+
* #733: whether the origin still asks a target about a handoff row. An open row always; an approved one for a grace period, because a reviewer can withdraw an approval and the origin only learns
|
|
204
|
+
* of it through the sequence; a cancelled one never. ponytail: the grace period is a fixed 7 days; a per-row "reopenable" fact from the target would end it, at the cost of one more field per state.
|
|
205
|
+
*/
|
|
206
|
+
export const HANDOFF_APPROVED_GRACE_MS = 7 * 24 * 3600_000;
|
|
207
|
+
export function handoffCatchUpRow(row: { state: string; updatedAt: number }, now: number): boolean {
|
|
208
|
+
if (row.state === "cancelled") return false;
|
|
209
|
+
return row.state !== "approved" || now - row.updatedAt <= HANDOFF_APPROVED_GRACE_MS;
|
|
210
|
+
}
|
|
211
|
+
/** One request carries at most this many task ids: the target refuses a longer list (`handoffStatesFor`). */
|
|
212
|
+
export const HANDOFF_STATES_CHUNK = 200;
|
|
213
|
+
/** Split a project's task ids into requests of at most `HANDOFF_STATES_CHUNK`, in order, none dropped. */
|
|
214
|
+
export function handoffChunks(ids: readonly number[]): number[][] {
|
|
215
|
+
const out: number[][] = [];
|
|
216
|
+
for (let i = 0; i < ids.length; i += HANDOFF_STATES_CHUNK) out.push(ids.slice(i, i + HANDOFF_STATES_CHUNK));
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
120
220
|
const SHA = /^[0-9a-f]{40}$/;
|
|
121
221
|
/** One line, no free text from the target beyond a PR number and a head: `agent-hub task 123 approved: PR #7 at <sha>`. */
|
|
122
222
|
export function followBackLine(remoteTask: number, event: HandoffEvent, evidence: { pr?: unknown; head?: unknown } = {}): string {
|
|
@@ -124,3 +224,42 @@ export function followBackLine(remoteTask: number, event: HandoffEvent, evidence
|
|
|
124
224
|
const head = typeof evidence.head === "string" && SHA.test(evidence.head) ? ` at ${evidence.head}` : "";
|
|
125
225
|
return `agent-hub task ${remoteTask} ${event.replace("_", " ")}${pr || head ? `:${pr}${head}` : ""}`;
|
|
126
226
|
}
|
|
227
|
+
|
|
228
|
+
export interface DuplicateRow { id: number; state: string; title: string; detail?: string; refs?: { repo?: string }; external?: { repo: string; issue: number } | undefined; sdlc?: { issue?: number; repo?: string } | undefined }
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* #1175: the ids of tasks already open on THIS board for the spec issue a handoff names. Pure: the caller passes the board's rows (a `[pii]` row is the caller's to leave out, so no
|
|
232
|
+
* private task id is ever named to another project) and the project's own repository slug. A task counts when it is not closed and one of these names the issue: its lifecycle
|
|
233
|
+
* receipt, a bare `#N` or an `owner/repo#N` of this repository in its title, or the `spec issue: #N in this repository` line a handoff keeps in its detail. A reference qualified
|
|
234
|
+
* with another repository never counts, and a number that appears in a title as another kind of reference (a PR) is the one false positive: the sender's `duplicate_ok` answers it.
|
|
235
|
+
*/
|
|
236
|
+
export function duplicateIssueTasks(tasks: readonly DuplicateRow[], issue: number, repository?: string): number[] {
|
|
237
|
+
const slug = (value: string | undefined): string | undefined => value !== undefined && /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}$/.test(value) ? value.toLowerCase() : undefined;
|
|
238
|
+
// A project with no GitHub remote has a path, not a slug, for its repository: nothing can then be compared, so no row is foreign and no qualified reference matches.
|
|
239
|
+
const own = slug(repository);
|
|
240
|
+
const names = (text: string, bare: boolean): boolean => {
|
|
241
|
+
for (const match of text.matchAll(/(?<![A-Za-z0-9_./-])(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)\b|#([1-9][0-9]*)\b)/g)) {
|
|
242
|
+
const qualified = match[1];
|
|
243
|
+
if (Number(match[2] ?? match[3]) !== issue) continue;
|
|
244
|
+
if (qualified ? qualified.toLowerCase() === own : bare) return true;
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
};
|
|
248
|
+
return tasks.filter(task => {
|
|
249
|
+
if (task.state === "approved" || task.state === "cancelled") return false;
|
|
250
|
+
// The repository each identity belongs to: `sdlc.repo`, `external.repo` (an imported issue) and a slug `refs.repo` (a checkout path says nothing). One naming another repository
|
|
251
|
+
// than this project's keeps its `#N` out: an issue number is only an identity together with its repository. With no own slug nothing can be compared, so the number counts and `duplicate_ok` answers.
|
|
252
|
+
const foreign = (repo: string | undefined): boolean => own !== undefined && slug(repo) !== undefined && slug(repo) !== own;
|
|
253
|
+
if (task.external?.issue === issue && !foreign(task.external.repo)) return true;
|
|
254
|
+
if (task.sdlc?.issue !== undefined) return task.sdlc.issue === issue && !foreign(task.sdlc.repo) && !foreign(task.refs?.repo);
|
|
255
|
+
if (foreign(task.external?.repo) || foreign(task.refs?.repo)) return false;
|
|
256
|
+
const line = /(^|\n)spec issue: #([1-9][0-9]*) in this repository/.exec(task.detail ?? "");
|
|
257
|
+
return names(task.title, true) || line !== null && Number(line[2]) === issue;
|
|
258
|
+
}).map(task => task.id).sort((a, b) => a - b);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** One refusal sentence that names the open tasks and both ways forward. The ids are this board's own; no title or text from a task is repeated. */
|
|
262
|
+
export function duplicateRefusalText(issue: number, ids: readonly number[]): string {
|
|
263
|
+
const named = ids.slice(0, 6).map(id => `#${id}`).join(", ") + (ids.length > 6 ? ` and ${ids.length - 6} more` : "");
|
|
264
|
+
return `spec issue #${issue} already has open ${ids.length === 1 ? "task" : "tasks"} here: ${named}. Use ${ids.length === 1 ? "it" : "them"} (ask the conductor there), or hand off again with duplicate_ok: <reason> (--duplicate-ok <reason> in the CLI) if this is genuinely separate work`;
|
|
265
|
+
}
|