diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 75b4d28..c8aae07 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -37,7 +37,7 @@ it. The layers, lowest first: | Sync | `synclock.ts`, `transport.ts`, `registrycache.ts`, `pusher.ts`, `puller.ts`, `pushermain.ts` | sending a server-backed store's queue and receiving the workspace's copy. `transport.ts` addresses and authenticates a request and reports a status, some headers and a body — and one further answer, `reached: false`, for a refused connection, an unresolvable name, a socket cut mid-body and a request that ran out of time alike, because all four leave the same question open. `pusher.ts` and `puller.ts` hold the two response tables, written out row by row and first match wins, and they are the review surface for what a status means: nothing else in the tree branches on one. `synclock.ts` is the single-flight lock — synchronous, chmod-free, never waited for — and the one rewrite it guards, which carries onto its replacement whatever the foreground appended while it was deciding. `registrycache.ts` caches the workspace's own project list and each project's server id, which is the one thing that tells a project this machine has never registered from one another machine deleted. This layer sits above Credential and below Pipeline and is the **only** part of the tree that reaches both a token and a store's files: the state writers may hold no import path to a credential, and this holds one, so it asks `pending.ts` and `logfile.ts` to write and they know nothing of it | | Domain | `dates.ts`, `skills.ts`, `runbooks.ts`, `instructions.ts` | what is left of the CLI's own domain layer. `dates.ts` holds the two refusals a typed date argument is judged by; the per-domain state shapes and their reducers — `completion.ts`, `objectives.ts`, `entities.ts`, and the calendar arithmetic — are `@superself/fold`. The other three are the derived readings of the record kinds that are composed out of the `entity.*` grammar rather than minted: which records answer as a skill, a runbook or an instruction, what each one is read as, and the lines each prints. They are derivations and nothing else — nothing here is stored, and no event type, reducer or reserved key belongs to them. Each reads the entity fold and its own peers only, never `model.ts`, so the store walk that says *which* records answer in a project is handed to them (`renderedIn`) rather than reached for | | Model | `model.ts` | the CLI's end of the fold: the one place a store directory becomes the arguments `@superself/fold` takes — the log, the registry's description, the workspace zone, the session, the verdicts — and the store walks (`workspaceModels`, `projectsHolding`, `readableModels`) that fold a project at a time. The calculation is the package's; this module is what a machine hands it. `foldedOthers` is where a workspace-wide answer folds the projects it is not about: one of them whose state will not read is left out and named on stderr rather than taking the whole answer down, while the project a command *is* about is folded by its caller and refuses loudly — `views.ts` folds the workspace in a second shape and asks the same function, so the rule is written once | -| Render | `view.ts`, `views.ts`, `pretty.ts`, `output.ts`, `reachability.ts` | HTML and terminal rendering of a folded model; `output.ts` is the render gate — the one function that puts a command's blocks on stdout, and the `notice` a lower layer's message is printed through; the block shapes themselves are declared in `types.ts` | +| Render | `view.ts`, `views.ts`, `pretty.ts`, `output.ts`, `reachability.ts`, `check.ts` | HTML and terminal rendering of a folded model, and the projections *of* one that are not the fold itself — `reachability.ts` judges evidence against this machine's git, and `check.ts` is the read-only direction check (#417 §6): folds in, findings out, and nothing else. `check.ts` reads no machine at all — no clock, no filesystem, no `process` — because its answer has to be the same on two machines holding the same events; `objective-check.test.mjs` holds its source to the same five rules `apps/fold/test/purity.test.mjs` holds the fold package to. It is here rather than in `@superself/fold` because it reads which records answer as a runbook run, and that derivation is this tree's `runbooks.ts`; `output.ts` is the render gate — the one function that puts a command's blocks on stdout, and the `notice` a lower layer's message is printed through; the block shapes themselves are declared in `types.ts` | | Fold | `fold.ts`, `connect.ts` | writing canonical markdown, views, and the managed agent block | | Pipeline | `pipeline.ts`, `sanitize.ts` | appending events, then refolding and committing. Which file an append lands in is what the store's mode decides: the store's own `log.jsonl` where it is git-backed, the queue where it is server-backed and `log.jsonl` beside it is the server's copy to write | | Command support | `artifact.ts`, `retirement.ts` | what more than one command surface shares: artifact staging (`artifact.ts` also holds the `artifact` verb), and the disclosure-and-approval path every destructive verb takes (`retirement.ts`, read by `main.ts`, `goals.ts` and `state.ts`) | diff --git a/apps/cli/src/check.ts b/apps/cli/src/check.ts new file mode 100644 index 0000000..4610d41 --- /dev/null +++ b/apps/cli/src/check.ts @@ -0,0 +1,730 @@ +// The read-only direction check (#417 §6). Given the fold of one project and +// the folds of the registered projects this machine could read, it states what +// is structurally inconsistent about that project's direction graph, what a +// person has to re-judge, and which done work could be cited as evidence — and +// it repairs, covers and records nothing. +// +// Three properties are the whole design, and each one is asserted rather than +// promised: +// +// - **It reads no machine.** No clock, no filesystem, no `process`, no +// network. `apps/cli/test/objective-check.test.mjs` cell 41 reads this +// file's source and holds it to the same five rules +// `apps/fold/test/purity.test.mjs` holds the fold package to. That is why +// no rule below reads `MilestoneState.state`: `missed` and `at-risk` are +// what `today` decides, so a finding resting on one would not be the same +// finding an hour later. Closure is read through `milestoneClosure`, which +// the fold extracted for exactly this reason. +// - **It is a function of its arguments.** Identical folds produce identical +// findings, in identical order, on any machine and whatever order the logs +// were merged in. The order is total and every comparison is codepoint +// order — `localeCompare` answers differently under a different locale, +// which is the one thing the determinism guarantee cannot allow. +// - **Uncertainty is never an all-clear.** A contribution whose target +// project this machine does not hold is reported as unchecked, never as +// closed and never as fine. +// +// It lives in the CLI rather than in `@superself/fold` because finding 6 reads +// which records answer as a runbook run, and that derivation is `runbooks.ts` +// — a CLI-owned reading of the entity grammar that ARCHITECTURE.md keeps out +// of the package. + +import { carriedJudgments, exitStanding, milestoneClosure, MilestoneState, ObjectiveState, openObjectives } from "@superself/fold"; +import { DecisionState, isOpenWork, ProjectModel, WorkState } from "./model.js"; +import { isRunbookRun } from "./runbooks.js"; + +/* ── what the check answers with ───────────────────────────────────── */ + +// A record named the way a reader can act on it: bare where this project owns +// it, and qualified by the owning slug where another project does. The pair is +// also the sort key, so a foreign objective sorts in one place on every +// machine rather than wherever a locale happens to put it. +interface QualifiedId +{ + id: string; + project?: string; +} + +// The seven kinds, fixed by design §6 and by nothing else. The order of this +// list is the order two findings on one record are printed in — the design's +// own numbering, which reads as a story, where alphabetical order would read +// as an accident. +export const FINDING_KINDS = [ + "obsolete-contributions", + "empty-successor", + "date-order", + "judgment-review", + "evidence-candidate", + "no-disposition", + "operational-objective" +] as const; + +type FindingKind = (typeof FINDING_KINDS)[number]; + +// What a reader is being asked to do. `structural` — commands repair the +// relationship, though a person chooses which; `review` — a person re-judges +// the meaning and no command decides it for them; `candidate` — information +// only, which is never acted on by this tool at all. +type FindingClass = "structural" | "review" | "candidate"; + +interface Finding +{ + kind: FindingKind; + class: FindingClass; + // The objective this finding sorts under. Absent puts it in the explicit + // unassigned group, which sorts after every named objective — a group with + // a name of its own, rather than findings quietly appended to the end. + objective?: QualifiedId; + record: QualifiedId; + // A further stable identifier inside the record — a criterion address, the + // decision or the predecessor a finding is about. It is the last term of + // the sort, so two findings of one kind on one record still order. + detail: string; + summary: string; + // The supported commands that answer it, in the order they are run. Every + // one is dispatchable on this branch and every one changes the condition + // the finding states: recording a decision is never offered as clearing a + // structural finding, because it moves no edge, no date and no criterion. + commands: string[]; +} + +// A contribution whose target could not be read here, which is a different +// fact from a contribution that is fine and from one that is closed. The +// owning project is not registered on this machine, is archived, or does not +// hold the id. +interface UncheckedTarget +{ + record: string; + target: QualifiedId; + why: string; +} + +interface CheckSummary +{ + findings: number; + structural: number; + review: number; + candidates: number; + unchecked: number; +} + +interface CheckReport +{ + project: string; + findings: Finding[]; + unchecked: UncheckedTarget[]; + summary: CheckSummary; +} + +/* ── the entry point ───────────────────────────────────────────────── */ + +// `available` is every other project's fold this machine could read, and the +// absence of one is the whole of what "not checked" means: an archived, an +// unregistered and an unreadable project all arrive here the same way, which +// is by not arriving. +export function checkDirection(project: ProjectModel, available: readonly ProjectModel[]): CheckReport +{ + const world = { project, available }; + const unchecked: UncheckedTarget[] = []; + const findings = [ + ...contributionFindings(world, unchecked), + ...checkpointFindings(world), + ...candidateFindings(world), + ...operationalFindings(world) + ].sort(byPlace); + return { + project: project.slug, + findings, + unchecked: unchecked.sort(byUnchecked), + summary: summarize(findings, unchecked) + }; +} + +interface World +{ + project: ProjectModel; + available: readonly ProjectModel[]; +} + +function summarize(findings: Finding[], unchecked: UncheckedTarget[]): CheckSummary +{ + const count = (kind: FindingClass): number => findings.filter((item) => item.class === kind).length; + return { + findings: findings.length, + structural: count("structural"), + review: count("review"), + candidates: count("candidate"), + unchecked: unchecked.length + }; +} + +/* ── the total order ───────────────────────────────────────────────── */ + +// Qualified objective, then qualified record, then kind, then the finding's +// own detail. Codepoint order throughout, and the unassigned group last: two +// machines holding the same events print the same bytes, whatever their +// locale and whatever order the logs were merged in. +function byPlace(left: Finding, right: Finding): number +{ + return compare(groupKey(left.objective), groupKey(right.objective)) + || compare(qualifiedKey(left.record), qualifiedKey(right.record)) + || FINDING_KINDS.indexOf(left.kind) - FINDING_KINDS.indexOf(right.kind) + || compare(left.detail, right.detail); +} + +function byUnchecked(left: UncheckedTarget, right: UncheckedTarget): number +{ + return compare(left.record, right.record) || compare(qualifiedKey(left.target), qualifiedKey(right.target)); +} + +// The unassigned group's key begins with a character no id or slug can carry, +// and it sorts after them because it is prefixed rather than because the +// comparator has a special case: a group that sorted by exception would be one +// more rule to keep true. +function groupKey(objective: QualifiedId | undefined): string +{ + return objective === undefined ? "~unassigned" : qualifiedKey(objective); +} + +// A local id sorts among local ids; a foreign one sorts under its owning slug. +// The separator is a space, which no slug and no id contains. +function qualifiedKey(record: QualifiedId): string +{ + return record.project === undefined ? record.id : `${record.project} ${record.id}`; +} + +function compare(left: string, right: string): number +{ + if (left === right) + { + return 0; + } + return left < right ? -1 : 1; +} + +/* ── reading a target's closure out of the supplied folds ──────────── */ + +// What one contribution edge resolves to. `open` and `closed` are verdicts; +// `unchecked` is the absence of one, and the three are kept apart because +// reporting the third as either of the first two is the mistake §7 is about. +interface ResolvedTarget +{ + state: "open" | "closed"; + // The objective the finding sorts under: the target itself where it is an + // objective, and the checkpoint's parent where it is a checkpoint. + objective: QualifiedId; + // The record the edge actually names, which is what a repair command has + // to spell. + label: QualifiedId; + reason: string; +} + +interface UnresolvedTarget +{ + state: "unchecked"; + target: QualifiedId; + why: string; +} + +type Resolution = ResolvedTarget | UnresolvedTarget; + +function isClosed(item: Resolution): item is ResolvedTarget +{ + return item.state === "closed"; +} + +function isUnchecked(item: Resolution): item is UnresolvedTarget +{ + return item.state === "unchecked"; +} + +function resolveLocal(model: ProjectModel, id: string): Resolution | null +{ + const objective = model.goals.objectives.find((item) => item.id === id); + if (objective !== undefined) + { + return objectiveResolution(objective, undefined); + } + const found = findCheckpoint(model, id); + return found === null ? null : checkpointResolution(found.objective, found.milestone, undefined); +} + +function objectiveResolution(objective: ObjectiveState, project: string | undefined): ResolvedTarget +{ + const place = { id: objective.id, ...(project === undefined ? {} : { project }) }; + return { + state: openObjectives({ objectives: [objective], proposals: [] }).length > 0 ? "open" : "closed", + objective: place, + label: place, + reason: objective.status + }; +} + +function checkpointResolution(objective: ObjectiveState, milestone: MilestoneState, project: string | undefined): ResolvedTarget +{ + const closure = milestoneClosure(milestone, objective); + const qualify = (id: string): QualifiedId => ({ id, ...(project === undefined ? {} : { project }) }); + return { + state: closure === undefined && openObjectives({ objectives: [objective], proposals: [] }).length > 0 ? "open" : "closed", + objective: qualify(objective.id), + label: qualify(milestone.id), + reason: closure ?? objective.status + }; +} + +function findCheckpoint(model: ProjectModel, id: string): { objective: ObjectiveState; milestone: MilestoneState } | null +{ + for (const objective of model.goals.objectives) + { + const milestone = objective.milestones.find((item) => item.id === id); + if (milestone !== undefined) + { + return { objective, milestone }; + } + } + return null; +} + +// A foreign contribution names a slug and an id, and this machine may hold +// neither. Both misses answer `unchecked` with the reason a reader can act on +// — register or restore the project, or look the id up where it lives. +function resolveForeign(world: World, target: QualifiedId): Resolution +{ + const owner = world.available.find((model) => model.slug === target.project); + if (owner === undefined) + { + return { state: "unchecked", target, why: `${target.project} is not a project this machine can read` }; + } + const objective = owner.goals.objectives.find((item) => item.id === target.id); + if (objective !== undefined) + { + return objectiveResolution(objective, owner.slug); + } + const found = findCheckpoint(owner, target.id); + return found === null + ? { state: "unchecked", target, why: `${owner.slug} holds no record ${target.id}` } + : checkpointResolution(found.objective, found.milestone, owner.slug); +} + +// Every current contribution a unit states, resolved. Membership is already +// lineage-local in the fold, so an edge a revision superseded is not in here +// at all — what is left is what the unit contributes to now. +function contributionsOfUnit(world: World, work: WorkState): Resolution[] +{ + return [ + ...work.objectives.map((id) => resolveLocal(world.project, id)), + ...work.milestones.map((id) => resolveLocal(world.project, id)), + ...work.foreignObjectives.map((link) => resolveForeign(world, { id: link.id, project: link.project })) + ].filter((item): item is Resolution => item !== null); +} + +/* ── findings 1 and 6: what a live unit contributes to ─────────────── */ + +function contributionFindings(world: World, unchecked: UncheckedTarget[]): Finding[] +{ + return liveWork(world.project).flatMap((work) => unitFinding(world, work, unchecked)); +} + +function liveWork(model: ProjectModel): WorkState[] +{ + return model.works.filter(isOpenWork); +} + +function unitFinding(world: World, work: WorkState, unchecked: UncheckedTarget[]): Finding[] +{ + const resolved = contributionsOfUnit(world, work); + const undecidable = resolved.filter(isUnchecked); + for (const item of undecidable) + { + unchecked.push({ record: work.id, target: item.target, why: item.why }); + } + if (resolved.length === 0) + { + return dispositionFinding(world, work); + } + // A unit with one contribution this machine could not read is a unit + // nothing may be concluded about: reporting it as adrift would be a + // verdict on a log that was never opened. + return undecidable.length > 0 || resolved.some((item) => item.state === "open") + ? [] + : [obsoleteFinding(world, work, resolved)]; +} + +function obsoleteFinding(world: World, work: WorkState, resolved: Resolution[]): Finding +{ + const closed = resolved.filter(isClosed).sort( + (left, right) => compare(qualifiedKey(left.label), qualifiedKey(right.label))); + const first = closed[0]; + return { + kind: "obsolete-contributions", + class: "structural", + objective: first.objective, + record: { id: work.id }, + detail: qualifiedKey(first.label), + summary: `every outcome ${work.id} contributes to is over — ` + + `${closed.map((item) => `${shown(item.label)} (${item.reason})`).join(", ")}`, + commands: [ + ...relinkCommands(world, work, closed), + ...closed.map((item) => `self work unlink ${work.id} ${targetFlag(item.label)}`), + `self work link ${work.id} --standalone --why ""`, + `self work retire ${work.id} --why ""` + ] + }; +} + +// The relink is offered only where the lineage ends somewhere open, which is +// the rule part (b)'s guard already states: a chain that ends closed would +// send a reader into a second refusal. A foreign successor is never offered — +// this fold cannot resolve another project's lineage, and part (b) does not +// either. +function relinkCommands(world: World, work: WorkState, closed: ResolvedTarget[]): string[] +{ + return closed.flatMap((item) => item.label.project !== undefined ? [] + : openSuccessor(world.project, item.label.id).map((successor) => + `self work link ${work.id} ${targetFlag(successor)}`)); +} + +// Single steps down the supersession chain, bounded the way every other walk +// in this tree is, so a cycle a hand-appended line left cannot loop it. +function openSuccessor(model: ProjectModel, id: string): QualifiedId[] +{ + let current = id; + for (let hops = 0; hops < 1000; hops += 1) + { + const next = successorOf(model, current); + if (next === undefined) + { + break; + } + current = next; + } + const resolution = resolveLocal(model, current); + return current !== id && resolution?.state === "open" ? [resolution.label] : []; +} + +function successorOf(model: ProjectModel, id: string): string | undefined +{ + const objective = model.goals.objectives.find((item) => item.id === id); + return objective !== undefined ? objective.supersededBy : findCheckpoint(model, id)?.milestone.supersededBy; +} + +// An objective and a milestone are named by different flags, and a render that +// guessed would print a command the parser refuses. +function targetFlag(record: QualifiedId): string +{ + return `${record.id.startsWith("m-") ? "--milestone" : "--objective"} ${record.id}`; +} + +function shown(record: QualifiedId): string +{ + return record.project === undefined ? record.id : `${record.id} (${record.project})`; +} + +// Finding 6. Three dispositions satisfy it and the check reads all three off +// explicit edges: a contribution, a standalone declaration, or an inbound +// `relates` edge a runbook run wrote. Nothing is read out of the unit's +// wording or its dates. +function dispositionFinding(world: World, work: WorkState): Finding[] +{ + if (work.standalone !== undefined || operationalUnits(world.project).has(work.id)) + { + return []; + } + return [{ + kind: "no-disposition", + class: "structural", + record: { id: work.id }, + detail: work.id, + summary: `${work.id} states nothing about what it contributes to`, + commands: [ + `self work link ${work.id} --objective `, + `self work link ${work.id} --standalone --why ""`, + `self work retire ${work.id} --why ""` + ] + }]; +} + +// The operational classification, and the whole of it (#417 §1): the work ids +// a runbook run names with a `relates` edge, which is what +// `self runbook link --work ` writes. +function operationalUnits(model: ProjectModel): Set +{ + return new Set(model.entities.filter(isRunbookRun) + .flatMap((run) => run.links.filter((link) => link.type === "relates").map((link) => link.target))); +} + +/* ── findings 2, 3 and 4: what a live checkpoint says ──────────────── */ + +function checkpointFindings(world: World): Finding[] +{ + return openObjectives(world.project.goals).flatMap((objective) => + objective.milestones.filter((milestone) => milestoneClosure(milestone, objective) === undefined) + .flatMap((milestone) => [ + ...successorFinding(world, objective, milestone), + ...dateFinding(objective, milestone), + ...judgmentFindings(objective, milestone), + ...assumptionFindings(world.project, objective, milestone) + ])); +} + +// Finding 2. A successor with nothing live on it, beside a predecessor that +// still has live work, is a carry somebody did not make — and no prose +// decision changes it, so none is offered. +function successorFinding(world: World, objective: ObjectiveState, milestone: MilestoneState): Finding[] +{ + const predecessors = allCheckpoints(world.project) + .filter((item) => item.supersededBy === milestone.id).map((item) => item.id).sort(compare); + const stranded = predecessors.flatMap((id) => linkedLive(world.project, id)); + if (stranded.length === 0 || linkedLive(world.project, milestone.id).length > 0) + { + return []; + } + return [{ + kind: "empty-successor", + class: "structural", + objective: { id: objective.id }, + record: { id: milestone.id }, + detail: predecessors.join(","), + summary: `${milestone.id} succeeds ${predecessors.join(", ")} and has no live work, ` + + `while ${stranded.join(", ")} still contributes to the predecessor`, + commands: [ + ...stranded.map((id) => `self work link ${id} --milestone ${milestone.id}`), + `self work propose "" --milestone ${milestone.id} …`, + `self milestone drop ${milestone.id} --why ""` + ] + }]; +} + +function allCheckpoints(model: ProjectModel): MilestoneState[] +{ + return model.goals.objectives.flatMap((objective) => objective.milestones); +} + +// The live units whose *current* membership names this checkpoint. Lineage is +// already settled in the fold, so a unit that names both a predecessor and its +// successor answers only for the successor — which is exactly what makes an +// uncarried predecessor visible here. +function linkedLive(model: ProjectModel, milestone: string): string[] +{ + return liveWork(model).filter((work) => work.milestones.includes(milestone)).map((work) => work.id).sort(compare); +} + +// Finding 3. Two stated dates, compared as the ISO strings they are. Either +// one absent means the ordering was never checkable, which is not a failure +// and is not turned into one. +function dateFinding(objective: ObjectiveState, milestone: MilestoneState): Finding[] +{ + if (milestone.target === undefined || objective.target === undefined || milestone.target <= objective.target) + { + return []; + } + return [{ + kind: "date-order", + class: "structural", + objective: { id: objective.id }, + record: { id: milestone.id }, + detail: milestone.target, + summary: `${milestone.id} is dated ${milestone.target}, past ${objective.id}'s ${objective.target}`, + commands: [ + `self milestone revise ${milestone.id} --target --why ""`, + `self objective revise ${objective.id} --target --why ""` + ] + }]; +} + +// Finding 4, first half. The judgment context part (b) records, read here from +// the log rather than from the derivation, so the check answers the same way +// whether or not an overlay has run. +function judgmentFindings(objective: ObjectiveState, milestone: MilestoneState): Finding[] +{ + return carriedJudgments(milestone, objective, exitStanding(milestone).live).map((context) => ({ + kind: "judgment-review" as const, + class: "review" as const, + objective: { id: objective.id }, + record: { id: milestone.id }, + detail: context.criterion, + summary: context.condition === "moved" + ? `${milestone.id} ${context.criterion} was judged under ${context.judgedUnder}, ` + + `and the checkpoint hangs under ${objective.id} now` + : `${milestone.id} ${context.criterion} was judged before the parent was recorded, ` + + `and the checkpoint has been carried since`, + commands: [`self milestone recheck ${milestone.id} --criterion ${context.criterion} --why ""`] + })); +} + +// Finding 4, second half. A checkpoint keeps naming the decision it was told +// to assume until somebody withdraws the edge, which is the point of part +// (a)'s model — so an assumption whose decision was replaced is a review +// signal, and the repair is the two statements part (a) documented. +function assumptionFindings(model: ProjectModel, objective: ObjectiveState, milestone: MilestoneState): Finding[] +{ + return milestone.assumes.flatMap((id) => + { + const decision = model.decisions.find((item) => item.id === id); + if (decision === undefined || decision.status === "proposed" || decision.status === "confirmed") + { + return []; + } + return [{ + kind: "judgment-review" as const, + class: "review" as const, + objective: { id: objective.id }, + record: { id: milestone.id }, + detail: id, + summary: `${milestone.id} assumes ${id}, which is ${decision.status}`, + commands: assumptionCommands(model, milestone.id, decision) + }]; + }); +} + +// A successor decision is linked before the old edge is withdrawn, so the +// checkpoint never stands on nothing in between. Where the decision was +// withdrawn with no successor there is none to invent: the unlink alone is +// offered, and choosing what replaces it is the person's. +function assumptionCommands(model: ProjectModel, milestone: string, decision: DecisionState): string[] +{ + const successor = model.decisions.filter((item) => item.supersedes.includes(decision.id)) + .map((item) => item.id).sort(compare)[0]; + return [ + ...(successor === undefined ? [] : [`self milestone link ${milestone} --decision ${successor}`]), + `self milestone unlink ${milestone} --decision ${decision.id}` + ]; +} + +/* ── finding 5: evidence candidates ────────────────────────────────── */ + +// Information and nothing else. The template carries a literal `cN`: pairing a +// unit to a criterion would be the text matching the design rules out, and +// covering one is a judgment somebody records. +function candidateFindings(world: World): Finding[] +{ + return openObjectives(world.project.goals).flatMap((objective) => + objective.milestones.filter((milestone) => milestoneClosure(milestone, objective) === undefined) + .flatMap((milestone) => milestoneCandidates(world.project, objective, milestone))); +} + +function milestoneCandidates(model: ProjectModel, objective: ObjectiveState, milestone: MilestoneState): Finding[] +{ + const open = exitStanding(milestone).open; + if (open.length === 0) + { + return []; + } + const cited = new Set(milestone.coverage.flatMap((claim) => claim.work === undefined ? [] : [claim.work])); + return model.works + .filter((work) => work.status === "done" && work.milestones.includes(milestone.id) + && work.reports.length > 0 && !cited.has(work.id)) + .map((work) => ({ + kind: "evidence-candidate" as const, + class: "candidate" as const, + objective: { id: objective.id }, + record: { id: milestone.id }, + detail: work.id, + summary: `${work.id} is done with reported evidence and contributes to ${milestone.id}, ` + + `whose ${open.join(", ")} ${open.length === 1 ? "is" : "are"} uncovered — a candidate, not a verdict`, + commands: [`self milestone met ${milestone.id} --criterion cN --why "" --work ${work.id}`] + })) + .sort((left, right) => compare(left.detail, right.detail)); +} + +/* ── finding 7: an objective whose whole live workload is operational ── */ + +// A narrow question, never a claim: it asks whether recurring maintenance was +// meant as a product checkpoint. An empty set never triggers it — "all of +// nothing is operational" is not an observation — and it prints no repair, +// because no command answers a question about intent. +function operationalFindings(world: World): Finding[] +{ + const runs = operationalUnits(world.project); + return openObjectives(world.project.goals).flatMap((objective) => + { + const units = checkpointWork(world.project, objective); + return units.length === 0 || !units.every((id) => runs.has(id)) ? [] : [{ + kind: "operational-objective" as const, + class: "review" as const, + objective: { id: objective.id }, + record: { id: objective.id }, + detail: units.join(","), + summary: `every live unit under ${objective.id}'s checkpoints is an occurrence of a runbook ` + + `(${units.join(", ")}) — was this maintenance meant as a product checkpoint?`, + commands: [`self objective show ${objective.id}`] + }]; + }); +} + +function checkpointWork(model: ProjectModel, objective: ObjectiveState): string[] +{ + const live = objective.milestones.filter((milestone) => milestoneClosure(milestone, objective) === undefined); + return [...new Set(live.flatMap((milestone) => linkedLive(model, milestone.id)))].sort(compare); +} + +/* ── the renders ───────────────────────────────────────────────────── */ + +// The page a person or a pipe reads. One fact per line, grouped by the +// objective a finding sits under, with the commands under each finding +// indented beneath it — so the answer to "what do I run" is on the line under +// the answer to "what is wrong". +export function checkLines(report: CheckReport): string[] +{ + const lines = [headerLine(report)]; + for (const group of groups(report.findings)) + { + lines.push("", group.heading, ...group.findings.flatMap(findingLines)); + } + if (report.unchecked.length > 0) + { + lines.push("", "not checked", ...report.unchecked.map((item) => + ` ${item.record} → ${shown(item.target)} — target state not checked: ${item.why}`)); + } + return lines; +} + +// The one line `self status` and `self context` carry, and the header of the +// check's own page. A run with findings never reads `ok`, and neither does one +// that found nothing but could not read a target: silence about a log nobody +// opened is the answer §7 forbids. +export function summaryLine(summary: CheckSummary): string +{ + if (summary.findings === 0 && summary.unchecked === 0) + { + return "ok"; + } + const counted = `${summary.findings} finding${summary.findings === 1 ? "" : "s"} ` + + `(${summary.structural} structural, ${summary.review} to review, ${summary.candidates} candidate` + + `${summary.candidates === 1 ? "" : "s"})`; + return summary.unchecked === 0 + ? counted + : `${counted}, ${summary.unchecked} contribution target${summary.unchecked === 1 ? "" : "s"} not checked`; +} + +function headerLine(report: CheckReport): string +{ + return report.summary.findings === 0 && report.summary.unchecked === 0 + ? "no findings" + : `${report.project} — ${summaryLine(report.summary)}`; +} + +// The unassigned group is named rather than left as the tail of the list: work +// that states nothing about what it contributes to is its own answer, and a +// reader scanning for it should not have to notice where the objectives ended. +function groups(findings: Finding[]): { heading: string; findings: Finding[] }[] +{ + const out: { heading: string; findings: Finding[] }[] = []; + for (const finding of findings) + { + const heading = finding.objective === undefined ? "unassigned" : shown(finding.objective); + const last = out[out.length - 1]; + if (last !== undefined && last.heading === heading) + { + last.findings.push(finding); + continue; + } + out.push({ heading, findings: [finding] }); + } + return out; +} + +function findingLines(finding: Finding): string[] +{ + return [` ${finding.class}: ${finding.summary}`, ...finding.commands.map((command) => ` ${command}`)]; +} diff --git a/apps/cli/src/connect.ts b/apps/cli/src/connect.ts index e25e2d3..8299602 100644 --- a/apps/cli/src/connect.ts +++ b/apps/cli/src/connect.ts @@ -200,6 +200,18 @@ const BLOCK_BODY: readonly string[] = [ " is a judgment to review, not a wrong one, and a person settles each one at a", " time with", " `self milestone recheck --criterion cN --why \"\"`.", + "- Before trusting the direction graph, read it: `self objective check` names", + " work whose every contribution is to an outcome that is over, a successor", + " checkpoint with no live work, a checkpoint dated past its objective, a coverage", + " judgment or an assumption to re-review, done work that is a candidate for an", + " uncovered criterion, work that states no disposition, and an objective whose", + " whole live workload is runbook occurrences. It changes nothing: every line it", + " prints is a command you run yourself, and it never relinks, covers, revises or", + " reclassifies anything. A candidate is information — nothing is paired to a", + " criterion by its wording, and maintenance is read off the run link and never", + " out of a record's text. Where a project a unit contributes to is not readable", + " here, the line says the target state was not checked rather than reporting all", + " clear. `self status` and `self context` carry the count.", "- A checkpoint may rest on decisions: `self milestone link --decision", " `, repeatable. When the ground moves, link the successor decision", " and then `self milestone unlink --decision ` — never silently drop", diff --git a/apps/cli/src/goals.ts b/apps/cli/src/goals.ts index f326ecd..4279394 100644 --- a/apps/cli/src/goals.ts +++ b/apps/cli/src/goals.ts @@ -24,6 +24,7 @@ import { import { presetRow } from "./aliases.js"; import { required, Requirement, requireOptions } from "./args.js"; import { attachedArtifactLines } from "./artifact.js"; +import { checkDirection, checkLines } from "./check.js"; import { branch, Command, CommandInput, CommandLeaf, leaf } from "./contract.js"; import { validDate } from "./dates.js"; import { renderMilestoneBody, renderObjectiveBody } from "./fold.js"; @@ -47,7 +48,7 @@ import { makeEvent, recordEvent, recordEvents } from "./pipeline.js"; import { recordRetirement, retiring, retirementIntent, supersedeTargets, supersedingRecord } from "./retirement.js"; import { admittingDemotions, confirmEntityUnit, Declaration, declarationOf, DECLARE_OPTIONS, demotionEvents, Placed, recordCoverage, recordOwner, requireDecision, tierOf } from "./state.js"; import { dim, errYellow, firstLine, markdownHeadings, plural, styled } from "./style.js"; -import { CliError, CommandOutput, ListingBlock, SelfEvent } from "./types.js"; +import { CliError, CommandOutput, JsonValue, ListingBlock, SelfEvent } from "./types.js"; const CONFIDENCE = ["low", "medium", "high"]; @@ -148,6 +149,10 @@ const ASSUMED_DECISION: Requirement = { flags: ["decision"], value: "", hint /* ── objectives ────────────────────────────────────────────────────── */ +// A read verb with a machine shape (#417 §6): `--json` hands an agent the +// findings whole, and the human render is the same answer in lines. +const CHECK_OPTIONS = { project: { type: "string" }, json: { type: "boolean" } } as const; + // Listing and showing are reads, so they answer for any project the workspace // knows; every verb that writes still records into the project this directory // belongs to, and resolves it only once the arguments are known to be good. A @@ -190,7 +195,15 @@ export const OBJECTIVE_COMMAND: Command = { ], verbs: ["revise"] }, - { syntax: "objective close --as reached|dropped [--why w]", description: ["--why is required when it is dropped"], verbs: ["close"] } + { syntax: "objective close --as reached|dropped [--why w]", description: ["--why is required when it is dropped"], verbs: ["close"] }, + { + syntax: "objective check [--project ] [--json]", + description: [ + "read the direction graph and state what is inconsistent, what needs re-judging,", + "and which done work could be cited as evidence — it changes nothing" + ], + verbs: ["check"] + } ], detail: [ "keep the time-boxed objectives that break the goal down, each with the", @@ -235,7 +248,29 @@ export const OBJECTIVE_COMMAND: Command = { " --as how `close` ends it: reached or dropped", " --why the reason for a revision or a decline, and for a close that drops", " --demote past a retention cap: the confirmed entity that frees its place by", - " moving one tier down (full → index, index → search); repeatable" + " moving one tier down (full → index, index → search); repeatable", + " --json on check, the findings as one machine-readable object", + "", + "check reads and never repairs. It states seven kinds of finding and no more:", + "work whose every current contribution is to an outcome that is over; a successor", + "checkpoint with no live work beside a predecessor that still has some; a", + "checkpoint dated past its objective; a coverage judgment made under a former", + "parent or an assumption on a decision that was replaced; done work that could be", + "cited as evidence for an uncovered criterion; work that states no disposition at", + "all; and an objective whose whole live checkpoint workload is runbook", + "occurrences. It never relinks, covers, revises or reclassifies anything: every", + "line it prints is a command you run yourself.", + "", + "a candidate is information. Nothing is paired to a criterion by its wording, and", + "no unit's evidence is applied on your behalf. Maintenance is read off the", + "`self runbook link` edge and never out of a record's text or its dates.", + "", + "check answers for one project — this directory's, or the one --project names —", + "and has no --workspace form: a report over every registered project would bury", + "the one you are standing in, and `self status` already carries each project's", + "count. A contribution to another project's objective is judged from that", + "project's own log where this machine holds it; where it does not, the line says", + "the target state was not checked rather than reporting all clear." ], guard: rejectManualProgress, // An unknown verb is answered before the id is resolved: telling someone @@ -253,6 +288,7 @@ export const OBJECTIVE_COMMAND: Command = { leaf("confirm", {}, 1, confirmObjective), leaf("decline", WHY_OPTION, 1, declineObjective, { requires: [WHY_TURNED_DOWN] }), retiring(leaf("revise", OBJECTIVE_REVISE_OPTIONS, 1, objectiveRevise, { requires: [WHY_CHANGED] })), + leaf("check", CHECK_OPTIONS, 0, objectiveCheck), retiring(leaf("close", OBJECTIVE_CLOSE_OPTIONS, 1, objectiveClose, { requires: [{ flags: ["as"], value: "reached|dropped", hint: "whether the outcome was reached or given up" }] })) @@ -260,6 +296,22 @@ export const OBJECTIVE_COMMAND: Command = { }) }; +// The check answers about the graph this project's log owns, against the +// target projects this machine can read. One fold per registered project, the +// same walk `objective list` makes, and no other input: no clock, no network, +// and nothing written. +function objectiveCheck({ values }: CommandInput): CommandOutput +{ + const scope = readScopes(process.cwd(), values)[0]; + const models = workspaceModels(scope.storeDir, scope.project); + const report = checkDirection(models[0], models.slice(1)); + return [{ + kind: "payload", + data: report as unknown as JsonValue, + plain: () => checkLines(report) + }]; +} + function objectiveList({ values }: CommandInput): CommandOutput { const scopes = readScopes(process.cwd(), values); diff --git a/apps/cli/src/guide.ts b/apps/cli/src/guide.ts index baa87aa..d7d05b9 100644 --- a/apps/cli/src/guide.ts +++ b/apps/cli/src/guide.ts @@ -342,6 +342,16 @@ export const TOPICS: Topic[] = [ "`self work --help` lists the flags, and the refusal names every one you", "left out at once.", "", + "Whether a unit's contributions still point anywhere is a read, never a", + "guess: `self objective check` names the units whose every current", + "contribution is to an outcome that is over, and the units that state no", + "disposition at all — and it names them beside the commands that answer", + "each one. It changes nothing; a candidate it lists is information, and", + "maintenance is read off the `self runbook link` edge rather than out of a", + "unit's wording. Where the project a unit contributes to is not readable", + "here, the line says the target state was not checked rather than reporting", + "all clear.", + "", "Related: `self help goals`." ] }, @@ -431,6 +441,32 @@ export const TOPICS: Topic[] = [ "statements, both on the record. Nothing infers an assumption from the", "checkpoint's wording, and no command replaces one on your behalf.", "", + "Reading the graph back is a command of its own, and it changes nothing:", + "", + " self objective check [--project ] [--json]", + "", + "It names seven things and no more: work whose every current contribution is", + "to an outcome that is over; a successor checkpoint with no live work beside a", + "predecessor that still has some; a checkpoint dated past its objective; a", + "coverage judgment made under a former parent or an assumption on a decision", + "that was replaced; done work that is a candidate for an uncovered criterion;", + "work that states no disposition at all; and an objective whose whole live", + "checkpoint workload is runbook occurrences.", + "", + "It never relinks, covers, revises or reclassifies. Every line it prints is a", + "command you run yourself, and the printed steps for the route you choose", + "clear the finding they were printed under — recording a decision is not", + "offered as clearing a structural finding, because it moves no edge, no date", + "and no criterion. A candidate is", + "information: nothing is paired to a criterion by its wording, no unit's", + "evidence is applied on your behalf, and maintenance is read off the run link", + "rather than out of a record's text or its dates.", + "", + "Where a unit contributes to another project's objective and this machine", + "cannot read that project, the answer says the target state was not checked", + "rather than reporting all clear. `self status` and `self context` carry the", + "count, so a session knows whether to run it.", + "", "Related: `self help work`." ] }, diff --git a/apps/cli/src/pretty.ts b/apps/cli/src/pretty.ts index dbfb0d6..0843987 100644 --- a/apps/cli/src/pretty.ts +++ b/apps/cli/src/pretty.ts @@ -818,6 +818,9 @@ interface SurfaceInput { model: ProjectModel; waiting: WaitingRow[]; + // The direction summary #417 §7 states, already composed by the render + // layer: this module prints one line and decides nothing about it. + direction: string; } interface StatusInput extends SurfaceInput @@ -837,6 +840,7 @@ export function renderStatus(input: StatusInput): string[] lines.push(heading("OBJECTIVES", input.objectives)); } lines.push(heading("DECISIONS WAITING", attentionCounts(model))); + lines.push(heading("DIRECTION", `${input.direction} — ${scoped("self objective check", project)}`)); // Ahead of the roll-ups: what waits on the reader is the only part of this // page they can act on without running another command (#264). lines.push("", ...waitingSection(input.waiting, scoped("self context", project))); @@ -865,7 +869,8 @@ export function renderContext(input: SurfaceInput): string[] { lines.push(dim(fitDisplay(oneLine(model.description), columns()))); } - lines.push(`Goal: ${fitDisplay(oneLine(model.goal ?? "(not set)") + otherGoals(model), columns() - 6)}`, ""); + lines.push(`Goal: ${fitDisplay(oneLine(model.goal ?? "(not set)") + otherGoals(model), columns() - 6)}`); + lines.push(`Direction: ${input.direction} — ${scoped("self objective check", project)}`, ""); // The two approval bands lead the page and stand next to each other: a // reader asking what is on them should not have to scroll past the work // roll-up to find out, or read half the answer here and half further down diff --git a/apps/cli/src/views.ts b/apps/cli/src/views.ts index d5659b3..bbb442b 100644 --- a/apps/cli/src/views.ts +++ b/apps/cli/src/views.ts @@ -12,6 +12,7 @@ import { pendingSummary, rendersIn } from "@superself/fold"; +import { checkDirection, summaryLine } from "./check.js"; import { commonProtocolLines } from "./connect.js"; import { instructionLines, isInstruction } from "./instructions.js"; import { claimNote, judgeProcess } from "./ledger.js"; @@ -79,13 +80,31 @@ function contextBodyLimit(scale: TokenScale): number } const REPORT_EXCERPT_LIMIT = 500; -// Console surfaces reuse the verdicts persisted by the last fold, so they -// agree with canonical state without re-running git. Artifacts are re-checked -// here instead: the store holds the bytes, so the answer never depends on -// which project checkout this command ran from. -function modelWithVerdicts(storeDir: string, slug: string): ProjectModel +// A project's page: the fold it renders from, and the one direction line it +// states (#417 §7). +// +// Console surfaces reuse the verdicts persisted by the last fold, so they agree +// with canonical state without re-running git. Artifacts are re-checked in +// `withVerdicts` instead: the store holds the bytes, so the answer never +// depends on which project checkout this command ran from. +// +// The two halves come out of one read on purpose. The check answers about the +// graph this project's own log owns, which is exactly what the re-scope below +// replaces with the units that render here whoever owns them — so asking for it +// afterwards would mean folding the whole workspace a second time for one line. +interface ProjectPage +{ + model: ProjectModel; + direction: string; +} + +function projectPage(storeDir: string, slug: string): ProjectPage { - return withVerdicts(storeDir, renderedModel(storeDir, slug)); + const own = buildModel(storeDir, slug, new Date()); + const others = foreignModels(storeDir, slug); + const direction = summaryLine(checkDirection(own, others).summary); + own.works = [...scopedWorks(own, slug), ...others.flatMap((other) => scopedWorks(other, slug))]; + return { model: withVerdicts(storeDir, own), direction }; } // A record scoped into an archived project is rechecked here for the same @@ -160,7 +179,7 @@ export function contextOutput(ctx: CliContext): CommandOutput { return workspaceContextOutput(ctx); } - const model = modelWithVerdicts(ctx.storeDir, ctx.project); + const { model, direction } = projectPage(ctx.storeDir, ctx.project); // An archived project still renders its context — a session standing in its // checkout has to be able to read the state it left (#283). What it owes // that session is one line saying the project is set aside and how it comes @@ -175,8 +194,8 @@ export function contextOutput(ctx: CliContext): CommandOutput } return [{ kind: "document", - plain: () => lines(projectContextText(ctx, model)), - pretty: () => renderContext({ model, waiting: unrankedWaitingRows(model) }) + plain: () => lines(projectContextText(ctx, model, direction)), + pretty: () => renderContext({ model, direction, waiting: unrankedWaitingRows(model) }) }]; } @@ -184,11 +203,11 @@ export function contextOutput(ctx: CliContext): CommandOutput // (#181 D2), and for what the linked foreign objectives can be said to hold — // status and target — at read time (#244). Inside the plain thunk, because // that is the render that spends them. -function projectContextText(ctx: CliContext, model: ProjectModel): string +function projectContextText(ctx: CliContext, model: ProjectModel, direction: string): string { const all = [model, ...foreignModels(ctx.storeDir, ctx.project)]; return renderProjectContext(model, contextBodyLimit(tokenScale(readStoreConfig(ctx.storeDir))), - scopedIn(all, model.slug), all); + scopedIn(all, model.slug), all, new Set(), direction); } // Handoff supplies the one model graph captured by its command. This helper @@ -473,10 +492,17 @@ interface ContextSection omission: (count: number) => string; } -function renderProjectContext(model: ProjectModel, limit: number, foreign: EntityState[] = [], all: ProjectModel[] = [], excluded = new Set()): string +// `direction` is the one line #417 §7 owes a session reading this page, and it +// goes in the head rather than into a section: the budget cuts sections, and a +// count of unresolved structural drift that the budget could drop is a count +// nobody can rely on. The handoff packet passes none — it is a snapshot of one +// unit, and a direction summary read at packet time would go stale in it. +function renderProjectContext(model: ProjectModel, limit: number, foreign: EntityState[] = [], all: ProjectModel[] = [], excluded = new Set(), direction?: string): string { const { head, sections } = projectContextSections(model, foreign, all, excluded); - return assembleContext(head, sections, fitKeeps(head, sections, limit)); + const headed = direction === undefined ? head + : [head[0], "", `Direction: ${direction} — ${scoped("self objective check", shellArgument(model.slug))}`, ...head.slice(1)]; + return assembleContext(headed, sections, fitKeeps(headed, sections, limit)); } // A full-exposure instruction is absent from every section below because it is @@ -1299,13 +1325,14 @@ export function statusOutput(ctx: CliContext): CommandOutput { return workspaceOverviewOutput(ctx); } - const model = modelWithVerdicts(ctx.storeDir, ctx.project); + const { model, direction } = projectPage(ctx.storeDir, ctx.project); const project = ctx.project; return [{ kind: "document", - plain: () => statusLines(project, model), + plain: () => statusLines(project, model, direction), pretty: () => renderStatus({ model, + direction, waiting: unrankedWaitingRows(model), objectives: objectiveCountLine(model), attempts: openProcesses(project, model) @@ -1317,12 +1344,13 @@ export function statusOutput(ctx: CliContext): CommandOutput // is running under it. The attention line is there only when there is a band // to describe, which is what keeps a project with no proposals from carrying // three zeroes. -function statusLines(project: string, model: ProjectModel): string[] +function statusLines(project: string, model: ProjectModel, direction: string): string[] { return [ `${model.slug} — goal: ${(model.goal ?? "(not set)") + otherGoals(model)}`, `work: ${countLine(model.works)}`, `objectives: ${objectiveCountLine(model)}`, + `direction: ${direction} — ${scoped("self objective check", shellArgument(model.slug))}`, `waiting on you: ${waitingCount(model)}`, `unshipped: ${unshippedLine(model)}`, ...(attentionRows(model).length > 0 ? [attentionLine(model)] : []), diff --git a/apps/cli/test/fixtures/golden/piped.txt b/apps/cli/test/fixtures/golden/piped.txt index 47e952d..b0c62ac 100644 --- a/apps/cli/test/fixtures/golden/piped.txt +++ b/apps/cli/test/fixtures/golden/piped.txt @@ -56,6 +56,8 @@ en $ self context (in project, exit 0) # demo +Direction: 1 finding (1 structural, 0 to review, 0 candidates) — self objective check --project 'demo' + the render gate scenario ## Work in progress @@ -79,6 +81,7 @@ $ self status (in project, exit 0) demo — goal: (not set) work: 1 active, 0 blocked, 0 next, 0 done objectives: 0 open, 0 of 0 milestones reached, 0 of 0 exit criteria covered +direction: 1 finding (1 structural, 0 to review, 0 candidates) — self objective check --project 'demo' waiting on you: 1 unshipped: nothing waiting to ship health: no friction on 1 of this project's 1 report in the last 30 days — self report … --friction "" @@ -207,6 +210,9 @@ usage: self --target moves freely; a date that leaves a live checkpoint beyond it warns objective close --as reached|dropped [--why w] --why is required when it is dropped + objective check [--project ] [--json] + read the direction graph and state what is inconsistent, what needs re-judging, + and which done work could be cited as evidence — it changes nothing milestone [--project ] list milestones with state, reason, and linked work milestone add "" --objective --exit "" [--target d] [--after m] [--supersedes m] [--decision d] milestone show [--project ] print a milestone, its exit criteria, and its coverage @@ -697,6 +703,16 @@ plan, confidence, expiry, and the objective or milestone it closes. `self work --help` lists the flags, and the refusal names every one you left out at once. +Whether a unit's contributions still point anywhere is a read, never a +guess: `self objective check` names the units whose every current +contribution is to an outcome that is over, and the units that state no +disposition at all — and it names them beside the commands that answer +each one. It changes nothing; a candidate it lists is information, and +maintenance is read off the `self runbook link` edge rather than out of a +unit's wording. Where the project a unit contributes to is not readable +here, the line says the target state was not checked rather than reporting +all clear. + Related: `self help goals`. $ self lang ko (in project, exit 0) diff --git a/apps/cli/test/fixtures/pre-cutover-reads/context.txt b/apps/cli/test/fixtures/pre-cutover-reads/context.txt index 8dee755..a2857a8 100644 --- a/apps/cli/test/fixtures/pre-cutover-reads/context.txt +++ b/apps/cli/test/fixtures/pre-cutover-reads/context.txt @@ -1,5 +1,7 @@ # demo +Direction: ok — self objective check --project 'demo' + fixture project - [goal] steady direction diff --git a/apps/cli/test/fixtures/pre-cutover-reads/status.txt b/apps/cli/test/fixtures/pre-cutover-reads/status.txt index bd56933..5cbaa8b 100644 --- a/apps/cli/test/fixtures/pre-cutover-reads/status.txt +++ b/apps/cli/test/fixtures/pre-cutover-reads/status.txt @@ -1,6 +1,7 @@ demo — goal: steady direction work: 0 active, 0 blocked, 0 next, 0 done objectives: 3 open, 0 of 2 milestones reached, 1 of 3 exit criteria covered +direction: ok — self objective check --project 'demo' waiting on you: 1 unshipped: nothing waiting to ship health: ok diff --git a/apps/cli/test/guards-carry.test.mjs b/apps/cli/test/guards-carry.test.mjs index c96737e..e037a3b 100644 --- a/apps/cli/test/guards-carry.test.mjs +++ b/apps/cli/test/guards-carry.test.mjs @@ -823,13 +823,12 @@ test("53: every command the three routes name is one this branch dispatches", () } }); -test("54: no entry route advertises the check part (c) has not shipped", () => -{ - for (const [route, text] of Object.entries(entryRoutes())) - { - assert.ok(!/objective check/.test(text), `the ${route} guidance advertises \`self objective check\``); - } -}); +// Cell 54 — "no entry route advertises the check part (c) has not shipped" — +// is retired: part (c) shipped `self objective check`, and cell 52 of +// docs/maintainers/case-tables/417-check.md is what replaces it. Cell 53 above +// still resolves every command every route names against the typed contract, +// so the rule the retired cell enforced — no route offers a command that does +// not dispatch — is unchanged and still proved here. /* ── 8: provenance stays off the generic history reader (#455) ─────── */ diff --git a/apps/cli/test/integrity.test.mjs b/apps/cli/test/integrity.test.mjs index f8b4e5e..a825777 100644 --- a/apps/cli/test/integrity.test.mjs +++ b/apps/cli/test/integrity.test.mjs @@ -95,6 +95,15 @@ const mixedLog = join(mixedBox.root, "ws", ".superself", "projects", "demo", "lo // `state show` pages, and `self log` — which still hold the pre-cutover // binary's own bytes. The other seven captures were rebaselined for #305, and // the diff that rebaselined them is on the pull request. +// +// `context` and `status` moved once more with #417 part (c), which gave both +// surfaces one direction line. That is a deliberate product change and reaches +// every store, so a pre-cutover one is not exempt from it — what E1 claims is +// that a log an old binary wrote still *folds* the same, and G5 below is where +// that claim is asserted about records rather than about a page's furniture. +// Both rebaselined captures read `ok`, which is the substantive evidence in +// the diff: the check states no finding at all against a legacy log, so it +// invents no drift out of history it cannot have caused. test("E1 / #305 G3: every captured read surface answers exactly as its capture states", async () => { const manifest = JSON.parse(readFileSync(join(fixtures, "pre-cutover-reads", "manifest.json"), "utf8")); diff --git a/apps/cli/test/objective-check.test.mjs b/apps/cli/test/objective-check.test.mjs new file mode 100644 index 0000000..c7d5890 --- /dev/null +++ b/apps/cli/test/objective-check.test.mjs @@ -0,0 +1,901 @@ +// The read-only direction check — #417, part (c). +// +// Every test below is one cell of docs/maintainers/case-tables/417-check.md, +// named by its cell number, and asserts that cell's stated outcome. The +// assertions read what the CLI prints and what its store holds; none of them +// calls the projection the implementation calls, so a projection that is wrong +// in the same way twice still fails here. +// +// Every shared fixture is built above the first `test()`. The driver runs the +// CLI in this process, so a top-level `await` between two registrations would +// let a test start while a fixture was still recording. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { COMMANDS } from "../dist/main.js"; +import { resolveCommand } from "../dist/contract.js"; +import { commonProtocolLines } from "../dist/connect.js"; +import { findTopic } from "../dist/guide.js"; +import { buildModel } from "../dist/model.js"; +import { checkDirection, FINDING_KINDS } from "../dist/check.js"; +import { ulid } from "../dist/ids.js"; +import { approvedIn, demoWorkspace, git, logFixture, machine, must, mustPerson, receiptIn } from "./harness.mjs"; + +const objectiveIdIn = (text) => text.match(/\bo-[0-9a-z]{5}\b/)[0]; +const milestoneIdIn = (text) => text.match(/\bm-[0-9a-z]{5}\b/)[0]; +const workIdIn = (text) => text.match(/\bw-[0-9a-z]{5}\b/)[0]; + +// One scratch machine holding one registered project, with the three ways this +// file drives it. +async function box() +{ + const machineBox = machine(); + const { ws, demo } = await demoWorkspace(machineBox); + return { + box: machineBox, + ws, + demo, + run: async (args) => (await must(machineBox, demo, args)).out, + person: async (args) => (await mustPerson(machineBox, demo, args)).out, + approved: (args, answer) => approvedIn(machineBox, demo, args, answer), + check: async () => (await must(machineBox, demo, ["objective", "check"])).out, + json: async () => JSON.parse((await must(machineBox, demo, ["objective", "check", "--json"])).out) + }; +} + +// The findings of one kind, as the JSON answer states them. Read off the +// machine shape rather than off the page, so a cell says which kind it means +// instead of matching a sentence that could move. +function kinds(report, kind) +{ + return report.findings.filter((finding) => finding.kind === kind); +} + +/* ── the shared fixtures, all built before the first test ──────────── */ + +// An objective, a checkpoint, two units on it, and a successor checkpoint that +// carried nothing — which is what `milestone add --supersedes` leaves behind. +// Cells 1, 2, 6, 7, 8, 11, 37 and 38 read it. +async function stranded() +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome", "--target", "2099-06-01"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true"])); + const first = workIdIn(await F.person(["work", "add", "a bounded effort"])); + const second = workIdIn(await F.person(["work", "add", "another bounded effort"])); + await F.run(["work", "link", first, "--milestone", milestone]); + await F.run(["work", "link", second, "--milestone", milestone]); + const successor = milestoneIdIn(receiptIn((await F.approved(["milestone", "add", "the checkpoint, restated", + "--objective", objective, "--exit", "the thing is true", "--supersedes", milestone], milestone)).printed)); + return { ...F, objective, milestone, successor, first, second }; +} + +const stray = await stranded(); + +// The same shape, with the successor reached instead of open: a lineage that +// ends closed offers no relink. Cell 6. +async function endsClosed() +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true"])); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", work, "--milestone", milestone]); + const successor = milestoneIdIn(receiptIn((await F.approved(["milestone", "add", "the checkpoint, restated", + "--objective", objective, "--exit", "the thing is true", "--supersedes", milestone], milestone)).printed)); + await F.run(["milestone", "met", successor, "--criterion", "c1", "--why", "it landed"]); + await F.run(["milestone", "reach", successor]); + return { ...F, objective, milestone, successor, work }; +} + +const closedLineage = await endsClosed(); + +// A checkpoint an objective revision carried, whose coverage was judged under +// the former parent. Cells 17 and 18. +async function carried() +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the first half lands", "--exit", "the second half lands"])); + await F.run(["milestone", "met", milestone, "--criterion", "c1", "--why", "the first half landed"]); + await F.run(["milestone", "met", milestone, "--criterion", "c2", "--why", "the second half landed"]); + const successor = objectiveIdIn(receiptIn((await F.approved(["objective", "revise", objective, + "--why", "the wording settled", "--outcome", "the outcome, restated"], objective)).printed)); + return { ...F, objective, successor, milestone }; +} + +const moved = await carried(); + +// A checkpoint standing on a decision that was later replaced, beside one +// standing on a decision that was retracted with nothing replacing it. +// Cells 19 and 20. +async function assumptions() +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const replaced = (await F.run(["decide", "the first take"])).match(/\[([^\]]+)\]/)[1]; + const successor = (await F.approved(["decide", "the second take", "--supersedes", replaced], replaced)) + .printed.match(/\[([0-9a-z]{26})\]/g).map((found) => found.slice(1, -1)).at(-1); + const withdrawn = (await F.run(["decide", "a take nobody replaced"])).match(/\[([^\]]+)\]/)[1]; + await F.approved(["decide", "retract", withdrawn, "--why", "it stopped holding"], withdrawn); + const first = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true", "--decision", replaced])); + const second = milestoneIdIn(await F.run(["milestone", "add", "another checkpoint", + "--objective", objective, "--exit", "the other thing is true", "--decision", withdrawn])); + return { ...F, objective, replaced, successor, withdrawn, first, second }; +} + +const assumed = await assumptions(); + +// A done unit with reported evidence under a checkpoint whose criteria are +// still open. Cells 21, 22 and 24. +async function candidates() +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true"])); + git(F.box, F.demo, ["commit", "--allow-empty", "-q", "-m", "the work"]); + const done = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", done, "--milestone", milestone]); + await F.run(["report", done, "the effort landed"]); + await F.run(["work", "done", done]); + return { ...F, objective, milestone, done }; +} + +const evidence = await candidates(); + +// The guard part (b) shipped refuses this at `milestone add`, so the state can +// only be reached by a store that already holds it: the checkpoint is dated +// under a later objective date, and the objective's own date then moves in. +async function datedPast(objectiveTarget, milestoneTarget, close) +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome", "--target", "2099-12-31"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true", + ...(milestoneTarget === undefined ? [] : ["--target", milestoneTarget])])); + const receipt = receiptIn((await F.approved(["objective", "revise", objective, "--why", "the date moved", + ...(objectiveTarget === undefined ? ["--target", ""] : ["--target", objectiveTarget])], objective)).printed); + const successor = objectiveIdIn(receipt); + if (close === true) + { + await F.approved(["milestone", "drop", milestone, "--why", "it stopped mattering"], milestone); + } + return { ...F, objective: successor, milestone }; +} + +const late = await datedPast("2099-06-01", "2099-09-01"); + +/* ── 1: finding 1 — every contribution points at an outcome that is over ── */ + +test("1: a live unit whose only contribution is a superseded checkpoint is named, with the relink", async () => +{ + const report = await stray.json(); + const found = kinds(report, "obsolete-contributions"); + assert.equal(found.length, 2); + assert.deepEqual(found.map((item) => item.record.id).sort(), [stray.first, stray.second].sort()); + assert.equal(found[0].class, "structural"); + assert.match(found[0].summary, new RegExp(`every outcome ${found[0].record.id} contributes to is over — ${stray.milestone} \\(closed\\)`)); + assert.ok(found[0].commands.includes(`self work link ${found[0].record.id} --milestone ${stray.successor}`)); + assert.ok(found[0].commands.includes(`self work unlink ${found[0].record.id} --milestone ${stray.milestone}`)); + assert.ok(found[0].commands.some((command) => command.startsWith(`self work retire ${found[0].record.id} `))); +}); + +test("2: one live contribution is enough — the check asks nothing about the rest", async () => +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const other = objectiveIdIn(await F.run(["objective", "add", "an unrelated outcome"])); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", work, "--objective", objective]); + await F.run(["work", "link", work, "--objective", other]); + await F.approved(["objective", "close", objective, "--as", "dropped", "--why", "it stopped mattering"], objective); + assert.deepEqual(kinds(await F.json(), "obsolete-contributions"), []); +}); + +test("3: a reached checkpoint is a closed outcome, exactly as the guard reads it", async () => +{ + const report = await closedLineage.json(); + const found = kinds(report, "obsolete-contributions"); + assert.equal(found.length, 1); + assert.equal(found[0].record.id, closedLineage.work); +}); + +test("4: a live checkpoint under a dropped objective is closed because its objective is", async () => +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true"])); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", work, "--milestone", milestone]); + await F.approved(["objective", "close", objective, "--as", "dropped", "--why", "it stopped mattering"], objective); + const found = kinds(await F.json(), "obsolete-contributions"); + assert.equal(found.length, 1); + assert.equal(found[0].record.id, work); +}); + +test("5: a done unit on a superseded checkpoint is history, not drift", async () => +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true"])); + git(F.box, F.demo, ["commit", "--allow-empty", "-q", "-m", "the work"]); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", work, "--milestone", milestone]); + await F.run(["report", work, "the effort landed"]); + await F.run(["work", "done", work]); + await F.approved(["milestone", "add", "the checkpoint, restated", "--objective", objective, + "--exit", "the thing is true", "--supersedes", milestone], milestone); + assert.deepEqual(kinds(await F.json(), "obsolete-contributions"), []); +}); + +test("6: a lineage that ends closed offers no relink, only the standalone and the retire", async () => +{ + const found = kinds(await closedLineage.json(), "obsolete-contributions")[0]; + assert.ok(!found.commands.some((command) => /self work link .* --milestone/.test(command)), + `a relink is offered into a closed lineage: ${found.commands.join(" | ")}`); + assert.ok(found.commands.some((command) => /--standalone --why/.test(command))); + assert.ok(found.commands.some((command) => command.startsWith("self work retire "))); +}); + +test("53: a unit contributing to two closed outcomes prints one unlink per target, and the printed route reconciles it", async () => +{ + const F = await box(); + const droppedObjective = objectiveIdIn(await F.run(["objective", "add", "an outcome given up"])); + const parent = objectiveIdIn(await F.run(["objective", "add", "another outcome"])); + const droppedMilestone = milestoneIdIn(await F.run(["milestone", "add", "a checkpoint given up", + "--objective", parent, "--exit", "the thing is true"])); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", work, "--objective", droppedObjective]); + await F.run(["work", "link", work, "--milestone", droppedMilestone]); + await F.approved(["objective", "close", droppedObjective, "--as", "dropped", "--why", "it stopped mattering"], droppedObjective); + await F.approved(["milestone", "drop", droppedMilestone, "--why", "it stopped mattering"], droppedMilestone); + + const found = kinds(await F.json(), "obsolete-contributions").find((item) => item.record.id === work); + const unlinks = found.commands.filter((command) => command.startsWith(`self work unlink ${work}`)); + assert.deepEqual(unlinks, [ + `self work unlink ${work} --milestone ${droppedMilestone}`, + `self work unlink ${work} --objective ${droppedObjective}` + ], "the printed unlinks do not name both closed targets in the summary's stable order"); + + for (const printed of unlinks) + { + await F.run(printed.split(" ").slice(1)); + } + await F.run(["work", "link", work, "--standalone", "--why", "it moves no stated outcome"]); + + const report = await F.json(); + assert.deepEqual(kinds(report, "obsolete-contributions").filter((item) => item.record.id === work), []); + assert.deepEqual(kinds(report, "no-disposition").filter((item) => item.record.id === work), []); + const model = buildModel(join(F.ws, ".superself"), "demo", new Date("2020-01-01T00:00:00.000Z")); + assert.notEqual(model.works.find((item) => item.id === work).status, "retired"); +}); + +/* ── 2: finding 2 — an empty successor beside a live predecessor ───── */ + +test("7: an uncarried successor is named beside the units still on its predecessor", async () => +{ + const found = kinds(await stray.json(), "empty-successor"); + assert.equal(found.length, 1); + assert.equal(found[0].record.id, stray.successor); + assert.equal(found[0].objective.id, stray.objective); + assert.match(found[0].summary, new RegExp(`${stray.successor} succeeds ${stray.milestone} and has no live work`)); + for (const unit of [stray.first, stray.second]) + { + assert.match(found[0].summary, new RegExp(unit)); + } +}); + +test("8: relinking both units clears the finding", async () => +{ + const F = await stranded(); + for (const unit of [F.first, F.second]) + { + await F.run(["work", "link", unit, "--milestone", F.successor]); + } + assert.deepEqual(kinds(await F.json(), "empty-successor"), []); +}); + +test("9: a revision's own carry leaves no finding — which is what `milestone revise` writes", async () => +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true"])); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", work, "--milestone", milestone]); + await F.approved(["milestone", "revise", milestone, "--why", "reworded", + "--outcome", "the checkpoint, restated"], milestone); + assert.deepEqual(kinds(await F.json(), "empty-successor"), []); +}); + +test("10: a predecessor holding only closed work strands nothing", async () => +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true"])); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", work, "--milestone", milestone]); + await F.approved(["work", "retire", work, "--why", "it moved elsewhere"], work); + await F.approved(["milestone", "add", "the checkpoint, restated", "--objective", objective, + "--exit", "the thing is true", "--supersedes", milestone], milestone); + assert.deepEqual(kinds(await F.json(), "empty-successor"), []); +}); + +test("11: the finding offers the relink, a successor proposal and the drop — and no decision", async () => +{ + const found = kinds(await stray.json(), "empty-successor")[0]; + assert.ok(found.commands.includes(`self work link ${stray.first} --milestone ${stray.successor}`)); + assert.ok(found.commands.some((command) => command.startsWith(`self work propose "" --milestone ${stray.successor}`))); + assert.ok(found.commands.some((command) => command.startsWith(`self milestone drop ${stray.successor} --why`))); + assert.ok(!found.commands.some((command) => command.startsWith("self decide")), + `a prose decision is advertised as clearing a structural finding: ${found.commands.join(" | ")}`); +}); + +/* ── 3: finding 3 — a checkpoint dated past its objective ──────────── */ + +test("12: a checkpoint dated past its objective names both dates and both revisions", async () => +{ + const found = kinds(await late.json(), "date-order"); + assert.equal(found.length, 1); + assert.equal(found[0].class, "structural"); + assert.match(found[0].summary, new RegExp(`${late.milestone} is dated 2099-09-01, past ${late.objective}'s 2099-06-01`)); + assert.ok(found[0].commands.some((command) => command.startsWith(`self milestone revise ${late.milestone} --target `))); + assert.ok(found[0].commands.some((command) => command.startsWith(`self objective revise ${late.objective} --target `))); +}); + +test("13: equal dates pass, exactly as they do at the guard", async () => +{ + const F = await datedPast("2099-09-01", "2099-09-01"); + assert.deepEqual(kinds(await F.json(), "date-order"), []); +}); + +test("14: a missing date is not an ordering failure", async () => +{ + const withoutObjective = await datedPast(undefined, "2099-09-01"); + assert.deepEqual(kinds(await withoutObjective.json(), "date-order"), []); + const withoutMilestone = await datedPast("2099-06-01", undefined); + assert.deepEqual(kinds(await withoutMilestone.json(), "date-order"), []); +}); + +test("15: revising the checkpoint's date clears the finding", async () => +{ + const F = await datedPast("2099-06-01", "2099-09-01"); + await F.approved(["milestone", "revise", F.milestone, "--why", "the date moved back", + "--target", "2099-05-01"], F.milestone); + assert.deepEqual(kinds(await F.json(), "date-order"), []); +}); + +test("16: a dropped checkpoint dated past its objective is history, not drift", async () => +{ + const F = await datedPast("2099-06-01", "2099-09-01", true); + assert.deepEqual(kinds(await F.json(), "date-order"), []); +}); + +/* ── 4: finding 4 — a judgment or an assumption made somewhere else ── */ + +test("17: a carried judgment is one review finding per affected criterion, with the recheck", async () => +{ + const found = kinds(await moved.json(), "judgment-review"); + assert.equal(found.length, 2); + assert.deepEqual(found.map((item) => item.detail), ["c1", "c2"]); + assert.equal(found[0].class, "review"); + assert.equal(found[0].objective.id, moved.successor); + assert.match(found[0].summary, new RegExp(`judged under ${moved.objective}`)); + assert.deepEqual(found[0].commands, + [`self milestone recheck ${moved.milestone} --criterion c1 --why ""`]); +}); + +test("18: the recheck the finding printed clears that criterion and leaves the other", async () => +{ + const F = await carried(); + await F.run(["milestone", "recheck", F.milestone, "--criterion", "c1", "--why", "still answered"]); + const found = kinds(await F.json(), "judgment-review"); + assert.deepEqual(found.map((item) => item.detail), ["c2"]); +}); + +test("19: an assumption on a replaced decision offers the link then the unlink, in that order", async () => +{ + const found = kinds(await assumed.json(), "judgment-review") + .filter((item) => item.record.id === assumed.first); + assert.equal(found.length, 1); + assert.equal(found[0].class, "review"); + assert.match(found[0].summary, new RegExp(`${assumed.first} assumes ${assumed.replaced}, which is superseded`)); + assert.deepEqual(found[0].commands, [ + `self milestone link ${assumed.first} --decision ${assumed.successor}`, + `self milestone unlink ${assumed.first} --decision ${assumed.replaced}` + ]); +}); + +test("20: a retracted decision with no successor offers the unlink alone", async () => +{ + const found = kinds(await assumed.json(), "judgment-review") + .filter((item) => item.record.id === assumed.second); + assert.equal(found.length, 1); + assert.match(found[0].summary, new RegExp(`assumes ${assumed.withdrawn}, which is retracted`)); + assert.deepEqual(found[0].commands, [`self milestone unlink ${assumed.second} --decision ${assumed.withdrawn}`]); +}); + +/* ── 5: finding 5 — evidence candidates ────────────────────────────── */ + +test("21: a done unit with evidence under an uncovered checkpoint is a candidate, with a literal cN", async () => +{ + const found = kinds(await evidence.json(), "evidence-candidate"); + assert.equal(found.length, 1); + assert.equal(found[0].class, "candidate"); + assert.equal(found[0].record.id, evidence.milestone); + assert.equal(found[0].detail, evidence.done); + assert.deepEqual(found[0].commands, + [`self milestone met ${evidence.milestone} --criterion cN --why "" --work ${evidence.done}`]); +}); + +test("22: a unit a claim already cites is not offered again", async () => +{ + const F = await candidates(); + await F.run(["milestone", "met", F.milestone, "--criterion", "c1", "--why", "the effort covered it", "--work", F.done]); + assert.deepEqual(kinds(await F.json(), "evidence-candidate"), []); +}); + +test("23: a checkpoint with nothing open offers no candidate, whatever the unit's own criteria say", async () => +{ + const F = await candidates(); + await F.run(["milestone", "met", F.milestone, "--criterion", "c1", "--why", "somebody else covered it"]); + assert.deepEqual(kinds(await F.json(), "evidence-candidate"), []); +}); + +test("24: a done unit carrying no report at all is nothing to cite", async () => +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the thing is true"])); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + await F.run(["work", "link", work, "--milestone", milestone]); + // The done gate refuses a bare claim, so a unit done with no evidence at + // all is history a store already holds rather than something a command + // writes. This is that history, appended the way the pipeline would have. + logFixture(F.ws, "demo", { + id: ulid(), + ts: "2026-01-01T00:00:00.000Z", + type: "entity.done", + origin: { actor: "agent", confirmed: true }, + project: "demo", + payload: { entity: work } + }); + assert.deepEqual(kinds(await F.json(), "evidence-candidate"), []); +}); + +/* ── 6: finding 6 — no disposition; finding 7 — all operational ────── */ + +test("25: a unit that states nothing is named in the unassigned group", async () => +{ + const F = await box(); + const work = workIdIn(await F.person(["work", "add", "an effort nobody spoke for"])); + const found = kinds(await F.json(), "no-disposition"); + assert.equal(found.length, 1); + assert.equal(found[0].record.id, work); + assert.equal(found[0].objective, undefined); + assert.match(await F.check(), /^unassigned$/m); +}); + +test("26: declaring standalone clears it", async () => +{ + const F = await box(); + const work = workIdIn(await F.person(["work", "add", "an effort nobody spoke for"])); + await F.run(["work", "link", work, "--standalone", "--why", "it moves no stated outcome"]); + assert.deepEqual(kinds(await F.json(), "no-disposition"), []); +}); + +// A registered runbook, one instance of it, and the work unit the instance +// names. Cells 27, 29 and 30 read it. +async function operational(units) +{ + const F = await box(); + const objective = objectiveIdIn(await F.run(["objective", "add", "the outcome"])); + const milestone = milestoneIdIn(await F.run(["milestone", "add", "the checkpoint", + "--objective", objective, "--exit", "the rotation happened"])); + const runbook = (await F.run(["runbook", "add", "key rotation", "--stage", "rotate the key"])) + .match(/\be-[0-9a-z]{5}\b/)[0]; + const made = []; + for (const [index, linked] of units.entries()) + { + const work = workIdIn(await F.person(["work", "add", `occurrence ${index + 1}`])); + await F.run(["work", "link", work, "--milestone", milestone]); + if (linked) + { + await F.run(["runbook", "start", runbook, "--instance", `E00${index + 1}`]); + await F.run(["runbook", "link", `E00${index + 1}`, "--work", work]); + } + made.push(work); + } + return { ...F, objective, milestone, runbook, works: made }; +} + +test("27: an inbound run link is a disposition, so no missing-contribution finding is emitted", async () => +{ + const F = await box(); + const runbook = (await F.run(["runbook", "add", "key rotation", "--stage", "rotate the key"])) + .match(/\be-[0-9a-z]{5}\b/)[0]; + const work = workIdIn(await F.person(["work", "add", "this month's rotation"])); + await F.run(["runbook", "start", runbook, "--instance", "E001"]); + await F.run(["runbook", "link", "E001", "--work", work]); + assert.deepEqual(kinds(await F.json(), "no-disposition"), []); +}); + +test("28: the classification is the edge, never the wording", async () => +{ + const F = await box(); + const work = workIdIn(await F.person(["work", "add", "the monthly signing-key rotation runbook occurrence"])); + assert.equal(kinds(await F.json(), "no-disposition").length, 1, + "a unit whose text reads as maintenance was classified out of its prose"); + assert.equal(kinds(await F.json(), "no-disposition")[0].record.id, work); +}); + +test("29: an objective whose whole live checkpoint workload is runbook occurrences asks the question", async () => +{ + const F = await operational([true, true]); + const found = kinds(await F.json(), "operational-objective"); + assert.equal(found.length, 1); + assert.equal(found[0].class, "review"); + assert.equal(found[0].record.id, F.objective); + assert.match(found[0].summary, /was this maintenance meant as a product checkpoint\?/); + assert.deepEqual(found[0].commands, [`self objective show ${F.objective}`]); +}); + +test("30: one unit outside the runs and the set is not all operational", async () => +{ + const F = await operational([true, false]); + assert.deepEqual(kinds(await F.json(), "operational-objective"), []); +}); + +test("31: an empty set never triggers it", async () => +{ + const F = await operational([]); + assert.deepEqual(kinds(await F.json(), "operational-objective"), []); +}); + +/* ── 7: foreign targets, availability and determinism ──────────────── */ + +// A second registered project owning the objective this project's unit +// contributes to. Cells 32, 33, 34 and 35 read it. +async function twoProjects() +{ + const machineBox = machine(); + const { ws, demo } = await demoWorkspace(machineBox); + const other = join(ws, "other"); + mkdirSync(other, { recursive: true }); + git(machineBox, other, ["init", "-q", "-b", "main"]); + await must(machineBox, other, ["project", "init", "--name", "other", "--desc", "the target project"]); + const objective = objectiveIdIn((await must(machineBox, other, ["objective", "add", "the far outcome"])).out); + const work = workIdIn((await mustPerson(machineBox, demo, ["work", "add", "a bounded effort"])).out); + await must(machineBox, demo, ["work", "link", work, "--objective", objective, "--objective-project", "other"]); + return { + box: machineBox, + ws, + demo, + other, + objective, + work, + run: async (args) => (await must(machineBox, demo, args)).out, + json: async () => JSON.parse((await must(machineBox, demo, ["objective", "check", "--json"])).out) + }; +} + +test("32: a closed foreign target is named qualified by its owning slug", async () => +{ + const F = await twoProjects(); + await approvedIn(F.box, F.other, ["objective", "close", F.objective, "--as", "dropped", + "--why", "it stopped mattering"], F.objective); + const report = await F.json(); + const found = kinds(report, "obsolete-contributions"); + assert.equal(found.length, 1); + assert.equal(found[0].objective.project, "other"); + assert.match(found[0].summary, new RegExp(`${F.objective} \\(other\\)`)); + assert.deepEqual(report.unchecked, []); +}); + +test("33, 35: a target project this machine never registered is a notice, not a finding and not an all-clear", async () => +{ + const F = await box(); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + // A log this machine pulled from another clone names a project it never + // registered. That is the case §7 is about, and no command here can write + // it: `work link --objective-project` resolves the slug first. + logFixture(F.ws, "demo", { + id: ulid(), + ts: "2026-01-01T00:00:00.000Z", + type: "entity.linked", + origin: { actor: "agent", confirmed: true }, + project: "demo", + payload: { entity: work, link: { type: "member-of", target: "o-faraw", project: "faraway" } } + }); + const report = await F.json(); + assert.deepEqual(report.findings, []); + assert.deepEqual(report.unchecked, [{ + record: work, + target: { id: "o-faraw", project: "faraway" }, + why: "faraway is not a project this machine can read" + }]); + assert.equal(report.summary.unchecked, 1); + const page = (await F.check()).trimEnd(); + assert.ok(!page.startsWith("no findings"), `an unreadable target read as an all-clear:\n${page}`); + assert.match(page, /1 contribution target not checked/); + assert.match(page, /target state not checked/); +}); + +test("34: an archived target project is not reached past either", async () => +{ + const F = await twoProjects(); + await approvedIn(F.box, F.demo, ["project", "archive", "other", "--why", "nobody is on it"], "other"); + const report = await F.json(); + assert.deepEqual(report.findings, []); + assert.equal(report.summary.unchecked, 1); +}); + +test("36: the same events in a different order, on a second machine, answer identically", async () => +{ + const first = await stranded(); + const events = readFileSync(join(first.ws, ".superself", "projects", "demo", "log.jsonl"), "utf8") + .split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line)); + const second = await box(); + // Reversed: a union merge orders lines by neither time nor dependency, and + // the check's answer may not depend on which order this clone happens to + // hold. The creation of the project itself is already in the second store, + // so only the records this fixture wrote are replayed. + for (const event of [...events].reverse().filter((item) => item.type !== "project.registered")) + { + logFixture(second.ws, "demo", event); + } + const left = await first.json(); + const right = await second.json(); + assert.deepEqual(right.findings, left.findings); + assert.deepEqual(right.summary, left.summary); +}); + +// The determinism input the part (b) review named for part (c): the standing +// judgment on a criterion is the newest claim, and "newest" may not mean +// "lowest in the file". Two claims on one criterion — the original and the +// recheck that settled it — are exactly the pair a merge can reorder, and +// reading them by physical position would make the review prompt come back on +// one clone and stay gone on the other. +test("36 (carried judgments): a recheck stays settled however the log is merged", async () => +{ + const F = await carried(); + await F.run(["milestone", "recheck", F.milestone, "--criterion", "c1", "--why", "still answered"]); + await F.run(["milestone", "recheck", F.milestone, "--criterion", "c2", "--why", "still answered"]); + const events = readFileSync(join(F.ws, ".superself", "projects", "demo", "log.jsonl"), "utf8") + .split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line)); + const second = await box(); + for (const event of [...events].reverse().filter((item) => item.type !== "project.registered")) + { + logFixture(second.ws, "demo", event); + } + assert.deepEqual(kinds(await second.json(), "judgment-review"), []); + assert.deepEqual((await second.json()).findings, (await F.json()).findings); +}); + +test("37: two findings on one record order by the design's own numbering", async () => +{ + const report = await stray.json(); + const order = report.findings.map((finding) => finding.kind); + assert.deepEqual(order, ["empty-successor", "obsolete-contributions", "obsolete-contributions"], + "the successor finding on the checkpoint sorts before the two on the units, by record then by kind"); +}); + +test("38: every named objective's findings come before the explicit unassigned group", async () => +{ + const F = await stranded(); + await F.person(["work", "add", "an effort nobody spoke for"]); + const page = (await F.check()).split("\n"); + const named = page.indexOf(F.objective); + const unassigned = page.indexOf("unassigned"); + assert.ok(named > 0 && unassigned > named, + `the unassigned group is not last:\n${page.join("\n")}`); + const report = await F.json(); + assert.equal(report.findings.at(-1).kind, "no-disposition"); +}); + +test("39: the same store folded at two very different clocks answers identically", () => +{ + // The CLI has no clock flag, so the two folds are built here: this is the + // proof that the check reads none of the overlay's clock-derived state — + // a rule reading `MilestoneState.state` would answer `missed` at the late + // clock and `on-track` at the early one, and the two reports would differ. + const store = join(stray.ws, ".superself"); + const early = buildModel(store, "demo", new Date("2020-01-01T00:00:00.000Z")); + const late = buildModel(store, "demo", new Date("2099-01-01T00:00:00.000Z")); + assert.deepEqual(checkDirection(late, []), checkDirection(early, [])); +}); + +/* ── 8: the projection stays read-only ─────────────────────────────── */ + +function storeSnapshot(ws) +{ + const root = join(ws, ".superself"); + const walk = (dir) => readdirSync(dir).sort().flatMap((entry) => + { + const path = join(dir, entry); + return statSync(path).isDirectory() ? walk(path) : [`${path}\n${readFileSync(path, "utf8")}`]; + }); + return walk(root).join("\n"); +} + +test("40: the store is byte-identical after a run, plain and --json alike", async () => +{ + const F = await stranded(); + const before = storeSnapshot(F.ws); + await F.check(); + await F.json(); + assert.equal(storeSnapshot(F.ws), before); +}); + +test("41: the projection's own source reaches no machine", () => +{ + const source = readFileSync(fileURLToPath(new URL("../src/check.ts", import.meta.url)), "utf8"); + const forbidden = [ + [/from "node:/, "imports a node builtin"], + [/\bprocess\s*\./, "reads `process`"], + [/\bDate\.now\s*\(/, "reads the clock"], + [/\bnew Date\s*\(/, "reads the clock"], + [/\bMath\.random\s*\(/, "is not a function of its arguments"] + ]; + const found = source.split("\n").flatMap((line, at) => line.trimStart().startsWith("//") ? [] + : forbidden.filter(([pattern]) => pattern.test(line)).map(([, why]) => `src/check.ts:${at + 1} ${why}`)); + assert.deepEqual(found, []); +}); + +test("42: --json is one object and nothing else, stating the same findings", async () => +{ + const printed = (await must(stray.box, stray.demo, ["objective", "check", "--json"])).out; + assert.equal(printed.trimEnd().split("\n").length, 1); + const report = JSON.parse(printed); + assert.equal(report.project, "demo"); + assert.equal(report.summary.findings, report.findings.length); + const page = await stray.check(); + for (const finding of report.findings) + { + assert.ok(page.includes(finding.summary), `the page omits a finding --json states: ${finding.summary}`); + } +}); + +test("R4: seven finding kinds and no more — a kind is added by a design decision", () => +{ + assert.equal(FINDING_KINDS.length, 7); + assert.deepEqual([...FINDING_KINDS], ["obsolete-contributions", "empty-successor", "date-order", + "judgment-review", "evidence-candidate", "no-disposition", "operational-objective"]); +}); + +test("43: a project with nothing to say answers `no findings`", async () => +{ + const F = await box(); + assert.equal((await F.check()).trim(), "no findings"); +}); + +/* ── 9: the health summary ─────────────────────────────────────────── */ + +test("44: `self status` states the count and the command", async () => +{ + const status = await stray.run(["status"]); + assert.match(status, /^direction: 3 findings \(3 structural, 0 to review, 0 candidates\) — self objective check --project /m); + assert.match(status, /^objectives: /m); +}); + +test("45: `self context` carries the same line in its head", async () => +{ + const context = await stray.run(["context"]); + assert.match(context.split("\n").slice(0, 4).join("\n"), + /^Direction: 3 findings \(3 structural, 0 to review, 0 candidates\) — self objective check --project /m); +}); + +test("46: nothing found but a target unread never reads as ok", async () => +{ + const F = await box(); + const work = workIdIn(await F.person(["work", "add", "a bounded effort"])); + logFixture(F.ws, "demo", { + id: ulid(), + ts: "2026-01-01T00:00:00.000Z", + type: "entity.linked", + origin: { actor: "agent", confirmed: true }, + project: "demo", + payload: { entity: work, link: { type: "member-of", target: "o-faraw", project: "faraway" } } + }); + const status = await F.run(["status"]); + assert.match(status, /^direction: 0 findings \(0 structural, 0 to review, 0 candidates\), 1 contribution target not checked — /m); +}); + +test("47: nothing found and nothing unread reads as ok", async () => +{ + const F = await box(); + assert.match(await F.run(["status"]), /^direction: ok — self objective check --project /m); +}); + +/* ── 10: one guidance contract, part (c) ───────────────────────────── */ + +const pluginRoot = new URL("../../dsh-plugin/", import.meta.url); + +function entryRoutes() +{ + const cliPages = [ + ...COMMANDS.filter((command) => ["work", "milestone", "objective"].includes(command.name)) + .flatMap((command) => [...command.usage.flatMap((line) => [line.syntax, ...line.description ?? []]), ...command.detail]), + ...["work", "goals"].flatMap((name) => findTopic(name).body) + ].join("\n"); + const plugin = readFileSync(new URL("src/tools.ts", pluginRoot), "utf8") + + readFileSync(new URL("README.md", pluginRoot), "utf8"); + return { cli: cliPages, block: commonProtocolLines().join("\n"), plugin }; +} + +// What every route has to say in part (c), stated as the fact a reader must +// come away with rather than as one exact sentence: three routes writing one +// sentence three times would be a copy check, not a parity check. +const SHIPPED = [ + [/self objective check/, "the command itself"], + [/changes nothing|never relinks|repairs, covers/, "that it changes nothing"], + [/candidate/, "that a candidate is information rather than coverage"], + [/not checked/, "that an unreadable target is reported rather than passed over"], + [/run link|runbook link/, "that maintenance is read off the run link and not out of prose"] +]; + +test("49, 50, 51: each entry route states the five facts part (c) ships", () => +{ + for (const [route, text] of Object.entries(entryRoutes())) + { + for (const [pattern, what] of SHIPPED) + { + assert.match(text, pattern, `the ${route} guidance never states ${what}`); + } + } + assert.equal(SHIPPED.length, 5); +}); + +test("48: `self objective --help` declares the check, its flags and why it has no workspace form", () => +{ + const objective = COMMANDS.find((command) => command.name === "objective"); + assert.ok(objective.usage.some((line) => line.syntax === "objective check [--project ] [--json]"), + "the objective usage never spells the check"); + const detail = objective.detail.join("\n"); + assert.match(detail, /^ {2}--json {2,}on check/m); + assert.match(detail, /has no --workspace form/); +}); + +function dispatches(path) +{ + const words = path.split(" "); + return words[0] === "help" + ? words.length === 1 || findTopic(words[1]) !== undefined + : resolveCommand(COMMANDS, words) !== null; +} + +function offeredCommands(text) +{ + const prose = text.replace(/```[\s\S]*?```/g, ""); + const quoted = [...prose.matchAll(/`([^`]*)`/g)].map((found) => found[1]); + const commandLines = text.split("\n").filter((line) => /^\s*self\s/.test(line)); + return [...quoted, ...commandLines] + .flatMap((source) => [...source.matchAll(/\bself ((?:[a-z][a-z0-9-]*)(?: [a-z][a-z0-9-]*)*)/g)]) + .map((found) => found[1]); +} + +test("52: every command the three routes name is one this branch dispatches", () => +{ + const routes = entryRoutes(); + for (const [route, text] of Object.entries(routes)) + { + for (const path of new Set(offeredCommands(text))) + { + assert.ok(dispatches(path), + `the ${route} guidance offers \`self ${path}\`, which this branch does not dispatch`); + } + } + assert.ok(Object.values(routes).every((text) => offeredCommands(text).includes("objective check")), + "a route names the check in prose without offering it as a command"); +}); diff --git a/apps/cli/test/render-gate-documents.test.mjs b/apps/cli/test/render-gate-documents.test.mjs index 4a8da02..1150f22 100644 --- a/apps/cli/test/render-gate-documents.test.mjs +++ b/apps/cli/test/render-gate-documents.test.mjs @@ -244,10 +244,13 @@ test("stage 4 cell 6: a piped `self status` keeps its roll-up lines and the proc assert.equal(printed[0], "demo — goal: every page answers through the gate"); assert.match(printed[1], /^work: \d+ active, /); assert.match(printed[2], /^objectives: /); - assert.match(printed[3], /^waiting on you: [1-9]/); - assert.match(printed[4], /^unshipped: /); - assert.match(printed[5], /^decisions waiting: /); - assert.match(printed[6], /^health: /); + // The direction summary sits beside the objective roll-up, because it is a + // reading of the same graph (#417 §7). + assert.match(printed[3], /^direction: .* — self objective check --project /); + assert.match(printed[4], /^waiting on you: [1-9]/); + assert.match(printed[5], /^unshipped: /); + assert.match(printed[6], /^decisions waiting: /); + assert.match(printed[7], /^health: /); assert.equal(printed.at(-1), `process ${running}: running (pid ${process.pid})`); }); diff --git a/apps/dsh-plugin/README.md b/apps/dsh-plugin/README.md index 0085481..d67e6f8 100644 --- a/apps/dsh-plugin/README.md +++ b/apps/dsh-plugin/README.md @@ -123,6 +123,30 @@ judgment to review, not a wrong one, and a person settles each one at a time: self milestone recheck --criterion cN --why "" ``` +Reading the direction graph back is a command of its own, and it changes +nothing: + +```bash +self objective check [--project ] [--json] +``` + +It names seven things and no more: work whose every current contribution is to +an outcome that is over; a successor checkpoint with no live work beside a +predecessor that still has some; a checkpoint dated past its objective; a +coverage judgment made under a former parent, or an assumption on a decision +that was replaced; done work that is a candidate for an uncovered criterion; +work that states no disposition at all; and an objective whose whole live +checkpoint workload is runbook occurrences. + +It never relinks, covers, revises or reclassifies. Every line it prints is a +command the model or the user runs themselves, and the printed steps for the +route chosen clear the finding they were printed under. A candidate is +information: nothing is paired to a criterion by its wording, and maintenance +is read off the run link rather than out of a record's text. Where a unit +contributes to another project's objective and this machine cannot read that +project, the answer says the target state was not checked rather than +reporting all clear. `self status` and `self context` carry the count. + ## What you get `/self` prints `self context` in the chat, without a model turn. diff --git a/apps/dsh-plugin/src/tools.ts b/apps/dsh-plugin/src/tools.ts index 0c1940e..4cfcf21 100644 --- a/apps/dsh-plugin/src/tools.ts +++ b/apps/dsh-plugin/src/tools.ts @@ -70,7 +70,7 @@ function workTool(run: Runner): ToolDefinition { return defineTool({ name: "superself_work", - description: "Superself work units. `list` prints the open work (`self work`); `show ` prints a unit's brief, reports and evidence; `start ` claims a unit for this session and prints its brief. Ids look like `w-abc12`. This tool reads; stating what a unit contributes to is a `self` command in the terminal — `self work link --objective ` or `--milestone `, `self work link --standalone --why \"\"` when it contributes to nothing on purpose, or `self runbook link --work ` for one occurrence of a repeated procedure. A contribution names an outcome that is still open: `self work link`, `self work propose` and `self work confirm` refuse a reached, dropped or superseded target and name the open successor. A plan whose gap closed before it was answered moves with `self work revise \"\" --why \"\" --milestone `, then `self work confirm `.", + description: "Superself work units. `list` prints the open work (`self work`); `show ` prints a unit's brief, reports and evidence; `start ` claims a unit for this session and prints its brief. Ids look like `w-abc12`. This tool reads; stating what a unit contributes to is a `self` command in the terminal — `self work link --objective ` or `--milestone `, `self work link --standalone --why \"\"` when it contributes to nothing on purpose, or `self runbook link --work ` for one occurrence of a repeated procedure. A contribution names an outcome that is still open: `self work link`, `self work propose` and `self work confirm` refuse a reached, dropped or superseded target and name the open successor. A plan whose gap closed before it was answered moves with `self work revise \"\" --why \"\" --milestone `, then `self work confirm `. Before trusting the graph, read it back with `self objective check` in the terminal — there is no tool for it: it names work whose every contribution is to an outcome that is over, work that states no disposition, a successor checkpoint with no live work, a checkpoint dated past its objective, a judgment or an assumption to re-review, and done work that is a candidate for an uncovered criterion. It changes nothing, a candidate is information rather than coverage, maintenance is read off the run link and never out of a record's wording, and a contribution whose project this machine cannot read is reported as not checked rather than as all clear.", parameters: { action: { type: "string", enum: ["list", "show", "start"], required: true, description: "list | show | start" }, id: { type: "string", description: "The work id (required for show and start)." }, diff --git a/apps/fold/src/model.ts b/apps/fold/src/model.ts index 3022f2b..d0afe64 100644 --- a/apps/fold/src/model.ts +++ b/apps/fold/src/model.ts @@ -286,6 +286,11 @@ export interface WorkState export type ScopableVerb = | "self work" | "self objective" + // The read-only direction check (#417 §6). A verb of its own in this union + // rather than a suffix a caller appends, for the reason the union exists: + // a scoped pointer has to stay a command the parser resolves, and + // `self objective --project check` is not that command. + | "self objective check" | "self milestone" | "self status" | "self context" diff --git a/apps/fold/src/objectives.ts b/apps/fold/src/objectives.ts index cadaced..ea50647 100644 --- a/apps/fold/src/objectives.ts +++ b/apps/fold/src/objectives.ts @@ -531,14 +531,29 @@ export function deriveGoals(goals: GoalState, works: LinkedWork[], now: Date, zo return signals; } -function deriveMilestone(milestone: MilestoneState, objective: ObjectiveState, works: LinkedWork[], today: string): void +// Which exit criteria still stand, and which of them a claim covers — read +// from the log alone, so the derivation below and the direction check (#417 §6) +// answer the same question once. A dropped criterion is neither met nor open: +// there is nothing left to judge. +export function exitStanding(milestone: MilestoneState): { live: string[]; met: string[]; open: string[] } { - const live = milestone.exit.filter((criterion) => criterion.dropped !== true); + const live = milestone.exit.filter((criterion) => criterion.dropped !== true).map((criterion) => criterion.id); const covered = new Set(milestone.coverage.map((item) => item.criterion)); - milestone.met = live.filter((criterion) => covered.has(criterion.id)).map((criterion) => criterion.id); - milestone.open = live.filter((criterion) => !covered.has(criterion.id)).map((criterion) => criterion.id); - milestone.stale = staleCoverage(milestone, objective, new Set(live.map((criterion) => criterion.id))); - milestone.judgmentContext = carriedJudgments(milestone, objective, live.map((criterion) => criterion.id)); + return { + live, + met: live.filter((id) => covered.has(id)), + open: live.filter((id) => !covered.has(id)) + }; +} + +function deriveMilestone(milestone: MilestoneState, objective: ObjectiveState, works: LinkedWork[], today: string): void +{ + const standing = exitStanding(milestone); + const live = standing.live; + milestone.met = standing.met; + milestone.open = standing.open; + milestone.stale = staleCoverage(milestone, objective, new Set(live)); + milestone.judgmentContext = carriedJudgments(milestone, objective, live); const linked = works.filter((work) => work.milestones.includes(milestone.id)); milestone.works = linked.map((work) => work.id); milestone.blockedWorks = linked.filter((work) => work.status === "blocked").map((work) => work.id); @@ -570,7 +585,7 @@ function staleCoverage(milestone: MilestoneState, objective: ObjectiveState, liv // checkpoint that has always had one objective establishes its own context, // and reclassifying its history would say the tool knows something it does // not. -function carriedJudgments(milestone: MilestoneState, objective: ObjectiveState, live: string[]): JudgmentContext[] +export function carriedJudgments(milestone: MilestoneState, objective: ObjectiveState, live: string[]): JudgmentContext[] { const latest = new Map(); for (const item of milestone.coverage) @@ -603,19 +618,33 @@ function evidenceOf(milestone: MilestoneState, linked: LinkedWork[]): string[] return [...new Set(hashes)]; } -function milestoneState(milestone: MilestoneState, objective: ObjectiveState, today: string): TargetState +// Whether a checkpoint is over, decided by the log alone (#417 §6): reached, +// superseded, dropped, or hanging under an objective that closed. The state +// below layers what the clock decides — missed, at-risk — on top of this, and +// the direction check reads this and nothing else, because a finding that +// changed with the hour would not be the same finding on two machines. +// +// Reached is answered first: a checkpoint whose evidence landed before anyone +// dropped it keeps that verdict, and the drop verb refuses it anyway. +export function milestoneClosure(milestone: MilestoneState, objective: ObjectiveState): "reached" | "closed" | undefined { if (milestone.reached !== undefined) { return "reached"; } - if (objective.status === "dropped" || objective.status === "superseded" - || milestone.supersededBy !== undefined || milestone.droppedWhy !== undefined) + return objective.status === "dropped" || objective.status === "superseded" + || milestone.supersededBy !== undefined || milestone.droppedWhy !== undefined + ? "closed" + : undefined; +} + +function milestoneState(milestone: MilestoneState, objective: ObjectiveState, today: string): TargetState +{ + const closure = milestoneClosure(milestone, objective); + if (closure !== undefined) { - return "closed"; + return closure; } - // Reached is checked above: a milestone whose evidence landed before anyone - // dropped it keeps that verdict, and the drop verb refuses it anyway. if (milestone.target !== undefined && daysBetween(today, milestone.target) < 0) { return "missed"; diff --git a/docs/content-plans/direction-check.en.md b/docs/content-plans/direction-check.en.md new file mode 100644 index 0000000..38384e1 --- /dev/null +++ b/docs/content-plans/direction-check.en.md @@ -0,0 +1,135 @@ +# Content brief: reading the direction graph back, and what the reading will not do + +- Status: approved +- Approved by: the operator, on 2026-09-05, as the approved #417 direction + management design v2.1 ("ok 승인할테니 착수해"). §6, §7 and §8 of that design + are the reader-facing scope of issue #417's part (c); this brief restates + them as a content unit and widens nothing. The authoring agent did not + approve its own brief. +- Revision: v0.1 +- Owner: Superself maintainers +- Reader outcome slug: `direction-check` +- Planned content: `apps/cli/src/goals.ts` (the `objective` help page and the + check's own rendered answer), `apps/cli/src/guide.ts` (`self help goals`, + `self help work`), `apps/cli/src/connect.ts` (the managed block every + session reads), `apps/dsh-plugin/src/tools.ts` and + `apps/dsh-plugin/README.md` (the plugin entry route), `docs/reference/cli.md` +- Quality review: `docs/content-reviews/direction-check.en.md` +- Korean: none in this revision. The English guidance is revised in place, and + no new Korean public copy is produced. + +## Who the reader is + +An agent session, or a person reading over one, arriving at a project whose +direction graph somebody else has been changing. They are about to treat what +`self context` prints as current truth, and they have no way to tell whether +the objectives, checkpoints and contributions in front of them still line up. + +They arrive through one of three doors and expect the same answer at each: + +- the CLI's own pages — `self objective --help`, `self help goals`, + `self help work`; +- the managed block `self connect` writes into `AGENTS.md` and `CLAUDE.md`, + which a session reads at start; +- the plugin's tool descriptions and its README, where the tool surface is five + read-and-report tools and the rest of the graph is CLI commands. + +They are not auditing a project. They are deciding whether the direction they +are reading can be acted on, and — if not — what one command to run next. + +## What they currently try, and where it fails + +They read `self context` and `self objective`, see a tidy list of open +outcomes, and start work. Nothing on either page says that a unit's only +contribution points at a checkpoint reached last quarter, that a successor +checkpoint was created and nobody moved the work onto it, that a checkpoint is +dated past the objective it sits inside, or that a criterion's verdict was +reached under a parent the checkpoint no longer hangs under. Parts (a) and (b) +now refuse the *creation* of three of those states; they reconcile none of the +stores that already hold them, and the design forbids automatic cleanup. + +So the reader either trusts a graph that is wrong, or reconstructs the check by +hand — reading each objective, each checkpoint and each unit's page in turn, +which is what nobody does at the moment they most need to. + +## Evidence and inferences + +| Type | Claim | Source | Cheapest disconfirming check | +| --- | --- | --- | --- | +| Observation | Work stayed on a superseded milestone while its successor had none | Issue #417, four recorded graph inconsistencies | Re-read the named records in the store | +| Observation | Recurring maintenance became a checkpoint dated after its objective | Issue #417 | Read the two `target` fields in the store | +| Observation | Completed work was not reconciled with the successor's criteria | Issue #417 | `self milestone show` on the successor after a carry | +| Observation | No command answered any of the four; parts (a) and (b) guard creation only | `OBJECTIVE_COMMAND` at `origin/main` has no read verb beyond `list` and `show` | `self objective --help` on the released CLI | +| Observation | A coverage claim now records the objective it was judged under | `payload.judgedUnder`, shipped in #456 | `self milestone show` after an `objective revise` | +| Observation | A contribution to another project's objective is resolved from that project's own log, which this machine may not hold | `foreignObjectives` in `apps/fold/src/model.ts` | Link to a foreign objective, then unregister that project | +| Inference | Agents reconstruct different methods because the guidance leaves these rules unstated | Issue #417 states this as a hypothesis, not a finding | Give fresh sessions the same seeded store and compare the records they write | + +The guidance states only what the command does. It makes no claim about why a +graph drifted, and none about whether any particular plan was well judged. + +## The one decision the content enables + +The reader decides whether the direction in front of them can be acted on, and +— when it cannot — which single command answers the finding they are looking +at: + +- a unit whose every contribution is over — relink to the open successor the + finding names, `self work link --objective `, then withdraw the old + edge with `self work unlink --objective `; or declare + `--standalone --why ""`; or `self work retire --why "…"`; +- a successor checkpoint with no live work — `self work link --milestone + ` for each unit the finding names, or `self milestone drop + --why "…"`; +- a checkpoint dated past its objective — `self milestone revise --target + --why "…"` or `self objective revise --target --why "…"`; +- a judgment made under a former parent — `self milestone recheck + --criterion cN --why ""`; +- an assumption on a decision that was replaced — `self milestone link + --decision ` then `self milestone unlink --decision `; +- a unit that states no disposition — link it, declare it standalone, or + retire it; +- an evidence candidate — nothing, unless a person judges it. The template + `self milestone met --criterion cN --why "…" --work ` is offered + with a literal `cN`, because the tool pairs nothing. + +## What the reader must be able to say back + +- `self objective check` reads and changes nothing. Every line it prints is a + command they run themselves. +- It states seven kinds of finding and no more. It is not an audit of the + project, the records or the code. +- A candidate is information, not coverage: nothing is paired to a criterion by + its wording, and no unit's evidence is applied on anybody's behalf. +- Maintenance is read off the `self runbook link` edge and never out of a + record's text or its dates. +- Where a project a unit contributes to is not readable on this machine, the + answer says the target state was not checked. It does not report all clear. +- `self status` and `self context` carry the count, so a session knows whether + to run it without running it first. + +## Included surfaces and explicit exclusions + +Included: the six production files named above, in English, plus the answer the +command itself renders — which is reader-facing text and is reviewed as such. + +Excluded, and not to be mentioned by this revision: + +- Any claim that the check repairs, relinks, covers, revises or reclassifies + anything, or that it can be made to; +- routine records (#451), progress percentages, prose classification, strategy + scoring, severity dials, filters, and any claim about whether a plan was well + judged; +- a general audit of a project, its records or its store — the seven finding + kinds are the whole of what this command says; +- Korean copy, marketing narrative, tutorials and new reader studies. This is a + revision of technical guidance whose every claim is checkable against the + CLI, not a new message or a new form. + +## Stop condition + +The content is sufficient when each of the three entry routes states the five +facts above in its own words, every command any of them offers is dispatchable +on this branch, each advertised recovery command reaches the state it promises +from the context it is read in, and no route claims the check does anything to +a record. An independent reviewer supplies the publication verdict; the author +does not. diff --git a/docs/content-reviews/direction-check.en.md b/docs/content-reviews/direction-check.en.md new file mode 100644 index 0000000..5a8f111 --- /dev/null +++ b/docs/content-reviews/direction-check.en.md @@ -0,0 +1,408 @@ +# Direction check guidance quality review + +- Brief: docs/content-plans/direction-check.en.md +- Brief revision: v0.1 +- Content file: apps/cli/src/goals.ts +- Content file: apps/cli/src/guide.ts +- Content file: apps/cli/src/connect.ts +- Content file: apps/cli/src/check.ts +- Content file: apps/dsh-plugin/src/tools.ts +- Content file: apps/dsh-plugin/README.md +- Content file: docs/reference/cli.md +- Content SHA-256: 8be714dbf31bddaafeca9aa5f97b7b3cc27145a44ee94304f14eb6ff31d8d72d +- Author: the implementing agent on branch `feat/417-objective-check`, and for + the F1 correction the fix agent `fix-457-multi-unlink` on `superself-dev` +- Reviewer: the independent review agent `review-457` on `superself-dev`, which + neither wrote nor amended any of the seven content files; the F1 follow-up + was reviewed by `review-457-f1` on `superself-dev`, which wrote no content + file either and authored only this receipt +- Reviewed at: 2026-09-05 +- Reviewed commit: 4ace65a49ac5c35ce597c3e40a9d574663ed57a3, the F1 correction. + The original review read the same seven files at + 594eedbb785de6b4410d63fd5f3b723d31995df1, whose content bytes are identical + to those at c5b72cab50f01c053c7531bf20b2bc291cf4eb37 — the head the follow-up + reviewer diffed the correction against +- Verdict: ready + +**Ready. The one finding that blocked publication is fixed and the fix was +verified independently.** This receipt records two rounds. In the first, a +reviewer distinct from the author read all seven content files at `594eedb`, +ran the command and the commands it prints against scratch workspaces, ruled +the two questions the author left open, and returned `revise` for a single +reachable overstatement in two of the routes — F1. In the second, a follow-up +reviewer distinct from both the implementing author and the fix author read the +correction at `4ace65a`, reproduced the failure against the pre-fix build and +the pass against the shipped one, and resolved F1. Both rounds' evidence is +kept below: F1's original reproduction stands as the record of what was wrong, +and its resolution states what was checked. Nothing else found in either round +blocks publication. + +`apps/cli/src/check.ts` is in the digest because the sentences the command +prints are reader-facing content: the finding summaries and the recovery +commands are what a reader acts on, and reviewing the help pages without them +would review a description of the answer rather than the answer. + +The digest above is reproduced from the repository root with: + +``` +python3 .agents/skills/content-quality-gate/scripts/verify_review_record.py \ + digest --root . apps/cli/src/goals.ts apps/cli/src/guide.ts \ + apps/cli/src/connect.ts apps/cli/src/check.ts \ + apps/dsh-plugin/src/tools.ts apps/dsh-plugin/README.md docs/reference/cli.md +``` + +The whole receipt is checked, digest included, with: + +``` +python3 .agents/skills/content-quality-gate/scripts/verify_review_record.py \ + verify --root . docs/content-reviews/direction-check.en.md +``` + +Both reviewers ran both commands. The digest above is the one that reproduces +at `4ace65a`; the digest the first round recorded, +`32ac08fd5ad452ebdcd329e998146b0724a44e7fcf2a5d2793dd0fe23831d450`, was correct +for `594eedb` and went stale when the F1 correction changed three of the seven +files. The verifier accepts this receipt at the `ready` verdict. + +## Review scope + +The reader outcome is that an agent arriving at a project whose direction graph +somebody else changed can find out whether what `self context` prints can be +acted on, and — when it cannot — which single supported command answers the +finding in front of them. The same answer has to be reachable through the CLI +pages, the managed block, or the plugin's guidance. + +The reviewed surfaces are the seven content files above. The supported path is +reading one entry route, running `self objective check` against a local +workspace store, and running the commands its findings print. + +The trust boundary is narrower than part (b)'s: this branch adds no writer at +all. `self objective check` appends nothing, writes no file, and reaches no +network; every command it *prints* is an existing part (a) or part (b) append +into the project the caller is in. The reviewer confirmed this independently +rather than accepting it: a store snapshot taken before and after a plain run +and a `--json` run is byte-identical (cell 40), and `check.ts` holds no import +of a writer, of `node:`, of `process`, of `Date` or of `Math.random` (cell 41). + +This review excludes Korean copy, marketing narrative, tutorials, and +everything the brief's exclusions list. It does **not** exclude the code +correctness of the seven findings where a finding's own printed sentence is the +content under review: F1 below is a copy claim falsified by running the copy's +own instruction, and the reviewer treated the run as the evidence rather than +deferring to the case table. + +## Claims and evidence + +Each claim below is one a reader is entitled to act on. Every row was checked +by the reviewer against the source record and against a live run at the +reviewed commit. The third column states what the reviewer observed, not what +the author reported. + +| Claim the copy makes | Where the code says it | Reviewer's observation | +| --- | --- | --- | +| The check reads and changes nothing | `objectiveCheck` in `goals.ts` returns a `payload` block and appends nothing; `check.ts` imports no writer | Confirmed. Cells 40 and 41 pass, and the reviewer read `check.ts` end to end for a reachable write and found none | +| It states seven kinds and no more | `FINDING_KINDS` in `check.ts` | Confirmed; cell R4 pins the list and its length, and no other kind is constructed anywhere in the module | +| Work whose every current contribution is over is named, with the relink and an unlink per closed target | `unitFinding`/`obsoleteFinding` | Confirmed for the naming and for the relink suppression where the lineage ends closed. The unlink the same finding prints was **F1**, and is confirmed at `4ace65a`: `obsoleteFinding` maps every closed target to its own unlink, in the one sorted order the summary is also built from | +| A successor with no live work is named beside the units still on its predecessor | `successorFinding` | Confirmed. No `self decide` is offered, which is the design's rule that a prose decision may not be advertised as clearing a structural condition | +| A checkpoint dated past its objective names both dates and both revisions | `dateFinding` | Confirmed by an independent run. The reviewer reached the condition the only way this branch leaves reachable — part (b) refuses creating such a checkpoint, so the objective's date has to move under it afterwards — and then ran the **objective** revise the finding prints, which the committed cells do not exercise: the finding was gone | +| A missing date is not an ordering failure | `dateFinding` returns early when either date is absent | Confirmed, both directions | +| A judgment made under a former parent is one to review, and the recheck settles it | `judgmentFindings`, over `carriedJudgments` in the fold | Confirmed; one criterion at a time, the unrechecked criterion surviving | +| An assumption on a replaced decision offers the link then the unlink, and invents no successor where there is none | `assumptionCommands` | Confirmed by an independent run: both printed lines run verbatim, in the printed order, and the finding is gone | +| A candidate is information, and nothing is paired to a criterion | `milestoneCandidates` emits a literal `cN` | Confirmed, along with the three suppressions — a unit a claim already cites via `--work`, a checkpoint with nothing open, and a unit carrying no report | +| Maintenance is read off the run link and never out of a record's text | `operationalUnits` reads `relates` edges from runbook runs | Confirmed. The classification is the edge; a unit whose text reads as maintenance and carries no edge is still reported | +| An objective whose whole live workload is runbook occurrences asks a question and prints no repair | `operationalFindings` | Confirmed, including that an empty set never triggers it | +| An unreadable target project is reported as not checked rather than as all clear | `resolveForeign`, and `unitFinding` suppressing the finding when any contribution is undecidable | Confirmed by an independent two-project run. With the target project readable and closed, one qualified finding and an empty `unchecked`; after archiving that project, zero findings, one `target state not checked` line, and a header reading `0 findings …, 1 contribution target not checked`. `self status` and `self context` carried the same words. No path produced a silent all-clear | +| The same events answer the same way whatever the merge order | `byPlace`, codepoint comparison throughout | Confirmed, and see the ordering note below | +| `self status` and `self context` carry the count | `projectPage` in `views.ts` | Confirmed. The reviewer also checked that `projectPage` reproduces the retired `renderedModel` exactly — same fold, same re-scope, same `withVerdicts` — and that the check's `available` set is the same list `objective check` itself uses, so the two surfaces cannot disagree about a count | + +**F1 — resolved at `4ace65a`. Was blocking, low severity. A unit whose +contributions were several closed outcomes was not answered by the line the +finding printed, and two routes promised it was.** At `594eedb`, +`obsoleteFinding` printed exactly one `self work unlink`, for the first closed +target only, while the finding's own summary named every closed target. `self +help goals` (`guide.ts`) and the plugin README both stated "each one clears the +finding it was printed under". The first round's reproduction, at `594eedb`, +running the printed lines verbatim: + +``` +$ self objective check +demo — 1 finding (1 structural, 0 to review, 0 candidates) + +o-gdd4d + structural: every outcome w-xrz1j contributes to is over — m-r1cgn (closed), m-s0veh (closed) + self work unlink w-xrz1j --milestone m-r1cgn + self work link w-xrz1j --standalone --why "" + self work retire w-xrz1j --why "" + +$ self work unlink w-xrz1j --milestone m-r1cgn +$ self work link w-xrz1j --standalone --why "it moves no stated outcome" +$ self objective check +demo — 1 finding (1 structural, 0 to review, 0 candidates) + +o-gdd4d + structural: every outcome w-xrz1j contributes to is over — m-s0veh (closed) + self work unlink w-xrz1j --milestone m-s0veh + … +``` + +The state reached is a unit linked to two dropped checkpoints, which is +supported throughout: design §1 keeps multiple contributions valid, and both +`work link` calls and both `milestone drop` calls are ordinary approved verbs. + +Severity is low and deliberately stated as low. The reader is not misled about +their graph — every run tells them the truth, the omitted edge is named in the +summary the whole time, and the next run prints the next unlink, so the route +converges. What was wrong was the promise, not the answer. It blocked `ready` only +because certifying `ready` means signing that the shipped sentences are true, +and that one was falsifiable by following it. + +**How it was corrected, and what the follow-up reviewer checked.** The fix +took the first of the two corrections this receipt offered, and took the second +as well where the sentence was loose. `check.ts` now emits +`...closed.map((item) => \`self work unlink ${work.id} ${targetFlag(item.label)}\`)` +in place of the single `first.label` line, so the finding prints one unlink per +closed target; and the two sentences were softened from "each one clears the +finding it was printed under" to the printed steps for the route chosen +clearing the finding they were printed under, which is what the reader actually +does — the three routes are the relink, the unlink-of-every-closed-target +followed by the standalone declaration, and the retire, and no single line is +the whole of any of them. + +The follow-up reviewer read the whole delta `c5b72cab..4ace65a` — five files, +46 insertions, 11 deletions — rather than accepting the fix agent's report, and +checked four things: + +- **The order is the summary's order.** `closed` is sorted once, by + `qualifiedKey`, and both the summary and the new `closed.map` read that same + array. They cannot disagree. `first` is still `closed[0]` and still supplies + only the finding's `objective` and `detail`, which is what it supplied + before. +- **Nothing else moved.** The change to `check.ts` is one line for one. The + relink was already plural — `relinkCommands` has always flat-mapped over + every closed target — and the successor, date, judgment, candidate, + disposition and operational findings, and the grouping, are untouched by this + delta. +- **No pre-existing case changed answer.** For a unit with exactly one closed + target, `closed.map` emits exactly the line `first.label` emitted, so the + output is identical by construction; and every pre-existing + `obsolete-contributions` cell asserts command membership rather than an exact + list. The first round's 51/51 therefore carries over without a rerun. +- **The new cell fails without the fix.** Cell 53 was run against the shipped + build and passed, then against a build with only that one line reverted to + its `first.label` form, where it failed on exactly the assertion that names + both targets. A regression that passes either way would have held nothing. + +Cell 53 is the cell this receipt asked for: a live unit contributing to a +dropped objective and a dropped milestone, every printed unlink run verbatim, +then the standalone declaration — after which the `obsolete-contributions` +finding and the `no-disposition` finding are both gone and the unit's status is +not `retired`, so the route reconciles the unit in one pass without giving up +the work. The run is recorded under *Supported-path run*. + +**F2 — corrected in this receipt, no production change.** The author's draft of +this file recorded that the `superself_work` tool description "omits finding 3 +(the date order) and finding 7". The shipped description omits finding 7 only; +"a checkpoint dated past its objective" is present. The row below states the +omission as it actually is. + +**Ordering note, no change required.** The part (b) review flagged +`carriedJudgments` as taking the newest coverage claim by physical log +position. The reviewer traced the claim's whole path rather than assuming +either answer. `recordCoverage` in `state.ts` is the only coverage writer on +this branch and emits `entity.covered` alone; those claims reach +`milestone.coverage` through `syncCoverage` in `model.ts`, fed by +`applyCoverage` in `entities.ts`, which sorts by `(ts, event id)` before +folding. So the standing judgment is merge-order invariant on the shipped path, +and cell 36's carried-and-rechecked fixture demonstrates it live rather than +asserting it. One residual worth recording and not fixing here: that sort uses +`localeCompare`, which `check.ts` R6 forbids itself for exactly the locale +reason — it is safe only because ULID event ids are `[0-9A-Z]` and timestamps +are fixed-shape ISO-8601, which collate identically to codepoint order under +any locale. It predates this branch and is not a regression from it. + +The reviewer looked specifically for a sentence that overstates what the +command does, since that is the failure this content unit could most easily +cause: a reader who believes the check repaired something would stop. None of +the seven files says the check repairs, relinks, covers, revises or +reclassifies; each route states the opposite in its own words. F1 is an +overstatement of a different kind — about the recovery route's completeness, +not about the tool acting — and it is the only one found. + +## Sentence and visual jobs + +`C` recognize context, `E` verify evidence, `A` take an action, `R` recognize +the result, `D` make a decision. The last two rows are the questions the author +left open; both are ruled here by the reviewer. + +| Location | C/E/A/R/D | Reader value | Reviewer's ruling | +| --- | --- | --- | --- | +| `objective check` usage line and description | C/A | The one line a reader sees before running it | Keep | +| `objective` help, the seven-finding paragraph | C/D | Names what the command will say, so a reader knows whether it answers their question | Keep | +| `objective` help, the "never relinks" paragraph | C/D | The rule that stops a reader believing something was fixed | Keep — this is the claim the whole unit turns on, and it is true | +| `objective` help, the candidate paragraph | C/D | Separates information from coverage | Keep | +| `objective` help, the scope paragraph | C/A | States why there is no `--workspace` form, which the contribution rules require a read verb to do | Keep | +| `check.ts`, the header line | C/E | The count, the classes, and whether anything was unread | Keep. Verified that it never reads `no findings` while a target went unchecked | +| `check.ts`, each finding's summary | C/E/D | What is wrong, in records the reader can open | Keep. The summary was complete where the command list was not, which is what made F1 recoverable rather than misleading; at `4ace65a` the two are built from the same sorted array and say the same thing | +| `check.ts`, each finding's commands | A/R | The line a reader copies | **Keep.** Every command is dispatchable — verified by resolving each against the contract and by running the date, assumption, relink, recheck, unlink and standalone routes by hand — and every one is relevant to its finding. The gap F1 named was coverage of the multi-target case, not correctness of any line; it is closed at `4ace65a`, where a finding over several closed targets prints an unlink for each | +| `check.ts`, the `not checked` section | C/D | The disclosure that separates uncertainty from an all-clear | Keep | +| `self help goals`, four added paragraphs | C/D/A/R | The longer form, where checkpoints are explained | Keep. The F1 sentence is corrected at `4ace65a`, and the corrected claim is true of the shipped code | +| `self help work`, one added paragraph | C/D/A | The two findings a work reader meets, pointing at the goals page for the rest | Keep | +| `docs/reference/cli.md`, one added entry | C/E/A | The reference statement | Keep. It makes no per-command clearing claim, so F1 does not reach it | +| Managed block, one added bullet (~12 lines) | C/D/A | The one place a session reads at start | **Keep.** Measured at the reviewed commit: 136 lines, 32 bullets, 9,609 characters, of which this bullet is about 12 lines. The bullet earns them — it is read at the one moment the reader has not yet run anything, and a session that never learns the check exists reads a drifted graph as truth, which is the failure #417 was opened over. The block's *total* size stays an open measurement carried from the part (b) review; that is a question for a measurement of whether sessions still run `self context` first, not a reason to cut this bullet, and the gate forbids ruling it on surface polish | +| `superself_work` tool description, ~5 added lines | C/A | The check, in the route that exposes no tool for it | **Keep, with a recorded reservation.** Measured at the reviewed commit: 1,559 characters against 193, 243, 232 and 179 for the other four tools — an outlier by more than six times, and it was already the outlier before this branch. The added text does two things. The pointer and the four rules — it changes nothing, a candidate is information, maintenance is the run link, an unreadable target is not an all-clear — change what a model does at the moment it reads them, and there is no tool through which it would learn them otherwise; those stay. The six-item enumeration of findings is the removable half: the README on the same route carries all seven, and a model gets the list by running the command. It is not removed here, because it does one job the README cannot do at that moment — letting a model decide whether the check answers the question it currently has without spending a call — and because shortening it is a budget judgment for the listing as a whole, which is the measurement the part (b) review already opened. A future revision that measures the listing budget should cut the enumeration first. Not a blocker: no factual error, no unsupported claim, every command in it dispatchable. The one factual point is F2 — the description omits finding 7 only, not findings 3 and 7 | + +No visual material changed. Every added sentence performs at least one job. + +## Supported-path run + +The reviewer's own runs, independent of the author's. Environment: Linux, Node +22.23.2, a read-only checkout of `594eedb` at +`/home/launchscreen/work/review-457` built once from source with `pnpm -r +build`. Writes were confined to scratch workspaces created per run under +`/tmp`; neither the implementation worktree nor any real store was written to. +Piped output, no terminal. + +| Step | Expected | Observed | +| --- | --- | --- | +| `self objective check` on a clean project | `no findings`, exit 0 | As expected. `--json` on the same store: zero findings, zero unchecked | +| `self status` on the same store | `direction: ok — self objective check --project 'demo'` | As expected | +| A live unit on two dropped checkpoints, then every printed line run verbatim | The finding is gone | **Not as expected at `594eedb` — F1.** The printed unlink withdrew one of the two edges and the finding stood, naming the second. Transcript above. Corrected at `4ace65a`; see the F1 follow-up run below | +| A checkpoint dated past its objective, reached the only way this branch leaves reachable, then the **objective** revise the finding prints | The finding is gone | As expected. This route is not covered by a committed cell; the reviewer ran it because a printed command nobody runs is a promise nobody has tested | +| An assumption on a superseded decision, both printed lines run verbatim in the printed order | The finding is gone | As expected | +| A foreign contribution to a readable, closed objective in a second registered project | One finding, qualified by the owning slug, grouped under the qualified objective | As expected. The group heading read `o-… (other)` and `unchecked` was empty | +| The unlink that finding prints, which names the foreign id without its project qualifier | Withdraws the foreign edge | As expected, exit 0. The obsolete-contributions finding cleared and the correct `no-disposition` finding took its place. The unqualified form is not a defect | +| The same store after archiving the target project | No finding, one `target state not checked` line, and a header that does not read `no findings` | As expected. `self status`, `self context` and `--json` all disclosed the unchecked target | +| `self objective check --project demo` run from the other project's checkout | The same answer as from `demo` itself | As expected, byte-identical | +| `self objective check --project other` where `other` is archived | The archived notice, then the answer | As expected — it does not crash and does not pretend the project is active | +| `self context` | The direction line in the head, above the body the budget can cut | As expected | + +Suites the reviewer ran to completion at the reviewed commit, in the review +checkout: `objective-check.test.mjs` 51/51; `guards-carry.test.mjs`, +`render-gate-documents.test.mjs`, `integrity.test.mjs`, `golden.test.mjs`, +`docs.test.mjs` and `contract.test.mjs` together 108/108; +`goal-record.test.mjs`, `milestone-progress.test.mjs` and +`objective-revise-carry.test.mjs` together 64/64 — the existing consumers of +the three helpers this branch extracted into the fold; and the fold's own +`determinism.test.mjs` and `purity.test.mjs` 9/9. `pnpm -r build` clean. CI's +`verify` job, the full tier, was green on `594eedb`. + +**F1 follow-up run.** The second round deliberately ran one cell, not a suite: +the correction is one line whose effect on every pre-existing case is identity, +so a rerun would have bought confidence rather than evidence. Environment: +Linux, Node 22.23.2, the branch worktree at `4ace65a` with a clean tree and a +build newer than its sources and carrying the fix, at +`/home/launchscreen/worktrees/417-objective-check`. The scratch reversion wrote +only to `apps/cli/dist/check.js`, which is not tracked, and was restored to its +exact bytes — verified by SHA-256 — leaving the tree clean. + +| Step | Expected | Observed | +| --- | --- | --- | +| `node --test --test-name-pattern "^53: " apps/cli/test/objective-check.test.mjs`, against the shipped build | The cell passes: one unlink per closed target, in the summary's order, and running them with the standalone declaration clears both findings without retiring the unit | As expected. `# pass 1 # fail 0`, 613 ms | +| The same cell, against a build with only the one fixed line reverted to `first.label` | The cell fails, on the assertion that both closed targets are named | As expected. `not ok 1`, on `the printed unlinks do not name both closed targets in the summary's stable order`. The regression is load-bearing | +| The same cell again, after restoring `dist/check.js` from the byte-exact backup | The cell passes and the worktree is clean | As expected. SHA-256 matched the pre-experiment digest, `git status` empty | +| `self help goals`, the corrected paragraph, rendered from the shipped build | The sentence no longer promises that each single printed command clears the finding | As expected. It reads "the printed steps for the route you choose clear the finding they were printed under" | + +CI on `4ace65a` was in flight when this receipt was written; the coordinator +owns that gate, and this receipt does not claim its result. + +## Independent reviewer answers + +**Who this is for.** An agent or a person who has just arrived at a project +whose direction graph somebody else moved, and who is about to act on what +`self context` prints. Not somebody auditing a store, and not somebody looking +for a strategy opinion — the copy is careful about both. + +**What changes for the reader.** Before this unit, a graph inconsistency was +only visible to somebody who already suspected it and knew which records to +open. After it, one command names the inconsistencies, says which of them a +person has to re-judge rather than repair, and prints the line that answers +each — and says so in the same words on all three entry routes, which the +reviewer verified by resolving every `self …` string each route names against +the dispatch contract. + +**What proves success.** Not that the pages read well. That a reader who never +saw the design can reach a repaired state from a finding without a second +refusal and without a command that does not exist. That is what the reviewer +spent the run on, and it is where F1 was found: it is the one place the printed +route does not reach the state the copy promises in one pass. + +**What can be removed.** The six-item finding enumeration in the +`superself_work` tool description, if and when the listing budget is measured — +the reasoning is in the jobs table. Nothing else in the seven files repeats a +job without adding information. The reviewer specifically checked whether the +seven-finding list appearing in five places is repetition: it is not, because +each appearance is read at a different moment and only two of them are read by +the same reader in one sitting. + +## Reader evidence + +Not required for this revision under the project rule that factual guidance for +existing commands does not open a new reader study: the commands documented +here ship in this pull request, so every claim is checkable against the CLI +rather than against a reader's impression. The reviewer relied on that rule +rather than inventing a study gate, and checked the claims against the CLI +instead — which is what surfaced F1. + +## Remaining hypotheses and next check + +The issue's causal hypothesis — that different agents reconstruct different +methods because the guidance leaves these rules unstated — is still untested. +The design names the cheapest disconfirming check: give fresh sessions the same +seeded store and compare the records they write. All three parts have now +landed or are in review, so this is the check that becomes runnable next, and +`self objective check` is what makes its result readable. + +Second: the managed block's total size, and with it the plugin listing's. The +part (b) review recorded the block at 124 lines and 31 bullets; it is 136 lines +and 32 bullets at this commit, and `superself_work` is 1,559 characters against +a 179–243 range for its four siblings. The cheapest check is unchanged — +measure whether a session that reads the block still runs `self context` and +`self instruction render` first — and it now covers two surfaces rather than +one. + +Third: whether the multi-target case F1 named has a sibling anywhere else. The +first-round reviewer checked the other six findings' command lists for the same +shape and found none — every other finding prints commands that address its +condition whole — but the check was reading, not exhaustive running. Cell 53 +now holds the one case that was wrong, and the follow-up reviewer re-read the +other six command builders against the corrected one: `assumptionCommands` and +the relink already fan out over their whole input, and the remaining findings +print a single command against a single subject, so there is no second place +for this shape to hide. This is recorded as settled by reading plus one cell, +not by exhaustive running. + +Fourth, and not blocking: the corrected paragraph in `guide.ts` is a list of +literal lines printed verbatim, and the rewrap left one short line — "and no +criterion. A candidate is" — in the middle of an otherwise even ~76-column +paragraph. It changes no meaning, no reading order and no action, so the gate's +rule against ruling on surface polish applies and it does not block `ready`. +The cheapest correction is rewrapping that paragraph's lines when `guide.ts` is +next edited; the follow-up reviewer did not make it, because this round's write +scope is this receipt alone. + +## Sufficient evidence and stop condition + +**Met.** The independent review the `ready` bar requires happened in the first +round: a reviewer distinct from the author read all seven content files at +`594eedb`, ran the command and its printed commands against scratch stores, +ruled both open length questions, and corrected one factual error in the +receipt itself. It returned `revise` on F1 alone. + +That round named its own closing condition: the F1 row, a cell for the +multi-target route, and a fresh digest, with no further reviewer round beyond +that. All three are here. F1 is fixed at `4ace65a` by the correction this +receipt preferred, and the two sentences it overstated are corrected as well; +cell 53 runs the multi-target route end to end and fails against the pre-fix +build, so it holds the claim rather than restating it; and the digest above +reproduces at the fix head over the same seven files. Four of those seven files +are byte-identical to the state the first round certified, so its evidence +carries over untouched; the three that changed — `check.ts`, `guide.ts` and the +plugin README — are the three F1 named, and each was re-read in full by the +follow-up reviewer. + +The remaining items are the two carried measurements — the managed block's and +the plugin listing's size — and the ragged wrap noted above. None is a factual +error, an unsupported claim, a failed command, or a missing reader action or +result, so none of them blocks publication; they are follow-ups. The verdict is +`ready`. It is not `validated`: no real reader has yet been observed using this +guidance, which is the Gate 3 evidence this receipt does not claim. diff --git a/docs/maintainers/case-tables/417-check.md b/docs/maintainers/case-tables/417-check.md new file mode 100644 index 0000000..6a8c247 --- /dev/null +++ b/docs/maintainers/case-tables/417-check.md @@ -0,0 +1,273 @@ +# Case table — the read-only direction check (#417, PR c of 3) + +Written before the code, and the review surface for it: a cell this table lacks +is a path nothing proves. Every test in `apps/cli/test/objective-check.test.mjs` +is one cell below, named by its cell number. Cells that belong to another file +say which file. + +This is part (c) of the three-part delivery the approved #417 design v2.1 +sequences. Part (a) shipped the link model — standalone dispositions, assumed +decisions, operational run links — as #452; part (b) shipped what a revision +carries, the target-open guards, the proposal retarget, the date ordering rule +and the objective identity a coverage judgment was made under, as #456. Both +are on `origin/main` and this branch is based on them. Part (c) ships one +command — `self objective check` — the seven findings it states, the foreign +availability notices, the evidence candidates, the health summary `self status` +and `self context` carry, and the guidance for all of it on the three entry +routes. + +It ships no new way to record anything. Every command it prints is one part (a) +or part (b) already dispatches. + +## The defect this part answers + +Issue #417 records four observed graph inconsistencies. Parts (a) and (b) +answered what the store could not *say*; this part answers what nobody could +*ask*: + +| Observed | What no command would answer | +|---|---| +| Work stayed on a superseded milestone | Nothing listed the live units whose every current contribution points at an outcome that is over | +| A successor checkpoint had no work while its predecessor still had live work | Nothing compared the two sides of a supersession | +| Recurring maintenance became a product checkpoint dated past its objective | Nothing read the two dates back out of a store that already held them, and nothing asked whether a checkpoint's whole live workload was operational | +| Completed work was not reconciled with successor criteria | Nothing offered the done units that could be cited as evidence for an uncovered criterion — while never citing one on anybody's behalf | + +Part (b) refuses the *creation* of three of these. It reconciles none of the +stores that already hold them, and the design forbids automatic cleanup. A +read-only check is what is left. + +## The rulings this implements + +| # | Question | Decision | +|---|---|---| +| R1 | Where does the projection live? | **`apps/cli/src/check.ts`, in the render layer**, beside the other projections of a folded model. It cannot live in `@superself/fold`: finding 6 reads which records answer as a runbook run, and that derivation is `apps/cli/src/runbooks.ts` — a CLI-owned reading of the entity grammar, which ARCHITECTURE.md keeps out of the package on purpose. Purity is asserted from the source instead, by cell 41 | +| R2 | What does it take, and what may it read? | **`checkDirection(project, available)`** — the owning project's fold and the folds of the registered projects this machine could read. Nothing else: no store directory, no clock, no network, no log. It reads only log-determined fields; `MilestoneState.state` is not one of them, because `missed` and `at-risk` are what `today` decides | +| R3 | What is a closed target, without a clock? | **`milestoneClosure` in `@superself/fold` `objectives.ts`** — reached, superseded, dropped, or hanging under an objective that is dropped or superseded. It is extracted out of `milestoneState`, which now calls it, so the check and the milestone page cannot disagree. An objective is open when `openObjectives` holds it, which is the reader that already existed | +| R4 | How many finding kinds are there? | **Seven, fixed**, one per bullet of design §6, in three classes: structural inconsistency (1, 2, 3, 6) — commands repair the relationship; review-needed (4, 7) — a person rejudges the meaning; evidence candidate (5) — information only. A kind is added by a design decision, never by an implementation | +| R5 | What does an unavailable target project do? | **It suppresses the finding and states a notice instead.** A unit whose contributions cannot all be resolved is never reported as "every outcome it contributes to is closed" — uncertainty is not a verdict. The notice names the record, the qualified target and the reason, and the header counts it apart from the findings | +| R6 | What is the total order? | **Qualified objective, then qualified record, then finding kind, then the finding's own stable detail.** An explicit unassigned group sorts after every named objective. Every comparison is codepoint order on strings — never `localeCompare`, whose answer depends on the machine's locale, which is exactly what the determinism guarantee forbids | +| R7 | Which commands may a finding print? | **Only ones this branch dispatches, and only ones that change the condition the finding states.** A prose decision is never offered as clearing a structural finding: recording a decision changes no edge, no date and no criterion. Where nothing repairs a review signal — finding 7 asks a question about intent — the finding prints the reading command and no repair at all | +| R8 | What is operational? | **An inbound `relates` edge from a runbook run**, which is what `self runbook link --work ` writes and part (a) documented. Never a label, a date, or a word in the text. An objective with no live milestone work does not trigger finding 7 — an empty set is not "all operational" | +| R9 | What may a candidate say? | **That a done unit exists and a criterion is open.** It never pairs the two: no text matching, no automatic coverage, and a unit a claim on that checkpoint already cites is left out. The unit's own criteria are not evidence for the checkpoint's | +| R10 | Where does the summary go? | **One line on `self status` and one on `self context`**, stating the count and the command, and disclosing unchecked targets when there are any. A run with findings never reads as `ok`, and neither does a run with nothing found but a target it could not read | + +## Rules the cells are derived from + +1. **The check never writes.** No event, no file, no log line — not even the + `notice` a lower layer prints. Cell 40 asserts the store is byte-identical + after a run; cell 41 asserts the module's source reaches no machine at all. +2. **Only live records are drift subjects.** A closed objective, a reached + checkpoint and a retired unit are history, and history is not drift. Done + work appears in exactly one place: as an evidence candidate. +3. **Uncertainty is never an all-clear.** Where a contribution's target cannot + be read, the answer says so and the summary counts it. Silence would be the + one wrong answer this command must never give. +4. **The same logs answer the same way.** Same events, any merge order, any + machine, any hour. Sorting is total and locale-free, and no rule reads + `today`. +5. **Every advertised command is dispatchable and relevant.** Each one is + resolved against the typed contract, and each one is run in a cell that + asserts the finding it was printed under is gone afterwards. +6. **Nothing is inferred from prose.** Not a maintenance classification, not a + criterion's evidence, not an assumption. Every classification this check + makes is read off an explicit edge somebody recorded. +7. **A missing date is not a failure.** Either date absent means the ordering + was not checked; the check states no finding and invents no ordering. + +## Narrow contract + +**Behavior.** One new read verb, `self objective check [--project ] +[--json]`, and one summary line on two existing read surfaces. It answers with +findings, notices and a header, and changes nothing. + +**Production surfaces.** + +| Surface | What changes | +|---|---| +| `apps/fold/src/objectives.ts` | `milestoneClosure` extracted out of `milestoneState`; `exitStanding` extracted out of `deriveMilestone`; `carriedJudgments` exported — three readings the check and the existing derivation now share rather than compute twice | +| `apps/cli/src/check.ts` | new. The projection: `checkDirection`, the seven findings, the notices, the summary, the total order | +| `apps/cli/src/goals.ts` | the `objective check` leaf, its handler and its plain render | +| `apps/cli/src/views.ts` | the direction summary on `self status` and `self context`, folded from the reads those pages already pay for | +| `apps/cli/src/pretty.ts` | the summary's place in the two terminal renders | +| `apps/cli/src/main.ts` | the `objective` usage line and flag glossary for `check` | +| `apps/cli/src/guide.ts` | `self help goals` and `self help work` | +| `apps/cli/src/connect.ts` | the managed block's check and recovery lines | +| `apps/dsh-plugin/src/tools.ts`, `apps/dsh-plugin/README.md` | the plugin's entry guidance, pointing at a CLI command it does not expose as a tool | +| `docs/reference/cli.md` | the `objective check` entry in the outcome-and-work section | +| `ARCHITECTURE.md` | `check.ts` named in the render layer | + +**Valid inputs.** The owning project's fold, and the folds of the active +registered projects this machine can read. Ids are whatever those folds +resolve. Dates are the `YYYY-MM-DD` values the typed date argument already +admits. Nothing else is an input — in particular, not the clock, not git, not +the network, and not a project's own store directory. + +**Trust boundary.** Unchanged and narrower than part (b)'s: this branch adds no +writer at all. Every value out of a log is read through the same defensive +guards the rest of the fold uses, and an id that resolves nowhere reads as +unavailable rather than crashing the projection. + +**Exclusions.** Out of scope, and not to be advertised by this branch: + +- A general audit. Seven finding kinds, fixed by the design; a malformed-store + audit, a lint of record text, and a check of anything outside the direction + graph are all somebody else's command. +- Routine records and scheduling (#451), prose classification, strategy + scoring, progress percentages, automatic cleanup of existing stores, + objective-level assumptions, a viewer redesign. +- Any repair. The check runs no command it prints, and no flag makes it. +- Dormant hooks for future findings: no severity dial, no filter flags, no + configuration, no plugin surface for a kind that does not exist yet. +- Korean reader-facing copy. The English guidance is revised in place. + +**Stop condition.** Stop when the cells below pass, `pnpm build`, +`pnpm typecheck`, `pnpm structure` and `pnpm smoke` pass, and the suites this +branch touches pass locally. The full tier is CI's `verify` job. No further +review rounds are opened from this branch. + +## Variables resolved outside the arguments + +| Variable | Resolved from | Why it matters to a cell | +|---|---|---| +| the project the check answers for | the working directory, or `--project ` through `readScopes` | cells 33–34 | +| which target projects are available | `workspaceModels`, which folds every active registered project | cells 25–28: an archived, unregistered or unreadable project is simply absent, and that absence is the notice | +| a target's closure | `milestoneClosure` and `openObjectives`, over the supplied folds | cells 1–6 | +| what is operational | inbound `relates` edges from runbook runs in the owning fold | cells 20–24 | +| today | not read at all by any rule here | cell 39 | + +## 1 — finding 1: every contribution points at an outcome that is over + +| # | Case | Outcome | +|---|---|---| +| 1 | live work whose only contribution is a superseded milestone | one structural finding naming the unit and the closed checkpoint, and printing the relink to the open terminal successor, the unlink, and the retire | +| 2 | the same unit also contributing to one open objective | no finding: one live contribution is enough, and the check never asks a unit to tidy the rest | +| 3 | live work whose only contribution is a reached milestone | a finding — reached is closed, exactly as the part (b) guard reads it | +| 4 | live work whose only contribution is a live checkpoint under a dropped objective | a finding: the checkpoint is closed because its objective is | +| 5 | a done unit whose only contribution is a superseded milestone | no finding of this kind: only live records are drift subjects | +| 6 | a lineage that ends closed — the successor was reached | the finding prints no relink; it names the standalone declaration and the retire, so no advertised command leads to a second refusal | +| 53 | live work whose current contributions are two closed outcomes — a dropped objective and a dropped milestone — named in the summary in the same stable order as the printed unlinks | one `self work unlink` per closed target; running every printed unlink and then declaring standalone reaches a reconciled state in one pass: the finding and any `no-disposition` finding are both gone, and the unit was never retired | + +## 2 — finding 2: an empty successor beside a predecessor that still has work + +| # | Case | Outcome | +|---|---|---| +| 7 | a successor checkpoint with no live linked work, whose predecessor holds two live units | one structural finding naming both checkpoints and the units still on the predecessor | +| 8 | the same pair after `self work link --milestone ` for each unit | the finding is gone | +| 9 | a successor that already carries live work | no finding — which is the state `milestone revise` leaves behind | +| 10 | a successor whose predecessor holds only done and retired work | no finding: there is nothing live to carry | +| 11 | the commands the cell-7 finding prints | the relink, a successor-work proposal, and `self milestone drop --why` — and no `self decide`, because recording a decision moves no edge | + +## 3 — finding 3: a checkpoint dated past its objective + +| # | Case | Outcome | +|---|---|---| +| 12 | a live checkpoint dated after its live objective | one structural finding naming both dates and both revise commands | +| 13 | equal dates | no finding — equal passes, as it does at the guard | +| 14 | the checkpoint has no date, or the objective has none | no finding and no ordering claim; the check states the pair was not compared rather than inventing a failure | +| 15 | `self milestone revise --target --why w` after cell 12 | the finding is gone | +| 16 | a closed checkpoint dated past its objective | no finding: history is not drift | + +## 4 — finding 4: a judgment or an assumption made somewhere else + +| # | Case | Outcome | +|---|---|---| +| 17 | a checkpoint an `objective revise` carried, with coverage judged under the former parent | one review finding per affected criterion, naming the objective it was judged under, printing `self milestone recheck --criterion cN --why w` | +| 18 | the recheck from cell 17, run verbatim | that criterion's finding is gone; a second criterion nobody rechecked still has its own | +| 19 | a checkpoint that assumes a decision that was later superseded | one review finding naming both decisions, printing the link to the successor and then the unlink of the old one — two statements, in that order | +| 20 | a checkpoint that assumes a decision that was retracted with no successor | the finding prints the unlink alone and invents no successor command | + +## 5 — finding 5: evidence candidates + +| # | Case | Outcome | +|---|---|---| +| 21 | a done unit with report evidence, linked to a live checkpoint with an uncovered criterion | one candidate naming the unit and the open criteria, printing the `milestone met` template with a literal `cN` — the check pairs nothing | +| 22 | the same unit after a claim on that checkpoint cites it with `--work` | no candidate: a unit already cited is not offered again | +| 23 | a done unit whose own declared criteria are covered, under a checkpoint with none open | no candidate — the unit's own criteria are not the checkpoint's evidence | +| 24 | a done unit carrying no report at all | no candidate: there is nothing to cite | + +## 6 — finding 6: no disposition, and finding 7: an all-operational objective + +| # | Case | Outcome | +|---|---|---| +| 25 | live work with no contribution, no standalone declaration and no run link | one structural finding in the explicit unassigned group, printing the link, the standalone declaration and the retire | +| 26 | the same unit after `self work link --standalone --why ""` | the finding is gone | +| 27 | live work an `entity.linked` `relates` edge from a runbook run names | no finding: that is the operational disposition part (a) documented | +| 28 | a unit whose text says "rotation" and carries no edge at all | a finding — the classification is the edge, never the wording | +| 29 | an objective whose live checkpoint work is two units, both named by runbook runs | one review finding asking whether the maintenance was meant as a product checkpoint, printing the reading command and no repair | +| 30 | the same objective with one of the two units not named by any run | no finding: the set is not all operational | +| 31 | an objective with no live checkpoint work at all | no finding: an empty set never triggers it | + +## 7 — foreign targets, availability and determinism + +| # | Case | Outcome | +|---|---|---| +| 32 | a unit whose only contribution is a foreign objective in a project this machine holds, and that objective is closed there | a finding of kind 1, naming the objective qualified by its owning slug | +| 33 | the same unit where the owning project is not registered on this machine | no finding, one notice — `target state not checked` — and the header counts it | +| 34 | the same unit where the owning project is registered and archived | the same notice: an archived project is out of the workspace answer, and the check does not reach past that | +| 35 | a project with nothing wrong and one unreadable foreign target | the header does not read `no findings`: it states zero findings and one unchecked target | +| 36 | the same log, events reordered, folded on a second scratch machine | identical findings — the determinism cell. Run twice: once over the stranded fixture, and once over a checkpoint whose criteria were covered, carried and then rechecked, which is the pair the part (b) review named as the one part (c) had to verify. It holds because `entities.ts` `ordered` already reads coverage claims in `(ts, event id)` order rather than in file order; this branch adds the proof, not the ordering | +| 37 | two findings on one record differing only in kind | ordered by the design's own finding numbering, not alphabetically | +| 38 | findings on a named objective and findings with none | every named objective's findings first, in qualified-id order, then the explicit unassigned group | +| 39 | the same store read at two very different clocks | identical output — no rule reads `today` | + +## 8 — the projection stays read-only + +| # | Case | Outcome | +|---|---|---| +| 40 | the store's files before and after `self objective check`, and after `--json` | byte-identical, log and pending queue alike | +| 41 | `apps/cli/src/check.ts` read as source | reaches no machine: no `node:` import, no `process`, no `Date`, no `Math.random` — the same five rules `apps/fold/test/purity.test.mjs` holds the package to | +| 42 | `self objective check --json` | one object on stdout and nothing else; the same findings the plain render states | +| 43 | `self objective check` in a project with nothing to say | `no findings`, exit 0 | + +## 9 — the health summary + +| # | Case | Outcome | +|---|---|---| +| 44 | `self status` in a project with findings | one line stating the count and `self objective check`, and the objective roll-up beside it unchanged | +| 45 | `self context` in the same project | the same line, in the page's head, where the render budget cannot cut it | +| 46 | `self status` with zero findings and one unchecked target | the line says so rather than `ok` | +| 47 | `self status` with nothing found and nothing unchecked | the line reads `ok` | + +## 10 — one guidance contract, part (c) + +| # | Case | Outcome | File | +|---|---|---|---| +| 48 | `self objective --help` | states `check`, its two flags, and why it offers no `--workspace` form | | +| 49 | `self help goals` and `self help work` | both name the check, what it will not do, and the recovery it prints | | +| 50 | the managed block `connect.ts` writes | names the check and the recovery route in the same words the CLI pages use | | +| 51 | the plugin's guidance — `superselfTools` descriptions and `README.md` | names the check as a terminal command and implies no tool for it | | +| 52 | every `self` command any of the three entry routes names | is dispatchable on this branch, and every route names the check as a command rather than only in prose — the parity proof, part (c)'s form of cell 53 of the part (b) table | | +| R4 | `FINDING_KINDS` | seven kinds and no more, in the design's own order | | +| 53 | `docs/reference/cli.md` | names `objective check` and no flag the parser refuses | `docs.test.mjs` (existing) | +| 54 | `checkContract(COMMANDS)` | still empty: the new leaf is declared, glossed and reachable | `contract.test.mjs` (existing) | +| 55 | a piped `self status` keeps its roll-up lines in order, with `direction:` among them | the pinned line order is updated, not loosened | `render-gate-documents.test.mjs` (existing) | +| 57 | the pre-cutover store's `context` and `status` captures | rebaselined by exactly the one direction line each, every other capture byte-identical — and both read `ok`, so the check states no finding against a legacy log | `integrity.test.mjs` (existing) | +| 56 | the committed golden fixture | regenerated: `self status`, `self context`, the root usage page and `self help work` all print one more thing, which is the intended outcome | `golden.test.mjs` (existing) | + +Cell 54 of +[`417-guards-carry.md`](417-guards-carry.md) — "no entry route advertises the +check part (c) has not shipped" — is retired by this branch, because part (c) +has shipped it. Its test is removed from `guards-carry.test.mjs` and cell 52 +above is what replaces it: the parity proof now resolves `self objective check` +against the contract rather than asserting nobody mentions it. + +## 11 — the issue's own verification table + +Issue #417's rows 1–10, against the cells that answer them. Rows 8 and 9 are +corrected where the reachable path differs from the issue's wording: the issue +asks for a "maintenance-work route", which on this branch is the runbook run +link part (a) shipped, and for "MCP guidance", which is the plugin's entry +guidance rather than an MCP tool the plugin does not expose. + +| Issue row | Cells | +|---|---| +| 1 — a correct graph reports nothing | 43, 47 | +| 2 — work linked only to a superseded milestone | 1, 6 | +| 3 — an empty successor beside a live predecessor | 7, 8, 11 | +| 4 — a checkpoint dated past its objective | 12, 15 | +| 5 — a stale parent revision or a superseded decision | 17, 18, 19, 20 | +| 6 — candidates, never coverage | 21, 22, 23 | +| 7 — standalone or operational work is not warned about | 27, 26 | +| 8 — maintenance on the product path, classified by edge and not by prose | 28, 29, 30, 31 | +| 9 — the three entry routes agree | 48, 49, 50, 51, 52 | +| 10 — replayed offline, merged either way, on two machines | 36, 39, 40 | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 02246ae..b081f7f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -459,6 +459,21 @@ self skill drop "deploy staging" --why "the deploy moved to the pipeline" former parent. `milestone recheck --criterion cN --why w` settles one, recording the parent the checkpoint hangs under now; criteria nobody rechecks stay listed, and no unit's evidence is applied on anyone's behalf. +- `objective check [--project ] [--json]` reads the direction graph and + changes nothing. It states seven kinds of finding and no more: work whose + every current contribution is to an outcome that is over; a successor + checkpoint with no live work beside a predecessor that still has some; a + checkpoint dated past its objective; a coverage judgment made under a former + parent, or an assumption on a decision that was replaced; done work that is a + candidate for an uncovered criterion; work that states no disposition at all; + and an objective whose whole live checkpoint workload is runbook occurrences. + Every line it prints is a command you run yourself: it never relinks, covers, + revises or reclassifies, a candidate is information rather than coverage, and + maintenance is read off the `runbook link` edge rather than out of a record's + wording. It answers for one project and has no `--workspace` form; a + contribution to another project's objective is judged from that project's own + log where this machine holds it, and reported as not checked where it does + not. `status` and `context` carry the count. - `work` creates and moves outcomes, links them to objectives or milestones, records the process running a unit, and shows its evidence and recovery path. `work done` is the judgment that the outcome was reached, and the