Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <watch_id> [--json]
```

## Evidence / store commands

```sh
Expand Down
2 changes: 1 addition & 1 deletion docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
47 changes: 47 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <watch_id> [--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]
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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<string, unknown>);
} 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<string, unknown>);
}
} else if (action === 'list') {
commandName = 'watch_list';
output = toStampedLegacyResult('watch_list', await watch_list() as Record<string, unknown>);
} else if (action === 'show') {
commandName = 'watch_show';
output = toStampedLegacyResult('watch_show', await watch_show(required(second, 'watch show requires a watch_id.')) as Record<string, unknown>);
} 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<string, unknown>);
} else if (action === 'remove') {
commandName = 'watch_remove';
output = toStampedLegacyResult('watch_remove', await watch_remove(required(second, 'watch remove requires a watch_id.')) as Record<string, unknown>);
} else {
usageError('watch requires add, list, show, recheck, or remove.');
}
} else if (command === 'capture') {
const action = first;
if (action === 'show') {
Expand Down
61 changes: 61 additions & 0 deletions src/contracts/watch.ts
Original file line number Diff line number Diff line change
@@ -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<typeof WatchTriggerSchema>;
export type WatchSnapshot = z.infer<typeof WatchSnapshotSchema>;
export type WatchRecord = z.infer<typeof WatchRecordSchema>;
export type WatchRecheck = z.infer<typeof WatchRecheckSchema>;
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
17 changes: 15 additions & 2 deletions src/core/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading