CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Failing after 1m5s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Skipped
The agent promotes a family idea when all three criteria hold, and no person approves it. The criteria are product text: services/family.py states them, and the promote tool's docstring names every criterion the service enforces.
- Criteria: each one vetoes on its own when its reasoning is blank. Platform terms also needs an applies-when and a platform scope; proven also needs named evidence. A veto keeps the idea a candidate and is logged, so it becomes precedent.
- Precedent: every promotion stores the decisions on the nearest ideas by meaning, plus any the caller names.
- Promotion sets canon, the applicability test and the platform scope, and opens an unassessed ledger row for each member project the promoter can write. Re-promotion moves the version past every version the idea has held.
- Retire and undo: undo reverses only the latest idea-level decision, restores its recorded before-state, and logs itself with the undone decision as its precedent. Settled here: leaving canon closes the unassessed rows but keeps the judged ones, which read as needing a recheck after a re-promotion.
- Triggers open evaluations but never promote:
- a cross-project lineage citation ("matching #N") on a note or task write;
- a same-meaning record in another project on a shared platform, on create, at 0.80 (measured: the known pattern's builds scored 0.79-0.82, an unrelated project's best match 0.65);
- a milestone closing on a platform.
Each fails open and rides the response as family_hint.
- Doors: seven MCP tools, /api/family REST endpoints, and a Family page (nav, /family) showing the criteria, the ideas, and the decision log with undo and retire.
- utils/recordHref.ts holds the one copy of "where a record opens", now shared with LessonDetailView.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
460 lines
15 KiB
Vue
460 lines
15 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Read a LESSON, and see both ends of what it came from.
|
|
*
|
|
* THE TRIGGER LEADS. A lesson is retrieved by the situation it applies to, so
|
|
* the page opens with that situation rather than with the claim — the same
|
|
* ordering the editor uses, and for the same reason: the trigger is the half
|
|
* a reader needs first to decide whether this is for them.
|
|
*
|
|
* GLOBAL BY DEFAULT HAS TO BE LEGIBLE. A lesson written on one project will
|
|
* surface on another, which reads as a bug unless the page says why. The
|
|
* origin line does exactly that, and says it as a property of the kind rather
|
|
* than as an apology.
|
|
*
|
|
* IT BINDS NOBODY, and the page says so once, plainly. That sentence is the
|
|
* difference between this kind and a rule, and it is the reason the kind
|
|
* exists (#3727) — sessions were proposing rules for things that should never
|
|
* have bound anyone.
|
|
*
|
|
* BUT IT CAN POINT AT ONE (milestone 440). A lesson that is an instance of a
|
|
* rule says so, and the page shows which of the three answers it holds: the
|
|
* rule(s) it was judged an instance of, "no rule fits" with the reason, or
|
|
* not yet judged. The third is the one worth acting on, so it is stated as
|
|
* plainly as the other two rather than left as an empty panel.
|
|
*/
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import {
|
|
deleteLesson,
|
|
getLesson,
|
|
judgeLessonLink,
|
|
type Lesson,
|
|
type LessonRuleLink,
|
|
} from "@/api/lessons";
|
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
|
import TagPill from "@/components/TagPill.vue";
|
|
import UsageBadge from "@/components/UsageBadge.vue";
|
|
import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import { renderMarkdown } from "@/utils/markdown";
|
|
import { canWriteRecord } from "@/utils/permission";
|
|
import { recordHref } from "@/utils/recordHref";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const toast = useToastStore();
|
|
|
|
const lesson = ref<Lesson | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
const confirming = ref(false);
|
|
|
|
const lessonId = computed(() => Number(route.params.id));
|
|
|
|
/** The insight rendered — the body minus the lines the service composed, so
|
|
* the trigger is not printed twice on a page that already leads with it. */
|
|
const insightHtml = computed(() =>
|
|
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
|
|
);
|
|
|
|
|
|
const canWrite = computed(() => canWriteRecord(lesson.value?.permission));
|
|
|
|
/** The links by state — each state reads differently. Confirmed links are
|
|
* the answer; suggested ones are evidence waiting on a judgment; rejected
|
|
* ones are kept so the pair is never proposed again, and shown so the reason
|
|
* stays readable. */
|
|
const linksIn = (state: LessonRuleLink["state"]) =>
|
|
computed(() => (lesson.value?.rules ?? []).filter((r) => r.state === state));
|
|
const confirmedRules = linksIn("confirmed");
|
|
const suggestedRules = linksIn("suggested");
|
|
const rejectedRules = linksIn("rejected");
|
|
|
|
/** A rule opens in the rules view's editor, which takes the rule alone. */
|
|
function ruleHref(ruleId: number) {
|
|
return { path: "/rules", query: { rule: String(ruleId) } };
|
|
}
|
|
|
|
/** What a suggestion rests on, in the units it was proposed by: distinct
|
|
* situations, not raw arrivals — one situation repeated proves nothing. */
|
|
function evidenceLine(ev: NonNullable<LessonRuleLink["evidence"]>): string {
|
|
const situations = `${ev.situations} distinct situation${ev.situations === 1 ? "" : "s"}`;
|
|
const projects = ev.projects > 1 ? `, across ${ev.projects} projects` : "";
|
|
return `Arrived together in ${situations}${projects}.`;
|
|
}
|
|
|
|
const judging = ref<number | null>(null);
|
|
|
|
/** Judge a suggested link from here. Re-reads the lesson rather than editing
|
|
* the list in place: confirming also clears a "no rule fits" answer, and the
|
|
* judgment line has to come back from the server to be right. */
|
|
async function judge(rule: LessonRuleLink, verdict: "confirm" | "reject") {
|
|
judging.value = rule.id;
|
|
try {
|
|
await judgeLessonLink(lessonId.value, rule.id, verdict);
|
|
lesson.value = await getLesson(lessonId.value);
|
|
toast.show(
|
|
verdict === "confirm"
|
|
? "Linked — the rule can now surface through this lesson"
|
|
: "Not an instance — this pair will not be suggested again",
|
|
);
|
|
} catch (e) {
|
|
toast.show(apiErrorMessage(e, "Failed to record the judgment"), "error");
|
|
} finally {
|
|
judging.value = null;
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
lesson.value = await getLesson(lessonId.value);
|
|
} catch (e) {
|
|
error.value = apiErrorMessage(e, "Failed to load this lesson");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
confirming.value = false;
|
|
try {
|
|
await deleteLesson(lessonId.value);
|
|
toast.show("Lesson moved to trash");
|
|
router.push("/knowledge?type=lesson");
|
|
} catch (e) {
|
|
toast.show(apiErrorMessage(e, "Failed to delete this lesson"), "error");
|
|
}
|
|
}
|
|
|
|
watch(lessonId, load);
|
|
onMounted(load);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="lesson-detail">
|
|
<p v-if="loading" class="ld-muted">Loading…</p>
|
|
<p v-else-if="error" class="ld-error" role="alert">{{ error }}</p>
|
|
|
|
<article v-else-if="lesson">
|
|
<header class="ld-head">
|
|
<span class="ld-kind">Lesson</span>
|
|
<!-- Said once, plainly. It is the whole difference from a rule. -->
|
|
<span class="ld-binds">binds nobody</span>
|
|
</header>
|
|
|
|
<!-- The trigger leads, in its own block: it is what a reader needs first
|
|
to decide whether this applies to them. -->
|
|
<section class="ld-trigger">
|
|
<h2 class="ld-trigger-label">When this applies</h2>
|
|
<p class="ld-trigger-text">{{ lesson.when_to_apply }}</p>
|
|
</section>
|
|
|
|
<div class="ld-what-row">
|
|
<h1 class="ld-what">{{ lesson.what }}</h1>
|
|
<!-- Lessons collected surfaced-vs-opened from the day the slot shipped
|
|
and showed it nowhere (#4196). It belongs here above anywhere
|
|
else: the reading that matters is almost never "delete this", it
|
|
is "the trigger is keyed to a situation nobody is in", and the
|
|
place to act on that is the lesson you are already looking at. -->
|
|
<UsageBadge
|
|
:usage="lesson.usage"
|
|
noun="lesson"
|
|
:dead-weight-advice="DEAD_WEIGHT_ADVICE.lesson"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="insightHtml"
|
|
class="ld-insight markdown-body"
|
|
v-html="insightHtml"
|
|
/>
|
|
<p v-else class="ld-muted ld-empty">
|
|
No detail was recorded — the claim above is the whole lesson.
|
|
</p>
|
|
|
|
<!-- What taught it. The provenance is the point: a lesson that loses its
|
|
incidents loses its evidence. -->
|
|
<section
|
|
v-if="lesson.learned_from_records?.length"
|
|
class="ld-panel"
|
|
>
|
|
<h2 class="ld-panel-label">Learned from</h2>
|
|
<ul class="ld-sources">
|
|
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
|
|
<router-link :to="recordHref(rec)" class="ld-link">
|
|
{{ rec.title }}
|
|
</router-link>
|
|
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<!-- Which rule this is an instance of. Absent when the links could not
|
|
be read: that is "not attached", never "no rule". -->
|
|
<section v-if="lesson.rule_judgment" class="ld-panel">
|
|
<h2 class="ld-panel-label">Instance of</h2>
|
|
|
|
<template v-if="lesson.rule_judgment === 'linked'">
|
|
<ul v-if="confirmedRules.length" class="ld-sources">
|
|
<li v-for="r in confirmedRules" :key="r.id">
|
|
<router-link :to="ruleHref(r.id)" class="ld-link">
|
|
{{ r.title }}
|
|
</router-link>
|
|
<span v-if="r.kind === 'preference'" class="rule-chip rule-chip-preference">
|
|
preference
|
|
</span>
|
|
<p v-if="r.note" class="ld-link-note">{{ r.note }}</p>
|
|
</li>
|
|
</ul>
|
|
<!-- Judged by its owner against a rule this reader cannot open. -->
|
|
<p v-else class="ld-muted">Linked to a rule that is not shared with you.</p>
|
|
</template>
|
|
<p v-else-if="lesson.rule_judgment === 'no_rule'" class="ld-judgment">
|
|
No rule — {{ lesson.no_rule?.why }}
|
|
</p>
|
|
<p v-else class="ld-muted ld-empty">
|
|
Not yet judged — no rule has been named for this situation, and
|
|
nobody has said none fits.
|
|
</p>
|
|
|
|
<template v-if="suggestedRules.length">
|
|
<h3 class="ld-sub-label">Suggested</h3>
|
|
<ul class="ld-sources">
|
|
<li v-for="r in suggestedRules" :key="r.id">
|
|
<router-link :to="ruleHref(r.id)" class="ld-link">
|
|
{{ r.title }}
|
|
</router-link>
|
|
<span v-if="r.kind === 'preference'" class="rule-chip rule-chip-preference">
|
|
preference
|
|
</span>
|
|
<p v-if="r.evidence" class="ld-link-note">{{ evidenceLine(r.evidence) }}</p>
|
|
<div v-if="canWrite" class="ld-judge">
|
|
<button
|
|
type="button"
|
|
class="ld-ghost ld-small"
|
|
:disabled="judging === r.id"
|
|
@click="judge(r, 'confirm')"
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="ld-ghost ld-small"
|
|
:disabled="judging === r.id"
|
|
@click="judge(r, 'reject')"
|
|
>
|
|
Not an instance
|
|
</button>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
|
|
<template v-if="rejectedRules.length">
|
|
<h3 class="ld-sub-label">Judged not an instance</h3>
|
|
<ul class="ld-sources ld-rejected">
|
|
<li v-for="r in rejectedRules" :key="r.id">
|
|
<router-link :to="ruleHref(r.id)" class="ld-link">
|
|
{{ r.title }}
|
|
</router-link>
|
|
<p v-if="r.note" class="ld-link-note">{{ r.note }}</p>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
</section>
|
|
|
|
<section v-if="lesson.tags?.length" class="ld-tags">
|
|
<TagPill v-for="t in lesson.tags" :key="t" :tag="t" />
|
|
</section>
|
|
|
|
<!-- Global-by-default, stated as a property rather than an apology. A
|
|
lesson meeting you on a project it was not written on is the kind
|
|
working, and the page has to say so or it reads as a bug. -->
|
|
<footer class="ld-origin">
|
|
<p v-if="lesson.project_id">
|
|
Learned on another project, and offered everywhere — a lesson is
|
|
retrieved by the situation it names, never by where it was written.
|
|
</p>
|
|
<p v-else>
|
|
Not tied to a project. Offered wherever the situation it names comes
|
|
up.
|
|
</p>
|
|
</footer>
|
|
|
|
<div class="ld-actions">
|
|
<router-link :to="`/lessons/${lesson.id}/edit`" class="ld-primary">
|
|
Edit
|
|
</router-link>
|
|
<button type="button" class="ld-ghost" @click="confirming = true">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</article>
|
|
|
|
<ConfirmDialog
|
|
v-if="confirming"
|
|
title="Delete this lesson?"
|
|
message="It moves to the trash and can be restored."
|
|
@confirm="remove"
|
|
@cancel="confirming = false"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- `.rule-chip` and its preference variant: the marker a rule's kind wears
|
|
everywhere else, reused rather than re-spelled.
|
|
Block 0, as in every pane that loads it: a shared src must sit at
|
|
one index everywhere (tests/test_frontend_shared_styles.py). -->
|
|
<style src="@/assets/rules-shared.css" />
|
|
<style scoped>
|
|
.lesson-detail {
|
|
max-width: 780px;
|
|
margin: 0 auto;
|
|
padding: var(--fs-layout-page-pad);
|
|
color: var(--fs-text-primary);
|
|
}
|
|
|
|
.ld-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
/* Neutral, matching the browse badge. A hue here would make the softest
|
|
record in the corpus look like the loudest. */
|
|
.ld-kind {
|
|
padding: 0.15rem 0.5rem;
|
|
border-radius: 10px;
|
|
background: var(--fs-surface-raised);
|
|
color: var(--fs-text-secondary);
|
|
font-size: 0.7rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
font-weight: 500;
|
|
}
|
|
.ld-binds { color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
|
|
|
.ld-trigger {
|
|
padding: 0.9rem 1rem;
|
|
border-left: 2px solid var(--fs-accent);
|
|
border-radius: var(--fs-radius-sm);
|
|
background: var(--fs-surface-raised);
|
|
margin-bottom: 1rem;
|
|
}
|
|
.ld-trigger-label {
|
|
margin: 0 0 0.3rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.ld-trigger-text { margin: 0; font-size: 0.98rem; line-height: 1.5; }
|
|
|
|
.ld-what-row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: var(--fs-space-2);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.ld-what {
|
|
margin: 0 0 1.25rem;
|
|
font-size: 1.25rem;
|
|
font-weight: 500;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.ld-insight { line-height: 1.6; font-size: 0.93rem; }
|
|
.ld-empty { font-style: italic; }
|
|
|
|
.ld-panel {
|
|
margin-top: 1.75rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid var(--fs-border-color);
|
|
}
|
|
.ld-panel-label {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.ld-sources { margin: 0; padding-left: 1.1rem; }
|
|
.ld-sources li { margin-bottom: 0.3rem; font-size: 0.9rem; }
|
|
.ld-link { color: var(--fs-accent); }
|
|
.ld-source-status {
|
|
margin-left: 0.4rem;
|
|
color: var(--fs-text-tertiary);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.ld-link-note {
|
|
margin: var(--fs-space-1) 0 0;
|
|
color: var(--fs-text-secondary);
|
|
font-size: var(--fs-size-label);
|
|
line-height: 1.45;
|
|
}
|
|
.ld-judgment { margin: 0; font-size: 0.9rem; line-height: 1.5; }
|
|
.ld-sub-label {
|
|
margin: var(--fs-space-3) 0 var(--fs-space-1);
|
|
font-size: var(--fs-size-label);
|
|
font-weight: var(--fs-weight-medium);
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.ld-rejected .ld-link { color: var(--fs-text-secondary); }
|
|
.ld-judge { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-1); }
|
|
.ld-small { padding: 0.15rem 0.6rem; font-size: var(--fs-size-label); }
|
|
.ld-ghost:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
|
|
|
.ld-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 1.25rem; }
|
|
|
|
.ld-origin {
|
|
margin-top: 1.5rem;
|
|
color: var(--fs-text-secondary);
|
|
font-size: 0.82rem;
|
|
line-height: 1.5;
|
|
}
|
|
.ld-origin p { margin: 0; }
|
|
|
|
.ld-actions {
|
|
display: flex;
|
|
gap: 0.6rem;
|
|
margin-top: 1.75rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid var(--fs-border-color);
|
|
}
|
|
.ld-primary {
|
|
padding: 0.45rem 1rem;
|
|
border-radius: var(--fs-radius-sm);
|
|
background: var(--fs-accent);
|
|
color: var(--fs-accent-fg);
|
|
font-size: 0.88rem;
|
|
}
|
|
.ld-ghost {
|
|
padding: 0.45rem 1rem;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
background: transparent;
|
|
color: var(--fs-text-primary);
|
|
font: inherit;
|
|
font-size: 0.88rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.ld-muted { color: var(--fs-text-secondary); font-size: 0.88rem; }
|
|
.ld-error {
|
|
padding: 0.6rem 0.8rem;
|
|
border-radius: var(--fs-radius-sm);
|
|
background: color-mix(in srgb, var(--fs-error) 12%, var(--fs-surface-raised));
|
|
color: color-mix(in srgb, var(--fs-error) 55%, var(--fs-text-primary));
|
|
font-size: 0.88rem;
|
|
}
|
|
</style>
|