dev → main: task claims (milestone 381), embedding model stamp, dedup copy band, sweep-shared.css #185

Merged
bvandeusen merged 12 commits from dev into main 2026-09-24 06:57:12 -04:00
44 changed files with 1649 additions and 252 deletions
@@ -0,0 +1,42 @@
"""embeddings_record_their_model — a vector says whose space it lives in (#4132)
Revision ID: 0109
Revises: 0108
Create Date: 2026-09-23
Every embedding table stamps `chunker_version`, so a change to the document
shape is caught per row and re-embedded. None stamped the MODEL. The column is
`vector(384)` — a width, not an identity — so swapping bge-small for any other
384-dim model would write a second geometry beside the first with no error,
and search would go on ranking by cosines between the two, which mean nothing.
`embedding_model` is the other half of `calibration_stamp()`, stored per row on
all four tables. The startup backfill now re-embeds on either half moving.
THE BACKFILL LITERAL IS SAFE BECAUSE NO INSTALL HAS EVER CHANGED MODELS. The
name is hardcoded in `services/embeddings.py`, and every vector ever written was
written by it — so stamping every existing row with that name states a fact,
not a guess. It is frozen here rather than imported: a migration records what
was true when it ran, and a later model change must not rewrite history.
"""
from alembic import op
revision = "0109"
down_revision = "0108"
branch_labels = None
depends_on = None
_TABLES = ("note_embeddings", "rule_embeddings", "milestone_embeddings", "system_embeddings")
_MODEL = "BAAI/bge-small-en-v1.5"
def upgrade() -> None:
for table in _TABLES:
op.execute(f"ALTER TABLE {table} ADD COLUMN embedding_model text")
op.execute(f"UPDATE {table} SET embedding_model = '{_MODEL}'")
op.execute(f"ALTER TABLE {table} ALTER COLUMN embedding_model SET NOT NULL")
def downgrade() -> None:
for table in _TABLES:
op.execute(f"ALTER TABLE {table} DROP COLUMN embedding_model")
+38
View File
@@ -0,0 +1,38 @@
"""task_claims — a task can say a session is working it (milestone 381 step 2)
Revision ID: 0110
Revises: 0109
Create Date: 2026-09-23
`status` is durable and nothing clears it, so `in_progress` cannot also mean
"someone is on this now". The claim is that second meaning, stored as WHO and
WHEN rather than as a flag, so it can be read as dead without anything having
cleared it (`services/task_claims.py` has the semantics).
Four nullable columns, no backfill: no existing row has ever been claimed, and
inventing a claim for one would assert attention nobody observed. No CHECK
enum is touched.
"""
import sqlalchemy as sa
from alembic import op
revision = "0110"
down_revision = "0109"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column(
"claimed_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
))
op.add_column("notes", sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("notes", sa.Column("claim_touched_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("notes", sa.Column("claim_session", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("notes", "claim_session")
op.drop_column("notes", "claim_touched_at")
op.drop_column("notes", "claimed_at")
op.drop_column("notes", "claimed_by")
+137
View File
@@ -0,0 +1,137 @@
/* The sweep-row visual language (#3207).
*
* A "sweep" is a pane that lists records in the order they most need a human
* — the note verification sweep, the rule sweep, preference drift — as one
* raised row per record: an italic title that opens it, an age or date pushed
* right, a small grid of facts, and a row of plain buttons. It was written
* out four times, scoped, before this sheet existed.
*
* Imported UNSCOPED, once, from main.ts, so every
* class is prefixed `sweep-`: `.row-title`, `.lede`, `.age` and `.actions`
* also exist, scoped, in views that have nothing to do with sweeps, and an
* unprefixed global would leak into all of them. What a pane does
* differently stays in its own scoped block — spacing remainders, a `code`
* style — rather than growing a modifier here per pane.
*/
/* The pane itself: header, filters, rows and footnote stacked. */
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.sweep-lede {
margin: 0;
max-width: 62ch;
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
line-height: var(--fs-leading-body);
}
.sweep-filters {
display: flex;
gap: var(--fs-space-5);
align-items: center;
flex-wrap: wrap;
}
.sweep-filter {
display: flex;
align-items: center;
gap: var(--fs-space-2);
font-size: 0.82rem;
color: var(--fs-text-secondary);
}
.sweep-filter input[type="checkbox"] { accent-color: var(--fs-accent); }
.sweep-state {
margin: 0;
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
}
.sweep-state.empty { color: var(--fs-text-tertiary); }
.sweep-rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--fs-space-3);
}
.sweep-row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.sweep-row-head {
display: flex;
align-items: baseline;
gap: var(--fs-space-2);
flex-wrap: wrap;
}
.sweep-row-title {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: Fraunces, serif;
font-style: italic;
font-size: 1.02rem;
color: var(--fs-text-primary);
text-align: left;
}
.sweep-row-title:hover { text-decoration: underline; }
/* The ORDER carries urgency — the top of a sweep is the thing that most needs
a look. No red/amber ramp: it would restate the ordering and force an
invented "stale after N days" threshold. "Never" is marked because it is
categorically DIFFERENT from a date, not a worse one. */
.sweep-age {
margin-left: auto;
font-size: 0.78rem;
color: var(--fs-text-secondary);
font-variant-numeric: tabular-nums;
}
.sweep-age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.sweep-check {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem var(--fs-space-3);
margin: var(--fs-space-3) 0 0;
}
.sweep-check dt {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
}
.sweep-check dd {
margin: 0;
font-size: 0.82rem;
color: var(--fs-text-primary);
min-width: 0;
}
.sweep-actions {
display: flex;
gap: var(--fs-space-2);
margin-top: var(--fs-space-3);
}
.sweep-actions button {
cursor: pointer;
font: inherit;
font-size: 0.78rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.sweep-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.sweep-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.sweep-footnote {
margin: 0;
max-width: 62ch;
font-size: 0.78rem;
color: var(--fs-text-tertiary);
line-height: 1.45;
}
+16 -64
View File
@@ -83,42 +83,42 @@ defineExpose({ reload });
<section class="sweep"> <section class="sweep">
<header> <header>
<h2>Due for verification</h2> <h2>Due for verification</h2>
<p class="lede"> <p class="sweep-lede">
Notes that assert a fact about something outside your control — what a Notes that assert a fact about something outside your control — what a
service does, how a tool behaves. Most notes are decisions and never service does, how a tool behaves. Most notes are decisions and never
appear here; they have no truth value to go stale. appear here; they have no truth value to go stale.
</p> </p>
</header> </header>
<div class="filters"> <div class="sweep-filters">
<label class="filter"> <label class="sweep-filter">
<input v-model="neverOnly" type="checkbox" @change="reload" /> <input v-model="neverOnly" type="checkbox" @change="reload" />
<span>Never checked only</span> <span>Never checked only</span>
</label> </label>
</div> </div>
<p v-if="loading" class="state">Loading…</p> <p v-if="loading" class="sweep-state">Loading…</p>
<!-- An empty sweep is GOOD NEWS and must not read like a broken page. --> <!-- An empty sweep is GOOD NEWS and must not read like a broken page. -->
<p v-else-if="!rows.length" class="state empty"> <p v-else-if="!rows.length" class="sweep-state empty">
Nothing to check. Nothing to check.
{{ neverOnly {{ neverOnly
? "Every note that carries a check has been confirmed at least once." ? "Every note that carries a check has been confirmed at least once."
: "No note carries a check yet — add one to a note that asserts a fact." }} : "No note carries a check yet — add one to a note that asserts a fact." }}
</p> </p>
<ol v-else class="rows"> <ol v-else class="sweep-rows">
<li v-for="n in rows" :key="n.id" class="row"> <li v-for="n in rows" :key="n.id" class="sweep-row">
<div class="row-head"> <div class="sweep-row-head">
<button class="row-title" @click="emit('open-note', n.id)">{{ n.title }}</button> <button class="sweep-row-title" @click="emit('open-note', n.id)">{{ n.title }}</button>
<span class="age" :class="{ unchecked: n.days_since_verified === null }"> <span class="sweep-age" :class="{ unchecked: n.days_since_verified === null }">
{{ n.days_since_verified === null {{ n.days_since_verified === null
? "never checked" ? "never checked"
: `${n.days_since_verified}d ago` }} : `${n.days_since_verified}d ago` }}
</span> </span>
</div> </div>
<dl class="check"> <dl class="sweep-check">
<dt>Check</dt> <dt>Check</dt>
<dd>{{ n.verify_with }}</dd> <dd>{{ n.verify_with }}</dd>
<template v-if="n.expires_when"> <template v-if="n.expires_when">
@@ -127,14 +127,14 @@ defineExpose({ reload });
</template> </template>
</dl> </dl>
<div class="actions"> <div class="sweep-actions">
<button :disabled="busyId === n.id" @click="verify(n.id, true)">Still true</button> <button :disabled="busyId === n.id" @click="verify(n.id, true)">Still true</button>
<button :disabled="busyId === n.id" @click="verify(n.id, false)">No longer true</button> <button :disabled="busyId === n.id" @click="verify(n.id, false)">No longer true</button>
</div> </div>
</li> </li>
</ol> </ol>
<p v-if="rows.length" class="footnote"> <p v-if="rows.length" class="sweep-footnote">
Record a result only after actually running the check. “No longer true” stores nothing Record a result only after actually running the check. “No longer true” stores nothing
on purpose — the note is wrong rather than in a state worth recording, so it keeps its on purpose — the note is wrong rather than in a state worth recording, so it keeps its
place here until you correct it, supersede it, or remove its check. place here until you correct it, supersede it, or remove its check.
@@ -143,56 +143,8 @@ defineExpose({ reload });
</template> </template>
<style scoped> <style scoped>
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
h2 { margin: 0; font-size: 1.05rem; } h2 { margin: 0; font-size: 1.05rem; }
.lede { .sweep-lede { margin-top: 0.35rem; }
margin: 0.35rem 0 0; /* A check can be a long URL or command with no break point. */
max-width: 62ch; .sweep-check dd { overflow-wrap: anywhere; }
font-size: 0.85rem;
color: var(--fs-text-secondary);
line-height: 1.5;
}
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
.row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
.row-title {
background: none; border: none; padding: 0; cursor: pointer;
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
color: var(--fs-text-primary); text-align: left;
}
.row-title:hover { text-decoration: underline; }
/* The ORDER carries urgency — the top of this list is the least-confirmed
thing in the corpus. No red/amber ramp: it would restate the ordering and
force an invented "stale after N days" threshold. "Never" is marked because
it is categorically DIFFERENT from a date, not a worse one. */
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
.actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
</style> </style>
@@ -77,7 +77,7 @@ onMounted(() => store.fetchDrift());
<section class="pane drift"> <section class="pane drift">
<header> <header>
<h2>Recent changes</h2> <h2>Recent changes</h2>
<p class="lede"> <p class="sweep-lede">
Preferences Scribe rewrote while working, most recently changed first. A Preferences Scribe rewrote while working, most recently changed first. A
preference is how you want work done, so sessions keep it current preference is how you want work done, so sessions keep it current
without asking — this is where you see what they decided. Rules are not without asking — this is where you see what they decided. Rules are not
@@ -85,18 +85,18 @@ onMounted(() => store.fetchDrift());
</p> </p>
</header> </header>
<p v-if="store.loading" class="state">Loading…</p> <p v-if="store.loading" class="sweep-state">Loading…</p>
<!-- Nothing changed is the ordinary state and must not read as a fault. --> <!-- Nothing changed is the ordinary state and must not read as a fault. -->
<p v-else-if="!store.drift.length" class="state empty"> <p v-else-if="!store.drift.length" class="sweep-state empty">
Nothing has been rewritten. A preference appears here the first time a Nothing has been rewritten. A preference appears here the first time a
session changes one — until then there is nothing to review. session changes one — until then there is nothing to review.
</p> </p>
<ol v-else class="rows"> <ol v-else class="sweep-rows">
<li v-for="row in store.drift" :key="row.rule.id" class="row"> <li v-for="row in store.drift" :key="row.rule.id" class="sweep-row">
<div class="row-head"> <div class="sweep-row-head">
<button class="row-title" @click="emit('open-rule', row.rule.id)"> <button class="sweep-row-title" @click="emit('open-rule', row.rule.id)">
{{ row.rule.title }} {{ row.rule.title }}
</button> </button>
<span class="when">{{ stamp(row.previous.created_at) }}</span> <span class="when">{{ stamp(row.previous.created_at) }}</span>
@@ -122,10 +122,10 @@ onMounted(() => store.fetchDrift());
<span class="was">Was:</span> {{ row.previous.when_to_apply || "nothing" }} <span class="was">Was:</span> {{ row.previous.when_to_apply || "nothing" }}
</p> </p>
<DiffView v-if="diffFor(row).length" :diff="diffFor(row)" /> <DiffView v-if="diffFor(row).length" :diff="diffFor(row)" />
<p v-else class="state"> <p v-else class="sweep-state">
The statement is unchanged — this edit moved another field. The statement is unchanged — this edit moved another field.
</p> </p>
<div class="actions"> <div class="sweep-actions">
<button <button
:disabled="busyId === row.rule.id" :disabled="busyId === row.rule.id"
@click="restore(row)" @click="restore(row)"
@@ -150,27 +150,6 @@ onMounted(() => store.fetchDrift());
<style src="@/assets/rules-shared.css" /> <style src="@/assets/rules-shared.css" />
<style scoped> <style scoped>
.drift { display: flex; flex-direction: column; gap: var(--fs-space-3); } .drift { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.lede {
margin: 0; max-width: 62ch; font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary); line-height: var(--fs-leading-body);
}
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
.row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
.row-title {
background: none; border: none; padding: 0; cursor: pointer;
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
color: var(--fs-text-primary); text-align: left;
}
.row-title:hover { text-decoration: underline; }
.when { .when {
margin-left: auto; font-size: var(--fs-size-tiny); margin-left: auto; font-size: var(--fs-size-tiny);
color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums;
@@ -205,19 +184,13 @@ onMounted(() => store.fetchDrift());
} }
.was { color: var(--fs-text-tertiary); } .was { color: var(--fs-text-tertiary); }
.actions { display: flex; align-items: baseline; gap: var(--fs-space-3); flex-wrap: wrap; }
.actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.actions-note { .actions-note {
flex: 1; min-width: 18ch; font-size: var(--fs-size-tiny); flex: 1; min-width: 18ch; font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
} }
.footnote { margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); } .footnote { margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
/* Drift's buttons sit beside a note inside the expanded detail, not under a
row of facts. */
.sweep-actions { align-items: baseline; gap: var(--fs-space-3); flex-wrap: wrap; margin-top: 0; }
</style> </style>
@@ -144,10 +144,10 @@ watch(() => props.ruleId, () => { selected.value = null; load(); });
</button> </button>
<div v-if="expanded" class="body"> <div v-if="expanded" class="body">
<p v-if="loading" class="state">Loading…</p> <p v-if="loading" class="sweep-state">Loading…</p>
<!-- Never reworded is the ordinary case, and must not read as a fault. --> <!-- Never reworded is the ordinary case, and must not read as a fault. -->
<p v-else-if="!versions.length" class="state empty"> <p v-else-if="!versions.length" class="sweep-state empty">
This rule has never been reworded. Nothing was recorded before the history This rule has never been reworded. Nothing was recorded before the history
existed, so an older rule starts empty too. existed, so an older rule starts empty too.
</p> </p>
@@ -172,7 +172,7 @@ watch(() => props.ruleId, () => { selected.value = null; load(); });
</button> </button>
<div v-if="selected?.id === v.id" class="detail"> <div v-if="selected?.id === v.id" class="detail">
<p v-if="loadingDetail" class="state">Loading…</p> <p v-if="loadingDetail" class="sweep-state">Loading…</p>
<template v-else> <template v-else>
<p v-if="checkChanged(i)" class="warn"> <p v-if="checkChanged(i)" class="warn">
This edit changed the rule's check, which cleared its verification This edit changed the rule's check, which cleared its verification
@@ -188,7 +188,7 @@ watch(() => props.ruleId, () => { selected.value = null; load(); });
</dl> </dl>
<h4>Statement</h4> <h4>Statement</h4>
<DiffView v-if="diff.length" :diff="diff" /> <DiffView v-if="diff.length" :diff="diff" />
<p v-else class="state">The statement did not change in this edit.</p> <p v-else class="sweep-state">The statement did not change in this edit.</p>
</template> </template>
</div> </div>
</li> </li>
@@ -213,8 +213,6 @@ watch(() => props.ruleId, () => { selected.value = null; load(); });
} }
.body { margin-top: var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-3); } .body { margin-top: var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-3); }
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.lede { .lede {
margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny); margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
+23 -71
View File
@@ -39,37 +39,37 @@ onMounted(reload);
<section class="pane sweep"> <section class="pane sweep">
<header> <header>
<h2>Due for verification</h2> <h2>Due for verification</h2>
<p class="lede"> <p class="sweep-lede">
Rules that assert a fact about something outside your control. Most rules are Rules that assert a fact about something outside your control. Most rules are
decisions and never appear here — they have no truth value to go stale. decisions and never appear here — they have no truth value to go stale.
</p> </p>
</header> </header>
<div class="filters"> <div class="sweep-filters">
<label class="filter"> <label class="sweep-filter">
<input v-model="neverOnly" type="checkbox" @change="reload" /> <input v-model="neverOnly" type="checkbox" @change="reload" />
<span>Never checked only</span> <span>Never checked only</span>
</label> </label>
</div> </div>
<p v-if="store.loading" class="state">Loading…</p> <p v-if="store.loading" class="sweep-state">Loading…</p>
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. --> <!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
<p v-else-if="!store.rulesDue.length" class="state empty"> <p v-else-if="!store.rulesDue.length" class="sweep-state empty">
Nothing to check. Nothing to check.
{{ neverOnly ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }} {{ neverOnly ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
</p> </p>
<ol v-else class="rows"> <ol v-else class="sweep-rows">
<li v-for="r in store.rulesDue" :key="r.id" class="row"> <li v-for="r in store.rulesDue" :key="r.id" class="sweep-row">
<div class="row-head"> <div class="sweep-row-head">
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button> <button class="sweep-row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
<span <span
v-if="!r.when_to_apply" v-if="!r.when_to_apply"
class="rule-chip rule-chip-inert" class="rule-chip rule-chip-inert"
title="No trigger, so nothing can retrieve it — this rule will never reach a session" title="No trigger, so nothing can retrieve it — this rule will never reach a session"
>never surfaces</span> >never surfaces</span>
<span class="age" :class="{ unchecked: r.days_since_verified === null }"> <span class="sweep-age" :class="{ unchecked: r.days_since_verified === null }">
{{ r.days_since_verified === null {{ r.days_since_verified === null
? "never checked" ? "never checked"
: `${r.days_since_verified}d ago` }} : `${r.days_since_verified}d ago` }}
@@ -78,7 +78,7 @@ onMounted(reload);
<p class="statement">{{ r.statement }}</p> <p class="statement">{{ r.statement }}</p>
<dl class="check"> <dl class="sweep-check">
<dt>Check</dt> <dt>Check</dt>
<dd><code>{{ r.verify_with }}</code></dd> <dd><code>{{ r.verify_with }}</code></dd>
<template v-if="r.expires_when"> <template v-if="r.expires_when">
@@ -87,14 +87,14 @@ onMounted(reload);
</template> </template>
</dl> </dl>
<div class="actions"> <div class="sweep-actions">
<button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button> <button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button>
<button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button> <button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button>
</div> </div>
</li> </li>
</ol> </ol>
<p v-if="store.rulesDue.length" class="footnote"> <p v-if="store.rulesDue.length" class="sweep-footnote">
Record a result only after actually running the check. “No longer true” stores nothing Record a result only after actually running the check. “No longer true” stores nothing
on purpose — the rule is wrong rather than in a state worth recording, so it keeps its on purpose — the rule is wrong rather than in a state worth recording, so it keeps its
place here until you correct or retire it. place here until you correct or retire it.
@@ -104,70 +104,22 @@ onMounted(reload);
<style src="@/assets/rules-shared.css" /> <style src="@/assets/rules-shared.css" />
<style scoped> <style scoped>
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.lede {
margin: 0;
max-width: 62ch;
font-size: 0.85rem;
color: var(--fs-text-secondary);
line-height: 1.5;
}
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
.filter select {
font: inherit; font-size: 0.82rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.2rem 0.4rem;
}
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
.row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
.row-title {
background: none; border: none; padding: 0; cursor: pointer;
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
color: var(--fs-text-primary); text-align: left;
}
.row-title:hover { text-decoration: underline; }
/* The ORDER carries urgency — the top of this list is the most overdue thing
in the rulebook. No red/amber ramp: it would restate the ordering and force
an invented "stale after N days" threshold. "Never" is marked because it is
categorically different from a date, not a worse one. */
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); } .statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); }
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; } .sweep-filter select {
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); } font: inherit;
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; } font-size: 0.82rem;
.check code { background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.2rem 0.4rem;
}
.sweep-check code {
font-family: var(--fs-font-mono); font-family: var(--fs-font-mono);
background: var(--fs-surface-code-inline); background: var(--fs-surface-code-inline);
border-radius: var(--fs-radius-sm); border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.3rem; padding: 0.05rem 0.3rem;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
.actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
</style> </style>
+3
View File
@@ -6,6 +6,9 @@ import "./assets/theme.css";
// After theme.css — it consumes the tokens declared there. // After theme.css — it consumes the tokens declared there.
import "./assets/components.css"; import "./assets/components.css";
import "./assets/prose.css"; import "./assets/prose.css";
// Global rather than `<style src>` per pane: plugin-vue cannot compile one
// src stylesheet shared by several SFCs (#3207 broke the image build).
import "./assets/sweep-shared.css";
const app = createApp(App); const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
+12
View File
@@ -29,6 +29,10 @@ export interface Note {
due_date: string | null; due_date: string | null;
started_at: string | null; started_at: string | null;
completed_at: string | null; completed_at: string | null;
// Which session is working this task now (milestone 381). Null when no
// session ever claimed it; `live` false when the claim's lease ran out —
// a session that went quiet mid-task, which is worth seeing, not hiding.
claim?: TaskClaim | null;
recurrence_rule: Record<string, unknown> | null; recurrence_rule: Record<string, unknown> | null;
recurrence_next_spawn_at: string | null; recurrence_next_spawn_at: string | null;
is_task: boolean; is_task: boolean;
@@ -53,3 +57,11 @@ export interface NoteListResponse {
notes: Note[]; notes: Note[];
total: number; total: number;
} }
export interface TaskClaim {
held_by: number | null;
session: string | null;
since: string | null;
touched: string | null;
live: boolean;
}
+19 -2
View File
@@ -15,7 +15,8 @@ import type { TaskStatus, TaskPriority } from "@/types/task";
import type { TaskKind } from "@/types/note"; import type { TaskKind } from "@/types/note";
import { useSystemsStore } from "@/stores/systems"; import { useSystemsStore } from "@/stores/systems";
import type { System } from "@/api/systems"; import type { System } from "@/api/systems";
import type { Note } from "@/types/note"; import type { Note, TaskClaim } from "@/types/note";
import { relativeTime } from "@/composables/useRelativeTime";
import type { Editor } from "@tiptap/vue-3"; import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue"; import TiptapEditor from "@/components/TiptapEditor.vue";
@@ -54,6 +55,7 @@ const parentId = ref<number | null>(null);
const parentTitle = ref(""); const parentTitle = ref("");
const startedAt = ref<string | null>(null); const startedAt = ref<string | null>(null);
const completedAt = ref<string | null>(null); const completedAt = ref<string | null>(null);
const claim = ref<TaskClaim | null>(null);
const recurrenceRule = ref<Record<string, unknown> | null>(null); const recurrenceRule = ref<Record<string, unknown> | null>(null);
const parentSearchQuery = ref(""); const parentSearchQuery = ref("");
const parentSearchResults = ref<{ id: number; title: string }[]>([]); const parentSearchResults = ref<{ id: number; title: string }[]>([]);
@@ -318,6 +320,7 @@ onMounted(async () => {
const noteTask = store.currentTask as unknown as Note; const noteTask = store.currentTask as unknown as Note;
startedAt.value = noteTask.started_at ?? null; startedAt.value = noteTask.started_at ?? null;
completedAt.value = noteTask.completed_at ?? null; completedAt.value = noteTask.completed_at ?? null;
claim.value = noteTask.claim ?? null;
recurrenceRule.value = noteTask.recurrence_rule ?? null; recurrenceRule.value = noteTask.recurrence_rule ?? null;
savedTitle = title.value; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
@@ -592,7 +595,21 @@ useEditorGuards(dirty, save);
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option> <option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
</select> </select>
</div> </div>
<div v-if="startedAt || completedAt" class="sb-timestamps"> <div v-if="startedAt || completedAt || claim" class="sb-timestamps">
<div v-if="claim && claim.live" class="sb-timestamp">
<span class="sb-ts-label">Being worked</span>
<span class="sb-ts-value">
by a session{{ claim.session ? ` (${claim.session.slice(0, 8)})` : "" }},
last active {{ claim.touched ? relativeTime(claim.touched) : "recently" }}
</span>
</div>
<div v-else-if="claim" class="sb-timestamp">
<span class="sb-ts-label">Went quiet</span>
<span class="sb-ts-value">
the session working this stopped
{{ claim.touched ? relativeTime(claim.touched) : "" }} without finishing it
</span>
</div>
<div v-if="startedAt" class="sb-timestamp"> <div v-if="startedAt" class="sb-timestamp">
<span class="sb-ts-label">Started</span> <span class="sb-ts-label">Started</span>
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span> <span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.23.2127", "version": "2026.09.24.1042",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+19
View File
@@ -71,6 +71,15 @@
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_outcome.sh\"" "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_outcome.sh\""
} }
] ]
},
{
"matcher": "mcp__.*__(update_task|add_task_log)",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_claim_session.sh\""
}
]
} }
], ],
"PreCompact": [ "PreCompact": [
@@ -92,6 +101,16 @@
} }
] ]
} }
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_end.sh\""
}
]
}
] ]
} }
} }
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Scribe — say WHICH session holds a task's claim (milestone 381 step 2).
#
# The server stamps a claim on the write that is the work — a task reaching
# `in_progress`, a work log on an open task — so every MCP client gets a claim
# that dies on its own once its lease runs out. What the server cannot know is
# which SESSION made the write: an MCP caller is a user and nothing more.
#
# The harness knows. This PostToolUse hook watches `update_task` and
# `add_task_log` and reports the event's `session_id` against the task the
# call named, so the claim can say "this session" rather than "someone,
# recently". Same evidence class as scribe_record_opened.sh: a tool call
# happened and the harness reported it; nothing here asks the model anything.
#
# It cannot CREATE a claim. The server binds the session only to a live claim
# the caller already holds, so a call that closed the task, or one on a task
# someone else is working, binds nothing.
#
# EXIT 0 AND SILENT, ALWAYS. This decorates a record; a PostToolUse hook that
# spoke would put a line after every task write, and a failure here must never
# turn a successful tool call into a hook error.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
task_id=$(scribe_json_pick "$event_flat" '.tool_input.task_id')
task_id=$(printf '%s' "$task_id" | tr -cd '0-9')
[ -n "$task_id" ] || exit 0
sid_enc=$(printf '%s' "$session_id" | scribe_urlenc) || exit 0
curl -fsS --max-time 4 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/claim-session?task_id=${task_id}&session_id=${sid_enc}" \
>/dev/null 2>&1 || true
exit 0
+8 -2
View File
@@ -187,8 +187,14 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
marker_why=${marker_read#*$'\t'} marker_why=${marker_read#*$'\t'}
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
scope=$(scribe_scope_query "$repo_dir") scope=$(scribe_scope_query "$repo_dir")
q="" # The source and the session id decide what the claim section says
[ -n "$scope" ] && q="?${scope}" # (milestone 381): a compaction gets back the work this session had claimed,
# with its latest logs; a new session hears about other sessions' claims.
sid_now=$(scribe_json_pick "$event_flat" '.session_id')
q="source=$(printf '%s' "$source" | scribe_urlenc)"
[ -n "$sid_now" ] && q="${q}&session_id=$(printf '%s' "$sid_now" | scribe_urlenc)"
[ -n "$scope" ] && q="${q}&${scope}"
q="?${q}"
# ONE FETCH, NAMED WHEN IT FAILS (#4366). This used to be `curl -f … || # ONE FETCH, NAMED WHEN IT FAILS (#4366). This used to be `curl -f … ||
# body=""`, which folded a timeout, an HTTP error and a refused key into one # body=""`, which folded a timeout, an HTTP error and a refused key into one
# sentence — so a session that started blind could not say why, and neither # sentence — so a session that started blind could not say why, and neither
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Scribe — release this session's task claims as it ends (milestone 381 step 4).
#
# A claim records a session's attention, and a session that ends has none left
# to give. This is the mechanical half of the hand-off: it needs nothing from
# the model, only the session id the harness reports. The other half — writing
# down where the work stands — only the model can do, and it is stated as a
# practice in the skill and the static context, not here.
#
# A TIDY-UP, NOT THE GUARANTEE. SessionEnd does not fire on a crash, a killed
# terminal or a dropped connection, and those are exactly the cases the claim
# was designed around. The lease is what makes a dead session's claim read as
# dead; this only keeps the ordinary exit from leaving a claim to run out.
#
# NOT ON /clear. A clear ends one conversation and starts the next in the same
# terminal, and SessionStart(source=clear) pushes back the work this session
# had claimed. Releasing here would leave that push with nothing to say. The
# next write moves the claim wherever the work actually continues.
#
# EXIT 0 AND SILENT, ALWAYS. Nobody reads a SessionEnd hook's output, and the
# session is ending whether this succeeds or not.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
reason=$(scribe_json_pick "$event_flat" '.reason')
[ "$reason" = "clear" ] && exit 0
session_id=$(scribe_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
sid_enc=$(printf '%s' "$session_id" | scribe_urlenc) || exit 0
curl -fsS --max-time 4 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/release-session?session_id=${sid_enc}" \
>/dev/null 2>&1 || true
exit 0
+3
View File
@@ -23,6 +23,9 @@ What only Claude Code needs said:
long session, log it to Scribe, then tell the operator it's a good moment to long session, log it to Scribe, then tell the operator it's a good moment to
`/compact` and name what you logged. You can't run it yourself; suggest it at `/compact` and name what you logged. You can't run it yourself; suggest it at
seams, not every turn. seams, not every turn.
- **When the operator wraps up, hand off first.** Log where each task you
worked stands before the session ends; the plugin releases your claims at
session end, but only you can say what happened (using-scribe, "Hand off").
- **Stored Processes arrive as skills** (`scribe-proc-*`), refreshed at session - **Stored Processes arrive as skills** (`scribe-proc-*`), refreshed at session
start. After a Process is added or edited, `/scribe:sync` makes it available start. After a Process is added or edited, `/scribe:sync` makes it available
straight away. straight away.
+10
View File
@@ -175,6 +175,16 @@ Two constraints on *how* that's achieved:
**complete** a task and when you **hit or discover a problem**, so a change **complete** a task and when you **hit or discover a problem**, so a change
of direction is on the record and not only the successes. of direction is on the record and not only the successes.
**Hand off before this session's context stops existing.** A compaction, a
`/clear`, the operator wrapping up for the day — each is the last moment the
reasoning behind the work lives anywhere but here. Log on the task you were
holding where it stands, what you tried and ruled out, and the next move:
write down what the next session needs, because it arrives with Scribe's
record and nothing else. Moving a task to `in_progress` or logging on it also
claims it for this session — that claim is what hands the work back to you
after a compaction, and it ends on its own when you stop, so there is nothing
to release by hand.
6. **Fixes are issues, not work-logs.** When you fix a problem — even one solved 6. **Fixes are issues, not work-logs.** When you fix a problem — even one solved
in passing — record it as its own issue (`create_task(kind="issue")`) with in passing — record it as its own issue (`create_task(kind="issue")`) with
symptom → root cause → fix, optionally linked to the task it arose from symptom → root cause → fix, optionally linked to the task it arose from
+15
View File
@@ -386,6 +386,21 @@ SMOKE_EVENTS: dict[str, str] = {
"tool_input": {"rule_id": 1, "outcome": "applied"}, "tool_input": {"rule_id": 1, "outcome": "applied"},
"tool_response": {}} "tool_response": {}}
), ),
# The claim-session binder (milestone 381). Silent like the ledgers above —
# a PostToolUse hook that spoke would add a line after every task write —
# and with no instance configured it must exit before reaching for one.
"scribe_claim_session.sh": json.dumps(
{"session_id": "smoke", "cwd": ".",
"tool_name": "mcp__scribe__add_task_log",
"tool_input": {"task_id": 1, "content": "smoke"},
"tool_response": {}}
),
# The SessionEnd claim release (milestone 381 step 4). Silent: nobody reads
# a SessionEnd hook's output, and with no instance it must exit first.
"scribe_session_end.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "hook_event_name": "SessionEnd",
"reason": "prompt_input_exit"}
),
# The shared library is sourced, never run; executed bare it defines # The shared library is sourced, never run; executed bare it defines
# functions and exits — silent by construction. # functions and exits — silent by construction.
"scribe_defs.sh": "", "scribe_defs.sh": "",
+11 -6
View File
@@ -183,11 +183,14 @@ async def create_note(
"When Forgejo issues run numbers per workflow rather than per "When Forgejo issues run numbers per workflow rather than per
repository", not "in six months". Constraints expire when the repository", not "in six months". Constraints expire when the
ground moves, not on a schedule. ground moves, not on a schedule.
force: Bypass the near-duplicate gate. By default, if a title- or force: Bypass the near-duplicate gate. By default, a note with the
meaning-similar note already exists in the same project, creation is same title, or one that reads as a copy, in the same project BLOCKS
BLOCKED and the existing note's id is returned so you update it the create and its id is returned so you update it instead. A note
instead (no duplicate bloat / no stale RAG copies). Set true only that reads CLOSE but not identical does not block: the note is
when you're sure this is a genuinely distinct note. created and the reply carries `overlaps` — open the top one and
judge it. Same thing: fold into it and delete the new one. A
sibling (the next dev-log, another part of one design): keep both.
Set force true only when a blocked note is genuinely distinct.
AN ID EXISTS ONLY ONCE A CREATE RETURNS IT. A body citing a `#N` that has AN ID EXISTS ONLY ONCE A CREATE RETURNS IT. A body citing a `#N` that has
not been assigned yet is refused — every session and user draws from one not been assigned yet is refused — every session and user draws from one
@@ -203,10 +206,11 @@ async def create_note(
""" """
uid = current_user_id() uid = current_user_id()
await refuse_guessed_ids(title, body) await refuse_guessed_ids(title, body)
overlaps: list = []
if not force: if not force:
dup = await dedup_svc.find_duplicate_note( dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None, uid, title, body, project_id=project_id or None,
is_task=False, note_type="note", is_task=False, note_type="note", overlaps=overlaps,
) )
if dup is not None: if dup is not None:
return dedup_svc.duplicate_response(dup, "note") return dedup_svc.duplicate_response(dup, "note")
@@ -231,6 +235,7 @@ async def create_note(
data = note.to_dict() data = note.to_dict()
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await supersession_svc.attach_relations(uid, note.id, data, hint=True) await supersession_svc.attach_relations(uid, note.id, data, hint=True)
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
return data return data
+40 -12
View File
@@ -304,10 +304,14 @@ async def create_task(
arose_from_id: For an issue, the id of the task/feature it arose from; arose_from_id: For an issue, the id of the task/feature it arose from;
for a spike, the record that raised the question — including a for a spike, the record that raised the question — including a
standing rule whose check just failed. 0 = none. standing rule whose check just failed. 0 = none.
force: Bypass the near-duplicate gate. By default, if a title- or force: Bypass the near-duplicate gate. By default, a task with the
meaning-similar task already exists in the same project, creation is same title, or one that reads as a copy, in the same project BLOCKS
BLOCKED and the existing task's id is returned so you update it the create and its id is returned so you update it instead. A task
instead. Set true only for a genuinely distinct task. that reads CLOSE but not identical does not block: the task is
created and the reply carries `overlaps` — open the top one and
judge whether it is the same work (fold in, delete the new one) or
separate work (keep both). Set force true only when a blocked task
is genuinely distinct.
AN ID EXISTS ONLY ONCE A CREATE RETURNS IT. Never write the id you expect AN ID EXISTS ONLY ONCE A CREATE RETURNS IT. Never write the id you expect
a record to get: every session and user draws from one sequence, so the a record to get: every session and user draws from one sequence, so the
@@ -332,10 +336,11 @@ async def create_task(
"task with create_task(milestone_id=<that milestone>)." "task with create_task(milestone_id=<that milestone>)."
) )
await refuse_guessed_ids(title, body) await refuse_guessed_ids(title, body)
overlaps: list = []
if not force: if not force:
dup = await dedup_svc.find_duplicate_note( dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None, uid, title, body, project_id=project_id or None,
is_task=True, note_type="note", is_task=True, note_type="note", overlaps=overlaps,
) )
if dup is not None: if dup is not None:
return dedup_svc.duplicate_response(dup, "task") return dedup_svc.duplicate_response(dup, "task")
@@ -356,6 +361,7 @@ async def create_task(
await systems_svc.set_record_systems(uid, note.id, system_ids) await systems_svc.set_record_systems(uid, note.id, system_ids)
data = note.to_dict() data = note.to_dict()
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
return await placement_svc.attach_placement(uid, data, note) return await placement_svc.attach_placement(uid, data, note)
@@ -477,6 +483,11 @@ async def add_task_log(task_id: int, content: str) -> dict:
cannot get from the body or the diff is what you tried, what you ruled cannot get from the body or the diff is what you tried, what you ruled
out, and where it actually stands. out, and where it actually stands.
A log on an open task also marks it as being worked by this session — its
`claim` (milestone 381). That is what brings the task and its newest
entries back to you after a compaction; it lapses by itself once the
session stops writing, and a status of done, cancelled or todo clears it.
The response shows the task's `systems` — or, if the task is an untagged The response shows the task's `systems` — or, if the task is an untagged
project record, the `systems_hint` question: logging work IS working in project record, the `systems_hint` question: logging work IS working in
some area, so answer it (update_task with system_ids, or create_system some area, so answer it (update_task with system_ids, or create_system
@@ -556,13 +567,23 @@ def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc
return items return items
async def _first_duplicate(uid: int, items: list, project_id: int | None) -> dict | None: async def _first_duplicate(
"""The duplicate gate over a whole batch — the first hit blocks all of it.""" uid: int, items: list, project_id: int | None,
overlaps: dict | None = None,
) -> dict | None:
"""The duplicate gate over a whole batch — the first hit blocks all of it.
`overlaps`, when given, collects each record's near matches below the copy
band by its 1-based position (#4306), for the reply once the batch is
created."""
for i, item in enumerate(items, start=1): for i, item in enumerate(items, start=1):
found: list = []
dup = await dedup_svc.find_duplicate_note( dup = await dedup_svc.find_duplicate_note(
uid, item.title, item.body, project_id=project_id, uid, item.title, item.body, project_id=project_id,
is_task=item.is_task, note_type="note", is_task=item.is_task, note_type="note", overlaps=found,
) )
if found and overlaps is not None:
overlaps[i] = found
if dup is not None: if dup is not None:
payload = dedup_svc.duplicate_response(dup, "task" if item.is_task else "note") payload = dedup_svc.duplicate_response(dup, "task" if item.is_task else "note")
payload["record"] = i payload["record"] = i
@@ -616,14 +637,17 @@ async def create_records(
uid = current_user_id() uid = current_user_id()
items = _batch_items(records) items = _batch_items(records)
await refuse_guessed_ids(*[t for item in items for t in (item.title, item.body)]) await refuse_guessed_ids(*[t for item in items for t in (item.title, item.body)])
overlaps: dict = {}
if not force: if not force:
dup = await _first_duplicate(uid, items, project_id or None) dup = await _first_duplicate(uid, items, project_id or None, overlaps)
if dup is not None: if dup is not None:
return dup return dup
_ms, notes = await batch_svc.create_batch( _ms, notes = await batch_svc.create_batch(
uid, items, project_id=project_id or None, milestone_id=milestone_id or None, uid, items, project_id=project_id or None, milestone_id=milestone_id or None,
) )
return {"ids": [n.id for n in notes], "records": [n.to_dict() for n in notes]} out = {"ids": [n.id for n in notes], "records": [n.to_dict() for n in notes]}
out.update(dedup_svc.batch_overlap_response(overlaps))
return out
async def start_planning( async def start_planning(
@@ -699,14 +723,18 @@ async def start_planning(
) )
if match is not None: if match is not None:
return match return match
overlaps: dict = {}
if items and not force: if items and not force:
dup = await _first_duplicate(uid, items, project_id or None) dup = await _first_duplicate(uid, items, project_id or None, overlaps)
if dup is not None: if dup is not None:
return dup return dup
return await planning_svc.start_planning( result = await planning_svc.start_planning(
user_id=uid, project_id=project_id, title=title, user_id=uid, project_id=project_id, title=title,
body=body or None, steps=items or None, body=body or None, steps=items or None,
) )
if isinstance(result, dict):
result.update(dedup_svc.batch_overlap_response(overlaps))
return result
async def delete_task(task_id: int) -> dict: async def delete_task(task_id: int) -> dict:
+11
View File
@@ -41,6 +41,14 @@ class NoteEmbedding(Base):
# any note whose rows carry a stale version — shape changes become a # any note whose rows carry a stale version — shape changes become a
# version bump instead of a table wipe. # version bump instead of a table wipe.
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False) chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
# embeddings.EMBEDDING_MODEL at write time — the SPACE the vector lives in
# (#4132). The column is `vector(384)`, a width and not an identity, so a
# swap to another 384-dim model writes a second geometry beside the first
# with no error, and cosine across the two is a number that means nothing.
# The version above says what text was embedded; this says in whose
# geometry. Either one moving makes the row stale, and the backfill
# re-embeds on both.
embedding_model: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(timezone.utc),
@@ -91,6 +99,7 @@ class RuleEmbedding(Base):
# centroid (measured in note 2485). # centroid (measured in note 2485).
chunk_text: Mapped[str] = mapped_column(Text, nullable=False) chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False) chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
embedding_model: Mapped[str] = mapped_column(Text, nullable=False) # see NoteEmbedding
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(timezone.utc),
@@ -124,6 +133,7 @@ class MilestoneEmbedding(Base):
embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False) embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False) chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False) chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
embedding_model: Mapped[str] = mapped_column(Text, nullable=False) # see NoteEmbedding
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(timezone.utc),
@@ -163,6 +173,7 @@ class SystemEmbedding(Base):
embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False) embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False) chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False) chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
embedding_model: Mapped[str] = mapped_column(Text, nullable=False) # see NoteEmbedding
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(timezone.utc),
+17
View File
@@ -76,6 +76,16 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column( recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
# The claim — which session is working this task NOW (milestone 381).
# Orthogonal to `status`: status is the work's state and durable, the claim
# is a session's attention and dies on read once `claim_touched_at` is past
# the lease. Semantics in services/task_claims.py.
claimed_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
claim_touched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
claim_session: Mapped[str | None] = mapped_column(Text, nullable=True)
# WHAT KIND of record this is, on the note/entity axis. Task-ness is tracked # WHAT KIND of record this is, on the note/entity axis. Task-ness is tracked
# by `status`, not here (person/place/list entity types removed 2026-07): # by `status`, not here (person/place/list entity types removed 2026-07):
# note (default) — authored prose, findable by what it is ABOUT # note (default) — authored prose, findable by what it is ABOUT
@@ -184,6 +194,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
"completed_at": iso(self.completed_at), "completed_at": iso(self.completed_at),
"recurrence_rule": self.recurrence_rule, "recurrence_rule": self.recurrence_rule,
"recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at), "recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at),
"claim": _claim_state(self),
"is_task": self.is_task, "is_task": self.is_task,
"note_type": self.note_type or "note", "note_type": self.note_type or "note",
"task_kind": self.task_kind, "task_kind": self.task_kind,
@@ -198,3 +209,9 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
"created_at": iso(self.created_at), "created_at": iso(self.created_at),
"updated_at": iso(self.updated_at), "updated_at": iso(self.updated_at),
} }
def _claim_state(note: "Note") -> dict | None:
from scribe.services.task_claims import claim_state
return claim_state(note)
+56 -1
View File
@@ -15,6 +15,7 @@ from scribe.config import Config
from scribe.services import plugin_context as plugin_ctx_svc from scribe.services import plugin_context as plugin_ctx_svc
from scribe.services import repo_bindings as repo_bindings_svc from scribe.services import repo_bindings as repo_bindings_svc
from scribe.services import report_check as report_check_svc from scribe.services import report_check as report_check_svc
from scribe.services import task_claims as task_claims_svc
from scribe.services.settings import get_admin_setting, set_setting from scribe.services.settings import get_admin_setting, set_setting
plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin") plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
@@ -69,10 +70,16 @@ async def session_context():
send it when a `.scribe` marker file names a project. Takes send it when a `.scribe` marker file names a project. Takes
precedence over `repo`. Access-checked like any other read — an id precedence over `repo`. Access-checked like any other read — an id
this account cannot read loads no project rather than failing. this account cannot read loads no project rather than failing.
source (optional str) — the host's SessionStart source (startup,
resume, compact, clear, fork); decides what the claim section says.
session_id (optional str) — the session's id, so a claim bound to it
reads as this session's own (milestone 381).
""" """
project_id, _repo, unbound_repo = await _project_scope() project_id, _repo, unbound_repo = await _project_scope()
result = await plugin_ctx_svc.build_session_context( result = await plugin_ctx_svc.build_session_context(
g.user.id, project_id, unbound_repo=unbound_repo g.user.id, project_id, unbound_repo=unbound_repo,
source=(request.args.get("source") or "").strip()[:20],
session_id=(request.args.get("session_id") or "").strip()[:200],
) )
return jsonify(result) return jsonify(result)
@@ -352,6 +359,54 @@ async def report_check():
return jsonify(body) return jsonify(body)
@plugin_bp.get("/claim-session")
@login_required
async def claim_session():
"""Bind the harness's session id to the caller's live claim on a task (milestone 381).
Called by `scribe_claim_session.sh` after `update_task` / `add_task_log`.
The server has already stamped the claim on that write; this only says
WHICH session made it — an id the harness reported, not one the model
asserted. A GET for the reason every plugin endpoint is one: a read-scoped
key must be enough to run the plugin.
Query:
task_id (int) — the task the tool call named.
session_id (str) — the Claude Code session id from the hook event.
Returns the claim when one was bound, `{"claim": null}` when there was
nothing to bind to (no live claim of the caller's, or not writable).
"""
try:
task_id = int(request.args.get("task_id") or "")
except ValueError:
return jsonify({"error": "task_id must be an integer"}), 400
session_id = (request.args.get("session_id") or "").strip()
if not session_id:
return jsonify({"error": "session_id is required"}), 400
claim = await task_claims_svc.bind_session(g.user.id, task_id, session_id)
return jsonify({"claim": claim})
@plugin_bp.get("/release-session")
@login_required
async def release_session():
"""Release the claims a session held, as it ends (milestone 381 step 4).
Called by `scribe_session_end.sh`. Best-effort by design: SessionEnd does
not fire on a crash, so the claim's lease — not this call — is what makes a
dead session's claim read as dead. This only makes the common case tidy.
Query:
session_id (str) — the ending session's id, from the hook event.
"""
session_id = (request.args.get("session_id") or "").strip()
if not session_id:
return jsonify({"error": "session_id is required"}), 400
released = await task_claims_svc.release_session(g.user.id, session_id)
return jsonify({"released": released})
@plugin_bp.get("/processes") @plugin_bp.get("/processes")
@login_required @login_required
async def process_manifest(): async def process_manifest():
+11 -2
View File
@@ -180,7 +180,13 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"forge_connection_id", "forge_connection_id",
}, },
"milestones": {"deleted_at", "deleted_batch_id"}, "milestones": {"deleted_at", "deleted_batch_id"},
"notes": {"deleted_at", "deleted_batch_id"}, "notes": {
"deleted_at", "deleted_batch_id",
# A claim is a session's attention, not the work's state (milestone
# 381). Restoring one would assert that a session on another install,
# possibly long gone, is working the task right now.
"claimed_by", "claimed_at", "claim_touched_at", "claim_session",
},
"task_logs": set(), "task_logs": set(),
"note_drafts": set(), "note_drafts": set(),
"note_versions": set(), "note_versions": set(),
@@ -268,7 +274,10 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"design_system_id", "inception", "design_system_id", "inception",
}, },
"milestones": {"id", "deleted_at", "deleted_batch_id"}, "milestones": {"id", "deleted_at", "deleted_batch_id"},
"notes": {"id", "deleted_at", "deleted_batch_id"}, "notes": {
"id", "deleted_at", "deleted_batch_id",
"claimed_by", "claimed_at", "claim_touched_at", "claim_session",
},
"task_logs": {"id"}, "task_logs": {"id"},
"note_drafts": {"id"}, "note_drafts": {"id"},
"note_versions": {"id"}, "note_versions": {"id"},
+98 -2
View File
@@ -94,6 +94,25 @@ _SNIPPET_SEMANTIC_THRESHOLD = 0.96
# records that can still be merged by hand. # records that can still be merged by hand.
_LESSON_SEMANTIC_THRESHOLD = 0.96 _LESSON_SEMANTIC_THRESHOLD = 0.96
# NOTES AND TASKS BLOCK ONLY A COPY, and surface the rest (#4306). Measured
# 2026-09-22 with find_duplicate_records(note, 0.85): of the 74 note pairs at or
# above the old 0.90 bar, almost all were DISTINCT siblings — consecutive
# dev-logs (0.90–0.94), sub-notes of one design (0.90–0.94), research parts
# (0.90–0.97), lore entries (0.95–0.98). The one clear copy sat at 0.997. A
# block in that band refused the next dev-log and taught force=true, the same
# finding #4134 made for rules. So only the copy band blocks; below it, a
# near match is shown on the create reply for the session to judge.
_NOTE_COPY_THRESHOLD = 0.98
# Where a near match starts being worth reading. The measured pair counts
# climb steeply under 0.87 (36 pairs at 0.87 against 200 capped at 0.85), and
# the reply lists at most _NOTE_OVERLAP_LIMIT, so this is a cost floor — what
# decides whether a match matters is the session reading it.
_NOTE_OVERLAP_FLOOR = 0.87
_NOTE_OVERLAP_LIMIT = 3
# The note_types the copy band applies to. A process is prose too, but its
# gate was not part of the measurement, so it keeps the general bar.
_COPY_BAND_TYPES = {"note"}
# The gate queries per CHUNK of the candidate (#280) — this caps how many # The gate queries per CHUNK of the candidate (#280) — this caps how many
# searches one save may cost. Eight chunks ≈ five thousand words of candidate; # searches one save may cost. Eight chunks ≈ five thousand words of candidate;
# a duplicate hiding past that is the duplicate report's job to find, not a # a duplicate hiding past that is the duplicate report's job to find, not a
@@ -236,9 +255,20 @@ def _semantic_threshold(note_type: str) -> float:
return _SNIPPET_SEMANTIC_THRESHOLD return _SNIPPET_SEMANTIC_THRESHOLD
if note_type == LESSON_NOTE_TYPE: if note_type == LESSON_NOTE_TYPE:
return _LESSON_SEMANTIC_THRESHOLD return _LESSON_SEMANTIC_THRESHOLD
if note_type in _COPY_BAND_TYPES:
return _NOTE_COPY_THRESHOLD
return _SEMANTIC_THRESHOLD return _SEMANTIC_THRESHOLD
@dataclass
class NoteOverlap:
"""An existing note or task close enough to read before keeping a new one,
and not close enough to be called a copy."""
id: int
title: str
similarity: float
async def find_duplicate_note( async def find_duplicate_note(
user_id: int, user_id: int,
title: str, title: str,
@@ -249,6 +279,7 @@ async def find_duplicate_note(
code: str = "", code: str = "",
locations: list[dict] | None = None, locations: list[dict] | None = None,
data: dict | None = None, data: dict | None = None,
overlaps: list[NoteOverlap] | None = None,
) -> DuplicateMatch | None: ) -> DuplicateMatch | None:
"""Best near-duplicate of (title, body) within the same owner + project + """Best near-duplicate of (title, body) within the same owner + project +
kind, or None. Title match first (cheap, exact), then — for snippets — the kind, or None. Title match first (cheap, exact), then — for snippets — the
@@ -264,6 +295,11 @@ async def find_duplicate_note(
carries the trigger, which the TITLE no longer does (milestone 427): the carries the trigger, which the TITLE no longer does (milestone 427): the
title check compares names, and the semantic check rebuilds the embedded title check compares names, and the semantic check rebuilds the embedded
document from `data`. document from `data`.
`overlaps`, when given, is filled with the near matches below the copy band
(#4306) — from the SAME searches, so asking costs nothing extra. Only the
kinds in _COPY_BAND_TYPES collect them. The caller creates the record and
returns them with `note_overlap_response`.
""" """
norm = " ".join((title or "").split()).lower() norm = " ".join((title or "").split()).lower()
@@ -319,6 +355,9 @@ async def find_duplicate_note(
# under its name and embedded under `name — trigger`, so the query # under its name and embedded under `name — trigger`, so the query
# document is built the way the corpus was, from `data`. # document is built the way the corpus was, from `data`.
doc_title = embeddings_svc.document_title(title, note_type, data, body) doc_title = embeddings_svc.document_title(title, note_type, data, body)
block_at = _semantic_threshold(note_type)
collect = overlaps is not None and note_type in _COPY_BAND_TYPES
near: dict[int, NoteOverlap] = {}
for query in embeddings_svc.chunk_document(doc_title, body)[:_GATE_MAX_CHUNKS]: for query in embeddings_svc.chunk_document(doc_title, body)[:_GATE_MAX_CHUNKS]:
# Scope the semantic check the same way as the title check: a record # Scope the semantic check the same way as the title check: a record
# in project P compares only to P; a project-less (orphan) record # in project P compares only to P; a project-less (orphan) record
@@ -330,7 +369,7 @@ async def find_duplicate_note(
user_id, query, project_id=project_id, is_task=is_task, user_id, query, project_id=project_id, is_task=is_task,
orphan_only=(project_id is None), orphan_only=(project_id is None),
limit=3, limit=3,
threshold=_semantic_threshold(note_type), threshold=_NOTE_OVERLAP_FLOOR if collect else block_at,
# Owner-only, deliberately: this gate BLOCKS a create and tells # Owner-only, deliberately: this gate BLOCKS a create and tells
# the caller to update the match instead. Matching someone # the caller to update the match instead. Matching someone
# else's record would refuse their write and point them at # else's record would refuse their write and point them at
@@ -346,12 +385,69 @@ async def find_duplicate_note(
for score, note in hits: for score, note in hits:
# semantic_search_notes doesn't filter note_type — enforce it # semantic_search_notes doesn't filter note_type — enforce it
# here so a note doesn't shadow a task of the same wording, etc. # here so a note doesn't shadow a task of the same wording, etc.
if note.note_type == note_type: if note.note_type != note_type:
continue
if score >= block_at:
return DuplicateMatch(note.id, note.title, round(score, 3), "semantic") return DuplicateMatch(note.id, note.title, round(score, 3), "semantic")
# Best chunk wins per record: one long note matching in two
# sections is one overlap, not two.
prior = near.get(note.id)
if prior is None or score > prior.similarity:
near[note.id] = NoteOverlap(note.id, note.title, round(score, 3))
if collect:
overlaps.extend(sorted(
near.values(), key=lambda o: o.similarity, reverse=True,
)[:_NOTE_OVERLAP_LIMIT])
return None return None
def note_overlap_response(overlaps: list[NoteOverlap], kind: str) -> dict:
"""The keys a note or task create adds to its reply when an existing record
reads closely like the one just written (#4306). Empty when none.
The judgement is the session's: the embedding cannot tell a restatement
from the next dev-log in a series, and a reader can in one look."""
if not overlaps:
return {}
top = overlaps[0]
named = "; ".join(f'#{o.id} "{o.title}" ({o.similarity})' for o in overlaps)
return {
"overlaps": [
{"id": o.id, "title": o.title, "similarity": o.similarity}
for o in overlaps
],
"overlap_note": (
f"Created — and it reads closely like: {named}. Open #{top.id} and "
f"judge it. If it records the same thing, fold what is new into it "
f"(update_{kind}) and delete this {kind}: two copies are found "
f"apart and drift apart. If it is a sibling — the next entry in a "
f"series, another part of one design — keep both."
),
}
def batch_overlap_response(per_record: dict[int, list[NoteOverlap]]) -> dict:
"""`note_overlap_response` for a batch create: each overlap names the
1-based record it belongs to, so the reader knows which new id to judge."""
rows = [
{"record": i, "id": o.id, "title": o.title, "similarity": o.similarity}
for i, found in sorted(per_record.items()) for o in found
]
if not rows:
return {}
return {
"overlaps": rows,
"overlap_note": (
"Created — and some records read closely like existing ones (see "
"`overlaps`, by record). Open each and judge it: the same thing "
"means fold what is new into the existing record and delete the "
"new one; a sibling (the next entry in a series, another part of "
"one design) means keep both."
),
}
# --- corpus-wide near-duplicate report (#2088) ------------------------------- # --- corpus-wide near-duplicate report (#2088) -------------------------------
# The gate above PREVENTS a new duplicate; merge_snippets CURES one you point it # The gate above PREVENTS a new duplicate; merge_snippets CURES one you point it
# at. Neither FINDS the duplicates already sitting in the record — someone had to # at. Neither FINDS the duplicates already sitting in the record — someone had to
+37 -5
View File
@@ -18,7 +18,7 @@ from collections.abc import Sequence
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import delete, func, or_, select from sqlalchemy import and_, delete, func, or_, select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding, RuleEmbedding from scribe.models.embedding import NoteEmbedding, RuleEmbedding
@@ -367,6 +367,34 @@ def calibration_stamp() -> dict:
""" """
return {"embedding_model": EMBEDDING_MODEL, "shape_version": CHUNKER_VERSION} return {"embedding_model": EMBEDDING_MODEL, "shape_version": CHUNKER_VERSION}
def is_current_stamp(table):
"""The rows written by THIS chunker in THIS model's space (#4132).
Every embedding table stores both halves of `calibration_stamp()` per row,
and a row is current only when both match. One predicate for all four
tables, because a backfill that checked the version alone is exactly how a
same-width model swap would have gone unnoticed.
"""
return and_(
table.chunker_version == CHUNKER_VERSION,
table.embedding_model == EMBEDDING_MODEL,
)
async def rows_off_the_live_model(table) -> int:
"""How many rows of an embedding table were NOT written in the live space.
Nonzero means the corpus is part-way through a model change: a search over
it compares vectors from two geometries, and a statistic computed from it
(`retrieval_migration.migrate_floor`) is a blend of both.
"""
async with async_session() as session:
return int((await session.execute(
select(func.count()).select_from(table)
.where(table.embedding_model != EMBEDDING_MODEL)
)).scalar_one())
# Character budget approximating the model window. Tokens-per-char varies by # Character budget approximating the model window. Tokens-per-char varies by
# content — ~4 chars/token for prose, closer to 3 for code and tables — so 1400 # content — ~4 chars/token for prose, closer to 3 for code and tables — so 1400
# chars sits at roughly 350-470 tokens, leaving headroom for the title prefixed # chars sits at roughly 350-470 tokens, leaving headroom for the title prefixed
@@ -633,6 +661,7 @@ async def upsert_note_embedding(
embedding=vector, embedding=vector,
chunk_text=chunk, chunk_text=chunk,
chunker_version=CHUNKER_VERSION, chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL,
) )
) )
await session.commit() await session.commit()
@@ -1092,7 +1121,7 @@ async def backfill_note_embeddings() -> None:
for row in ( for row in (
await session.execute( await session.execute(
select(NoteEmbedding.note_id).where( select(NoteEmbedding.note_id).where(
NoteEmbedding.chunker_version == CHUNKER_VERSION is_current_stamp(NoteEmbedding)
) )
) )
).fetchall() ).fetchall()
@@ -1294,6 +1323,7 @@ async def upsert_rule_embedding(
embedding=vector, embedding=vector,
chunk_text=chunk, chunk_text=chunk,
chunker_version=CHUNKER_VERSION, chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL,
) )
) )
await session.commit() await session.commit()
@@ -1464,7 +1494,7 @@ async def backfill_rule_embeddings() -> None:
try: try:
async with async_session() as session: async with async_session() as session:
current = select(RuleEmbedding.rule_id).where( current = select(RuleEmbedding.rule_id).where(
RuleEmbedding.chunker_version == CHUNKER_VERSION is_current_stamp(RuleEmbedding)
) )
# IDS ONLY — the text is re-read per rule below (#4262). # IDS ONLY — the text is re-read per rule below (#4262).
by_version = { by_version = {
@@ -1563,6 +1593,7 @@ async def upsert_milestone_embedding(
session.add(MilestoneEmbedding( session.add(MilestoneEmbedding(
milestone_id=milestone_id, chunk_index=index, embedding=vector, milestone_id=milestone_id, chunk_index=index, embedding=vector,
chunk_text=chunk, chunker_version=CHUNKER_VERSION, chunk_text=chunk, chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL,
)) ))
await session.commit() await session.commit()
except Exception: except Exception:
@@ -1725,6 +1756,7 @@ async def upsert_system_embedding(
session.add(SystemEmbedding( session.add(SystemEmbedding(
system_id=system_id, chunk_index=index, embedding=vector, system_id=system_id, chunk_index=index, embedding=vector,
chunk_text=chunk, chunker_version=CHUNKER_VERSION, chunk_text=chunk, chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL,
)) ))
await session.commit() await session.commit()
except Exception: except Exception:
@@ -1841,7 +1873,7 @@ async def backfill_system_embeddings() -> None:
try: try:
async with async_session() as session: async with async_session() as session:
current = select(SystemEmbedding.system_id).where( current = select(SystemEmbedding.system_id).where(
SystemEmbedding.chunker_version == CHUNKER_VERSION is_current_stamp(SystemEmbedding)
) )
# IDS ONLY — the charter is re-read per System below (#4262). # IDS ONLY — the charter is re-read per System below (#4262).
by_version = { by_version = {
@@ -1884,7 +1916,7 @@ async def backfill_milestone_embeddings() -> None:
try: try:
async with async_session() as session: async with async_session() as session:
current = select(MilestoneEmbedding.milestone_id).where( current = select(MilestoneEmbedding.milestone_id).where(
MilestoneEmbedding.chunker_version == CHUNKER_VERSION is_current_stamp(MilestoneEmbedding)
) )
# IDS ONLY — the plan is re-read per milestone below (#4262). # IDS ONLY — the plan is re-read per milestone below (#4262).
by_version = { by_version = {
+45 -19
View File
@@ -284,7 +284,7 @@ def build_note(
except ValueError: except ValueError:
raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}") raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}")
return Note( note = Note(
user_id=user_id, user_id=user_id,
title=title, title=title,
body=body, body=body,
@@ -304,6 +304,49 @@ def build_note(
verify_with=verify_with, verify_with=verify_with,
expires_when=expires_when, expires_when=expires_when,
) )
# A create that names a status is the same transition an update to it is
# (#3683) — otherwise `create_task(status="in_progress")` writes a row no
# update could produce: started, with no `started_at`.
if status is not None:
apply_status_transition(note, user_id)
return note
def apply_status_transition(note: Note, user_id: int | None = None) -> None:
"""Stamp what reaching `note.status` implies — the ONE statement of it.
Called by the update path whenever `status` is written and by `build_note`
whenever a create names one, so a task created at a status is
indistinguishable from one that reached it by update. Two copies of "what
a status implies" are where the two drift, and #3683 was that drift.
`user_id` is whoever is making the change: reaching `in_progress` is the
moment a session takes the work on, so it stamps that user's claim, and a
status that ends or un-starts the work releases it (milestone 381).
"""
from scribe.services.task_claims import release_claim, stamp_claim
_now = datetime.now(timezone.utc)
if note.status == TaskStatus.in_progress.value:
if note.started_at is None:
note.started_at = _now
if user_id is not None:
stamp_claim(note, user_id, _now)
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
release_claim(note)
note.completed_at = _now
if note.recurrence_rule:
from scribe.services.recurrence import calculate_next_due
base = note.due_date or _now.date()
next_due = calculate_next_due(note.recurrence_rule, base)
note.recurrence_next_spawn_at = datetime(
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
)
elif note.status == TaskStatus.todo.value:
release_claim(note)
note.started_at = None
note.completed_at = None
note.recurrence_next_spawn_at = None
async def get_note(user_id: int, note_id: int) -> Note | None: async def get_note(user_id: int, note_id: int) -> Note | None:
@@ -653,25 +696,8 @@ async def update_note(
recompose = _mirror_recomposers().get(note.note_type or "") recompose = _mirror_recomposers().get(note.note_type or "")
if recompose is not None: if recompose is not None:
note.data = recompose(note) note.data = recompose(note)
# Auto-set lifecycle timestamps on status transitions
if "status" in fields: if "status" in fields:
_now = datetime.now(timezone.utc) apply_status_transition(note, user_id)
if note.status == TaskStatus.in_progress.value:
if note.started_at is None:
note.started_at = _now
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
note.completed_at = _now
if note.recurrence_rule:
from scribe.services.recurrence import calculate_next_due
base = note.due_date or _now.date()
next_due = calculate_next_due(note.recurrence_rule, base)
note.recurrence_next_spawn_at = datetime(
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
)
elif note.status == TaskStatus.todo.value:
note.started_at = None
note.completed_at = None
note.recurrence_next_spawn_at = None
note.updated_at = datetime.now(timezone.utc) note.updated_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
await session.refresh(note) await session.refresh(note)
+20 -1
View File
@@ -26,6 +26,7 @@ from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc from scribe.services import projects as projects_svc
from scribe.services import shape_ledger as shape_ledger_svc from scribe.services import shape_ledger as shape_ledger_svc
from scribe.services import snippets as snippets_svc from scribe.services import snippets as snippets_svc
from scribe.services import task_claims as task_claims_svc
from scribe.services.access import label_shared_items, owner_names_for from scribe.services.access import label_shared_items, owner_names_for
from scribe.services.embeddings import ( from scribe.services.embeddings import (
document_title, document_title,
@@ -3055,7 +3056,8 @@ def _goal_line(goal: str, project_id: int) -> str:
async def build_session_context( async def build_session_context(
user_id: int, project_id: int = 0, unbound_repo: str = "" user_id: int, project_id: int = 0, unbound_repo: str = "",
source: str = "", session_id: str = "",
) -> dict: ) -> dict:
"""Render the SessionStart context for a user, optionally project-scoped. """Render the SessionStart context for a user, optionally project-scoped.
@@ -3069,6 +3071,12 @@ async def build_session_context(
unbound_repo: when the hook sent a repo remote that maps to no project, unbound_repo: when the hook sent a repo remote that maps to no project,
its normalized key — triggers a one-line "bind this repo" hint so its normalized key — triggers a one-line "bind this repo" hint so
the binding is self-healing. the binding is self-healing.
source / session_id: the host's SessionStart `source` and the
session's id, when the adapter sends them. They decide what the
claim section says (milestone 381 step 3, `task_claims.
render_claims`): a compaction gets back the work it had claimed
with its latest logs; a new session hears about other sessions'
live and abandoned claims; a resume hears nothing.
Returns {"context": str, "project": dict | None}. Returns {"context": str, "project": dict | None}.
@@ -3135,6 +3143,17 @@ async def build_session_context(
f"`get_design_system({design['id']})` → " f"`get_design_system({design['id']})` → "
f"`resolved_guidance`.", f"`resolved_guidance`.",
] ]
# The claim section goes after the project block and before any "nothing
# loaded" note: claimed work is the most specific thing this session can be
# told, and it is true whether or not a project resolved. Best-effort — a
# session start never fails on it.
try:
lines += await task_claims_svc.claims_for_session_start(
user_id, project_dict["id"] if project_dict else 0, source, session_id,
)
except Exception: # noqa: BLE001 - context is best-effort
logger.warning("claim section skipped", exc_info=True)
# Nothing loaded — say which nothing (#4085). This used to hang off the # Nothing loaded — say which nothing (#4085). This used to hang off the
# `if project_id:` above as an `elif`, which meant an id that was SENT and # `if project_id:` above as an `elif`, which meant an id that was SENT and
# did not resolve produced no message at all: the outer branch was taken, # did not resolve produced no message at all: the outer branch was taken,
@@ -58,9 +58,11 @@ import logging
from sqlalchemy import select from sqlalchemy import select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
from scribe.models.retrieval_log import RetrievalLog from scribe.models.retrieval_log import RetrievalLog
from scribe.services.embeddings import ( from scribe.services.embeddings import (
calibration_stamp, calibration_stamp,
rows_off_the_live_model,
semantic_search_notes, semantic_search_notes,
semantic_search_rules, semantic_search_rules,
) )
@@ -117,6 +119,19 @@ _RESCORERS = {
"report_preference": lambda u, q, p: _rescore_rules(u, q, p, "preference"), "report_preference": lambda u, q, p: _rescore_rules(u, q, p, "preference"),
} }
# The embedding table each surface's re-scorer reads. A migration from a corpus
# that is still part-way through a model change re-scores against a blend of
# two geometries and writes a floor computed from it, with a confident reason
# attached (#4132) — so the corpus has to be wholly in the live space first.
_CORPUS = {
"auto_inject": NoteEmbedding,
"write_path": NoteEmbedding,
"write_path_rule": RuleEmbedding,
"pre_tool_rule": RuleEmbedding,
"prompt_rule": RuleEmbedding,
"report_preference": RuleEmbedding,
}
def _floor_admitting(scores: list[float], fraction: float) -> float: def _floor_admitting(scores: list[float], fraction: float) -> float:
"""The floor that admits `fraction` of `scores`, on this scale. """The floor that admits `fraction` of `scores`, on this scale.
@@ -161,6 +176,21 @@ async def migrate_floor(
"arm's own corpus filters." "arm's own corpus filters."
) )
off_model = await rows_off_the_live_model(_CORPUS[surface])
if off_model:
# Refused before sampling anything. The order of operations — re-embed,
# THEN migrate — used to be held only in the operator's memory.
stamp = calibration_stamp()
return {
"surface": surface, "migrated": False,
"why": f"{off_model} embedding row(s) this surface searches are not "
f"yet in {stamp['embedding_model']}'s space. Re-scoring now "
"would measure a blend of two models; let the startup "
"backfill finish re-embedding, then migrate",
"rows_off_model": off_model,
"calibration": stamp,
}
old_floor = await floor_for(user_id, surface) old_floor = await floor_for(user_id, surface)
async with async_session() as session: async with async_session() as session:
rows = (await session.execute( rows = (await session.execute(
+313
View File
@@ -0,0 +1,313 @@
"""A task's claim — which session is working it right now (milestone 381 step 2).
`status` says where THE WORK stands and is durable: `in_progress` means
committed to, not finished. It cannot also say "someone is on this now",
because nothing ever clears it — a session that crashes, is killed or simply
moves on leaves `in_progress` behind, and the row goes on asserting attention
nobody is paying. The claim is the other half, a property of a session's
attention rather than of the work, and it is built so that nothing has to
clear it either.
WHO STAMPS IT. The server, on the write that IS the work: a transition to
`in_progress` and a work log on an open task. No tool asks the model to claim
anything, because a claim the model has to remember is the flag this replaces.
Any MCP client gets the lease; the Claude Code plugin's PostToolUse hook then
binds the harness's session id to it (`bind_session`), so the claim can say
WHICH session and not only "someone, recently" — the harness reports the id,
the model asserts nothing.
HOW IT DIES. On read. A claim is live while its last touch is inside
`CLAIM_LEASE`; past that it reads as dead, whatever the row still holds. There
is no sweep: a job that tidies claims would reintroduce exactly the dependency
on something running that this is designed out of. A status that ends or
un-starts the work (done, cancelled, todo) releases it outright.
`in_progress` with no live claim is the state this exists to make sayable:
committed to, and nobody on it.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from scribe.models.note import Note
# How long a claim stays live after its last touch. Long enough that a
# compaction, a resume or a long read does not kill it; short enough that a
# session gone overnight reads as gone. The cost either way is stated rather
# than hidden: readers show the age beside `live`, never the boolean alone.
CLAIM_LEASE = timedelta(hours=2)
def _now() -> datetime:
return datetime.now(timezone.utc)
def claim_is_live(note: Note, now: datetime | None = None) -> bool:
touched = note.claim_touched_at
return touched is not None and (now or _now()) - touched < CLAIM_LEASE
def stamp_claim(note: Note, user_id: int, now: datetime | None = None) -> None:
"""Record that `user_id` is working this task now.
The most recent worker holds it: a live claim by someone else is taken
over rather than refused, because the write that stamps a claim has already
happened — it is the evidence of who is on the task, and refusing the claim
would only make the record less true. `claimed_at` restarts whenever the
holder changes or the previous claim had died, so "since" means since THIS
stretch of attention, not since the task was first touched.
"""
now = now or _now()
if note.claimed_by != user_id or not claim_is_live(note, now):
note.claimed_by = user_id
note.claimed_at = now
note.claim_session = None
note.claim_touched_at = now
def release_claim(note: Note) -> None:
"""Clear the claim. Idempotent — releasing nothing is not an error."""
note.claimed_by = None
note.claimed_at = None
note.claim_touched_at = None
note.claim_session = None
def claim_state(note: Note, now: datetime | None = None) -> dict | None:
"""The claim as a reader sees it, or None when there has never been one.
A dead claim is returned, not hidden: "last worked by that session three
days ago" is what a resuming session needs to know, and `live: false` says
no-one should read it as current.
"""
if note.claimed_at is None:
return None
from scribe.models.base import iso
return {
"held_by": note.claimed_by,
"session": note.claim_session,
"since": iso(note.claimed_at),
"touched": iso(note.claim_touched_at),
"live": claim_is_live(note, now),
}
async def bind_session(user_id: int, task_id: int, session_id: str) -> dict | None:
"""Attach a harness-reported session id to the caller's live claim.
Called by the plugin's PostToolUse hook after `update_task` or
`add_task_log`. A no-op — returning None — when the task is not writable
by the caller, has no live claim, or the live claim is someone else's: the
hook reports what happened, it cannot create a claim the server did not
stamp. A different session taking over a live claim restarts `since`,
for the reason `stamp_claim` gives.
"""
from scribe.models import async_session
from scribe.models.note import Note
from scribe.services.access import can_write_note
session_id = (session_id or "").strip()[:200]
if not session_id or not await can_write_note(user_id, task_id):
return None
async with async_session() as session:
note = await session.get(Note, task_id)
if note is None or note.claimed_by != user_id or not claim_is_live(note):
return None
now = _now()
if note.claim_session not in (None, session_id):
note.claimed_at = now
note.claim_session = session_id
note.claim_touched_at = now
await session.commit()
return claim_state(note, now)
# --- The readers (milestone 381 step 3) ---------------------------------------
#
# A claim nobody reads is the state before this milestone. SessionStart is the
# reader that pays rent to the session that set it, and it branches on the
# `source` the host sends, because the same claim means different things
# depending on what just happened to the context:
#
# compact the context was summarised away and the claim is certainly ours.
# Push the claimed work AND its latest log entries — the state a
# compaction destroys, which the record already holds. A count of
# open tasks cannot answer "where were we".
# clear the context was wiped, so the same push; and other sessions' claims
# are worth knowing about, as on a startup.
# startup a new session. Claims held by OTHER sessions are the news: live
# ones may be running right now, dead ones were abandoned mid-task.
# fork the session carries a conversation that held claims under another
# id. Two sessions now believe they hold the same work, so the
# live claims are named as possibly-the-parent's, with what a write
# does about it.
# resume the context was restored intact. Say nothing.
# What a session is told, per source. Pure data so the branch is one lookup.
_PUSH_OWN = {"compact", "clear"}
_NAME_OTHERS = {"startup", "clear", "fork"}
# Caps: a session-start block is read by every session, so it is sized for the
# few claims that matter rather than for the worst case.
_OWN_CAP = 5
_OTHERS_CAP = 5
_LOGS_PER_TASK = 2
_LOG_CHARS = 600
def _age(when: datetime | None, now: datetime) -> str:
if when is None:
return "at an unknown time"
secs = max(0, int((now - when).total_seconds()))
if secs < 90:
return "just now"
if secs < 5400:
return f"{secs // 60}m ago"
if secs < 2 * 86400:
return f"{secs // 3600}h ago"
return f"{secs // 86400}d ago"
def render_claims(
source: str,
session_id: str,
claims: list,
logs: dict[int, list],
now: datetime | None = None,
) -> list[str]:
"""The claim section of the SessionStart context, as markdown lines.
`claims` are the caller's claimed tasks (objects with id, title, status and
the claim columns); `logs` maps a task id to its newest log entries
(objects with `created_at` and `content`), newest first. Empty when there is
nothing this source should say — silence is the right answer on a resume,
and on any start with no claims.
"""
from scribe.services.text import elide
now = now or _now()
source = (source or "").strip()
session_id = (session_id or "").strip()
ours = [c for c in claims if claim_is_live(c, now)
and (c.claim_session == session_id or c.claim_session is None)]
others_live = [c for c in claims if claim_is_live(c, now)
and c.claim_session not in (None, session_id)]
abandoned = [c for c in claims if not claim_is_live(c, now)
and c.status == "in_progress"]
lines: list[str] = []
if source in _PUSH_OWN and session_id and ours:
lines += [
"",
"## In flight — the work this session had claimed",
"Scribe's record of what you were doing before the context was "
"lost. Carry on from here; the full log is `get_task(id)`.",
]
for c in ours[:_OWN_CAP]:
lines.append(
f"- #{c.id} \"{c.title}\" ({c.status}) — claimed "
f"{_age(c.claimed_at, now)}, last touched {_age(c.claim_touched_at, now)}"
)
for entry in logs.get(c.id, [])[:_LOGS_PER_TASK]:
text, _ = elide(" ".join((entry.content or "").split()), _LOG_CHARS)
lines.append(f" - log {_age(entry.created_at, now)}: {text}")
if source in _NAME_OTHERS and (others_live or abandoned):
lines += ["", "## Work other sessions were doing"]
if source == "fork":
lines.append(
"This session was forked, so a live claim below may be the "
"session you were forked from — two sessions now think they "
"hold it. Your next log or status change on a task moves its "
"claim here; leave it alone if the other session is still on it."
)
for c in others_live[:_OTHERS_CAP]:
lines.append(
f"- #{c.id} \"{c.title}\" — claimed by another session, last "
f"touched {_age(c.claim_touched_at, now)}. It may still be "
"running; check before working the same task."
)
for c in abandoned[:_OTHERS_CAP]:
lines.append(
f"- #{c.id} \"{c.title}\" — in progress, but the session "
f"working it went quiet {_age(c.claim_touched_at, now)} without "
"finishing. Read its log and continue it, or set it back to todo."
)
return lines
async def claims_for_session_start(
user_id: int, project_id: int, source: str, session_id: str,
) -> list[str]:
"""Load the caller's claims (in the active project, when one resolved) and
their newest logs, and render them for this `source`.
"The caller's claims" is `claimed_by == user_id` — a statement about whose
attention a claim records, not an access filter: a claim is only ever
stamped by a write the caller was already allowed to make.
"""
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.task_log import TaskLog
# No source means the caller did not ask — another client, or an adapter
# older than this section — and a resume restored everything already.
if (source or "") in ("", "resume"):
return []
async with async_session() as session:
q = select(Note).where(
Note.claimed_by == user_id,
Note.claimed_at.is_not(None),
Note.deleted_at.is_(None),
)
if project_id:
q = q.where(Note.project_id == project_id)
claims = list((await session.execute(
q.order_by(Note.claim_touched_at.desc()).limit(_OWN_CAP + 2 * _OTHERS_CAP)
)).scalars().all())
logs: dict[int, list] = {}
if claims:
rows = (await session.execute(
select(TaskLog)
.where(TaskLog.task_id.in_([c.id for c in claims]))
.order_by(TaskLog.created_at.desc())
)).scalars().all()
for row in rows:
bucket = logs.setdefault(row.task_id, [])
if len(bucket) < _LOGS_PER_TASK:
bucket.append(row)
return render_claims(source, session_id, claims, logs)
async def release_session(user_id: int, session_id: str) -> int:
"""Release every claim the caller holds under `session_id` (milestone 381 step 4).
Called by the plugin's SessionEnd hook: the session's context is about to
stop existing, so the attention its claims record is ending too. A TIDY-UP,
not the guarantee — SessionEnd does not fire on a crash, a killed terminal
or a dropped connection, and those are what the lease is for. Returns how
many were released; 0 is the ordinary answer for a session that claimed
nothing.
"""
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
session_id = (session_id or "").strip()[:200]
if not session_id:
return 0
async with async_session() as session:
held = (await session.execute(
select(Note).where(
Note.claimed_by == user_id, Note.claim_session == session_id,
)
)).scalars().all()
for note in held:
release_claim(note)
await session.commit()
return len(held)
+16 -7
View File
@@ -6,8 +6,9 @@ from sqlalchemy import func, select
from scribe.models import async_session from scribe.models import async_session
from scribe.models.task_log import TaskLog from scribe.models.task_log import TaskLog
from scribe.models.note import Note from scribe.models.note import Note, TaskStatus
from scribe.services.access import can_read_note, readable_notes_clause from scribe.services.access import can_read_note, can_write_note, readable_notes_clause
from scribe.services.task_claims import stamp_claim
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,13 +51,21 @@ async def create_log(
content: str, content: str,
duration_minutes: int | None = None, duration_minutes: int | None = None,
) -> TaskLog: ) -> TaskLog:
# Whoever may WRITE the task may log on it (rule #78) — a collaborator on a
# shared project included. This used to be a bare owner filter, which
# refused exactly the person a shared task exists for.
if not await can_write_note(user_id, task_id):
raise ValueError(f"Task {task_id} not found")
async with async_session() as session: async with async_session() as session:
# Verify task exists and belongs to user result = await session.execute(select(Note).where(Note.id == task_id))
result = await session.execute( task = result.scalars().first()
select(Note).where(Note.id == task_id, Note.user_id == user_id) if task is None:
)
if result.scalars().first() is None:
raise ValueError(f"Task {task_id} not found") raise ValueError(f"Task {task_id} not found")
# Logging IS working the task, so it stamps the claim (milestone 381) —
# unless the work is over: a retrospective note on a closed task is not
# a session picking it up.
if task.status not in (TaskStatus.done.value, TaskStatus.cancelled.value):
stamp_claim(task, user_id)
log = TaskLog( log = TaskLog(
task_id=task_id, task_id=task_id,
user_id=user_id, user_id=user_id,
+6
View File
@@ -183,6 +183,9 @@ def fake_note(**attrs) -> MagicMock:
# Milestone 317: a truthy mock here reads as "this note carries a # Milestone 317: a truthy mock here reads as "this note carries a
# check", which trips the guard on records that may not have one. # check", which trips the guard on records that may not have one.
"verify_with": None, "expires_when": None, "verified_at": None, "verify_with": None, "expires_when": None, "verified_at": None,
# Milestone 381: a truthy mock would read as a live claim.
"claimed_by": None, "claimed_at": None, "claim_touched_at": None,
"claim_session": None,
}, attrs) }, attrs)
@@ -193,6 +196,9 @@ def fake_task(**attrs) -> MagicMock:
"tags": [], "parent_id": None, "project_id": None, "is_task": True, "tags": [], "parent_id": None, "project_id": None, "is_task": True,
"task_kind": "work", "user_id": 7, "deleted_at": None, "task_kind": "work", "user_id": 7, "deleted_at": None,
"verify_with": None, "expires_when": None, "verified_at": None, "verify_with": None, "expires_when": None, "verified_at": None,
# Milestone 381: a truthy mock would read as a live claim.
"claimed_by": None, "claimed_at": None, "claim_touched_at": None,
"claim_session": None,
}, attrs) }, attrs)
+15
View File
@@ -228,6 +228,7 @@ async def test_upsert_stores_one_versioned_row_per_chunk():
assert [r.chunk_index for r in rows] == list(range(len(chunks))) assert [r.chunk_index for r in rows] == list(range(len(chunks)))
assert [r.chunk_text for r in rows] == chunks assert [r.chunk_text for r in rows] == chunks
assert {r.chunker_version for r in rows} == {emb.CHUNKER_VERSION} assert {r.chunker_version for r in rows} == {emb.CHUNKER_VERSION}
assert {r.embedding_model for r in rows} == {emb.EMBEDDING_MODEL}
assert {r.user_id for r in rows} == {42} assert {r.user_id for r in rows} == {42}
session.execute.assert_awaited() # the delete that makes replacement atomic session.execute.assert_awaited() # the delete that makes replacement atomic
@@ -314,3 +315,17 @@ async def test_backfill_reembeds_notes_with_a_stale_chunker_version():
embedded = [call.args[0] for call in upsert.call_args_list] embedded = [call.args[0] for call in upsert.call_args_list]
assert embedded == [2], "only the stale note is re-embedded" assert embedded == [2], "only the stale note is re-embedded"
def test_a_row_is_current_only_in_the_live_models_space():
"""#4132: the column is a width, not an identity, so a same-width model
swap writes a second geometry with no error. The backfill's "current"
predicate has to name BOTH halves of the stamp, on every embedding table."""
from scribe.models.embedding import (
MilestoneEmbedding, NoteEmbedding, RuleEmbedding, SystemEmbedding,
)
from scribe.services import embeddings as emb
for table in (NoteEmbedding, RuleEmbedding, MilestoneEmbedding, SystemEmbedding):
clause = str(emb.is_current_stamp(table))
assert "chunker_version" in clause and "embedding_model" in clause, table
+5
View File
@@ -155,6 +155,11 @@ TOPICS: tuple[Topic, ...] = (
Topic("a retrieved rule outranks a default habit", U, ("outranks a default habit",), Topic("a retrieved rule outranks a default habit", U, ("outranks a default habit",),
"a retrieved rule outranks a default habit"), "a retrieved rule outranks a default habit"),
Topic("log on completion and on a problem", U, ("hit or discover a problem",), "hit or discover a problem"), Topic("log on completion and on a problem", U, ("hit or discover a problem",), "hit or discover a problem"),
# Milestone 381 step 4: the half of a hand-off only the model can do. The
# release is mechanical (SessionEnd hook); saying what happened is not.
Topic("hand off before the context stops existing", U,
("hand off", "claims it for this session"),
"write down what the next session needs"),
Topic("the project's design system binds ui", U, ("resolve_design_system",), Topic("the project's design system binds ui", U, ("resolve_design_system",),
"building ui: the project's design system binds", index=("resolve_design_system",)), "building ui: the project's design system binds", index=("resolve_design_system",)),
Topic("name the record, never just its number", U, ("name the record",), Topic("name the record, never just its number", U, ("name the record",),
+2 -1
View File
@@ -23,7 +23,7 @@ from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.services import lessons as lessons_svc from scribe.services import lessons as lessons_svc
from scribe.services.embeddings import CHUNKER_VERSION, semantic_search_notes from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
from tests.helpers import ensure_user from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -71,6 +71,7 @@ async def corpus():
note_id=note.id, chunk_index=0, user_id=owner.id, note_id=note.id, chunk_index=0, user_id=owner.id,
embedding=QUERY_VEC, chunk_text=note.title, embedding=QUERY_VEC, chunk_text=note.title,
chunker_version=CHUNKER_VERSION, chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL,
)) ))
ids = {k: n.id for k, n in rows.items()} ids = {k: n.id for k, n in rows.items()}
ids["owner"], ids["a"], ids["b"] = owner.id, a.id, b.id ids["owner"], ids["a"], ids["b"] = owner.id, a.id, b.id
+3 -2
View File
@@ -17,7 +17,7 @@ from scribe.models.embedding import EMBEDDING_DIM, MilestoneEmbedding
from scribe.models.milestone import Milestone from scribe.models.milestone import Milestone
from scribe.models.project import Project from scribe.models.project import Project
from scribe.services import dedup as dedup_svc from scribe.services import dedup as dedup_svc
from scribe.services.embeddings import CHUNKER_VERSION, semantic_search_milestones from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_milestones
from tests.helpers import ensure_user from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")] pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
@@ -48,7 +48,8 @@ async def roadmap():
await s.flush() await s.flush()
for ms, vec in ((m3, NEAR), (done, NEAR), (unrelated, FAR), (foreign, NEAR)): for ms, vec in ((m3, NEAR), (done, NEAR), (unrelated, FAR), (foreign, NEAR)):
s.add(MilestoneEmbedding(milestone_id=ms.id, chunk_index=0, embedding=vec, s.add(MilestoneEmbedding(milestone_id=ms.id, chunk_index=0, embedding=vec,
chunk_text=ms.title, chunker_version=CHUNKER_VERSION)) chunk_text=ms.title, chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL))
ids = {"owner": owner.id, "stranger": stranger.id, "mine": mine.id, ids = {"owner": owner.id, "stranger": stranger.id, "mine": mine.id,
"m3": m3.id, "done": done.id, "unrelated": unrelated.id, "foreign": foreign.id} "m3": m3.id, "done": done.id, "unrelated": unrelated.id, "foreign": foreign.id}
await s.commit() await s.commit()
+2 -1
View File
@@ -33,7 +33,7 @@ def _vec(*nonzero_first):
def _emb(note_id, user_id, chunk_index, vec): def _emb(note_id, user_id, chunk_index, vec):
"""A chunk row at the current chunker version (#280, migration 0077).""" """A chunk row at the current chunker version (#280, migration 0077)."""
from scribe.services.embeddings import CHUNKER_VERSION from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL
return NoteEmbedding( return NoteEmbedding(
note_id=note_id, note_id=note_id,
@@ -42,6 +42,7 @@ def _emb(note_id, user_id, chunk_index, vec):
embedding=vec, embedding=vec,
chunk_text=f"chunk {chunk_index} of note {note_id}", chunk_text=f"chunk {chunk_index} of note {note_id}",
chunker_version=CHUNKER_VERSION, chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL,
) )
+2 -1
View File
@@ -18,7 +18,7 @@ from scribe.models.embedding import EMBEDDING_DIM, RuleEmbedding
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.share import ProjectShare from scribe.models.share import ProjectShare
from scribe.services import rulebooks as rulebooks_svc from scribe.services import rulebooks as rulebooks_svc
from scribe.services.embeddings import CHUNKER_VERSION, semantic_search_rules from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_rules
from tests.helpers import ensure_user from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@@ -66,6 +66,7 @@ async def homes():
s.add(RuleEmbedding( s.add(RuleEmbedding(
rule_id=rule.id, chunk_index=0, embedding=QUERY_VEC, rule_id=rule.id, chunk_index=0, embedding=QUERY_VEC,
chunk_text=rule.title, chunker_version=CHUNKER_VERSION, chunk_text=rule.title, chunker_version=CHUNKER_VERSION,
embedding_model=EMBEDDING_MODEL,
)) ))
await s.commit() await s.commit()
ids.update(glob=glob.id, on_a=on_a.id, on_b=on_b.id) ids.update(glob=glob.id, on_a=on_a.id, on_b=on_b.id)
+6 -3
View File
@@ -152,9 +152,12 @@ def test_a_lesson_is_judged_at_the_trigger_dominated_bar():
"about one area would refuse each other" "about one area would refuse each other"
) )
assert _LESSON_SEMANTIC_THRESHOLD > _SEMANTIC_THRESHOLD assert _LESSON_SEMANTIC_THRESHOLD > _SEMANTIC_THRESHOLD
# An ordinary note is untouched — the carve-out is per kind, not a # The carve-out is per kind, not a loosening of the gate: a lesson's bar is
# loosening of the gate. # its own, apart from the note's copy band (#4306).
assert _semantic_threshold("note") == _SEMANTIC_THRESHOLD from scribe.services.dedup import _NOTE_COPY_THRESHOLD
assert _semantic_threshold("note") == _NOTE_COPY_THRESHOLD
assert _NOTE_COPY_THRESHOLD != _LESSON_SEMANTIC_THRESHOLD
@pytest.mark.asyncio @pytest.mark.asyncio
+34
View File
@@ -15,6 +15,9 @@ WHAT THIS PINS
mid-backfill would end up with every bar at zero. mid-backfill would end up with every bar at zero.
4. **Every registry surface can be migrated.** A seventh arm that nobody adds 4. **Every registry surface can be migrated.** A seventh arm that nobody adds
a re-scorer for is one whose floor silently cannot survive a model change. a re-scorer for is one whose floor silently cannot survive a model change.
5. **A half-migrated corpus is a refusal (#4132).** While any row the surface
searches is stamped with another model, a re-score measures a blend of two
geometries — so nothing is sampled until the backfill has finished.
""" """
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -43,11 +46,42 @@ def _session_with(rows):
return session return session
@pytest.fixture(autouse=True)
def _corpus_on_the_live_model():
"""Every test below starts from a wholly re-embedded corpus; the one that
is about a half-migrated corpus patches this again."""
with patch.object(rm, "rows_off_the_live_model", AsyncMock(return_value=0)):
yield
def test_every_surface_has_a_rescorer(): def test_every_surface_has_a_rescorer():
"""Otherwise a surface's floor cannot cross a model change at all.""" """Otherwise a surface's floor cannot cross a model change at all."""
assert set(rm._RESCORERS) == set(SURFACES) assert set(rm._RESCORERS) == set(SURFACES)
def test_every_surface_names_the_corpus_it_searches():
"""Otherwise the half-migrated check has no table to count for it."""
assert set(rm._CORPUS) == set(SURFACES)
@pytest.mark.asyncio
async def test_a_corpus_part_way_through_a_model_change_refuses_before_sampling():
rows = _logs([("q1", None, 0.9), ("q2", None, 0.1)])
rescore = AsyncMock(return_value=0.4)
with patch.object(rm, "rows_off_the_live_model", AsyncMock(return_value=17)) as off, \
patch.object(rm, "async_session", MagicMock(return_value=_session_with(rows))), \
patch.object(rm, "floor_for", AsyncMock(return_value=0.5)), \
patch.object(rm, "set_dial", AsyncMock()) as set_dial, \
patch.dict(rm._RESCORERS, {"prompt_rule": rescore}):
out = await rm.migrate_floor(1, "prompt_rule", apply=True)
assert out["migrated"] is False
assert out["rows_off_model"] == 17
assert off.await_args.args[0] is rm.RuleEmbedding
rescore.assert_not_called()
set_dial.assert_not_called()
def test_the_floor_that_admits_a_fraction_is_an_observed_score(): def test_the_floor_that_admits_a_fraction_is_an_observed_score():
scores = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.05] scores = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.05]
# 30% of ten is three; the third-best score is the bar that admits exactly # 30% of ten is three; the third-best score is the bar that admits exactly
+77 -3
View File
@@ -43,8 +43,9 @@ async def test_short_body_skips_semantic_check():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_semantic_match_when_body_substantial(): async def test_semantic_match_when_body_substantial():
# A note blocks only in the copy band (#4306).
hit = fake_note(id=20, title="Existing", note_type="note") hit = fake_note(id=20, title="Existing", note_type="note")
sem = AsyncMock(return_value=[(0.93, hit)]) sem = AsyncMock(return_value=[(0.99, hit)])
with patch("scribe.services.dedup.async_session", with patch("scribe.services.dedup.async_session",
return_value=session_returning(None)), \ return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem): patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
@@ -54,7 +55,80 @@ async def test_semantic_match_when_body_substantial():
assert dup is not None assert dup is not None
assert dup.id == 20 assert dup.id == 20
assert dup.reason == "semantic" assert dup.reason == "semantic"
assert dup.similarity == 0.93 assert dup.similarity == 0.99
@pytest.mark.asyncio
async def test_a_near_note_is_surfaced_not_blocked():
"""#4306: sibling notes — the next dev-log, another part of one design —
measured 0.90–0.98, so a match there is shown for the session to judge
instead of refusing the write."""
from scribe.services.dedup import _NOTE_OVERLAP_FLOOR
hit = fake_note(id=20, title="Dev-log day 3", note_type="note")
sem = AsyncMock(return_value=[(0.93, hit)])
overlaps: list = []
with patch("scribe.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(
7, "Dev-log day 4", body="x" * 250, project_id=2, is_task=False,
note_type="note", overlaps=overlaps,
)
assert dup is None
assert [(o.id, o.similarity) for o in overlaps] == [(20, 0.93)]
# Asked at the overlap floor, so the one search serves both answers.
assert sem.await_args.kwargs["threshold"] == _NOTE_OVERLAP_FLOOR
@pytest.mark.asyncio
async def test_a_record_matching_in_two_chunks_is_one_overlap():
para = ("A paragraph long enough for the chunker to keep as its own "
"section of real content in this test body. ") * 4
body = "\n\n".join(f"## Part {i}\n\n{para} (p{i})" for i in range(8))
hit = fake_note(id=31, title="Design part one", note_type="note")
sem = AsyncMock(return_value=[(0.9, hit)])
overlaps: list = []
with patch("scribe.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
await find_duplicate_note(
7, "Design part two", body=body, project_id=2, note_type="note",
overlaps=overlaps,
)
assert [o.id for o in overlaps] == [31]
@pytest.mark.asyncio
async def test_without_an_overlaps_list_the_gate_asks_at_the_copy_band():
"""A caller that only wants the block (create_process) does not pay for
a wider search it will not read."""
from scribe.services.dedup import _NOTE_COPY_THRESHOLD
sem = AsyncMock(return_value=[])
with patch("scribe.services.dedup.async_session",
return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
await find_duplicate_note(7, "T", body="x" * 250, note_type="note")
assert sem.await_args.kwargs["threshold"] == _NOTE_COPY_THRESHOLD
def test_the_overlap_reply_leaves_the_judgement_to_the_reader():
from scribe.services.dedup import NoteOverlap, note_overlap_response
assert note_overlap_response([], "note") == {}
out = note_overlap_response([NoteOverlap(9, "Dev-log day 3", 0.93)], "task")
assert out["overlaps"] == [{"id": 9, "title": "Dev-log day 3", "similarity": 0.93}]
assert "update_task" in out["overlap_note"]
assert "keep both" in out["overlap_note"]
def test_a_batch_overlap_names_its_record():
from scribe.services.dedup import NoteOverlap, batch_overlap_response
assert batch_overlap_response({}) == {}
out = batch_overlap_response({2: [NoteOverlap(9, "X", 0.9)]})
assert out["overlaps"] == [{"record": 2, "id": 9, "title": "X", "similarity": 0.9}]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -73,7 +147,7 @@ async def test_gate_catches_a_duplicate_hiding_in_a_later_chunk():
hit = fake_note(id=30, title="The existing decision", note_type="note") hit = fake_note(id=30, title="The existing decision", note_type="note")
# Every chunk misses except the LAST one the gate will ask about. # Every chunk misses except the LAST one the gate will ask about.
sem = AsyncMock(side_effect=[[] for _ in range(n_chunks - 1)] + [[(0.94, hit)]]) sem = AsyncMock(side_effect=[[] for _ in range(n_chunks - 1)] + [[(0.99, hit)]])
with patch("scribe.services.dedup.async_session", with patch("scribe.services.dedup.async_session",
return_value=session_returning(None)), \ return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem): patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
+35
View File
@@ -8,6 +8,8 @@ MCP, recurrence, snippets — gets it by construction rather than by remembering
These test the helper directly. The point of the change is that there is now ONE These test the helper directly. The point of the change is that there is now ONE
place to test. place to test.
""" """
import pytest
from scribe.services import notes as notes_svc from scribe.services import notes as notes_svc
# --- inline embedding (#2056) ----------------------------------------------- # --- inline embedding (#2056) -----------------------------------------------
@@ -69,3 +71,36 @@ def test_embed_note_swallows_an_indexing_failure():
note = MagicMock(id=5, user_id=42, title="T", body="B") note = MagicMock(id=5, user_id=42, title="T", body="B")
with patch("asyncio.create_task", side_effect=ValueError("model gone")): with patch("asyncio.create_task", side_effect=ValueError("model gone")):
notes_svc.embed_note(note) # must not raise notes_svc.embed_note(note) # must not raise
# --- #3683: a create that names a status is the transition an update is ------
_LIFECYCLE = ("started_at", "completed_at", "recurrence_next_spawn_at")
@pytest.mark.parametrize("status", ["todo", "in_progress", "done", "cancelled"])
def test_a_task_created_at_a_status_matches_one_updated_to_it(status):
"""The invariant, not the instance that surfaced it: which lifecycle
stamps a row carries must not depend on whether it was created at a status
or reached it by update. Recurrence is included because done/cancelled
schedule the next spawn."""
rule = {"type": "interval", "unit": "week", "every": 1}
created = notes_svc.build_note(1, title="t", status=status, recurrence_rule=rule)
updated = notes_svc.build_note(1, title="t", recurrence_rule=rule)
updated.status = status
notes_svc.apply_status_transition(updated)
for field in _LIFECYCLE:
assert (getattr(created, field) is None) == (getattr(updated, field) is None), (status, field)
def test_a_task_created_in_progress_knows_when_it_started():
note = notes_svc.build_note(1, title="t", status="in_progress")
assert note.started_at is not None
assert note.completed_at is None
def test_a_note_with_no_status_gets_no_lifecycle_stamps():
note = notes_svc.build_note(1, title="t")
assert all(getattr(note, f) is None for f in _LIFECYCLE)
+301
View File
@@ -0,0 +1,301 @@
"""A task's claim — which session is working it, and how it dies (milestone 381 step 2).
WHAT THIS PINS
1. **The write that is the work stamps it.** Reaching `in_progress` — by
update OR by a create that names it (#3683) — claims the task for whoever
made the change. Nothing asks the model to claim anything.
2. **Ending or un-starting the work releases it**, and releasing is
idempotent.
3. **A claim dies on read.** Past the lease it reads `live: false` with
nothing having run — no sweep, which is the whole design.
4. **The latest worker holds it**, and `since` restarts when the holder
changes; the same holder touching a live claim keeps `since`.
5. **The session is bound, never created.** `bind_session` attaches a
harness-reported id only to a live claim the caller already holds.
"""
import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import pytest_asyncio
from scribe.services import notes as notes_svc
from scribe.services import task_claims as tc
from tests.helpers import ensure_user
ROOT = Path(__file__).resolve().parents[1]
def _task(status="todo"):
return notes_svc.build_note(1, title="t", status=status)
# --- 1. the write that is the work stamps it ---------------------------------
def test_a_task_created_in_progress_is_claimed_by_its_creator():
note = notes_svc.build_note(42, title="t", status="in_progress")
state = tc.claim_state(note)
assert state["held_by"] == 42 and state["live"] is True
assert state["session"] is None, "the server never invents a session"
def test_reaching_in_progress_by_update_claims_for_the_acting_user():
note = _task()
note.status = "in_progress"
notes_svc.apply_status_transition(note, user_id=9)
assert tc.claim_state(note)["held_by"] == 9
def test_a_task_never_worked_has_no_claim():
assert tc.claim_state(_task()) is None
assert _task().to_dict()["claim"] is None
# --- 2. ending the work releases it ------------------------------------------
@pytest.mark.parametrize("status", ["done", "cancelled", "todo"])
def test_ending_or_unstarting_the_work_releases_the_claim(status):
note = notes_svc.build_note(42, title="t", status="in_progress")
note.status = status
notes_svc.apply_status_transition(note, user_id=42)
assert tc.claim_state(note) is None
def test_releasing_nothing_is_not_an_error():
note = _task()
tc.release_claim(note)
tc.release_claim(note)
assert tc.claim_state(note) is None
# --- 3. a claim dies on read -------------------------------------------------
def test_a_claim_past_its_lease_reads_dead_with_nothing_having_run():
then = datetime.now(timezone.utc) - tc.CLAIM_LEASE - timedelta(minutes=1)
note = _task("in_progress")
tc.stamp_claim(note, 42, now=then)
state = tc.claim_state(note)
assert state["live"] is False
assert state["held_by"] == 42, "a dead claim is still reported, not hidden"
def test_in_progress_and_unclaimed_is_now_representable():
"""The state the milestone exists to make sayable: committed, nobody on it."""
then = datetime.now(timezone.utc) - timedelta(days=3)
note = _task("in_progress")
tc.stamp_claim(note, 42, now=then)
assert note.status == "in_progress" and not tc.claim_is_live(note)
# --- 4. the latest worker holds it -------------------------------------------
def test_the_same_holder_touching_a_live_claim_keeps_since():
t0 = datetime.now(timezone.utc) - timedelta(minutes=30)
note = _task("in_progress")
tc.stamp_claim(note, 42, now=t0)
tc.stamp_claim(note, 42)
assert note.claimed_at == t0
assert note.claim_touched_at > t0
def test_another_worker_takes_the_claim_over_and_since_restarts():
t0 = datetime.now(timezone.utc) - timedelta(minutes=30)
note = _task("in_progress")
tc.stamp_claim(note, 42, now=t0)
note.claim_session = "a-session"
tc.stamp_claim(note, 7)
assert note.claimed_by == 7
assert note.claimed_at > t0
assert note.claim_session is None, "the old holder's session is not the new one's"
def test_a_dead_claim_restarts_rather_than_resumes():
t0 = datetime.now(timezone.utc) - tc.CLAIM_LEASE - timedelta(hours=1)
note = _task("in_progress")
tc.stamp_claim(note, 42, now=t0)
tc.stamp_claim(note, 42)
assert note.claimed_at > t0
# --- the plugin half ---------------------------------------------------------
def test_the_binder_hook_watches_the_two_writes_that_stamp_a_claim():
hooks = json.loads((ROOT / "plugin/hooks/hooks.json").read_text())["hooks"]
blocks = [b for b in hooks["PostToolUse"]
if any("scribe_claim_session.sh" in h["command"] for h in b["hooks"])]
assert len(blocks) == 1
matcher = re.compile(blocks[0]["matcher"])
assert matcher.fullmatch("mcp__plugin_scribe_scribe__update_task")
assert matcher.fullmatch("mcp__scribe__add_task_log")
assert not matcher.fullmatch("mcp__scribe__get_task")
# --- 5. binding a session (integration) --------------------------------------
@pytest_asyncio.fixture
async def users(_dispose_engine):
from scribe.models import async_session
async with async_session() as session:
a = (await ensure_user(session, "claims_itest_a")).id
b = (await ensure_user(session, "claims_itest_b")).id
await session.commit()
return a, b
@pytest.mark.integration
async def test_a_session_binds_to_its_own_live_claim_only(users):
owner, stranger = users
task = await notes_svc.create_note(owner, title="claim bind", status="in_progress")
assert await tc.bind_session(stranger, task.id, "their-session") is None
bound = await tc.bind_session(owner, task.id, "sess-1")
assert bound["session"] == "sess-1" and bound["live"] is True
# A different session taking over a live claim restarts `since`.
again = await tc.bind_session(owner, task.id, "sess-2")
assert again["session"] == "sess-2"
assert again["since"] >= bound["since"]
@pytest.mark.integration
async def test_a_closed_task_binds_nothing(users):
owner, _ = users
task = await notes_svc.create_note(owner, title="claim closed", status="in_progress")
await notes_svc.update_note(owner, task.id, status="done")
assert await tc.bind_session(owner, task.id, "sess") is None
# --- step 3: the readers ----------------------------------------------------
from types import SimpleNamespace # noqa: E402
_NOW = datetime(2026, 9, 24, 12, 0, tzinfo=timezone.utc)
def _claimed(id, session, touched_ago, status="in_progress", title=None):
t = _NOW - touched_ago
return SimpleNamespace(
id=id, title=title or f"task {id}", status=status,
claimed_by=1, claimed_at=t, claim_touched_at=t, claim_session=session,
)
def _log(content, ago=timedelta(minutes=5)):
return SimpleNamespace(content=content, created_at=_NOW - ago)
def _render(source, claims, logs=None, sid="me"):
return "\n".join(tc.render_claims(source, sid, claims, logs or {}, now=_NOW))
def test_a_compaction_gets_back_its_own_claimed_work_and_latest_logs():
"""The measurement the milestone names: a compacted session comes back
holding its own state, without being told to go looking."""
mine = _claimed(10, "me", timedelta(minutes=3), title="wire the reader")
out = _render("compact", [mine], {10: [_log("ruled out the cache theory")]})
assert "#10" in out and "wire the reader" in out
assert "ruled out the cache theory" in out
def test_a_resume_says_nothing():
mine = _claimed(10, "me", timedelta(minutes=3))
assert _render("resume", [mine]) == ""
def test_a_startup_names_other_sessions_live_and_abandoned_claims():
live = _claimed(11, "other", timedelta(minutes=10))
gone = _claimed(12, "older", timedelta(days=3))
out = _render("startup", [live, gone])
assert "#11" in out and "may still be running" in out
assert "#12" in out and "went quiet" in out
def test_a_startup_does_not_push_this_sessions_own_work():
"""A new session id owns nothing yet; the own-work push is for a context
that was lost, not one that never existed."""
mine = _claimed(10, "me", timedelta(minutes=3))
assert "In flight" not in _render("startup", [mine])
def test_a_fork_is_told_two_sessions_may_hold_the_same_claim():
parent = _claimed(13, "parent", timedelta(minutes=2))
out = _render("fork", [parent], sid="child")
assert "forked" in out and "#13" in out
def test_a_dead_claim_on_finished_work_is_not_news():
done = _claimed(14, "older", timedelta(days=3), status="done")
assert _render("startup", [done]) == ""
def test_the_session_start_hook_sends_the_source_and_the_session():
"""The reader branches on what the hook sends; a hook that stopped sending
either would leave every session on the no-claims path, silently."""
text = (ROOT / "plugin/hooks/scribe_session_context.sh").read_text()
assert re.search(r'q="source=\$\(printf', text)
assert "session_id=$(printf" in text
@pytest.mark.integration
async def test_session_start_after_a_compaction_carries_the_claimed_task(users):
from scribe.services import task_logs
from scribe.services.plugin_context import build_session_context
owner, _ = users
task = await notes_svc.create_note(owner, title="claimed then compacted",
status="in_progress")
await task_logs.create_log(owner, task.id, "halfway: the migration is written")
await tc.bind_session(owner, task.id, "sess-compact")
ctx = (await build_session_context(
owner, source="compact", session_id="sess-compact"))["context"]
assert "claimed then compacted" in ctx
assert "the migration is written" in ctx
# --- step 4: the hand-off ----------------------------------------------------
def test_session_end_releases_claims_but_not_on_clear():
"""The mechanical half of the hand-off. A /clear keeps the claim, because
SessionStart(source=clear) pushes the claimed work straight back."""
hooks = json.loads((ROOT / "plugin/hooks/hooks.json").read_text())["hooks"]
commands = [h["command"] for b in hooks.get("SessionEnd", []) for h in b["hooks"]]
assert any("scribe_session_end.sh" in c for c in commands)
text = (ROOT / "plugin/hooks/scribe_session_end.sh").read_text()
assert '[ "$reason" = "clear" ] && exit 0' in text
assert "/api/plugin/release-session" in text
@pytest.mark.integration
async def test_ending_a_session_releases_only_that_sessions_claims(users):
owner, _ = users
kept = await notes_svc.create_note(owner, title="other session's", status="in_progress")
gone = await notes_svc.create_note(owner, title="ending session's", status="in_progress")
await tc.bind_session(owner, kept.id, "sess-stays")
await tc.bind_session(owner, gone.id, "sess-ends")
assert await tc.release_session(owner, "sess-ends") >= 1
assert (await notes_svc.get_note(owner, gone.id)).claimed_at is None
assert (await notes_svc.get_note(owner, kept.id)).claim_session == "sess-stays"
assert await tc.release_session(owner, "") == 0
@pytest.mark.integration
async def test_a_collaborator_with_write_access_can_log_and_so_claims(users):
"""create_log used to filter on the task's OWNER (a rule #78 violation), so
a collaborator on a shared task could not log on it — nor, since logging
stamps the claim, ever be seen working it."""
from scribe.services import sharing, task_logs
owner, collaborator = users
task = await notes_svc.create_note(owner, title="shared work", status="todo")
await sharing.share_note(owner, task.id, target_user_id=collaborator,
permission="editor")
await task_logs.create_log(collaborator, task.id, "picked this up")
got = await notes_svc.get_note(owner, task.id)
assert got.claimed_by == collaborator
+3 -1
View File
@@ -125,7 +125,7 @@ def test_a_short_task_with_a_short_log_is_still_one_sharp_chunk():
# is #4241's half-surface one layer down: the entry is readable, and the search # is #4241's half-surface one layer down: the entry is readable, and the search
# still answers as though it were never written. # still answers as though it were never written.
from unittest.mock import MagicMock, patch # noqa: E402 from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402
import pytest # noqa: E402 import pytest # noqa: E402
@@ -141,6 +141,7 @@ async def test_writing_a_work_log_refreshes_the_tasks_embedding():
session = session_returning(note) session = session_returning(note)
with ( with (
patch.object(svc, "async_session", return_value=session), patch.object(svc, "async_session", return_value=session),
patch.object(svc, "can_write_note", AsyncMock(return_value=True)),
patch("scribe.services.notes.embed_note") as embed, patch("scribe.services.notes.embed_note") as embed,
): ):
await svc.create_log(42, 7, "what I tried") await svc.create_log(42, 7, "what I tried")
@@ -193,6 +194,7 @@ async def test_a_failed_refresh_does_not_fail_the_log_that_saved():
session = session_returning(fake_note(id=7, title="T", body="b", is_task=True)) session = session_returning(fake_note(id=7, title="T", body="b", is_task=True))
with ( with (
patch.object(svc, "async_session", return_value=session), patch.object(svc, "async_session", return_value=session),
patch.object(svc, "can_write_note", AsyncMock(return_value=True)),
patch("scribe.services.notes.embed_note", side_effect=RuntimeError("boom")), patch("scribe.services.notes.embed_note", side_effect=RuntimeError("boom")),
): ):
log = await svc.create_log(42, 7, "what I tried") log = await svc.create_log(42, 7, "what I tried")