diff --git a/CHANGELOG.md b/CHANGELOG.md index c02e835..0a520e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Local watch registry (GW-048): `gitworthy watch add|list|show|recheck|remove` and MCP `watch_*`. Fingerprint recheck reports exact deltas. No auto-create from WATCH routes and no GitHub writes. - Contribution routing v2 (GW-043–047): `worth_check` can attach a `routing` decision; `gitworthy portfolio` / MCP `portfolio` ranks issue+PR opportunities by contribution mode with separate `dispatch_state`; `gitworthy prs` / MCP `pr_scan` is a bounded two-stage PR inventory. Verdict policy is unchanged. Hermes `contribution_profile` examples stay in docs, not global defaults. Org portfolio fans out PR scans to at most 5 hunt repos (inventory ≤25, enrich ≤5). Advisory `failed_checks` do not demote BUILD. - Agent Plugins v1.0.0 packaging: `plugin.json`, `mcp.json`, canonical `skills/gitworthy/SKILL.md`, CI sync check (`pnpm agent-plugins:check`). - Docs: [`CORPUS_CONTRIB.md`](./docs/CORPUS_CONTRIB.md) — how dogfooders grow Track O (local) vs Track F (public fixtures) without mixing corpora. diff --git a/docs/CLI.md b/docs/CLI.md index 48e694b..f9fb3fe 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -61,6 +61,16 @@ gitworthy portfolio org-name --org [--json] gitworthy prs owner/repo [--include-bots] [--include-merged] [--json] ``` +### Watch + +Local-only registry. Recheck compares fingerprints and reports field deltas. Never writes to GitHub. + +```sh +gitworthy watch add owner/repo#123 [--note text] [--json] +gitworthy watch list [--json] +gitworthy watch recheck [--json] +``` + ## Evidence / store commands ```sh diff --git a/docs/MCP.md b/docs/MCP.md index b5109ed..292ab4c 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -26,7 +26,7 @@ Evidence tools support investigation; they are not substitutes for `worth_check` | primary | `doctor`, `worth_check`, `hunt`, `portfolio`, `brief`, `brief_show`, `store_outcome_record`, `store_outcome_reconcile`, `store_outcome_backfill` | | evidence | `scan`, `org_scan`, `pr_scan`, `branch_scan`, `issue_vs_main`, `release_gap`, `dupe_cluster`, `related_cluster`, `linked_work`, `contention`, `scope_check`, `contrib_policy`, `list_probe_templates` | | config | `config_validate`, `config_show`, `profile_show` | -| store | `ledger_*`, `store_target_show`, `store_decision_list`, `store_recheck`, `store_export` | +| store | `ledger_*`, `watch_*`, `store_target_show`, `store_decision_list`, `store_recheck`, `store_export` | | admin | `store_migrate_ledger`, `store_rebuild_indexes`, `capture_*`, `case_promote` | Each registration includes a long description, MCP annotations, and `_meta.gitworthy_role` (see `src/mcp/tool-meta.ts`). diff --git a/src/cli/index.ts b/src/cli/index.ts index 7e38d12..13f61a5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,6 +16,11 @@ import { resumeHunt, portfolio, pr_scan, + watch_add, + watch_list, + watch_show, + watch_recheck, + watch_remove, issue_vs_main, ledger_list, ledger_lookup, @@ -101,6 +106,8 @@ Usage: gitworthy hunt owner/repo|org [--manifest path] [--max-checks 3] [--label ...] [--keywords ...] [--since 90d] [--limit 25] [--max-repos 8] [--max-pages 1] [--skill-profile ...] [--explain-ranking] [--skip-policy-gate] [--no-land-hints] [--capture] [--capture-local-private] [--json] gitworthy portfolio owner/repo|org [--org] [--max-checks 3] [--max-items 10] [--include-watch] [--no-prs] [--label ...] [--keywords ...] [--json] gitworthy prs owner/repo [--include-bots] [--include-merged] [--json] + gitworthy watch add owner/repo#123|--pr N [--note text] [--json] + gitworthy watch list|show|recheck|remove [--json] gitworthy branches owner/repo keyword[,keyword] [--json] [--force-refresh] gitworthy issue owner/repo 123 [--json] gitworthy release owner/repo package-name [--probe-glob glob] [--probe-contains text] [--probe-template id] [--json] @@ -289,6 +296,8 @@ const CLI_OPTIONS = { 'no-prs': { type: 'boolean' }, 'include-bots': { type: 'boolean' }, 'include-merged': { type: 'boolean' }, + pr: { type: 'string' }, + note: { type: 'string' }, 'explain-ranking': { type: 'boolean' }, 'no-land-hints': { type: 'boolean' }, capture: { type: 'boolean' }, @@ -925,6 +934,44 @@ export async function runCli(argv = process.argv.slice(2), stdout: Write = (text } else { usageError('outcome requires show, list, record, reconcile, or backfill.'); } + } else if (command === 'watch') { + const action = first; + if (action === 'add') { + commandName = 'watch_add'; + const prRaw = stringValue(parsed.values.pr); + if (prRaw) { + const repo = repoArg(second, 'watch add --pr requires owner/repo.'); + output = toStampedLegacyResult('watch_add', await watch_add({ + repo, + pr_number: parseArg(IssueNumberStringSchema, prRaw, 'invalid_usage'), + note: stringValue(parsed.values.note) + }) as Record); + } else { + const ref = parseIssueRef(required(second, 'watch add requires owner/repo#123 or owner/repo --pr N.')); + output = toStampedLegacyResult('watch_add', await watch_add({ + repo: ref.repo, + issue_number: ref.issue_number, + note: stringValue(parsed.values.note) + }) as Record); + } + } else if (action === 'list') { + commandName = 'watch_list'; + output = toStampedLegacyResult('watch_list', await watch_list() as Record); + } else if (action === 'show') { + commandName = 'watch_show'; + output = toStampedLegacyResult('watch_show', await watch_show(required(second, 'watch show requires a watch_id.')) as Record); + } else if (action === 'recheck') { + commandName = 'watch_recheck'; + output = toStampedLegacyResult('watch_recheck', await watch_recheck({ + watch_id: required(second, 'watch recheck requires a watch_id.'), + write: parsed.values.write !== false + }) as Record); + } else if (action === 'remove') { + commandName = 'watch_remove'; + output = toStampedLegacyResult('watch_remove', await watch_remove(required(second, 'watch remove requires a watch_id.')) as Record); + } else { + usageError('watch requires add, list, show, recheck, or remove.'); + } } else if (command === 'capture') { const action = first; if (action === 'show') { diff --git a/src/contracts/watch.ts b/src/contracts/watch.ts new file mode 100644 index 0000000..bed1407 --- /dev/null +++ b/src/contracts/watch.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; +import { OpportunityTargetSchema } from './opportunities.js'; + +export const WATCH_VERSION = 1 as const; + +export const WatchTriggerSchema = z.enum([ + 'target_state_changed', + 'new_pr', + 'pr_state_changed', + 'ci_changed', + 'maintainer_activity', + 'staleness_threshold', + 'manual' +]); + +export const WatchSnapshotSchema = z.object({ + issue_state: z.string().optional(), + issue_updated_at: z.string().optional(), + assignees: z.array(z.string()).default([]), + linked_prs: z.array(z.object({ + number: z.number().int().positive(), + state: z.string(), + draft: z.boolean().optional(), + merged: z.boolean().optional(), + updated_at: z.string().optional() + })).default([]), + ci_state: z.string().optional(), + maintainer_activity_at: z.string().optional() +}).strict(); + +export const WatchRecordSchema = z.object({ + watch_version: z.literal(WATCH_VERSION), + watch_id: z.string().min(1), + target: OpportunityTargetSchema, + created_at: z.string().datetime(), + updated_at: z.string().datetime(), + last_fingerprint: z.string().min(1), + last_snapshot: WatchSnapshotSchema, + note: z.string().optional() +}).strict(); + +export const WatchFieldDeltaSchema = z.object({ + path: z.string().min(1), + before: z.unknown(), + after: z.unknown() +}).strict(); + +export const WatchRecheckSchema = z.object({ + watch_id: z.string().min(1), + changed: z.boolean(), + triggers: z.array(WatchTriggerSchema).default([]), + deltas: z.array(WatchFieldDeltaSchema).default([]), + fingerprint_before: z.string(), + fingerprint_after: z.string(), + updated: z.boolean() +}).strict(); + +export type WatchTrigger = z.infer; +export type WatchSnapshot = z.infer; +export type WatchRecord = z.infer; +export type WatchRecheck = z.infer; diff --git a/src/core/index.ts b/src/core/index.ts index 071ffe2..1a924af 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -15,6 +15,7 @@ export { org_scan } from './org-scan.js'; export { hunt, resumeHunt } from './hunt.js'; export { portfolio } from './portfolio.js'; export { pr_scan } from './pr-scan.js'; +export { watch_add, watch_list, watch_show, watch_recheck, watch_remove, listLocalWatches } from './watch.js'; export { GENERIC_CONTRIBUTION_PROFILE, parseContributionProfile } from './contribution-profile.js'; export { scoreOpportunity } from './opportunity-score.js'; export { capture_show, capture_list, case_promote } from './capture-commands.js'; diff --git a/src/core/portfolio.ts b/src/core/portfolio.ts index 574a2c7..6dcb0ee 100644 --- a/src/core/portfolio.ts +++ b/src/core/portfolio.ts @@ -480,8 +480,21 @@ export async function portfolio(input: PortfolioInput, deps: PortfolioDeps = {}) } if (input.include_watch) { - const watch = deps.listWatch ? await deps.listWatch() : []; - if (watch.length === 0) notChecked.push('Watch registry is empty or not wired until the watch slice.'); + const { listLocalWatches } = await import('./watch.js'); + const watch = deps.listWatch ? await deps.listWatch() : await listLocalWatches(); + if (watch.length === 0) notChecked.push('Watch registry is empty; WATCH routes never auto-create watches.'); + checked.push(`included ${watch.length} local watches`); + for (const record of watch as Array<{ target: OpportunityTarget }>) { + const already = items.some((item) => JSON.stringify(item.target) === JSON.stringify(record.target)); + if (already) continue; + items.push(PortfolioItemSchema.parse({ + target: record.target, + primary_mode: 'WATCH', + dispatch_state: 'watching', + score: 0.2, + reasons: ['Local watch registry; routing never auto-creates this record.'] + })); + } } const maxItems = Math.max(1, input.max_items ?? 10); diff --git a/src/core/watch.ts b/src/core/watch.ts new file mode 100644 index 0000000..f8a44eb --- /dev/null +++ b/src/core/watch.ts @@ -0,0 +1,298 @@ +/** + * Local watch registry (GW-048). No GitHub writes. No automatic creation from WATCH routes. + */ + +import { randomUUID } from 'node:crypto'; +import { githubJson } from '../lib/github.js'; +import { stateFingerprint } from '../lib/state-fingerprint.js'; +import { getWatchRecord, listWatchRecords, putWatchRecord, removeWatchRecord } from '../lib/watch-store.js'; +import { GitworthyError, createEnvelope, type Envelope } from './envelope.js'; +import { linked_work } from './linked-work.js'; +import type { OpportunityTarget } from '../contracts/opportunities.js'; +import { + WatchRecordSchema, + type WatchRecord, + type WatchRecheck, + type WatchSnapshot, + type WatchTrigger +} from '../contracts/watch.js'; + +type GithubIssue = { + state?: string; + updated_at?: string; + assignees?: Array<{ login?: string }>; + pull_request?: unknown; +}; + +type GithubPull = { + number: number; + state?: string; + draft?: boolean; + merged_at?: string | null; + updated_at?: string; +}; + +export type WatchAddInput = { + repo: string; + issue_number?: number; + pr_number?: number; + note?: string; +}; + +function targetFromInput(input: WatchAddInput): OpportunityTarget { + if (input.issue_number && input.pr_number) { + throw new GitworthyError({ + code: 'watch_invalid_input', + message: 'watch add requires issue_number or pr_number, not both.', + not_checked: ['watch add requires exactly one target kind.'] + }); + } + if (input.issue_number) { + return { kind: 'issue', repo: input.repo, issue_number: input.issue_number }; + } + if (input.pr_number) { + return { kind: 'pull_request', repo: input.repo, pr_number: input.pr_number }; + } + throw new GitworthyError({ + code: 'watch_invalid_input', + message: 'watch add requires issue_number or pr_number.', + not_checked: ['watch add requires a target.'] + }); +} + +export async function snapshotTarget(target: OpportunityTarget): Promise<{ snapshot: WatchSnapshot; fingerprint: string }> { + if (target.kind === 'eval_anomaly') { + throw new GitworthyError({ + code: 'watch_unsupported_target', + message: 'eval_anomaly watch is not supported in this slice.', + not_checked: ['eval_anomaly watch is deferred.'] + }); + } + if (target.kind === 'issue') { + const issue = await githubJson(`/repos/${target.repo}/issues/${target.issue_number}`); + const linked = await linked_work({ repo: target.repo, issue_number: target.issue_number }); + const linked_prs = (linked.evidence as Array<{ + kind?: string; + number?: number; + state?: string; + draft?: boolean; + merged?: boolean; + updated_at?: string; + }>).filter((item) => item.kind === 'linked_pr' && typeof item.number === 'number') + .map((item) => ({ + number: item.number!, + state: item.state ?? 'open', + draft: item.draft === true, + merged: item.merged === true, + updated_at: item.updated_at + })); + const snapshot: WatchSnapshot = { + issue_state: issue.state, + issue_updated_at: issue.updated_at, + assignees: (issue.assignees ?? []).map((row) => row.login ?? '').filter(Boolean), + linked_prs + }; + return { + snapshot, + fingerprint: stateFingerprint({ + repo: target.repo, + issue_number: target.issue_number, + issue_state: snapshot.issue_state, + issue_updated_at: snapshot.issue_updated_at, + assignees: snapshot.assignees, + linked_prs + }) + }; + } + const pr = await githubJson(`/repos/${target.repo}/pulls/${target.pr_number}`); + const snapshot: WatchSnapshot = { + issue_state: pr.state, + issue_updated_at: pr.updated_at, + assignees: [], + linked_prs: [{ + number: pr.number, + state: pr.state ?? 'open', + draft: pr.draft === true, + merged: Boolean(pr.merged_at), + updated_at: pr.updated_at + }] + }; + return { + snapshot, + fingerprint: stateFingerprint({ + repo: target.repo, + issue_number: target.pr_number, + issue_state: snapshot.issue_state, + issue_updated_at: snapshot.issue_updated_at, + linked_prs: snapshot.linked_prs + }) + }; +} + +function diffSnapshots(before: WatchSnapshot, after: WatchSnapshot): { deltas: WatchRecheck['deltas']; triggers: WatchTrigger[] } { + const deltas: WatchRecheck['deltas'] = []; + const triggers: WatchTrigger[] = []; + if (before.issue_state !== after.issue_state) { + deltas.push({ path: 'issue_state', before: before.issue_state, after: after.issue_state }); + triggers.push('target_state_changed'); + } + if (before.issue_updated_at !== after.issue_updated_at) { + deltas.push({ path: 'issue_updated_at', before: before.issue_updated_at, after: after.issue_updated_at }); + if (!triggers.includes('target_state_changed')) triggers.push('target_state_changed'); + } + const beforePrs = new Set(before.linked_prs.map((pr) => pr.number)); + const afterPrs = new Set(after.linked_prs.map((pr) => pr.number)); + for (const number of afterPrs) { + if (!beforePrs.has(number)) { + deltas.push({ path: `linked_prs.${number}`, before: null, after: number }); + triggers.push('new_pr'); + } + } + for (const pr of after.linked_prs) { + const prior = before.linked_prs.find((row) => row.number === pr.number); + if (prior && (prior.state !== pr.state || prior.merged !== pr.merged || prior.draft !== pr.draft)) { + deltas.push({ path: `linked_prs.${pr.number}.state`, before: prior, after: pr }); + triggers.push('pr_state_changed'); + } + } + if (before.ci_state !== after.ci_state && (before.ci_state || after.ci_state)) { + deltas.push({ path: 'ci_state', before: before.ci_state, after: after.ci_state }); + triggers.push('ci_changed'); + } + if (before.maintainer_activity_at !== after.maintainer_activity_at && after.maintainer_activity_at) { + deltas.push({ path: 'maintainer_activity_at', before: before.maintainer_activity_at, after: after.maintainer_activity_at }); + triggers.push('maintainer_activity'); + } + return { deltas, triggers: [...new Set(triggers)] }; +} + +export async function watch_add(input: WatchAddInput): Promise { + const target = targetFromInput(input); + const { snapshot, fingerprint } = await snapshotTarget(target); + const now = new Date().toISOString(); + const watch = WatchRecordSchema.parse({ + watch_version: 1, + watch_id: `watch_${randomUUID()}`, + target, + created_at: now, + updated_at: now, + last_fingerprint: fingerprint, + last_snapshot: snapshot, + ...(input.note ? { note: input.note } : {}) + }); + await putWatchRecord(watch); + return { + ...createEnvelope({ + verdict_summary: `watching ${target.kind} locally; no GitHub write occurred.`, + evidence: [{ kind: 'watch', watch_id: watch.watch_id, url: undefined }], + checked: ['wrote local watch record'], + not_checked: ['Watch is local-only. Routing never auto-creates watches.'] + }), + watch + }; +} + +export async function watch_list(): Promise { + const watches = await listWatchRecords(); + return { + ...createEnvelope({ + verdict_summary: watches.length === 0 ? 'no local watches.' : `${watches.length} local watches.`, + evidence: watches.map((watch) => ({ kind: 'watch', watch_id: watch.watch_id })), + checked: ['listed local watch registry'], + not_checked: ['Watch list does not call GitHub.'] + }), + watches + }; +} + +export async function watch_show(watchId: string): Promise { + const watch = await getWatchRecord(watchId); + if (!watch) { + throw new GitworthyError({ + code: 'watch_not_found', + message: `watch ${watchId} was not found.`, + not_checked: ['Watch show is local-only.'] + }); + } + return { + ...createEnvelope({ + verdict_summary: `local watch ${watchId}.`, + evidence: [{ kind: 'watch', watch_id: watchId }], + checked: ['read local watch record'], + not_checked: ['Show does not recheck GitHub unless you run watch recheck.'] + }), + watch + }; +} + +export async function watch_recheck(input: { + watch_id: string; + write?: boolean; +}): Promise { + const existing = await getWatchRecord(input.watch_id); + if (!existing) { + throw new GitworthyError({ + code: 'watch_not_found', + message: `watch ${input.watch_id} was not found.`, + not_checked: ['Recheck needs a local watch record.'] + }); + } + const { snapshot, fingerprint } = await snapshotTarget(existing.target); + const { deltas, triggers } = diffSnapshots(existing.last_snapshot, snapshot); + const changed = fingerprint !== existing.last_fingerprint || deltas.length > 0; + const shouldWrite = input.write !== false; + let watch = existing; + if (changed && shouldWrite) { + watch = await putWatchRecord({ + ...existing, + updated_at: new Date().toISOString(), + last_fingerprint: fingerprint, + last_snapshot: snapshot + }); + } + const recheck: WatchRecheck = { + watch_id: existing.watch_id, + changed, + triggers: changed ? (triggers.length > 0 ? triggers : ['manual']) : [], + deltas, + fingerprint_before: existing.last_fingerprint, + fingerprint_after: fingerprint, + updated: changed && shouldWrite + }; + return { + ...createEnvelope({ + verdict_summary: changed + ? `watch ${existing.watch_id} changed (${recheck.triggers.join(', ') || 'manual'}).` + : `watch ${existing.watch_id} unchanged.`, + evidence: [{ kind: 'watch_recheck', watch_id: existing.watch_id, changed }], + checked: ['compared fingerprint and snapshot fields'], + not_checked: ['Watch recheck never writes to GitHub.'] + }), + recheck, + watch + }; +} + +export async function watch_remove(watchId: string): Promise { + const removed = await removeWatchRecord(watchId); + if (!removed) { + throw new GitworthyError({ + code: 'watch_not_found', + message: `watch ${watchId} was not found.`, + not_checked: ['Remove is local-only.'] + }); + } + return { + ...createEnvelope({ + verdict_summary: `removed local watch ${watchId}.`, + evidence: [{ kind: 'watch_removed', watch_id: watchId }], + checked: ['deleted local watch record'], + not_checked: ['No GitHub mutation.'] + }), + removed: true + }; +} + +export async function listLocalWatches(): Promise { + return listWatchRecords(); +} diff --git a/src/lib/watch-store.ts b/src/lib/watch-store.ts new file mode 100644 index 0000000..a3a8dd3 --- /dev/null +++ b/src/lib/watch-store.ts @@ -0,0 +1,53 @@ +import { readdir, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { WatchRecordSchema, type WatchRecord } from '../contracts/watch.js'; +import { storeRoot, withStoreLock, writeJsonAtomic } from './store-fs.js'; + +function watchDir(): string { + return path.join(storeRoot(), 'watch'); +} + +function watchFile(id: string): string { + return path.join(watchDir(), `${id}.json`); +} + +export async function putWatchRecord(record: WatchRecord): Promise { + const parsed = WatchRecordSchema.parse(record); + await withStoreLock(`watch:${parsed.watch_id}`, async () => { + await writeJsonAtomic(watchFile(parsed.watch_id), parsed); + }); + return parsed; +} + +export async function getWatchRecord(watchId: string): Promise { + try { + const raw = await readFile(watchFile(watchId), 'utf8'); + return WatchRecordSchema.parse(JSON.parse(raw)); + } catch { + return null; + } +} + +export async function listWatchRecords(): Promise { + try { + const names = await readdir(watchDir()); + const rows: WatchRecord[] = []; + for (const name of names) { + if (!name.endsWith('.json')) continue; + const row = await getWatchRecord(name.replace(/\.json$/, '')); + if (row) rows.push(row); + } + return rows.sort((left, right) => right.updated_at.localeCompare(left.updated_at)); + } catch { + return []; + } +} + +export async function removeWatchRecord(watchId: string): Promise { + const existing = await getWatchRecord(watchId); + if (!existing) return false; + await withStoreLock(`watch:${watchId}`, async () => { + await rm(watchFile(watchId), { force: true }); + }); + return true; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9106eeb..5fd6a6a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -13,6 +13,11 @@ import { hunt, portfolio, pr_scan, + watch_add, + watch_list, + watch_show, + watch_recheck, + watch_remove, issue_vs_main, ledger_list, ledger_lookup, @@ -374,6 +379,20 @@ export function createMcpServer(): McpServer { withToolErrors('brief', () => generateBrief(parseToolInput(BriefShowInputSchema, input)))); server.registerTool('brief', toolConfig('brief', { decision_id: z.string(), config_path: z.string().optional(), cwd: z.string().optional() }), async (input) => withToolErrors('brief', () => generateBrief(parseToolInput(BriefShowInputSchema, input)))); + server.registerTool('watch_add', toolConfig('watch_add', { + repo: z.string(), + issue_number: z.number().optional(), + pr_number: z.number().optional(), + note: z.string().optional() + }), async (input) => withToolErrors('watch_add', async () => stamp('watch_add')(await watch_add(input as { repo: string; issue_number?: number; pr_number?: number; note?: string })))); + server.registerTool('watch_list', toolConfig('watch_list', {}), async () => + withToolErrors('watch_list', async () => stamp('watch_list')(await watch_list()))); + server.registerTool('watch_show', toolConfig('watch_show', { watch_id: z.string() }), async (input) => + withToolErrors('watch_show', async () => stamp('watch_show')(await watch_show(String((input as { watch_id: string }).watch_id))))); + server.registerTool('watch_recheck', toolConfig('watch_recheck', { watch_id: z.string(), write: z.boolean().optional() }), async (input) => + withToolErrors('watch_recheck', async () => stamp('watch_recheck')(await watch_recheck(input as { watch_id: string; write?: boolean })))); + server.registerTool('watch_remove', toolConfig('watch_remove', { watch_id: z.string() }), async (input) => + withToolErrors('watch_remove', async () => stamp('watch_remove')(await watch_remove(String((input as { watch_id: string }).watch_id))))); server.registerTool('portfolio', toolConfig('portfolio', { repo: z.string().optional(), org: z.string().optional(), diff --git a/src/mcp/tool-meta.ts b/src/mcp/tool-meta.ts index 9e5548f..1af78a6 100644 --- a/src/mcp/tool-meta.ts +++ b/src/mcp/tool-meta.ts @@ -312,6 +312,41 @@ export const TOOL_META = { 'List local capture manifests.', annotations: { ...readLocal, title: 'List captures' } }, + watch_add: { + title: 'Watch add', + role: 'store', + description: + 'Create a local-only watch record for an issue or PR. Never writes to GitHub and is never created automatically from a WATCH routing mode.', + annotations: { ...writeLocal, title: 'Watch add' } + }, + watch_list: { + title: 'Watch list', + role: 'store', + description: + 'List local watch records. Local store read only; no GitHub calls.', + annotations: { ...readLocal, title: 'Watch list' } + }, + watch_show: { + title: 'Watch show', + role: 'store', + description: + 'Show one local watch record. Local store read only.', + annotations: { ...readLocal, title: 'Watch show' } + }, + watch_recheck: { + title: 'Watch recheck', + role: 'store', + description: + 'Fetch current target state, compare the stored fingerprint, and report exact field deltas. Updates the local record unless write=false. Never writes to GitHub.', + annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true, title: 'Watch recheck' } + }, + watch_remove: { + title: 'Watch remove', + role: 'store', + description: + 'Delete a local watch record. Local-only; no GitHub mutation.', + annotations: { ...mutateLocal, title: 'Watch remove' } + }, case_promote: { title: 'Promote capture', role: 'admin', diff --git a/test/mcp-echo.test.ts b/test/mcp-echo.test.ts index 3673f2e..f93ee61 100644 --- a/test/mcp-echo.test.ts +++ b/test/mcp-echo.test.ts @@ -49,6 +49,11 @@ describe('MCP tools', () => { 'store_recheck', 'store_rebuild_indexes', 'store_target_show', + 'watch_add', + 'watch_list', + 'watch_remove', + 'watch_recheck', + 'watch_show', 'worth_check' ].sort()); await client.close(); diff --git a/test/portfolio.test.ts b/test/portfolio.test.ts index 86d3ffc..6f9c415 100644 --- a/test/portfolio.test.ts +++ b/test/portfolio.test.ts @@ -256,6 +256,23 @@ describe('portfolio capacity and dispatch', () => { expect(result.items).toEqual([]); }); + it('merges local watches into portfolio items when include_watch is set', async () => { + const result = await portfolio({ repo: 'o/r', include_prs: false, include_watch: true }, { + hunt: async () => ({ + verdict_summary: 'hunt', + evidence: [], + signals: [], + checked: ['hunt'], + not_checked: ['none'], + cached: false, + fetched_at: '2026-08-01T00:00:00.000Z' + }), + listOutcomes: async () => [], + listWatch: async () => [{ target: { kind: 'issue', repo: 'o/r', issue_number: 77 } }] + }); + expect(result.items.some((item) => item.primary_mode === 'WATCH' && item.dispatch_state === 'watching')).toBe(true); + }); + it('fans out bounded PR scans across org hunt repos', async () => { const scanned: string[] = []; const result = await portfolio({ org: 'acme', max_items: 10 }, { diff --git a/test/watch.test.ts b/test/watch.test.ts new file mode 100644 index 0000000..26f3c73 --- /dev/null +++ b/test/watch.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const mocks = vi.hoisted(() => ({ + githubJson: vi.fn(), + linked_work: vi.fn() +})); + +vi.mock('../src/lib/github.js', () => ({ + githubJson: mocks.githubJson +})); + +vi.mock('../src/core/linked-work.js', () => ({ + linked_work: mocks.linked_work +})); + +const { watch_add, watch_list, watch_recheck, watch_remove } = await import('../src/core/watch.js'); + +describe('local watch registry', () => { + let dir = ''; + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'gw-watch-')); + process.env.GITWORTHY_STORE_DIR = dir; + mocks.githubJson.mockReset(); + mocks.linked_work.mockReset(); + mocks.linked_work.mockResolvedValue({ evidence: [] }); + mocks.githubJson.mockResolvedValue({ + state: 'open', + updated_at: '2026-08-01T00:00:00.000Z', + assignees: [{ login: 'alice' }] + }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + delete process.env.GITWORTHY_STORE_DIR; + }); + + it('adds, lists, and removes a local watch without writing upstream', async () => { + const added = await watch_add({ repo: 'o/r', issue_number: 3, note: 'wait for CI' }); + expect(added.watch.target).toEqual({ kind: 'issue', repo: 'o/r', issue_number: 3 }); + expect(added.not_checked.join(' ')).toMatch(/local-only|never auto-creates/i); + const listed = await watch_list(); + expect(listed.watches).toHaveLength(1); + await watch_remove(added.watch.watch_id); + expect((await watch_list()).watches).toHaveLength(0); + }); + + it('recheck reports exact fingerprint deltas and can update local state', async () => { + const added = await watch_add({ repo: 'o/r', issue_number: 3 }); + mocks.githubJson.mockResolvedValue({ + state: 'closed', + updated_at: '2026-08-20T00:00:00.000Z', + assignees: [{ login: 'alice' }] + }); + mocks.linked_work.mockResolvedValue({ + evidence: [{ kind: 'linked_pr', number: 9, state: 'open', draft: false, merged: false, updated_at: '2026-08-20T00:00:00.000Z' }] + }); + const recheck = await watch_recheck({ watch_id: added.watch.watch_id, write: true }); + expect(recheck.recheck.changed).toBe(true); + expect(recheck.recheck.triggers).toEqual(expect.arrayContaining(['target_state_changed', 'new_pr'])); + expect(recheck.recheck.deltas.some((delta) => delta.path === 'issue_state')).toBe(true); + expect(recheck.recheck.updated).toBe(true); + expect(recheck.watch.last_snapshot.issue_state).toBe('closed'); + }); + + it('does not invent a watch from a WATCH routing mode', async () => { + expect(typeof watch_add).toBe('function'); + expect((await watch_list()).watches).toEqual([]); + }); +});