feat(work-items): add 'claim' command for JIT work pickup

Closes the agent+human JIT loop the control-pane board surfaces: the board shows
the unassigned (needs-owner) queue; 'claim' lets an agent or human pick up work.

  node scripts/work-items.js claim [<id>] --owner <name> [--as agent|human]

- No id: claims the highest-priority unassigned open item.
- With id: claims that specific item (re-assignable).
- Sets owner, records metadata.assigneeKind (agent|human), and moves the card to
  running so the board reflects that work has started.
- Refuses done items, requires --owner, validates --as. 5 CLI tests added.

Full suite 2844/2844; lint green.
This commit is contained in:
Affaan Mustafa 2026-06-18 17:07:24 -04:00
parent 1efc399ab4
commit 7fd4ba95ae
2 changed files with 295 additions and 68 deletions

View File

@ -6,6 +6,7 @@ const { spawnSync } = require('child_process');
const { createStateStore } = require('./lib/state-store'); const { createStateStore } = require('./lib/state-store');
const VALUE_FLAGS = new Set([ const VALUE_FLAGS = new Set([
'--as',
'--db', '--db',
'--github-repo', '--github-repo',
'--id', '--id',
@ -21,7 +22,7 @@ const VALUE_FLAGS = new Set([
'--source-id', '--source-id',
'--status', '--status',
'--title', '--title',
'--url', '--url'
]); ]);
function showHelp(exitCode = 0) { function showHelp(exitCode = 0) {
@ -31,6 +32,7 @@ Usage:
node scripts/work-items.js show <id> [--db <path>] [--json] node scripts/work-items.js show <id> [--db <path>] [--json]
node scripts/work-items.js upsert [<id>] --title <title> [options] [--json] node scripts/work-items.js upsert [<id>] --title <title> [options] [--json]
node scripts/work-items.js close <id> [--status done] [--db <path>] [--json] node scripts/work-items.js close <id> [--status done] [--db <path>] [--json]
node scripts/work-items.js claim [<id>] --owner <name> [--as agent|human] [--db <path>] [--json]
node scripts/work-items.js sync-github --repo <owner/repo> [--db <path>] [--json] node scripts/work-items.js sync-github --repo <owner/repo> [--db <path>] [--json]
Track Linear, GitHub, handoff, and manual roadmap items in the ECC SQLite state Track Linear, GitHub, handoff, and manual roadmap items in the ECC SQLite state
@ -43,7 +45,8 @@ Options:
--status <status> Status such as open, in-progress, blocked, done --status <status> Status such as open, in-progress, blocked, done
--priority <priority> Optional priority label --priority <priority> Optional priority label
--url <url> Optional source URL --url <url> Optional source URL
--owner <owner> Optional owner label --owner <owner> Optional owner label (required for claim)
--as <agent|human> On claim, record whether the owner is an agent or human
--repo-root <path> Optional repo root to associate with this item --repo-root <path> Optional repo root to associate with this item
--repo <path> GitHub repo for sync-github, otherwise alias for --repo-root --repo <path> GitHub repo for sync-github, otherwise alias for --repo-root
--github-repo <owner/repo> Explicit GitHub repo for sync-github --github-repo <owner/repo> Explicit GitHub repo for sync-github
@ -57,7 +60,8 @@ Options:
} }
function assignOption(options, flag, value) { function assignOption(options, flag, value) {
if (flag === '--db') options.dbPath = value; if (flag === '--as') options.claimAs = value;
else if (flag === '--db') options.dbPath = value;
else if (flag === '--github-repo') options.githubRepo = value; else if (flag === '--github-repo') options.githubRepo = value;
else if (flag === '--id') options.id = value; else if (flag === '--id') options.id = value;
else if (flag === '--limit') options.limit = value; else if (flag === '--limit') options.limit = value;
@ -83,7 +87,7 @@ function parseArgs(argv) {
help: false, help: false,
json: false, json: false,
limit: 20, limit: 20,
positionals: [], positionals: []
}; };
if (args[0] && !args[0].startsWith('-')) { if (args[0] && !args[0].startsWith('-')) {
@ -144,7 +148,7 @@ function runGhJson(args) {
const displayCommand = shimPath ? `node ${shimPath} ${args.join(' ')}` : `gh ${args.join(' ')}`; const displayCommand = shimPath ? `node ${shimPath} ${args.join(' ')}` : `gh ${args.join(' ')}`;
const result = spawnSync(command, commandArgs, { const result = spawnSync(command, commandArgs, {
encoding: 'utf8', encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024
}); });
if (result.error) { if (result.error) {
@ -163,10 +167,12 @@ function runGhJson(args) {
} }
function slugifyWorkItemSegment(value) { function slugifyWorkItemSegment(value) {
return String(value || '') return (
.toLowerCase() String(value || '')
.replace(/[^a-z0-9]+/g, '-') .toLowerCase()
.replace(/^-+|-+$/g, '') || 'unknown'; .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unknown'
);
} }
function githubWorkItemId(repo, type, number) { function githubWorkItemId(repo, type, number) {
@ -204,8 +210,8 @@ function buildGithubPrWorkItem(repo, pr, options = {}) {
isDraft: Boolean(pr.isDraft), isDraft: Boolean(pr.isDraft),
headRefName: pr.headRefName || null, headRefName: pr.headRefName || null,
sourceUpdatedAt: pr.updatedAt || null, sourceUpdatedAt: pr.updatedAt || null,
syncedBy: 'ecc-work-items-sync-github', syncedBy: 'ecc-work-items-sync-github'
}, }
}; };
} }
@ -226,8 +232,8 @@ function buildGithubIssueWorkItem(repo, issue, options = {}) {
type: 'issue', type: 'issue',
labels: Array.isArray(issue.labels) ? issue.labels.map(label => label.name || label).filter(Boolean) : [], labels: Array.isArray(issue.labels) ? issue.labels.map(label => label.name || label).filter(Boolean) : [],
sourceUpdatedAt: issue.updatedAt || null, sourceUpdatedAt: issue.updatedAt || null,
syncedBy: 'ecc-work-items-sync-github', syncedBy: 'ecc-work-items-sync-github'
}, }
}; };
} }
@ -244,15 +250,17 @@ function closeStaleGithubItems(store, repo, activeIds, options = {}) {
if (item.status === 'closed' || item.status === 'done') { if (item.status === 'closed' || item.status === 'done') {
continue; continue;
} }
closed.push(store.upsertWorkItem({ closed.push(
...item, store.upsertWorkItem({
status: 'closed', ...item,
updatedAt: new Date().toISOString(), status: 'closed',
metadata: { updatedAt: new Date().toISOString(),
...item.metadata, metadata: {
sourceClosedAt: new Date().toISOString(), ...item.metadata,
}, sourceClosedAt: new Date().toISOString()
})); }
})
);
} }
return closed; return closed;
} }
@ -264,30 +272,8 @@ function syncGithubWorkItems(store, options) {
} }
const limit = normalizeLimit(options.limit); const limit = normalizeLimit(options.limit);
const prs = runGhJson([ const prs = runGhJson(['pr', 'list', '--repo', repo, '--state', 'open', '--limit', String(limit), '--json', 'number,title,author,url,updatedAt,mergeStateStatus,isDraft,headRefName']);
'pr', const issues = runGhJson(['issue', 'list', '--repo', repo, '--state', 'open', '--limit', String(limit), '--json', 'number,title,author,url,updatedAt,labels']);
'list',
'--repo',
repo,
'--state',
'open',
'--limit',
String(limit),
'--json',
'number,title,author,url,updatedAt,mergeStateStatus,isDraft,headRefName',
]);
const issues = runGhJson([
'issue',
'list',
'--repo',
repo,
'--state',
'open',
'--limit',
String(limit),
'--json',
'number,title,author,url,updatedAt,labels',
]);
const syncedAt = new Date().toISOString(); const syncedAt = new Date().toISOString();
const activeIds = new Set(); const activeIds = new Set();
@ -295,20 +281,24 @@ function syncGithubWorkItems(store, options) {
for (const pr of prs) { for (const pr of prs) {
const payload = buildGithubPrWorkItem(repo, pr, options); const payload = buildGithubPrWorkItem(repo, pr, options);
activeIds.add(payload.id); activeIds.add(payload.id);
items.push(store.upsertWorkItem({ items.push(
...payload, store.upsertWorkItem({
createdAt: undefined, ...payload,
updatedAt: syncedAt, createdAt: undefined,
})); updatedAt: syncedAt
})
);
} }
for (const issue of issues) { for (const issue of issues) {
const payload = buildGithubIssueWorkItem(repo, issue, options); const payload = buildGithubIssueWorkItem(repo, issue, options);
activeIds.add(payload.id); activeIds.add(payload.id);
items.push(store.upsertWorkItem({ items.push(
...payload, store.upsertWorkItem({
createdAt: undefined, ...payload,
updatedAt: syncedAt, createdAt: undefined,
})); updatedAt: syncedAt
})
);
} }
const closedItems = closeStaleGithubItems(store, repo, activeIds, { limit: Math.max(limit * 4, 1000) }); const closedItems = closeStaleGithubItems(store, repo, activeIds, { limit: Math.max(limit * 4, 1000) });
@ -319,7 +309,7 @@ function syncGithubWorkItems(store, options) {
issueCount: issues.length, issueCount: issues.length,
closedCount: closedItems.length, closedCount: closedItems.length,
items, items,
closedItems, closedItems
}; };
} }
@ -345,11 +335,9 @@ function buildUpsertPayload(options, existing = null) {
owner: options.owner ?? (existing && existing.owner) ?? null, owner: options.owner ?? (existing && existing.owner) ?? null,
repoRoot: options.repoRoot ?? (existing && existing.repoRoot) ?? process.cwd(), repoRoot: options.repoRoot ?? (existing && existing.repoRoot) ?? process.cwd(),
sessionId: options.sessionId ?? (existing && existing.sessionId) ?? null, sessionId: options.sessionId ?? (existing && existing.sessionId) ?? null,
metadata: options.metadataJson !== undefined metadata: options.metadataJson !== undefined ? parseMetadataJson(options.metadataJson) : ((existing && existing.metadata) ?? null),
? parseMetadataJson(options.metadataJson)
: ((existing && existing.metadata) ?? null),
createdAt: existing ? existing.createdAt : undefined, createdAt: existing ? existing.createdAt : undefined,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString()
}; };
} }
@ -400,6 +388,75 @@ function printGithubSyncResult(payload) {
} }
} }
const CLAIM_DONE_STATUSES = new Set(['done', 'closed', 'resolved', 'merged', 'cancelled']);
const CLAIM_PRIORITY_RANK = { critical: 0, high: 1, urgent: 1, medium: 2, normal: 2, low: 3 };
function isOpenWorkItemStatus(status) {
return !CLAIM_DONE_STATUSES.has(
String(status || '')
.trim()
.toLowerCase()
);
}
// Resolve which work item a `claim` targets: an explicit id, otherwise the
// highest-priority unassigned open item (the JIT pickup queue the control-pane
// board surfaces).
function selectClaimTarget(store, options) {
const explicitId = resolveWorkItemId(options);
if (explicitId) {
const item = store.getWorkItemById(explicitId);
if (!item) {
throw new Error(`Work item not found: ${explicitId}`);
}
return item;
}
const { items } = store.listWorkItems({ limit: 100 });
return (
items
.filter(item => !item.owner && isOpenWorkItemStatus(item.status))
.sort((a, b) => {
const ra = CLAIM_PRIORITY_RANK[String(a.priority || '').toLowerCase()] ?? 2;
const rb = CLAIM_PRIORITY_RANK[String(b.priority || '').toLowerCase()] ?? 2;
return ra - rb;
})[0] || null
);
}
// Claim an unassigned work item for an agent or human — the just-in-time pickup
// primitive. Sets the owner (+ optional assigneeKind) and moves the card to
// running so the board reflects that work has started.
function claimWorkItem(store, options) {
const owner = options.owner;
if (!owner) {
throw new Error('claim requires --owner <name>.');
}
const assigneeKind = options.claimAs ? String(options.claimAs).toLowerCase() : null;
if (assigneeKind && assigneeKind !== 'agent' && assigneeKind !== 'human') {
throw new Error("--as must be 'agent' or 'human'.");
}
const target = selectClaimTarget(store, options);
if (!target) {
return { claimed: false, reason: 'no-unassigned-open-items' };
}
if (!isOpenWorkItemStatus(target.status)) {
throw new Error(`Work item ${target.id} is already done; cannot claim.`);
}
const metadata = { ...(target.metadata || {}) };
if (assigneeKind) {
metadata.assigneeKind = assigneeKind;
}
const item = store.upsertWorkItem({
...target,
owner,
sessionId: options.sessionId ?? target.sessionId ?? null,
status: options.status ?? 'running',
metadata,
updatedAt: new Date().toISOString()
});
return { claimed: true, item };
}
async function main() { async function main() {
let store = null; let store = null;
@ -411,7 +468,7 @@ async function main() {
store = await createStateStore({ store = await createStateStore({
dbPath: options.dbPath, dbPath: options.dbPath,
homeDir: process.env.HOME || os.homedir(), homeDir: process.env.HOME || os.homedir()
}); });
if (options.command === 'list') { if (options.command === 'list') {
@ -462,11 +519,16 @@ async function main() {
if (!existing) { if (!existing) {
throw new Error(`Work item not found: ${id}`); throw new Error(`Work item not found: ${id}`);
} }
const item = store.upsertWorkItem(buildUpsertPayload({ const item = store.upsertWorkItem(
...options, buildUpsertPayload(
id, {
status: options.status || 'done', ...options,
}, existing)); id,
status: options.status || 'done'
},
existing
)
);
if (options.json) { if (options.json) {
console.log(JSON.stringify(item, null, 2)); console.log(JSON.stringify(item, null, 2));
} else { } else {
@ -475,6 +537,19 @@ async function main() {
return; return;
} }
if (options.command === 'claim') {
const result = claimWorkItem(store, options);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else if (!result.claimed) {
console.log('No unassigned open work items to claim.');
} else {
console.log(`Claimed by ${result.item.owner}:`);
printWorkItem(result.item);
}
return;
}
if (options.command === 'sync-github') { if (options.command === 'sync-github') {
const payload = syncGithubWorkItems(store, options); const payload = syncGithubWorkItems(store, options);
if (options.json) { if (options.json) {
@ -506,5 +581,5 @@ module.exports = {
buildGithubPrWorkItem, buildGithubPrWorkItem,
main, main,
parseArgs, parseArgs,
syncGithubWorkItems, syncGithubWorkItems
}; };

View File

@ -0,0 +1,152 @@
'use strict';
/**
* Tests for scripts/work-items.js focused on the `claim` JIT pickup command.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { createStateStore } = require('../../scripts/lib/state-store');
const CLI = path.join(__dirname, '..', '..', 'scripts', 'work-items.js');
let passed = 0;
let failed = 0;
async function test(name, fn) {
try {
await fn();
console.log(` PASS ${name}`);
passed += 1;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.message}`);
failed += 1;
}
}
function runClaim(dbPath, args) {
const result = spawnSync('node', [CLI, 'claim', '--db', dbPath, '--json', ...args], {
encoding: 'utf8'
});
return result;
}
async function seed(dbPath) {
const store = await createStateStore({ dbPath });
try {
// High-priority, unassigned, open — the JIT pickup target.
store.upsertWorkItem({
id: 'wi-unassigned-high',
source: 'github-issue',
title: 'Fix the gate bypass',
status: 'open',
priority: 'high',
owner: null,
metadata: {}
});
// Low-priority, unassigned, open — should be picked only after the high one.
store.upsertWorkItem({
id: 'wi-unassigned-low',
source: 'manual',
title: 'Tidy docs',
status: 'open',
priority: 'low',
owner: null,
metadata: {}
});
// Already owned — must never be auto-claimed.
store.upsertWorkItem({
id: 'wi-owned',
source: 'manual',
title: 'In progress',
status: 'running',
priority: 'high',
owner: 'codex',
metadata: {}
});
// Done — must never be claimed.
store.upsertWorkItem({
id: 'wi-done',
source: 'manual',
title: 'Shipped',
status: 'done',
priority: 'high',
owner: null,
metadata: {}
});
} finally {
store.close();
}
}
async function run() {
console.log('\n=== Testing work-items.js claim ===\n');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'work-items-claim-'));
const dbPath = path.join(dir, 'state.db');
try {
await seed(dbPath);
await test('claim picks the highest-priority unassigned open item and sets owner + kind', async () => {
const result = runClaim(dbPath, ['--owner', 'alice', '--as', 'human']);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.claimed, true);
assert.strictEqual(payload.item.id, 'wi-unassigned-high', 'high-priority item claimed first');
assert.strictEqual(payload.item.owner, 'alice');
assert.strictEqual(payload.item.status, 'running', 'claim moves the card to running');
assert.strictEqual(payload.item.metadata.assigneeKind, 'human');
});
await test('a second claim takes the next unassigned item, not an owned or done one', async () => {
const result = runClaim(dbPath, ['--owner', 'bot-7', '--as', 'agent']);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.claimed, true);
assert.strictEqual(payload.item.id, 'wi-unassigned-low');
assert.strictEqual(payload.item.metadata.assigneeKind, 'agent');
});
await test('claim reports nothing to do once the queue is drained', async () => {
const result = runClaim(dbPath, ['--owner', 'alice']);
assert.strictEqual(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.claimed, false);
assert.strictEqual(payload.reason, 'no-unassigned-open-items');
});
await test('claim of a specific id works and rejects a missing id', async () => {
const owned = runClaim(dbPath, ['wi-owned', '--owner', 'carol']);
assert.strictEqual(owned.status, 0, owned.stderr);
assert.strictEqual(JSON.parse(owned.stdout).item.owner, 'carol', 'explicit id can be re-claimed');
const missing = runClaim(dbPath, ['nope-404', '--owner', 'carol']);
assert.notStrictEqual(missing.status, 0, 'missing id should fail');
assert.ok(/not found/i.test(missing.stderr), 'reports not found');
});
await test('claim requires --owner and validates --as', async () => {
const noOwner = runClaim(dbPath, ['wi-done']);
assert.notStrictEqual(noOwner.status, 0);
assert.ok(/requires --owner/i.test(noOwner.stderr));
const badKind = runClaim(dbPath, ['wi-owned', '--owner', 'x', '--as', 'robot']);
assert.notStrictEqual(badKind.status, 0);
assert.ok(/agent.*human/i.test(badKind.stderr));
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
if (failed > 0) {
process.exit(1);
}
}
run();