Skip to content

Commit 5cc2f0f

Browse files
renezander030claude
andcommitted
operator-deck: typed relate cards + scoped web-search research cards
Relate: classify each semantically-close pair into a typed ATS relationship (depends-on / blocks / parent / supports / supersedes / related) with one bounded LLM call, and show the move from the anchor task's view (e.g. 'Depends on "X"', '"X" is a subtask of this'). Approve files the TYPED link via relateTask({type}). Research (cron only): for research-y tasks (evaluate/compare/vs/best/how-to...) with no references yet, run a web search via headless claude -p and propose 2-3 sources + a next step. Gated behind allowResearch so the slow web call never hits the interactive refill path — only the cadence cron runs it, and it injects a research card even when the queue is full. Approve adds them to ## References + logs it. Model overridable (OPERATOR_RESEARCH_MODEL=sonnet) since small models can hallucinate URLs; HITL approval is the backstop. pickDiverse leads with research. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 534a481 commit 5cc2f0f

2 files changed

Lines changed: 132 additions & 18 deletions

File tree

examples/operator-deck/cadence.mjs

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import fs from 'node:fs';
1313
import path from 'node:path';
1414
import os from 'node:os';
1515
import { fileURLToPath } from 'node:url';
16-
import { loadCorpus, taskMetadataForRead } from '@reneza/ats-core';
16+
import { loadCorpus, taskMetadataForRead, LINK_TYPES } from '@reneza/ats-core';
1717
import { findSimilar } from '../../packages/adapter-ticktick/embedding.js';
1818
import { loadState, benchReason, driftPenalty, recencyScore, impressionPenalty } from './state.mjs';
1919
import { hasGoal } from './format.mjs';
@@ -97,11 +97,65 @@ function draftIntents(tasks) {
9797
});
9898
}
9999

100+
// Shared headless-Claude call; returns stdout (or '' on error). Web search is
101+
// available under bypassPermissions, so a prompt may instruct the model to use it.
102+
function callClaude(prompt, { timeout = 90000, model = MODEL } = {}) {
103+
return new Promise((resolve) => {
104+
const env = { ...process.env, HOME: '/home/debian' };
105+
delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT;
106+
const child = execFile('/home/debian/.local/bin/claude', ['-p', '--model', model, '--permission-mode', 'bypassPermissions'],
107+
{ env, cwd: '/home/debian/claude', timeout, maxBuffer: 4 * 1024 * 1024 },
108+
(err, stdout) => resolve(err ? '' : String(stdout)));
109+
child.stdin.end(prompt);
110+
});
111+
}
112+
// Research can hallucinate URLs on a small model — default to the batch model but
113+
// allow bumping (OPERATOR_RESEARCH_MODEL=sonnet) for better-grounded web results.
114+
const RESEARCH_MODEL = process.env.OPERATOR_RESEARCH_MODEL || MODEL;
115+
116+
// Classify each semantically-close pair into a typed ATS relationship + the move
117+
// to make, so a relate card says "B is a subtask of A -> link as parent" instead
118+
// of a bare "link these two". One bounded call for the whole batch. Fails soft.
119+
async function classifyRelations(pairs) {
120+
if (pairs.length === 0) return {};
121+
const payload = pairs.map((p) => ({ id: p.id, a: p.t.title, b: p.nt.title,
122+
an: String(p.t.content || '').replace(/\s+/g, ' ').slice(0, 160), bn: String(p.nt.content || '').replace(/\s+/g, ' ').slice(0, 160) }));
123+
const prompt = [
124+
'For each pair of tasks A and B, decide how they relate (direction is A -> B). Pick ONE type:',
125+
'- depends-on: A cannot proceed until B is done',
126+
'- blocks: A must be done before B can proceed',
127+
'- parent: B is a subtask / part of A',
128+
'- supports: both advance the same goal, neither blocks the other',
129+
'- supersedes: A and B are near-duplicates; A is the keeper, B is redundant',
130+
'- related: loosely connected, just cross-link',
131+
'Return ONLY a JSON array, one object per pair: {id, type, move, why}.',
132+
'move = imperative <=7 words (e.g. "link as depends-on", "mark B a subtask of A"). why = <=14 words.',
133+
'Pairs:', JSON.stringify(payload),
134+
].join('\n');
135+
const out = {};
136+
try { const m = (await callClaude(prompt, { timeout: 60000 })).match(/\[[\s\S]*\]/); for (const r of (m ? JSON.parse(m[0]) : [])) if (r && r.id) out[r.id] = r; } catch { /* fail soft */ }
137+
return out;
138+
}
139+
140+
// Research a task with web search; returns { refs:[{title,url}], nextStep } or null.
141+
// Scoped to research-y tasks and gated to the cron build (slow), never the hot path.
142+
async function researchTask(task) {
143+
const prompt = [
144+
'You research a task using web search. Find 2-3 high-quality, current references',
145+
'(official docs, credible comparisons or guides) that would help do this task.',
146+
`Task: ${JSON.stringify(task.title)}`,
147+
`Notes: ${JSON.stringify(String(task.content || '').replace(/\s+/g, ' ').slice(0, 240))}`,
148+
'Return ONLY JSON: {"refs":[{"title":"...","url":"..."}],"nextStep":"<one concrete next action, <=12 words>"}. No prose.',
149+
].join('\n');
150+
try { const m = (await callClaude(prompt, { timeout: 120000, model: RESEARCH_MODEL })).match(/\{[\s\S]*\}/); const o = m ? JSON.parse(m[0]) : null; if (o && Array.isArray(o.refs)) return o; } catch { /* fail soft */ }
151+
return null;
152+
}
153+
100154
// Round-robin across kinds (highest-scored first within each) so a batch always
101155
// carries a MIX — not just goal cards. `relate` leads each round because it's the
102156
// underrepresented axis ("which tasks are similar, link them?"); intent/next follow.
103157
function pickDiverse(cards, limit) {
104-
const order = ['relate', 'intent', 'next'];
158+
const order = ['research', 'relate', 'intent', 'next'];
105159
const buckets = new Map(order.map((k) => [k, []]));
106160
const extra = [];
107161
for (const c of cards) (buckets.get(c.kind) || extra).push(c);
@@ -117,7 +171,7 @@ function pickDiverse(cards, limit) {
117171
return out;
118172
}
119173

120-
export async function buildBatch(adapter, { existingIds = new Set(), dismissed: dismissedMap = activeDismissed(), limit = BATCH } = {}) {
174+
export async function buildBatch(adapter, { existingIds = new Set(), dismissed: dismissedMap = activeDismissed(), limit = BATCH, allowResearch = false } = {}) {
121175
const now = Date.now();
122176
const { corpus } = await loadCorpus(adapter, { cache: true });
123177
const notes = await noteProjectSet(adapter);
@@ -217,6 +271,7 @@ export async function buildBatch(adapter, { existingIds = new Set(), dismissed:
217271
// already full of intent/next, so a total-count cap would skip relate entirely
218272
// and the queue drifts to all-goal. Generate a pool; pickDiverse selects the mix.
219273
const pairSeen = new Set();
274+
const relatePairs = [];
220275
let relateCount = 0;
221276
const relateTarget = Math.max(limit, 10);
222277
for (const { t, s } of ranked) {
@@ -236,26 +291,75 @@ export async function buildBatch(adapter, { existingIds = new Set(), dismissed:
236291
const id = `relate:${t.id}:${nid}`;
237292
if (!fresh(id)) continue;
238293
pairSeen.add(key);
239-
cards.push({ id, kind: 'relate', score: s,
240-
items: [{ adapter: 'ticktick', title: t.title }, { adapter: 'ticktick', title: nt.title }],
241-
action: 'Link these two tasks',
242-
back: { heading: 'Why', body: `Semantically close (${Math.round(sc * 100)}%) but not linked. Approving files a Related link.` },
243-
exec: { type: 'relate', source: { projectId: t.projectId, taskId: t.id }, target: { projectId: nt.projectId, taskId: nid }, targetTitle: nt.title } });
294+
relatePairs.push({ id, t, nt, sim: sc, s });
244295
relateCount += 1;
245296
break;
246297
}
247298
}
248299

300+
// Classify the pairs into a typed ATS relationship + the move to make, then emit
301+
// the cards. One bounded LLM call for the whole batch (fails soft to 'related').
302+
const relations = await classifyRelations(relatePairs);
303+
const shortT = (s) => (s.length > 26 ? `${s.slice(0, 25)}…` : s);
304+
const phrase = {
305+
'depends-on': (b) => `Depends on “${b}”`,
306+
blocks: (b) => `Blocks “${b}”`,
307+
parent: (b) => `“${b}” is a subtask of this`,
308+
supports: (b) => `Supports “${b}”`,
309+
supersedes: (b) => `Duplicate of “${b}” — keep this`,
310+
related: (b) => `Link to “${b}”`,
311+
};
312+
for (const p of relatePairs) {
313+
const c = relations[p.id] || {};
314+
const type = LINK_TYPES.includes(c.type) ? c.type : 'related';
315+
const action = (phrase[type] || phrase.related)(shortT(p.nt.title));
316+
cards.push({ id: p.id, kind: 'relate', score: p.s,
317+
items: [{ adapter: 'ticktick', title: p.t.title }, { adapter: 'ticktick', title: p.nt.title }],
318+
action,
319+
back: { heading: type === 'related' ? 'Why' : `Suggested: ${type}`, body: String(c.why || '').trim() || `Semantically close (${Math.round(p.sim * 100)}%) but not linked yet.` },
320+
exec: { type: 'relate', source: { projectId: p.t.projectId, taskId: p.t.id }, target: { projectId: p.nt.projectId, taskId: p.nt.id }, targetTitle: p.nt.title, relType: type, relDesc: String(c.why || '').trim() } });
321+
}
322+
323+
// Stage 3 (cron only — slow web search): for research-y tasks with no references
324+
// yet, look up a few sources and suggest adding them. Gated to keep it off the
325+
// interactive refill path; bounded by OPERATOR_RESEARCH_BUDGET.
326+
if (allowResearch) {
327+
const RESEARCH_BUDGET = Number(process.env.OPERATOR_RESEARCH_BUDGET || 2);
328+
const researchRe = /\b(evaluate|assess|compare|comparison|versus|vs|research|investigate|explore|options|alternatives|best|which|how to|how do|learn|study|benchmark|shortlist|pros and cons|decide between|tool[s]? for)\b/i;
329+
const researchy = ranked
330+
.filter(({ t }) => !usedTask.has(t.id) && fresh(`research:${t.id}`)
331+
&& researchRe.test(`${t.title} ${String(t.content || '').slice(0, 200)}`)
332+
&& !((meta.get(t.id)?.references || []).length))
333+
.slice(0, RESEARCH_BUDGET);
334+
for (const { t, s } of researchy) {
335+
const r = await researchTask(t);
336+
const refs = (r?.refs || []).filter((x) => x && x.url).slice(0, 3);
337+
if (!refs.length) continue;
338+
cards.push({ id: `research:${t.id}`, kind: 'research', score: s + 0.2,
339+
items: [{ adapter: 'ticktick', title: t.title }],
340+
action: r.nextStep ? `Research: ${r.nextStep}` : `Add ${refs.length} researched references`,
341+
back: { heading: `Found ${refs.length} references (web)`, body: refs.map((x) => x.title).join(' · ') },
342+
exec: { type: 'research', source: { projectId: t.projectId, taskId: t.id }, refs, nextStep: r.nextStep || '' } });
343+
usedTask.add(t.id);
344+
}
345+
}
346+
249347
return pickDiverse(cards, limit);
250348
}
251349

252-
// CLI: top the queue up to BATCH, appending fresh cards.
350+
// CLI (cron): top the queue up to BATCH, and — since this is the only path that
351+
// runs the slow web-search stage — also inject a research card when the queue has
352+
// none, even if it's otherwise full. allowResearch is on here and ONLY here.
253353
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
254354
const mod = await import(process.env.ATS_ADAPTER || '@reneza/ats-adapter-ticktick');
255355
const adapter = mod.default || mod;
256356
const queue = loadQueue();
257-
if (queue.length >= BATCH) { console.log(`queue already at ${queue.length}; nothing to do`); process.exit(0); }
258-
const batch = await buildBatch(adapter, { existingIds: new Set(queue.map((c) => c.id)), limit: BATCH - queue.length });
259-
saveQueue([...queue, ...batch]);
260-
console.log(`added ${batch.length} (${batch.map((c) => c.kind).join(',')}); queue now ${queue.length + batch.length}`);
357+
const need = Math.max(0, BATCH - queue.length);
358+
const hasResearch = queue.some((c) => c.kind === 'research');
359+
if (need === 0 && hasResearch) { console.log(`queue at ${queue.length} with research; nothing to do`); process.exit(0); }
360+
const limit = Math.max(need, hasResearch ? 0 : 3); // build a few even when full, so a research card can surface
361+
const batch = await buildBatch(adapter, { existingIds: new Set(queue.map((c) => c.id)), limit, allowResearch: true });
362+
const merged = [...queue, ...batch.filter((b) => !queue.some((c) => c.id === b.id))];
363+
saveQueue(merged);
364+
console.log(`added ${batch.length} (${batch.map((c) => c.kind).join(',')}); queue now ${merged.length}`);
261365
}

examples/operator-deck/suggest.mjs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Suggestion engine for the HITL operator deck. Derives "best next actions" from
22
// the live ATS corpus (no external proposal queue), so every card is grounded in
33
// real task state and approving it performs a real ATS mutation.
4-
import { loadCorpus, taskMetadataForRead, relateTask, setTaskLifecycle, recordAction } from '@reneza/ats-core';
4+
import { loadCorpus, taskMetadataForRead, relateTask, addTaskReference, setTaskLifecycle, recordAction } from '@reneza/ats-core';
55
import { setGoal, appendLog } from './format.mjs';
66

77
const STALE_DAYS = 21;
@@ -148,10 +148,20 @@ export async function executeSuggestion(adapter, s) {
148148
};
149149

150150
if (e.type === 'relate') {
151-
const r = await relateTask(adapter, e.source, e.target); // files ## Related / ## References
152-
await logTo(`linked to "${e.targetTitle || 'related task'}"`);
153-
audit({ agent: 'operator-deck', action: 'suggestion.approved', task: e.source, sources: [], output: `relate → ${r.routedTo}`, advanced: true });
154-
return { ok: true, summary: `Linked (## ${r.routedTo === 'references' ? 'References' : 'Related'})` };
151+
const relType = e.relType || 'related';
152+
const r = await relateTask(adapter, e.source, e.target, { type: relType, desc: e.relDesc }); // files ## Related / ## References
153+
const typeNote = relType === 'related' ? '' : ` (${relType})`;
154+
await logTo(`linked to "${e.targetTitle || 'related task'}"${typeNote}`);
155+
audit({ agent: 'operator-deck', action: 'suggestion.approved', task: e.source, sources: [], output: `relate ${relType}${r.routedTo}`, advanced: true });
156+
return { ok: true, summary: `Linked${typeNote} (## ${r.routedTo === 'references' ? 'References' : 'Related'})` };
157+
}
158+
if (e.type === 'research') {
159+
const refs = (e.refs || []).filter((x) => x && x.url);
160+
for (const x of refs) await addTaskReference(adapter, e.source, { url: x.url, title: x.title, desc: x.desc });
161+
const tail = e.nextStep ? `; next: ${e.nextStep}` : '';
162+
await logTo(`researched: added ${refs.length} reference${refs.length === 1 ? '' : 's'}${tail}`);
163+
audit({ agent: 'operator-deck', action: 'suggestion.approved', task: e.source, sources: [], output: `research +${refs.length} refs`, advanced: true });
164+
return { ok: true, summary: `Added ${refs.length} reference${refs.length === 1 ? '' : 's'}` };
155165
}
156166
if (e.type === 'intent') {
157167
await logTo('goal set', (c) => setGoal(c, e.goal || e.outcome || ''));

0 commit comments

Comments
 (0)