Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
8e8b44b
docs: design the MCP write-capable tools slice (#131)
MrReasonable Aug 14, 2026
00578b7
docs: fold /review-plan findings into the MCP write-tools design (#131)
MrReasonable Aug 14, 2026
f1355b9
docs: fold second /review-plan findings into the MCP write-tools desi…
MrReasonable Aug 14, 2026
a7ebd7e
docs: fold third /review-plan findings into the MCP write-tools desig…
MrReasonable Aug 14, 2026
4ac605e
docs: add the MCP write-tools implementation plan (#131)
MrReasonable Aug 14, 2026
f317c42
fix(vault): guard _render_new's 5 non-identity fields against frontma…
MrReasonable Aug 14, 2026
0deb677
docs: remove an empty stub test from the plan's Task 4 (pre-flight fix)
MrReasonable Aug 14, 2026
6087df0
fix(apply): guard ats against frontmatter injection, add require_stat…
MrReasonable Aug 14, 2026
4d248ef
feat(cv): add require_pending/stale CAS guard to sign_off, widen sign…
MrReasonable Aug 14, 2026
27ab639
fix(vault): serialize _cas_write per-path to close an intra-process T…
MrReasonable Aug 14, 2026
1dd2f91
fix(test): add liveness guards to the _cas_write race test
MrReasonable Aug 14, 2026
f878bfb
feat(core): add Sluice.dismiss_lead(), the first exact-match write ov…
MrReasonable Aug 14, 2026
8e8736c
test(core): pin note_appended's snapshot half against a status-only w…
MrReasonable Aug 14, 2026
27c5c5c
feat(cli): add job-sluice leads dismiss, the human-reproduction path …
MrReasonable Aug 14, 2026
6a2b23a
feat(core): add Sluice.create_lead(), a direct manual-entry write path
MrReasonable Aug 14, 2026
62c84be
feat(core): add out_of_scope_verdict and UNTRUSTED_DERIVED_CONTENT_WA…
MrReasonable Aug 14, 2026
8834aaa
feat(mcp): add the dismiss_lead and apply_record tool functions
MrReasonable Aug 14, 2026
d95c512
feat(mcp): add the cv_run and cv_signoff tool functions, including th…
MrReasonable Aug 14, 2026
f0e432c
test(mcp): close two reviewer-flagged coverage gaps for cv_signoff
MrReasonable Aug 14, 2026
650d868
feat(mcp): add the create_lead tool function
MrReasonable Aug 14, 2026
842c974
test(mcp): add coverage for create_lead's merged outcome and update d…
MrReasonable Aug 14, 2026
e7fbd7b
feat(mcp): gate the five write tools behind --write, add the isolatio…
MrReasonable Aug 14, 2026
be6f6ca
test(mcp): close the gap on --write never being exercised end-to-end
MrReasonable Aug 14, 2026
f22e864
test(mcp): add layer-2 contract coverage for the write=True registrat…
MrReasonable Aug 14, 2026
52d2c0b
docs(mcp): document the five write-capable MCP tools and the --write …
MrReasonable Aug 14, 2026
0bf9c21
chore: regenerate rulesync output for the dismiss_lead never-regress …
MrReasonable Aug 14, 2026
a2ee4e5
fix(docs): correct create_lead's updated-vs-merged mechanism in USAGE…
MrReasonable Aug 14, 2026
540da7f
fix(mcp): correct create_lead docstring's merged example -- location …
MrReasonable Aug 14, 2026
ca5e0c2
fix(mcp): never guess an identity in create_lead/dismiss_lead (final …
MrReasonable Aug 14, 2026
e6ece4a
fix(vault): _lock_for resolves symlinks; de-flake the per-path lock test
MrReasonable Aug 14, 2026
9312d9b
fix(mcp): close the isolation-sweep gap; harden the write-tool surface
MrReasonable Aug 14, 2026
5dd04c6
docs(mcp): fix create_lead's collide-onto-one-note overclaim in USAGE.md
MrReasonable Aug 14, 2026
1da1c4a
test(mcp): strengthen the CLI same-day-repeat dismiss test (Deferred #6)
MrReasonable Aug 14, 2026
95778d6
fix(mcp): create_lead's slug narrowing must prioritize url proof over…
MrReasonable Aug 15, 2026
5c4b05c
fix(store): widen Store.upsert to return UpsertResult, ending create_…
MrReasonable Aug 15, 2026
2fc0d27
test(store): fix non-falsifying UpsertResult tests found by review (#…
MrReasonable Aug 15, 2026
5efa66b
test(store): generalize the distinguishing test to the store-agnostic…
MrReasonable Aug 15, 2026
da69e24
test(mcp): replace assert-False sentinels with pytest.raises in list_…
MrReasonable Aug 15, 2026
c9f3b03
test(fixtures): extend LOCATIONS to a 4th synthetic entry, drop the "…
MrReasonable Aug 15, 2026
b72d96b
fix(mcp): translate cv_run's invalid backend from BackendError to Val…
MrReasonable Aug 16, 2026
226fe14
test(core): pin the two-match witness in out_of_scope_verdict's ambig…
MrReasonable Aug 16, 2026
ef8a0cf
test(cli): add coverage for leads dismiss's ambiguous and refused_sta…
MrReasonable Aug 16, 2026
17f8e58
test(apply): rename a bare "E" fixture identity to the reviewed "Exam…
MrReasonable Aug 16, 2026
b2c6bec
docs(mcp): clarify leads-dismiss wording and fix two grammar nits
MrReasonable Aug 17, 2026
8c5071a
test(mcp): strengthen three round-5 review findings
MrReasonable Aug 17, 2026
6f787fe
test(vault): harden the frontmatter-setter sweep against unrelated li…
MrReasonable Aug 17, 2026
ef09c06
docs(mcp): fix another markdownlint fence, harden the sweep's own saf…
MrReasonable Aug 17, 2026
044ccce
docs(mcp): document leads dismiss's write-unconditionally exception
MrReasonable Aug 17, 2026
99803fd
test(mcp): close the conflict/raced coverage gap across three write t…
MrReasonable Aug 17, 2026
780f466
test(mcp): widen the dismiss_lead concurrency sanity check's outcome set
MrReasonable Aug 17, 2026
b6e0120
fix(cli): cv signoff's not_found message overclaimed a shortlist-only…
MrReasonable Aug 17, 2026
186b6cc
test(store): stop the conformance suite pinning MAY-return outcomes a…
MrReasonable Aug 17, 2026
bf2a37b
fix(tests): remove a real airline's brand name from a fixture comment
MrReasonable Aug 17, 2026
944ea6d
test(mcp): assert the confirm_token schema default key exists, not ju…
MrReasonable Aug 17, 2026
11f3c44
fix(tests): use a synthetic ATS name in the newly-added record() tests
MrReasonable Aug 17, 2026
80036d5
test(core): pin the in-scope witness in out_of_scope_verdict's no-op …
MrReasonable Aug 17, 2026
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
11 changes: 10 additions & 1 deletion .rulesync/rules/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ print and change nothing until told otherwise, and none offers `--dry-run` — t
run, and a flag that does nothing is drift. `triage run`/`ingest run`/`track run` invert both halves. The distinguishing
property is whose judgement the write encodes: a pipeline command acts on a verdict the user
configured, while a `leads` pass writes over a set the TOOL computed, so a mistyped one should print
a list rather than change a hundred notes. (`docs/ARCHITECTURE.md` has the per-pass mechanics.)
a list rather than change a hundred notes. **Exception: `leads dismiss` writes unconditionally on
every call** (#131), like the pipeline commands, not like its `leads` siblings — the verdict it
writes is the one the user typed (`--lead`/`--reason`), not one the tool computed. (`docs/ARCHITECTURE.md`
has the per-pass mechanics.)

**Backends are selected by role, not provider.** `--backend` takes `auto|primary|fallback`
(`claude-max`/`deepseek` survive as deprecated aliases). Which provider fills each role is config
Expand Down Expand Up @@ -312,6 +315,12 @@ application-owned state) and passes that same set as `require_status`, which is
the invariant when a lead enters the application lifecycle mid-sweep. It is NOT unconditional: a
lead holding a `pending_cv` sign-off hold (#60) is refused, because dismissing it silently discards
a composed CV no human has signed off. Any second bulk-dismiss path must refuse the same.
`Sluice.dismiss_lead()` (#131) is that second writer -- a single-lead dismiss, not a
bulk sweep, so `expire_report`'s pre-filtering argument does not apply to it; it uses
its own `_DISMISSABLE_FROM` (the full `TRIAGE_OWNED` set, `dismiss` included) rather
than `_EXPIRABLE`, and its `pending_cv` sign-off-hold refusal is checked CAS-fresh
inside the write transform via `require_blank` -- unlike `leads expire`'s equivalent
refusal, which is still decided from a snapshot.

**Empty config means abstain, not match-nothing.** Every preference gate (`accept_titles`,
`target_locations`, `reject_companies`, `relevance_keep`/`relevance_drop`, pay floors) defaults to
Expand Down
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,23 +285,37 @@ stream each command writes to: [`docs/USAGE.md`](https://github.com/MrReasonable
| `job-sluice cv` | compose, gate and render a tailored CV, then sign off on it (`run`, `signoff`) |
| `job-sluice apply` | stage a CV + prep packet, then record a submitted application (`prep`, `record`) |
| `job-sluice track` | reconcile the funnel from email + calendar signals (`run`, `confirm`, `dismiss`) |
| `job-sluice leads` | maintenance passes -- report by default, write only when told (`dedupe`, `expire`, `reconcile`) |
| `job-sluice leads` | maintenance passes -- report by default, write only when told (`dedupe`, `expire`, `reconcile`); `dismiss` writes unconditionally, like a pipeline command |
| `job-sluice health` | per-source scrape baseline + retire state |
| `job-sluice mcp` | run a Model Context Protocol server over stdio, for an agent to drive sluice directly (`serve`) |
| `job-sluice mcp` | run a Model Context Protocol server over stdio, for an agent to drive sluice directly (`serve [--write]`) |

## MCP server

`job-sluice mcp serve` runs sluice as a Model Context Protocol server over stdio, so
an agent (Claude Code or otherwise) can call `list_leads`/`get_lead`/`doctor`/`health`
directly instead of shelling out to the CLI and parsing its stdout. Read-only for
now -- see [`docs/ARCHITECTURE.md`](https://github.com/MrReasonable/sluice/blob/main/docs/ARCHITECTURE.md)'s surface/adapter section. Needs `pip install -e '.[mcp]'`.
directly instead of shelling out to the CLI and parsing its stdout. Read-only by
default -- see [`docs/ARCHITECTURE.md`](https://github.com/MrReasonable/sluice/blob/main/docs/ARCHITECTURE.md)'s surface/adapter section. Needs `pip install -e '.[mcp]'`.

Register it with Claude Code:
Pass `--write` to also register five write-capable tools -- `dismiss_lead`,
`apply_record`, `cv_run`, `cv_signoff`, `create_lead` -- each a thin translation
layer over one `Sluice` write method, never a raw store write. `--write` is a
per-registration trust decision about one MCP client, not a property of the
installation: every existing read-only registration is unaffected, and a read-only
server's `tools/list` genuinely omits the five write tools' names and schemas, not
merely refusing them at call time.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Register it with Claude Code (read-only):

```bash
claude mcp add job-sluice -- job-sluice mcp serve
```

...or with write tools enabled:

```bash
claude mcp add job-sluice -- job-sluice mcp serve --write
```

## Rendering prerequisites (`cv.renderer: template` only)

Everything in this section is a prerequisite of ONE renderer -- `template`, the default.
Expand Down
21 changes: 18 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,14 @@ adapters AND drives the pipeline, so a surface no longer constructs a `Vault()`
or a `Camofox()`, builds a backend, or duplicates the triage/compose/prep/record/
track wiring itself. `cli.py` is now a thin shell over `Sluice` -- each command
builds one, calls one method, and formats the result for the terminal -- so a surface
built today has nothing left in `cli.py` worth forking. `sluice/mcpserver.py` (#105)
is the first one: a Model Context Protocol server exposing four read-only tools
(`list_leads`, `get_lead`, `doctor`, `health`) over stdio.
built today has nothing left in `cli.py` worth forking. `sluice/mcpserver.py` (#105,
extended #131) is the first one: a Model Context Protocol server exposing four
read-only tools (`list_leads`, `get_lead`, `doctor`, `health`) always, and five
write-capable tools (`dismiss_lead`, `apply_record`, `cv_run`, `cv_signoff`,
`create_lead`) under `--write`. Every write tool is a thin translation layer over
exactly one `Sluice` write method -- `sluice/mcpserver.py` itself contains no store
write (AST-enforced) -- so a write tool can never become a second, undocumented
write path for an invariant `Sluice`'s own methods already hold.

`tests/conformance/test_store_contract.py` is parameterised over every registered
store and asserts never-clobber (a re-scrape touches only `last_seen`, and that
Expand Down Expand Up @@ -463,6 +468,16 @@ frozen `StalenessPolicy` built by `Sluice.staleness()`. Staleness is a cheap
proxy: whether a role is still open can only be answered on the employer's own
site, so it does not replace checking before applying.

**`job-sluice leads dismiss --lead SLUG --reason REASON`** (#131) writes `dismiss`
by the same route as `leads expire` -- `require_status=_DISMISSABLE_FROM` re-read
inside the CAS transform, `require_blank={"pending_cv"}` refusing a lead holding
a #60 sign-off hold -- but is the ONE `leads` pass that writes unconditionally on
every call rather than reporting by default: the verdict it writes is one the
USER typed (`--lead`/`--reason`), not one this tool computed, matching the
pipeline commands' contract rather than `dedupe`/`expire`/`reconcile`'s. Resolves
by EXACT slug equality, never substring, and refuses (writes nothing) when the
slug names two or more notes rather than picking one.

**Preventing overwrites of hand-edits** (#109): a sibling guard to
`require_status`, also on `Vault.update_fields`, named `require_blank`. It
re-reads the named fields' **current** content inside the CAS transform and
Expand Down
81 changes: 70 additions & 11 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ exists). Auto-advances status only on a proof-grade, authenticated, non-multi-te
above the configured confidence floor; every weaker signal becomes a dead-letter proposal that
resurfaces on every run until a human acts. Prints a summary plus the open-proposal list to
**stderr** (the whole block, including the digest line):
```
```text
track: msgs=N classified=N auto=N proposed=N calendar_added=N failures=N open=N
FAILED <message_id>: <cause>
WARNING: N calendar entries booked from a DTSTART with no usable timezone ...
Expand Down Expand Up @@ -210,6 +210,11 @@ exit 0: `track-dismiss: <cleared|would clear> N entr(y|ies)`.

Maintenance passes. **Report by default; none of these offers `--dry-run`, because the
default *is* the dry run** — a write happens only with the flag named below.
**Exception: `leads dismiss` writes unconditionally on every call** (#131), like the
pipeline commands (`ingest run`/`triage run`/`cv run`/`apply record`/`track run`), not
like its `leads` siblings — the distinguishing property is who decided what to write:
`dismiss` acts on a verdict the USER typed (`--lead`/`--reason`), while
`dedupe`/`expire`/`reconcile` write over a set the TOOL computed.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### `job-sluice leads dedupe [--merge ID ...] [--json]`

Expand All @@ -235,6 +240,22 @@ outcome is `no-match`/`conflict`/`unreadable`/`skipped`/`ambiguous`. A lead unde
`pending_cv` sign-off hold is refused rather than dismissed, since that would silently discard
a composed CV no human has reviewed yet.

### `job-sluice leads dismiss --lead SLUG --reason REASON`

Dismisses ONE lead by EXACT store-issued slug (never a substring match), with
`--reason` required and appended to `relevance_notes` under a same-day idempotency
tag -- a same-day repeat is a real `unchanged`, not a duplicate note. Resolves over
every triage-owned status, `dismiss` included, so re-dismissing an already-dismissed
lead is a legitimate no-op rather than a regression. Reuses `Sluice.dismiss_lead`
verbatim -- the same write path the `dismiss_lead` MCP tool calls (see below).
Refused (writes nothing) if the slug is ambiguous (a name collision), the lead has
moved into the application lifecycle, or it is held by a #60 sign-off
(`pending_cv`) -- resolve that first: `job-sluice cv signoff --lead "<slug>"
--discard`.

Exit 1 for no match, ambiguous, either refusal, or a lost write race (`conflict`);
exit 0 for `dismissed` or `unchanged`.

### `job-sluice leads reconcile [--apply] [--json]`

Reports (or with `--apply`, moves) each lead note into the folder its `status` implies — only
Expand All @@ -253,16 +274,54 @@ No flags. Per-source scrape baseline and retire state, one line each:

## `job-sluice mcp`

### `job-sluice mcp serve`

No flags. Runs sluice as a Model Context Protocol server over stdio, so an agent (Claude Code
or otherwise) can call `list_leads`/`get_lead`/`doctor`/`health` directly instead of shelling
out to the CLI and parsing its stdout. Read-only for now -- see
[`docs/ARCHITECTURE.md`](ARCHITECTURE.md)'s surface/adapter section. Needs
`pip install -e '.[mcp]'`; if the `mcp` package is not installed, exits 2 with a stderr message
naming `job-sluice[mcp]` as the extra to install (see `sluice/mcpserver.py`'s
`McpNotInstalled`) rather than a traceback. Blocks for the life of the process once started;
there is no `--dry-run`.
### `job-sluice mcp serve [--write]`

Runs sluice as a Model Context Protocol server over stdio, so an agent (Claude Code
or otherwise) can call sluice's tools directly instead of shelling out to the CLI
and parsing its stdout. Needs `pip install -e '.[mcp]'`; if the `mcp` package is not
installed, exits 2 with a stderr message naming `job-sluice[mcp]` as the extra to
install (see `sluice/mcpserver.py`'s `McpNotInstalled`) rather than a traceback.
Blocks for the life of the process once started; there is no `--dry-run`.

**Without `--write`** (the default), four read-only tools are registered:
`list_leads`, `get_lead`, `doctor`, `health`.

**With `--write`**, five more tools are registered:

- `dismiss_lead(lead, reason)` -- dismiss one lead by EXACT slug, recording `reason`.
- `apply_record(lead, ats=None, url=None)` -- record a sent application (shortlist
-> applied).
- `cv_run(lead, backend="auto")` -- compose and render a CV for one shortlisted
lead. The composed text itself is never included in the response.
- `cv_signoff(lead, discard=False, confirm_token=None)` -- resolve a #60 sign-off
hold. `discard=True` clears it outright. **Promoting needs TWO calls**: the first
(no `confirm_token`) writes nothing and returns a `confirm_token` bound to the
exact claims text; relay the claims to a human, get explicit approval, then call
again passing that token back to actually promote. A token whose claims have
since changed (a re-compose interleaved) returns `stale_confirmation` with a
fresh token, having written nothing.
- `create_lead(title, company, url, location="", salary="", job_type="",
source="manual")` -- create a new lead note directly, for a job a human found
that no scanner ingested. Lands at `status: new`; `job-sluice triage run`
promotes it from there. Reports `upsert`'s own outcome vocabulary verbatim:
identity is company+title, and a second call at that same identity bumps
`last_seen` ONLY, reported as `updated` when the incoming url (or, absent a
url match, the location) proves the same posting, or `merged` when neither
does (inconclusive evidence -- e.g. a blank-url lead whose location is
blank, or is compared against a note whose own location is blank) -- UNLESS
the two locations are proven DIFFERENT (two non-blank, non-overlapping
locations), in which case the call creates a genuinely NEW note instead
(`created` again -- a second real note at the same company+title). Both
`updated` and `merged` are a bare `last_seen` bump; the new
url/salary/location is NOT recorded either way. `slug` is OMITTED from the
response only for `refused`/`merged_away`/`merged_away_unproven`, which
write nothing and so never have a slug to report -- `created`/`updated`/
`merged` always carry the slug of the note this call actually touched, the
store's own answer, never a guess.

`--write` is a per-registration trust decision about one MCP client: every existing
read-only registration is unaffected, and a read-only server's `tools/list`
genuinely omits the five write tools, not merely refusing them at call time.

## `job-sluice init`

Expand Down
Loading