diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..cf8c1bc --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + enabled: true + drafts: true + auto_incremental_review: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e571ce9..090beeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: python -m pip install --only-binary=:all: --require-hashes \ -r "$RUNNER_TEMP/sia-ci-requirements.txt" - name: compile all python - run: python3 -m py_compile bin/*.py bin/sia bin/sia-brainstem bin/sia-ledger bin/sia-mcp + run: python3 -m py_compile bin/*.py bin/sia bin/sia-brainstem bin/sia-ledger bin/sia-mcp bin/sia-intent-review - name: shell syntax run: bash -n install.sh uninstall.sh bin/sia-setup - name: release artifact contracts diff --git a/CHANGELOG.md b/CHANGELOG.md index 997af36..c748f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## 1.8.1 — 2026-09-20 · reviewable commitments and a steadier cockpit + +### Grading and configuration + +A claim the engine's CLI would read as a flag is no longer reported as a +completed corpus search. It never ran, so the recall now says so and the take +stays open, which is what `GradingEvidenceUnavailable` has always specified: +"only a completed evidence read may be judged UNRESOLVABLE". Previously such a +take was closed and scored from the organ half of the evidence lane alone, while +its published page still said it had been judged against a signed evidence +snapshot. The guard is also narrower: it rejected every claim opening with a +dash, which silently removed the recall lane from legitimate takes like +"-1 regressions will land by March" that `sia take -- …` exists to let you +register. Measured against the shipped engine, those run as ordinary searches. + +`mind` is accepted as a top-level configuration section, which the shipped +`config.example.json` already carried. + +### Cockpit + +Commitments now show their full task and calendar due date, explain that overdue +is a review reminder, and offer **Review commitment…**. The terminal review reads +the current open task through the resident CLI and requires a written outcome +and explicit confirmation before closing it. Cancelling leaves it open; stale +or failed requests never claim completion. `sia intend --show ` exposes +the full open task as JSON without internal storage paths. Review does not hold +a corpus lock while waiting for the operator and never executes task prose. + +Snapshot refresh and graph motion now wait until arrival, and the graph image is +painted before presentation to avoid exposing uninitialized pixels on remap. +The freshness glow and the root halo move off the main graph canvas onto a +dedicated lighter one driven by native Qt rings, so animating them no longer +forces a full graph repaint. Inspecting a settled node no longer keeps the +layout simulation running. Routine status +refreshes use the existing cockpit instead of flashing the first-install gate; +snapshot admission and action guards remain unchanged. Status counts guard both +the status and graph snapshots. Backup schedule details expand on demand, and +generated entries use more readable prose typography and wrapped origin labels. + +The graph no longer vanishes for ten seconds a minute. The brainstem +republishes `graph.json` up to four times a pulse (two partial scans, then the +complete generation, then its post-source rebuild) and names the final one in +`status.json` about eleven seconds after the first write. The cockpit withdrew +the graph the moment the file changed and, once reread, refused the newer +generation because no status named it yet — so for about eleven seconds of every sixty +the center card said `current graph unavailable`. The cockpit now keeps the +generation the current status names on screen (`admittedGraph`) while a newer +publication waits for the status that will name it, and the header says so: +`graph published 42s ago · complete · newer graph publication awaiting its +status`. The combined claim is unchanged: the displayed graph is always the +one the displayed status names, and a rejected or vanished resident graph still +withdraws it. An unnamed generation never touches the layout, so hover, +neighbourhoods, and a locked selection survive the republish; a running growth +replay survives the beat between generations too. + +The remaining beat — the status reread itself — is one settle wait (60 ms, was +150–200 ms) plus a frame; the entry stream fades in over 140 ms, while the graph +paints directly without a full-image fade. `current graph unavailable` is only said once +the graph has been absent for 400 ms. Boundaries that name a routine +revalidation (`… pending validation`) render in the muted text colour; an +unavailable or rejected source keeps the urgent colour. + ## 1.8.0 — 2026-09-19 · the receipts survive the move Four reporters and one migrated maintainer machine hit this release where diff --git a/Cockpit.qml b/Cockpit.qml index 4cba75b..7428c52 100644 --- a/Cockpit.qml +++ b/Cockpit.qml @@ -33,6 +33,14 @@ Item { property bool statusLoadValid: false property bool installCompletionResolved: false property var graph: null + // The graph generation the current status names. `graph` holds the watched + // file's latest validated bytes and is withdrawn the instant that file + // changes; `admittedGraph` is the last graph a validated status named, and + // it is displayed only while that same status is still the current status. + // The brainstem republishes graph.json several times a pulse and names the + // final one in status.json seconds later; withholding the still-named + // generation in between blanked the graph for ten seconds a minute. + property var admittedGraph: null property var thoughts: [] property bool thoughtsResolved: false property bool thoughtsLoadValid: false @@ -44,6 +52,14 @@ Item { property var hiddenKinds: ({}) property real revealT: 1.0 property bool playing: false + // True while the graph has something to move; every input that can move + // it sets it, and the frame loop clears it once the layout has settled. + property bool layoutLive: true + onPlayingChanged: layoutLive = true + // Inspection changes ink, not node positions. A stationary pointer must + // not keep the expensive graph/label canvas painting every display frame. + onEffIdChanged: graphCanvas.requestPaint() + onHiddenKindsChanged: graphCanvas.requestPaint() property string verifyMsg: "" property bool verifyOk: false property string graphBoundary: "" @@ -58,6 +74,8 @@ Item { property var continuitySchedule: null property string continuityScheduleBoundary: "" property bool continuitySheetOpen: false + property bool continuityExpanded: false + property string intentReviewFeedback: "" property string continuityPage: "overview" property bool restoreConfirmOpen: false property string continuityActionMsg: "" @@ -127,6 +145,12 @@ Item { : Model.guidedLifecycle(root.runtimeEvidence, root.installCompletion, root.pluginVersion) readonly property bool setupRequired: root.releaseLifecycle !== "ready" + // Remember presentation history only; this never admits data or actions. + // A watched-file refresh should show CHECKING in the existing cockpit, + // not replace an established session with the first-install screen. + property bool lifecycleWasReady: false + readonly property bool showSetupGate: root.setupRequired + && !(root.lifecycleWasReady && root.releaseLifecycle === "checking") // A caveat on the wording, never a lifecycle. It cannot reach // releaseLifecycle, so no timeout can move this gate to ready, and it // asserts nothing about the installer beyond what SIA has observed. @@ -186,6 +210,22 @@ Item { && root.focusedWorkspaceName !== root.workspaceLockName readonly property bool cockpitVisible: root.opened && !root.workspaceLockMismatch + // Let the compositor move the finished surface. Repainting a full-screen + // opacity/translation every frame stalls the software Qt renderer. + Timer { + id: presentationHold + interval: 450 + onTriggered: { + // The keepLoaded file watchers already withdraw changed publications. + // Refresh again after arrival instead of parsing every snapshot while + // the first surface is being painted. + statusFile.reload(); installCompletionFile.reload() + graphFile.reload(); thoughtsFile.reload() + continuityFile.reload() + continuityScheduleRefresh.restart() + graphCanvas.requestPaint() + } + } readonly property bool continuityStale: Model.continuityStale(root.continuity, root.nowMs, Model.continuityStaleAfterSec()) @@ -217,9 +257,20 @@ Item { } function currentGraphSnapshot() { - return root.graphBoundary === "" - && Model.snapshotGenerationsMatch(root.currentStatus, root.graph) - ? root.graph : null + if (root.graphBoundary === "" + && Model.snapshotGenerationsMatch(root.currentStatus, root.graph)) + return root.graph + // A newer graph publication is on disk — pending validation, or validated + // but not yet named by any status. The current status still names the + // admitted generation, so that pair remains the combined snapshot claim. + var newerPublication = root.graphBoundary === "" + ? !!root.graph + : /pending validation$/.test(root.graphBoundary) + if (newerPublication + && Model.snapshotGenerationsMatch(root.currentStatus, + root.admittedGraph)) + return root.admittedGraph + return null } function isPlainRecord(value) { @@ -474,7 +525,24 @@ Item { return parts.join(" · ") } + // A boundary that names a routine revalidation beat is context, not an + // alarm; an unavailable or rejected source keeps the urgent colour. + function boundaryColor(text) { + return /pending validation$/.test(text) + ? Qt.alpha(root.fg, 0.55) : root.urgent + } + function graphSnapshotText() { + var shown = root.currentGraph + if (shown && shown !== root.graph) { + // The admitted generation the current status names, while a newer + // graph publication waits for the status that will name it. + return "graph published " + + Model.timeAgo(shown.ts, root.nowMs) + " · " + + (shown.snapshot && shown.snapshot.complete === true + ? "complete" : "partial") + + " · newer graph publication awaiting its status" + } if (root.graphBoundary !== "") return root.graphBoundary if (!root.graph || !root.graph.ts) return "no graph snapshot" if (!root.currentStatus) @@ -2037,6 +2105,20 @@ Item { root.setWorkspaceLock(root.focusedWorkspaceName) } + function reviewIntent(iid) { + if (root.setupRequired || !root.currentStatus + || !/^[0-9a-f]{10}$/.test(iid)) return + var rows = root.currentStatus.intents || [] + if (!rows.some(function(row) { return row.id === iid })) return + root.intentReviewFeedback = "Review terminal requested. If it did not open, check your desktop terminal launcher. No commitment is closed by this button." + // Only an admitted identity becomes an argument. Task prose is never code. + Quickshell.execDetached([ + "/usr/bin/env", "-u", "BASH_ENV", "-u", "ENV", + "omarchy-launch-terminal", "/usr/bin/python3", "-I", + root.pluginRoot + "/bin/sia-intent-review", iid]) + root.close() + } + function launchSetup() { // This is the sole UI launch edge. It is reached only from an explicit // click/key action; loading or enabling the plugin never executes setup. @@ -2192,6 +2274,7 @@ Item { if (root.workspaceLockMismatch) root.clearWorkspaceLock() opened = true workspaceLockFeedback = "" + intentReviewFeedback = "" root.clearVerification() continuityActionMsg = "" continuityActionOk = false @@ -2220,15 +2303,9 @@ Item { // age on this screen, the installing horizon included, is measured // against it; re-read it before any of them are painted. nowMs = Date.now() - // Opening requests fresh bytes without discarding the last validated - // generation. Cold startup and every failed load still resolve fail-closed. - statusFile.reload(); installCompletionFile.reload() - graphFile.reload(); thoughtsFile.reload() - continuityFile.reload() - continuityScheduleRefresh.restart() - if (root.currentGraph && graphCanvas.width > 0) - Model.syncGraph( - root.currentGraph, graphCanvas.width, graphCanvas.height) + // Watched publications and canvas resize handlers maintain the layout + // while closed. The presentation timer requests a fresh read on arrival. + presentationHold.restart() Qt.callLater(function() { if (payload.mode === "continuity") root.openContinuity("overview") else if (root.cockpitVisible && root.setupActionAllowed) @@ -2259,6 +2336,7 @@ Item { root.clearContinuityInputs() root.clearWorkspaceLock() workspaceLockFeedback = "" + intentReviewFeedback = "" } function dismiss() { @@ -2267,7 +2345,11 @@ Item { } onCockpitVisibleChanged: { - if (!root.cockpitVisible) return + if (!root.cockpitVisible) { + presentationHold.stop() + return + } + presentationHold.restart() Qt.callLater(function() { if (!root.cockpitVisible) return if (root.setupActionAllowed) firstLightButton.forceActiveFocus() @@ -2278,19 +2360,27 @@ Item { } onCurrentGraphChanged: { - if (root.currentGraph && graphCanvas.width > 0) + root.layoutLive = true + if (root.currentGraph && graphCanvas.width > 0 && graphCanvas.height > 0) Model.syncGraph( root.currentGraph, graphCanvas.width, graphCanvas.height) if (!root.currentGraph) { + // Withdrawn: hover is re-derived from the pointer, but a locked + // selection and a running replay survive the beat between one named + // generation and the next. root.hoverId = "" - root.selectedId = "" - root.playing = false - root.revealT = 1.0 + graphGapTimer.restart() + } else { + graphGapTimer.stop() + root.graphGapSettled = false + if (root.selectedId !== "" && !root.graphHasNode(root.selectedId)) + root.selectedId = "" } graphCanvas.requestPaint() } onReleaseLifecycleChanged: { + if (root.releaseLifecycle === "ready") root.lifecycleWasReady = true // Ahead of the visibility guard on purpose. The horizon has to keep // running while the cockpit is closed, which is where an installer // usually dies. @@ -2321,6 +2411,10 @@ Item { root.status = parsed root.statusLoadValid = true root.statusBoundary = "" + // A status that names the resident graph admits that generation. + if (root.graphBoundary === "" && root.graph + && Model.snapshotGenerationsMatch(root.status, root.graph)) + root.admittedGraph = root.graph root.stale = Model.timestampStale( parsed.ts, Date.now(), root.staleAfterSec) } catch (e) { @@ -2342,12 +2436,18 @@ Item { root.graphBoundary = root.graph || root.graphBoundary.indexOf("last good graph;") === 0 ? "last good graph; latest graph rejected" : "no valid graph snapshot" + root.admittedGraph = null return } - if (graphCanvas.width > 0) + // Only a generation the current status names may touch the layout: + // syncing an unnamed newer publication would rebuild the rings and + // adjacency under the admitted graph still on screen. + var named = Model.snapshotGenerationsMatch(root.currentStatus, g) + if (named && graphCanvas.width > 0 && graphCanvas.height > 0) Model.syncGraph(g, graphCanvas.width, graphCanvas.height) root.graph = g root.graphBoundary = "" + if (named) root.admittedGraph = g if (root.selectedId !== "" && !root.graphHasNode(root.selectedId)) root.selectedId = "" if (root.hoverId !== "" && !root.graphHasNode(root.hoverId)) @@ -2357,6 +2457,7 @@ Item { root.graphBoundary = root.graph || root.graphBoundary.indexOf("last good graph;") === 0 ? "last good graph; latest graph rejected" : "no valid graph snapshot" + root.admittedGraph = null } } @@ -2769,8 +2870,17 @@ Item { statusApply.restart() } } - Timer { id: statusApply; interval: 150; repeat: false + // Every snapshot is published by one atomic rename, so the settle wait only + // has to outlast the watcher's own burst of events for that rename. At + // 150-200 ms the beat between one named generation and the next was a + // visible blink; the reread itself validates whatever it finds. + Timer { id: statusApply; interval: 60; repeat: false onTriggered: statusFile.reload() } + // "current graph unavailable" is said only once the graph has been absent + // long enough to be a state, not the beat between two named generations. + property bool graphGapSettled: false + Timer { id: graphGapTimer; interval: 400; repeat: false + onTriggered: root.graphGapSettled = true } FileView { id: graphFile @@ -2795,6 +2905,7 @@ Item { var hadLastGood = !!root.graph || root.graphBoundary.indexOf("last good graph;") === 0 root.graph = null + root.admittedGraph = null root.selectedId = "" root.hoverId = "" graphCanvas.requestPaint() @@ -2806,18 +2917,19 @@ Item { root.clearVerification() readyProc.cancel() root.clearReadyCheck() - root.graphBoundary = root.graph + // One rename can arrive as a burst of change events; the first already + // withdrew `graph`, but the admitted generation is still the last good + // graph on screen. + root.graphBoundary = root.graph || root.admittedGraph ? "last good graph; newer graph snapshot pending validation" : "resident graph snapshot pending validation" root.graph = null - root.selectedId = "" - root.hoverId = "" graphCanvas.requestPaint() graphFile.refreshPending = true graphApply.restart() } } - Timer { id: graphApply; interval: 200; repeat: false + Timer { id: graphApply; interval: 60; repeat: false onTriggered: graphFile.reload() } FileView { @@ -2853,7 +2965,7 @@ Item { thoughtsApply.restart() } } - Timer { id: thoughtsApply; interval: 200; repeat: false + Timer { id: thoughtsApply; interval: 60; repeat: false onTriggered: thoughtsFile.reload() } FileView { @@ -3708,7 +3820,7 @@ Item { id: win visible: root.cockpitVisible anchors { top: true; bottom: true; left: true; right: true } - color: Color.background + color: root.bg exclusionMode: ExclusionMode.Ignore WlrLayershell.namespace: "sia-cockpit" WlrLayershell.layer: WlrLayer.Overlay @@ -3719,6 +3831,12 @@ Item { id: keyCatcher anchors.fill: parent focus: true + enabled: root.cockpitVisible + Rectangle { + anchors.fill: parent + color: root.bg + z: -1 + } // Sheets contain focusable controls, so Esc must remain available even // when a field rather than this catcher owns active focus. @@ -3980,9 +4098,10 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: "PUBLISHED SNAPSHOT · " + root.graphSnapshotText() - color: root.graphBoundary !== "" || (root.snap - && root.snap.complete !== true) - ? root.urgent : Qt.alpha(root.fg, 0.5) + color: root.graphBoundary !== "" && !root.currentGraph + ? root.boundaryColor(root.graphBoundary) + : (root.snap && root.snap.complete !== true) + ? root.urgent : Qt.alpha(root.fg, 0.5) font.family: root.fontFamily font.pixelSize: Style.font.caption } @@ -4268,6 +4387,7 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.continuityWeeklyText() + visible: root.continuityExpanded wrapMode: Text.WordWrap color: Qt.alpha(root.fg, 0.56) font.family: root.fontFamily @@ -4278,6 +4398,7 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.continuitySleepText() + visible: root.continuityExpanded wrapMode: Text.WordWrap color: Qt.alpha(root.fg, 0.48) font.family: root.fontFamily @@ -4289,6 +4410,7 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.continuityRepositoryText() + visible: root.continuityExpanded elide: Text.ElideMiddle color: Qt.alpha(root.fg, 0.7) font.family: root.fontFamily @@ -4299,6 +4421,7 @@ Item { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.continuityLatestText() + visible: root.continuityExpanded wrapMode: Text.WordWrap color: Qt.alpha(root.fg, 0.52) font.family: root.fontFamily @@ -4320,6 +4443,14 @@ Item { font.pixelSize: Style.font.caption } + Ui.Button { + text: root.continuityExpanded ? "Less detail ▴" : "Schedule & recovery details ▾" + fontSize: Style.font.caption + focusable: true + Accessible.name: text + onClicked: root.continuityExpanded = !root.continuityExpanded + } + Row { spacing: Style.spacing.sm Ui.Button { @@ -4449,12 +4580,12 @@ Item { rowSpacing: Style.space(2) Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: "memories"; color: Qt.alpha(root.fg, 0.55) font.family: root.fontFamily; font.pixelSize: Style.font.bodySmall } - Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: root.currentGraph ? String(root.currentStatus.pages) : "—" + Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: root.currentStatus && root.currentGraph ? String(root.currentStatus.pages) : "—" color: root.fg; font.bold: true font.family: root.fontFamily; font.pixelSize: Style.font.bodySmall } Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: "links"; color: Qt.alpha(root.fg, 0.55) font.family: root.fontFamily; font.pixelSize: Style.font.bodySmall } - Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: root.currentGraph ? String(root.currentStatus.graph_edges) : "—" + Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: root.currentStatus && root.currentGraph ? String(root.currentStatus.graph_edges) : "—" color: root.fg; font.bold: true font.family: root.fontFamily; font.pixelSize: Style.font.bodySmall } Text { textFormat: Text.PlainText; renderType: Text.NativeRendering; text: "events today"; color: Qt.alpha(root.fg, 0.55) @@ -5206,26 +5337,62 @@ Item { font.pixelSize: Style.font.caption font.bold: true } + Text { + textFormat: Text.PlainText + width: parent.width + wrapMode: Text.WordWrap + text: "Past due means a task needs review, not that SIA is broken. Review the records, then record an outcome to close it." + color: Qt.alpha(root.fg, 0.6) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + Text { + visible: root.intentReviewFeedback !== "" + textFormat: Text.PlainText + width: parent.width + wrapMode: Text.WordWrap + text: root.intentReviewFeedback + color: Qt.alpha(root.fg, 0.7) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } Repeater { model: root.currentStatus && root.currentStatus.intents ? root.currentStatus.intents : [] - delegate: Text { + delegate: Column { required property var modelData - textFormat: Text.PlainText - renderType: Text.NativeRendering width: intentCol.width - wrapMode: Text.WordWrap - text: (modelData.days_left < 0 - ? "➤ OVERDUE " + (-modelData.days_left) + "d — " - : modelData.days_left === 0 - ? "➤ due today — " - : "➤ in " + modelData.days_left + "d — ") - + modelData.text - color: modelData.days_left < 0 ? root.urgent - : modelData.days_left <= 2 ? root.accent - : Qt.alpha(root.fg, 0.7) - font.family: root.fontFamily - font.pixelSize: Style.font.caption + spacing: Style.space(4) + Text { + textFormat: Text.PlainText + renderType: Text.NativeRendering + width: parent.width + wrapMode: Text.WordWrap + text: modelData.text + color: Qt.alpha(root.fg, 0.85) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + Text { + textFormat: Text.PlainText + width: parent.width + wrapMode: Text.WordWrap + text: "Due " + modelData.due + (modelData.days_left < 0 + ? " · overdue — review needed" + : modelData.days_left === 0 ? " · today" : "") + color: modelData.days_left < 0 ? root.urgent : root.accent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + Ui.Button { + text: "Review commitment…" + fontSize: Style.font.caption + focusable: true + enabled: !!root.currentStatus && !root.setupRequired + Accessible.name: "Review commitment due " + modelData.due + Accessible.description: "Opens the full task in a terminal. Closing requires an outcome and your confirmation." + onClicked: root.reviewIntent(modelData.id) + } } } } @@ -5339,7 +5506,10 @@ Item { text: [root.graphBoundary, root.statusBoundary] .filter(function(value) { return value !== "" }).join(" · ") wrapMode: Text.WordWrap - color: root.urgent + color: [root.graphBoundary, root.statusBoundary].every( + function(value) { + return value === "" || /pending validation$/.test(value) + }) ? Qt.alpha(root.fg, 0.55) : root.urgent font.family: root.fontFamily font.pixelSize: Style.font.caption } @@ -5466,7 +5636,7 @@ Item { anchors.leftMargin: body.gap anchors.rightMargin: body.gap radius: Style.cornerRadius - color: Qt.alpha(root.fg, 0.03) + color: Qt.tint(root.bg, Qt.alpha(root.fg, 0.03)) border.color: Qt.alpha(root.fg, 0.10) border.width: 1 clip: true @@ -5475,25 +5645,75 @@ Item { id: graphCanvas anchors.fill: parent anchors.margins: 2 - renderStrategy: Canvas.Cooperative - - onWidthChanged: if (root.currentGraph && width > 0) + renderStrategy: Canvas.Immediate + // Finish the image before presenting the surface. Cooperative + // painting exposed an unpainted texture during remapping here. + opacity: root.currentGraph ? 1 : 0 + // The glow layer follows every graph frame, so it never shows + // a halo where a node no longer is. + onPainted: glowCanvas.requestPaint() + + // Both dimensions, not just width: the first size a canvas + // reports has its width and a zero height, and Model refuses to + // seed a layout on a zero-size canvas. + onWidthChanged: if (root.currentGraph && width > 0 && height > 0) { Model.syncGraph(root.currentGraph, width, height) - onHeightChanged: if (root.currentGraph && width > 0) + root.layoutLive = true + } + onHeightChanged: if (root.currentGraph && width > 0 && height > 0) { Model.syncGraph(root.currentGraph, width, height) + root.layoutLive = true + } - Timer { - interval: 40 - running: root.opened && root.currentGraph !== null - repeat: true + // The graph moves on the display's own frame clock and stops + // when nothing moves. A 40 ms Timer used to drive both the + // physics and the growth reveal: under the software-rendered + // canvas it fired late, so the 12-second replay stretched + // and every frame landed off the display's beat. + FrameAnimation { + id: graphFrames + running: root.cockpitVisible && !presentationHold.running + && root.currentGraph !== null + && root.layoutLive onTriggered: { + // A slow frame advances the layout by the time it took, up + // to 250 ms (Model integrates it in sub-ticks). Treating a + // slow frame as one 40 ms tick made wall-clock convergence + // six times slower on the 220 ms frames of a software + // renderer, so the loop ran through every pulse. + var ms = frameTime * 1000 + if (!(ms > 0)) ms = 40 + else if (ms > 250) ms = 250 if (root.playing) { - root.revealT = Math.min(1, root.revealT + 40 / 12000) + // Wall-clock reveal: twelve seconds regardless of frame rate. + root.revealT = Math.min(1, root.revealT + ms / 12000) if (root.revealT >= 1) root.playing = false } Model.step(root.currentGraph, - graphCanvas.width, graphCanvas.height, root.revealT) + graphCanvas.width, graphCanvas.height, + root.revealT, ms) graphCanvas.requestPaint() + if (!root.playing && Model.settled()) + root.layoutLive = false + } + } + + // Settled: nothing moves, but fresh memories breathe and the root + // keeps its halo. Five slow frames a second carry that, and only + // on glowCanvas: a full graph frame costs about 90 ms under a + // software renderer at 2x scale, and ten of those a second held + // a settled cockpit at 40% of a core. The physics does not run + // again until something changes. + Timer { + id: graphBreath + interval: 200 + repeat: true + running: root.cockpitVisible && !presentationHold.running + && root.currentGraph !== null + && !root.layoutLive + onTriggered: { + Model.breathe(interval) + glowCanvas.requestPaint() } } @@ -5501,6 +5721,8 @@ Item { var ctx = getContext("2d") ctx.reset() ctx.clearRect(0, 0, width, height) + ctx.fillStyle = graphCard.color + ctx.fillRect(0, 0, width, height) var graph = root.currentGraph if (!graph || !graph.nodes) return var now = root.nowMs > 0 ? root.nowMs : Date.now() @@ -5564,27 +5786,10 @@ Item { top: p.y - r - 3, bottom: p.y + r + 3 }) var col = Model.nodeColor(n, root.pal) - var fresh = Model.freshness(n, now) - if (fresh > 0.02 && !dimmed) { - var breathe = 0.75 + 0.25 * Math.sin(Model.phase() * 2 - + p.x * 0.05) - ctx.fillStyle = Qt.alpha(root.accent, 0.28 * fresh * breathe) - ctx.beginPath() - ctx.arc(p.x, p.y, r + 5 + 4 * fresh, 0, 2 * Math.PI) - ctx.fill() - } + // The fresh glow and the root halo breathe on glowCanvas. ctx.fillStyle = dimmed ? Qt.alpha(col, 0.22) : col ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 2 * Math.PI) ctx.fill() - if (n.id === "sia/cortex" && !dimmed) { - var halo = 0.35 + 0.20 * Math.sin(Model.phase()) - ctx.strokeStyle = Qt.alpha(root.fg, halo) - ctx.lineWidth = 1.2 - ctx.beginPath() - ctx.arc(p.x, p.y, r + 3.5, 0, 2 * Math.PI) - ctx.stroke() - ctx.lineWidth = 1 - } if (n.id === eff) { ctx.strokeStyle = root.fg ctx.beginPath() @@ -5713,6 +5918,59 @@ Item { } } + // Native rings avoid uploading a full transparent canvas for a + // small halo. They do not intercept graph inspection underneath. + Item { + id: glowCanvas + anchors.fill: graphCanvas + opacity: graphCanvas.opacity + property var rings: [] + function requestPaint() { + var next = [] + var graph = root.currentGraph + if (graph && graph.nodes) { + var eff = root.effId + var nbrs = eff !== "" ? Model.neighbors(eff) : null + for (var i = 0; i < graph.nodes.length; i++) { + var n = graph.nodes[i] + var p = Model.posOf(n.id) + if (!p || !root.nodeVisible(n)) continue + if (eff !== "" && n.id !== eff + && !Model.hasNeighbor(nbrs, n.id)) continue + var r = Model.nodeRadius(n) + var fresh = Model.freshness(n, root.nowMs) + if (fresh > 0.02) { + var breathe = 0.75 + 0.25 * Math.sin(Model.phase() * 2 + + p.x * 0.05) + next.push({ x: p.x, y: p.y, radius: r + 5 + 4 * fresh, + thickness: 5 + 4 * fresh, + ink: Qt.alpha(root.accent, 0.28 * fresh * breathe) }) + } + if (n.id === "sia/cortex") + next.push({ x: p.x, y: p.y, radius: r + 3.5, + thickness: 1.2, + ink: Qt.alpha(root.fg, 0.35 + 0.20 * Math.sin(Model.phase())) }) + } + } + rings = next + } + Repeater { + model: glowCanvas.rings + delegate: Rectangle { + required property var modelData + x: modelData.x - modelData.radius + y: modelData.y - modelData.radius + width: modelData.radius * 2 + height: width + radius: modelData.radius + color: "transparent" + border.width: modelData.thickness + border.color: modelData.ink + antialiasing: true + } + } + } + Text { textFormat: Text.PlainText renderType: Text.NativeRendering @@ -5867,7 +6125,7 @@ Item { + " of " + root.currentGraph.pages_total + " memories · " + root.currentGraph.edges.length + " links · " + (root.snap && root.snap.complete ? "complete" : "partial") - : "current graph unavailable" + : root.graphGapSettled ? "current graph unavailable" : "" color: Qt.alpha(root.fg, 0.45) font.family: root.fontFamily font.pixelSize: Style.font.caption @@ -6074,7 +6332,7 @@ Item { width: thoughtHeader.width text: root.thoughtsBoundary wrapMode: Text.WordWrap - color: root.urgent + color: root.boundaryColor(root.thoughtsBoundary) font.family: root.fontFamily font.pixelSize: Style.font.caption } @@ -6089,6 +6347,12 @@ Item { anchors.topMargin: Style.space(6) contentWidth: width contentHeight: thoughtCol.implicitHeight + // A revalidated stream fades back in; a withdrawn one empties + // at once. + opacity: root.thoughtsLoadValid ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } pixelAligned: true clip: true boundsBehavior: Flickable.StopAtBounds @@ -6130,19 +6394,22 @@ Item { width: parent.width text: thoughtRow.modelData.text wrapMode: Text.WordWrap + lineHeight: 1.2 color: thoughtRow.urgencyState === "unrecorded" ? Qt.alpha(root.fg, 0.6) : thoughtRow.urgencyState === "urgent" ? root.urgent : Qt.alpha(root.fg, 0.85) font.family: root.fontFamily - font.pixelSize: Style.font.caption + font.pixelSize: Style.font.bodySmall } Text { textFormat: Text.PlainText renderType: Text.NativeRendering text: root.thoughtRowMetadata( thoughtRow.modelData, root.nowMs) - color: Qt.alpha(root.fg, 0.35) + width: parent.width + wrapMode: Text.WordWrap + color: Qt.alpha(root.fg, 0.58) font.family: root.fontFamily font.pixelSize: Style.font.caption } @@ -6161,7 +6428,7 @@ Item { Rectangle { id: firstLightGate anchors.fill: parent - visible: root.setupRequired + visible: root.showSetupGate z: 30 color: Color.background diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 101569f..5dbbaed 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -3,6 +3,21 @@ SIA is maintained by **Khephri Labs** ([@AnubisQuantumCipher](https://github.com/AnubisQuantumCipher)), who holds release authority and the marketplace verify chain. +## Download and traffic reporting + +Run `python3 scripts/collect_github_metrics.py` with a `gh` login that can read +this repository's traffic. It archives GitHub's aggregate clone/view windows +and uploaded release-asset download counters under +`~/.local/state/sia-maintainer/github-traffic/`. Schedule it daily to retain +history beyond GitHub's rolling traffic window. It does not modify the SIA +installer or collect reports from users' machines. + +Keep daily observations as observations: overlapping traffic windows and +unique-cloner counts cannot be summed into lifetime people or installations. +Uploaded release assets expose download counters; generated source archives +are not uploaded assets. Neither clones nor downloads prove a successful +installation. Failed API requests produce no partial snapshot. + ## Credited reviewers Two outside contributors found SIA within a day of its marketplace listing and diff --git a/Model.js b/Model.js index a231239..39f4008 100644 --- a/Model.js +++ b/Model.js @@ -13,7 +13,7 @@ // one edge of the canvas. .pragma library -function releaseVersion() { return "1.8.0" } +function releaseVersion() { return "1.8.1" } // The checkout and the resident runtime advance as one release generation. // Only an exact release match may expose the cockpit. Comparison stays on @@ -461,7 +461,7 @@ function residentIntentShape(intents) { var row = intents[i] if (!recordHasExactly(row, ["id", "text", "due", "days_left"]) || typeof row.id !== "string" || !/^[0-9a-f]{10}$/.test(row.id) - || !redactionFreeStatusString(row.text, 70, true) + || !redactionFreeStatusString(row.text, 300, true) || !validCalendarDate(row.due) || !integerNumber(row.days_left)) return false } @@ -1581,7 +1581,7 @@ function resetLayout() { L.pos = {}; L.seeded = false; L.replaySeed = false; L.phase = 0 L.adj = {}; L.edgesByNode = {}; L.rings = []; L.targetAngle = {} L.sectorWidth = {} - L.width = 0; L.height = 0 + L.width = 0; L.height = 0; L.maxSpeed = 0 } function replayLayout(graph, w, h) { @@ -1623,6 +1623,12 @@ function targetRadius(n, half) { function syncGraph(graph, w, h) { if (!graph || !graph.nodes) return + // A canvas reports its width before its height while the overlay is laid + // out. Seeding against a zero dimension put every memory on one point (a + // ring of radius zero), and the layout then spent minutes of violent + // repulsion escaping it, one clamped tick per 240 ms software frame. + // Wait for a real canvas; the resize handlers seed once it has one. + if (!(w > 0) || !(h > 0)) return var cx = w / 2, cy = h / 2, half = Math.min(w, h) / 2 var i, n @@ -1806,24 +1812,61 @@ function syncGraph(graph, w, h) { L.seeded = true } -function step(graph, w, h, revealT) { - if (!graph || !graph.nodes || !L.seeded) return +function step(graph, w, h, revealT, dtMs) { + // Forces, damping and the speed cap were tuned per 40 ms tick. A frame + // clock delivers uneven intervals (8 ms at 120 Hz, longer under load), so + // the elapsed time is spent in sub-ticks of at most one tuned tick each: + // an explicit integrator fed a 2.5-tick step overshoots its own springs + // and never settles (under a software renderer with 100 ms frames the + // layout loop then runs at full frame rate forever, which is what a + // cockpit sitting still at 64% of a core was). Up to 250 ms of motion + // (6.25 ticks) is spent per call, so a 220 ms frame moves the layout by + // 220 ms of wall clock. Velocity stays in px per tick, so the cap below + // keeps its meaning; a settled graph still costs exactly one sub-tick to + // confirm it is settled. + var total = (typeof dtMs === "number" && isFinite(dtMs) && dtMs > 0) + ? Math.min(6.25, dtMs / 40) : 1 + var parts = Math.max(1, Math.ceil(total - 1e-9)) + L.maxSpeed = 0 + for (var k = 0; k < parts; k++) { + var peak = stepOnce(graph, w, h, revealT, total / parts) + if (peak > L.maxSpeed) L.maxSpeed = peak + } +} + +function stepOnce(graph, w, h, revealT, s) { + // One integration sub-tick of `s` tuned ticks (0 < s <= 1). Returns the + // fastest node's speed this sub-tick; step() keeps the maximum. + var damp = Math.pow(0.82, s) + var maxSpeed = 0 + if (!graph || !graph.nodes || !L.seeded) return 0 var nodes = graph.nodes, edges = graph.edges var cx = w / 2, cy = h / 2, half = Math.min(w, h) / 2 var i, j, a, b, dx, dy, d2, d, f var K_REP = 760, K_SPRING = 0.009, REST = 44 - var K_RAD = 0.085, K_ANGLE = 0.032, K_ORGAN = 0.16, DAMP = 0.82 - var active = {} - for (i = 0; i < nodes.length; i++) - active[graphMapKey(nodes[i].id)] = nodes[i].id === "sia/cortex" + var K_RAD = 0.085, K_ANGLE = 0.032, K_ORGAN = 0.16 + // Index the graph once per sub-tick. The pair loop below visits every + // node pair, and keying L.pos by string inside it built and discarded two + // strings per pair: 67 000 per tick on a 260-memory graph. Under the + // shell's interpreter that allocation, not the arithmetic, was the tick, + // and a tick was most of a 240 ms frame. Keys are built once per node. + var count = nodes.length + var keys = new Array(count), pos = new Array(count) + var active = new Array(count), index = {} + for (i = 0; i < count; i++) { + keys[i] = graphMapKey(nodes[i].id) + index[keys[i]] = i + pos[i] = L.pos[keys[i]] || null + active[i] = nodes[i].id === "sia/cortex" || nodes[i].t === "organ" || revealT === undefined || (nodes[i].tsNorm || 0) <= revealT - for (i = 0; i < nodes.length; i++) { - if (!active[graphMapKey(nodes[i].id)]) continue - a = L.pos[graphMapKey(nodes[i].id)]; if (!a) continue - for (j = i + 1; j < nodes.length; j++) { - if (!active[graphMapKey(nodes[j].id)]) continue - b = L.pos[graphMapKey(nodes[j].id)]; if (!b) continue + } + for (i = 0; i < count; i++) { + if (!active[i]) continue + a = pos[i]; if (!a) continue + for (j = i + 1; j < count; j++) { + if (!active[j]) continue + b = pos[j]; if (!b) continue dx = a.x - b.x; dy = a.y - b.y d2 = dx * dx + dy * dy if (d2 > 26000) continue @@ -1835,27 +1878,28 @@ function step(graph, w, h, revealT) { } f = K_REP / d2 d = Math.sqrt(d2) - a.vx += (dx / d) * f; a.vy += (dy / d) * f - b.vx -= (dx / d) * f; b.vy -= (dy / d) * f + a.vx += (dx / d) * f * s; a.vy += (dy / d) * f * s + b.vx -= (dx / d) * f * s; b.vy -= (dy / d) * f * s } } for (i = 0; i < edges.length; i++) { - if (!active[graphMapKey(edges[i].s)] - || !active[graphMapKey(edges[i].d)]) continue - a = L.pos[graphMapKey(edges[i].s)] - b = L.pos[graphMapKey(edges[i].d)] + var si = index[graphMapKey(edges[i].s)] + var di = index[graphMapKey(edges[i].d)] + if (si === undefined || di === undefined + || !active[si] || !active[di]) continue + a = pos[si]; b = pos[di] if (!a || !b) continue dx = b.x - a.x; dy = b.y - a.y d = Math.sqrt(dx * dx + dy * dy) || 1 f = K_SPRING * (d - REST) - a.vx += (dx / d) * f; a.vy += (dy / d) * f - b.vx -= (dx / d) * f; b.vy -= (dy / d) * f + a.vx += (dx / d) * f * s; a.vy += (dy / d) * f * s + b.vx -= (dx / d) * f * s; b.vy -= (dy / d) * f * s } - for (i = 0; i < nodes.length; i++) { + for (i = 0; i < count; i++) { var n = nodes[i] - var activeKey = graphMapKey(n.id) - a = L.pos[activeKey]; if (!a) continue - if (!active[activeKey]) { a.vx = 0; a.vy = 0; continue } + var activeKey = keys[i] + a = pos[i]; if (!a) continue + if (!active[i]) { a.vx = 0; a.vy = 0; continue } if (n.id === "sia/cortex") { a.x = cx; a.y = cy; a.vx = 0; a.vy = 0 continue @@ -1863,8 +1907,8 @@ function step(graph, w, h, revealT) { var organAngle = L.targetAngle[activeKey] var organX = cx + Math.cos(organAngle) * R_ORGAN * half var organY = cy + Math.sin(organAngle) * R_ORGAN * half - a.vx += (organX - a.x) * K_ORGAN - a.vy += (organY - a.y) * K_ORGAN + a.vx += (organX - a.x) * K_ORGAN * s + a.vy += (organY - a.y) * K_ORGAN * s } else { // Time owns radius; semantic ownership softly owns angle. The latter is // a tether, not a fixed point, so repulsion and links can still arrange @@ -1872,22 +1916,35 @@ function step(graph, w, h, revealT) { dx = a.x - cx; dy = a.y - cy var r = Math.sqrt(dx * dx + dy * dy) || 1 var want = targetRadius(n, half) - a.vx += (dx / r) * (want - r) * K_RAD - a.vy += (dy / r) * (want - r) * K_RAD + a.vx += (dx / r) * (want - r) * K_RAD * s + a.vy += (dy / r) * (want - r) * K_RAD * s var turn = angleDelta(L.targetAngle[activeKey], Math.atan2(dy, dx)) * r * K_ANGLE - a.vx += (-dy / r) * turn - a.vy += (dx / r) * turn + a.vx += (-dy / r) * turn * s + a.vy += (dx / r) * turn * s } - a.vx *= DAMP; a.vy *= DAMP + a.vx *= damp; a.vy *= damp var vm = Math.sqrt(a.vx * a.vx + a.vy * a.vy) if (vm > 6) { a.vx *= 6 / vm; a.vy *= 6 / vm } - a.x += a.vx; a.y += a.vy + if (vm > maxSpeed) maxSpeed = vm + a.x += a.vx * s; a.y += a.vy * s var m = Math.max(12, nodeRadius(n) + 6) if (a.x < m) a.x = m; if (a.x > w - m) a.x = w - m if (a.y < m) a.y = m; if (a.y > h - m) a.y = h - m } - L.phase += 0.03 + L.phase += 0.03 * s + return maxSpeed +} + +function breathe(dtMs) { + // Advance only the glow phase, at the same rate a 40 ms tick advanced it. + L.phase += 0.03 * ((typeof dtMs === "number" && dtMs > 0) ? dtMs / 40 : 1) +} + +function settled() { + // Motion below a fifth of a pixel per tick is not visible; the frame loop + // stops there and restarts on any input, so the settled graph costs nothing. + return (L.maxSpeed || 0) < 0.2 } // Candidate label centers, ordered from the node's outward radial side to diff --git a/README.md b/README.md index 79a5ec5..8a4b5c5 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,12 @@ watch its generated-entry stream and query the memory it has admitted. You can audit every stored word because the corpus is markdown in git and the daemon signs its own acts. -**Current release: v1.8.0.** Two receipts bind storage by device and inode +**Current release: v1.8.1.** This maintenance update makes commitments +reviewable from the cockpit, preserves the graph during status refreshes, +and reduces entrance and idle rendering work. See [the changelog](CHANGELOG.md) +for the complete changes. + +**Storage recovery introduced in v1.8.0.** Two receipts bind storage by device and inode number — the installer's corpus receipt and the delivery epoch's adoption — and a btrfs subvolume change, an `rsync` of the home directory, or a restore by copy changes those numbers. After that every pulse refused, `sia ready` diff --git a/ROADMAP.md b/ROADMAP.md index ad7dc15..098bdc0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,6 +111,12 @@ freeze ends on evidence, not on mood. first with a declaration-only `state=none` commit. That administrative closure is not the fix: the following push is the one permitted fix-only movement of `main`, and a later validation attempt must bind its resulting exact SHA separately. + - Operator-directed replacement: the operator explicitly requested that 1.8.1, + rather than the older commit, be reviewed. Retire issue #7719 before moving + `main`, merge the release through required checks, then submit its final + default-branch SHA for fresh review. This closes the old attempt; it does not + transfer its validation or approval to the replacement. Freeze `main` again + before submitting the replacement request. - The rule is mechanically checked by the `marketplace-freeze` job in `.github/workflows/ci.yml`, which reads the declaration below and turns a push to the frozen branch that is not the bound commit red. It cannot protect the @@ -308,10 +314,12 @@ maintainer's constraint is not "bind a good commit" but "bind the commit that is currently HEAD" — and this repository kept moving HEAD. The correction is upstream of the verify form: **do not push to `main` while a -verification is pending.** The current review binds `8a624ef…` with the form intact. -Until the listing flips or the maintainer closes the cycle, releases queue on branches. -After it flips, normal cadence resumes — one re-bind per release, after tagging, and -never while validation is in flight. +verification is pending.** The operator has directed replacement of the old +`f9a2b9838926904fff236d0c74c3ebd8fa30e487` request (#7719) with SIA 1.8.1. +Retire that request before merging release PR #20. Once required checks pass, +merge the release, establish the final frozen default-branch HEAD, and request +fresh verification of that exact commit. Do not reuse the old scan as evidence +for 1.8.1. Keep subsequent changes on branches until the replacement review ends. The binding is declared here, in one machine-readable line, because a rule only a reader can see is the rule that was already broken three times. CI reads this exact @@ -320,7 +328,7 @@ line (`.github/workflows/ci.yml`, job `marketplace-freeze`): while the state is Closing the cycle means editing the line to `state=none`; re-binding means editing `sha=` and nothing else in the same commit, because no commit can name its own SHA. - sia-freeze: state=none branch=main sha=8a624efc911457ae393a72758ade8729de5ba45d + sia-freeze: state=none branch=main sha=f9a2b9838926904fff236d0c74c3ebd8fa30e487 --- diff --git a/bin/sia b/bin/sia index 0ad9461..1c0e699 100755 --- a/bin/sia +++ b/bin/sia @@ -42,7 +42,8 @@ biological brain and does not establish cognition or neuroscience. sia takes list predictions and their status sia intend "task" --by YYYY-MM-DD dated commitment that SIA surfaces - as its deadline nears (--list · --done [note]); + as its deadline nears (--list · --show + · --done [note]); -- ends options here too sia grade [id] grade due predictions now (configured judge, deterministic Brier); with id, grade that one @@ -573,6 +574,36 @@ def _live_view_text(value): return result +def _live_lane_present(): + """Whether this machine has a live loop for `sia status` to report on. + + The live loop belongs to the opt-in controller-source lane. With the + opt-in off and no retained lane state or cached view, there is nothing + to inspect, and a refusal plus its boundaries would describe an absence + as a failure. Anything unreadable reports as present, so a real refusal + is never hidden. + """ + try: + import sialiveview + memo = sialib.load_memo() + return (sialib._controller_source_enabled() + or sialib._controller_source_present(memo) + or any(key in memo for key in ( + "live_loop_committed", "live_loop_pending", + "controller_checkpoint_chain", "controller_delivery_epoch")) + or os.path.lexists(sialiveview._cache_path(sialib.__dict__))) + except Exception: + return True + + +def _print_live_status_section(): + """The live section of `sia status`: the retained view, or one line saying off.""" + if _live_lane_present(): + return _print_live_summary() + print(" live off · mind.controller_source is false and no live loop is retained") + return {"status": "off"} + + def _print_live_summary(value=None): selected = _read_live_view_result() if value is None else value try: @@ -652,7 +683,7 @@ def cmd_status(): except sialib.OwnerBusy: print("SIA · resident pulse in progress · readiness check deferred") _print_pulse_failure() - selected = _print_live_summary() + selected = _print_live_status_section() return 1 if selected["status"] == "refused" else 0 @@ -758,7 +789,7 @@ def _cmd_status_owned(): print(f" errors {st['errors']}") if sialib.CONFIG_ERRORS: print(f" config {sialib.CONFIG_ERRORS}") - _print_live_summary() + _print_live_status_section() return 0 @@ -1481,7 +1512,7 @@ def cmd_rehearse(argv): def cmd_think(): - _print_live_summary() + _print_live_status_section() store = sialib.load_thoughts() for t in store["thoughts"][-15:]: mark = "!" if t.get("urgent") else "·" @@ -2519,6 +2550,20 @@ def cmd_ledger(): def cmd_intend(argv): """Manage dated commitments that SIA surfaces near their deadlines.""" import siatakes + if argv and argv[0] == "--show": + if len(argv) != 2 or re.fullmatch(r"[0-9a-f]{10}", argv[1]) is None: + print("usage: sia intend --show ") + return 2 + matches = [it for it in siatakes.open_intents() + if it["id"] == argv[1]] + if len(matches) != 1: + print("No unique open commitment matches that id.") + return 1 + it = matches[0] + print(json.dumps({key: it[key] for key in + ("id", "text", "due", "holder", "created")}, + ensure_ascii=True)) + return 0 if argv and argv[0] == "--history": cursor = argv[2] if len(argv) >= 3 and argv[1] == "--cursor" else None if len(argv) not in (1, 3) or len(argv) == 3 and argv[1] != "--cursor": @@ -2541,7 +2586,7 @@ def cmd_intend(argv): d = it["days_left"] when = (f"OVERDUE {-d}d" if d < 0 else "due today" if d == 0 else f"due in {d}d") - print(f" {it['id'][:6]} [{when:>11}] " + print(f" {it['id']} [{when:>11}] " f"{sialib.clip(it['text'], 70)}") return 0 if argv[0] == "--done": diff --git a/bin/sia-intent-review b/bin/sia-intent-review new file mode 100644 index 0000000..63823fd --- /dev/null +++ b/bin/sia-intent-review @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Terminal review UI; all reads/writes pass through the resident SIA CLI. + +No corpus lease is held while the operator considers a commitment. The final +CLI call re-admits readiness and closes only a still-open, exact identity. +""" +import json +import os +import re +import subprocess +import sys + + +def display(text): + # Stored prose must never become terminal control sequences. + return "".join(c if c.isprintable() else " " for c in str(text)) + + +def run_cli(args): + return subprocess.run( + [os.path.expanduser("~/.local/bin/sia"), "intend", *args], + capture_output=True, text=True, timeout=120, check=False) + + +def review(iid): + result = run_cli(["--show", iid]) + if result.returncode: + print("Unable to read this commitment. It may be closed, or SIA may need an update or recovery.") + print(display(result.stdout or result.stderr)) + return 1 + row = json.loads(result.stdout) + if (not isinstance(row, dict) or row.get("id") != iid + or not all(isinstance(row.get(k), str) + for k in ("text", "due", "holder", "created"))): + raise ValueError("Commitment response did not match the request") + print("\nREVIEW COMMITMENT\n") + print(display(row["text"])) + print("\nDue: " + display(row["due"]) + " · Holder: " + display(row["holder"])) + print("Recorded: " + display(row["created"])) + print("\nA passed due date is a reminder, not a system failure.") + print("Review the relevant records before closing this task.") + print("Closing records your outcome; it does not verify a prediction or repair a problem.") + if input("\nType done to record completion, or press Enter to leave open: ").strip() != "done": + print("Left open. Nothing changed.") + return 0 + note = input("Outcome or supporting record (required, at most 200 characters): ").strip() + if not note or len(note) > 200 or any(not c.isprintable() for c in note): + print("A printable outcome of at most 200 characters is required. Left open.") + return 2 + print("\nOutcome: " + display(note)) + if input("Type close to save this outcome and close the commitment: ").strip() != "close": + print("Left open. Nothing changed.") + return 0 + result = run_cli(["--done", iid, note]) + print(display(result.stdout or result.stderr)) + if result.returncode == 0: + print("Closed. The cockpit updates after SIA's next successful publication.") + return result.returncode + + +def main(argv): + if len(argv) != 1 or re.fullmatch(r"[0-9a-f]{10}", argv[0]) is None: + print("usage: sia-intent-review ") + return 2 + if not sys.stdin.isatty(): + print("Review requires an interactive terminal; nothing changed.") + return 2 + try: + return review(argv[0]) + except (EOFError, KeyboardInterrupt): + print("\nReview interrupted. No completion is claimed; check SIA before retrying.") + return 1 + except (OSError, ValueError, subprocess.SubprocessError) as exc: + print("Review stopped: " + display(exc)) + print("No completion is claimed. Check SIA before retrying.") + return 1 + finally: + try: + input("\nPress Enter to return to the desktop.") + except (EOFError, KeyboardInterrupt): + pass + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bin/sia-mcp b/bin/sia-mcp index 34189a6..571d10f 100755 --- a/bin/sia-mcp +++ b/bin/sia-mcp @@ -22,7 +22,7 @@ import siaqueue HOME = os.path.expanduser("~") SIA = os.path.join(HOME, ".local/bin/sia") STATE = os.path.join(HOME, ".local/state/sia") -SERVER_VERSION = "1.8.0" +SERVER_VERSION = "1.8.1" MODERN_PROTOCOL = "2026-07-28" LEGACY_PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") diff --git a/bin/siacheckpointcycle.py b/bin/siacheckpointcycle.py index 3a96e17..33b3d09 100644 --- a/bin/siacheckpointcycle.py +++ b/bin/siacheckpointcycle.py @@ -254,7 +254,8 @@ def route(owner, *, memo, configured_directory, clock, journal_limits, RETIRED_KEYS = ("controller_source_committed", "controller_checkpoint_chain", "controller_delivery_epoch", "live_loop_committed") RETIRED_FILES = (("live_generation", "LIVE_STATE_PATH"), - ("live_candidate", "LIVE_CANDIDATE_PATH")) + ("live_candidate", "LIVE_CANDIDATE_PATH"), + ("live_view", "LIVE_VIEW_CACHE_PATH")) RETIREMENT_SCHEMA = "sia-controller-source-retirement-v1" RETIREMENT_DIRECTORY = "controller-source-superseded" RETIREMENT_NOTE = ("the checkpoint chain has no rollover; its archives, chain " @@ -312,7 +313,11 @@ def retire(owner, *, memo, apply, reason, retired_at): retired = {key: memo[key] for key in RETIRED_KEYS if key in memo} files = {} for label, name in RETIRED_FILES: - path = owner[name] + # The cached live view is the cockpit's copy of the retired lane's + # last generation; left in place, `sia status` on the released lane + # keeps printing a retained pulse that is no longer being advanced. + path = os.path.join(owner["STATE"], "live-view.json") \ + if name == "LIVE_VIEW_CACHE_PATH" else owner[name] if owner["_live_present"](path): with open(path, "rb") as stream: digest = owner["hashlib"].sha256(stream.read()).hexdigest() diff --git a/bin/sialib.py b/bin/sialib.py index ae00e82..d158d08 100644 --- a/bin/sialib.py +++ b/bin/sialib.py @@ -636,7 +636,7 @@ def _build_organs(): HIGH_TAGS = ["integrity-failure", "refusal", "crash", "coredump", "failed", "collapse", "healing", "urgent"] -VERSION = "1.8.0" +VERSION = "1.8.1" # Corpus bytes and their derived PGLite/graph projections form one publication @@ -8710,7 +8710,7 @@ def _status_intents_shape(value): or not isinstance(row.get("id"), str) \ or re.fullmatch(r"[0-9a-f]{10}", row["id"]) is None \ or not _status_display_string( - row.get("text"), nonempty=True, limit=70) \ + row.get("text"), nonempty=True, limit=300) \ or not _status_calendar_date(row.get("due")) \ or isinstance(row.get("days_left"), bool) \ or not isinstance(row.get("days_left"), int) \ @@ -10408,7 +10408,7 @@ def _pulse_transaction_guarded( "pinned": memory_state.get("pinned", 0)}, "takes": takes_sum, "intents": [{"id": it.get("id", "?"), - "text": clip(it.get("text", ""), 70), + "text": clip(it.get("text", ""), 300), "due": it.get("due", ""), "days_left": it.get("days_left", 0)} for it in intents_open[:MAX_STATUS_INTENTS] diff --git a/bin/siatakes.py b/bin/siatakes.py index cabb890..41d8a73 100644 --- a/bin/siatakes.py +++ b/bin/siatakes.py @@ -64,9 +64,13 @@ "schema", "kind", "event", "source_sha256", "target_sha256", "target_size", "target_text", "retire", }) +# Every top-level key the runtime's own config loader admits. A key missing +# here (the "mind" section the shipped config.example.json carries was one) +# makes the judge read the whole config as malformed and fail closed to +# none, silently: grading never ran for anyone on the example config. CONFIG_TOP_LEVEL_KEYS = frozenset({ "_comment", "_egress_trust_boundary", "judge", "senses", "skills", - "custom_senses", "chains", "retrieval", + "custom_senses", "chains", "retrieval", "mind", }) JUDGE_CONFIG_KEYS = frozenset({"_comment", "backend", "model"}) # are deliberately much larger than the admitted grading excerpts while still @@ -1148,7 +1152,50 @@ def _unverified_jackal_slug(slug): ("events/jackal/", "epochs/jackal/")) +def _option_shaped(query): + """Whether the engine's CLI would read this claim as a flag, not as text. + + Measured against the shipped engine rather than reasoned about. `--help` + and `-h` make `gbrain query` print its usage text instead of results. + `-1 regressions will land by March` runs an ordinary search and returns + them. The engine has no end-of-options escape: passing `--` consumes the + text and fails with `invalid_params`, so it would break the recall rather + than rescue it. Token shape is the only safe discrimination. + + A leading dash followed by a letter is a flag. A leading dash followed by + a digit or a decimal point is a negative number, which is a legitimate + claim that `sia take -- …` deliberately exists to let a caller register. + """ + q = query.lstrip() + if not q.startswith("-"): + return False + rest = q.lstrip("-") + return not (rest[:1].isdigit() or rest.startswith(".")) + + def _recall(query, k=6): + if not isinstance(query, str) or not query.strip(): + return RecallEvidence( + False, "", frozenset(), + "claim is empty, so no recall was attempted") + if _option_shaped(query): + # The engine's CLI reads this claim as a flag and prints usage text + # instead of a result list, which admission then refuses. That is an + # evidence lane that DID NOT RUN, and `GradingEvidenceUnavailable` + # states the consequence: the take stays open, because "only a + # completed evidence read may be judged UNRESOLVABLE". + # + # Reporting `completed=True` here closed the take on a corpus search + # that never happened. The judge still received organ snapshots, could + # return TRUE or FALSE from that half of the lane, and the published + # page said it was "judged against a signed evidence snapshot" with + # nothing to distinguish "recall ran and admitted nothing" from "recall + # never ran". A take left open is a visible junk claim; a take closed + # that way is a scored one. + return RecallEvidence( + False, "", frozenset(), + "claim is option-shaped, so the engine CLI would read it as a " + "flag and no recall was attempted") try: # Imported lazily to avoid the sialib -> siatakes module cycle. Every # shipped PGLite operation must enter the same cross-process owner diff --git a/docs/CONTINUITY.md b/docs/CONTINUITY.md index 96ae80f..5dce2d5 100644 --- a/docs/CONTINUITY.md +++ b/docs/CONTINUITY.md @@ -1,6 +1,6 @@ # SIA continuity -**Describes SIA v1.8.0 · 2026-09-19** +**Describes SIA v1.8.1 · 2026-09-20** SIA continuity is the backup and clean-machine recovery boundary for SIA, the Omarchy Brain. “Brain” is a product metaphor for auditable local machine memory; diff --git a/docs/MANUAL.md b/docs/MANUAL.md index cd71fed..0929578 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -3,7 +3,7 @@ “Brain” is a product metaphor for auditable local machine memory; it is not a biological brain and does not establish cognition or neuroscience. -**Describes SIA v1.8.0 · 2026-09-19** +**Describes SIA v1.8.1 · 2026-09-20** *Sia: the Egyptian personification of perception, who rode the solar barque beside Hu (utterance) and Heka (magic).* @@ -262,6 +262,34 @@ activating its controller are separate deployment steps. ## 2. The cockpit +**Commitments:** a past due date means a task still needs review; it does not +mean SIA is broken. **Review commitment…** opens the full task, holder, and due +date in a terminal and closes the cockpit so the terminal is accessible. Check +the relevant records first. To finish, type `done`, enter a short outcome or +supporting record, then type `close`. Enter at either confirmation leaves the +task open. Closing records your outcome; it does not verify predictions or +repair a system problem. The reminder clears after the next successful SIA +publication. If the terminal cannot launch, use `sia intend --list` and +`sia intend --show `; the latter reads the full open task as JSON. +Existing installations need the matching updated runtime for this review flow. + +The cockpit leaves entrance and dismissal motion to the compositor, avoiding +a second full-screen fade in Qt. For a straight-down entrance on Omarchy's Lua +Hyprland configuration, add this scoped rule to `~/.config/hypr/looknfeel.lua`: + +```lua +hl.layer_rule({ + name = "sia-cockpit-presentation", + match = { namespace = "^sia-cockpit$" }, + animation = "slide top", +}) +``` + +Reload with `hyprctl reload` and check `hyprctl configerrors`. This only changes +SIA's direction; desktop animation timing and reduced-motion preferences still +apply. The continuity card's **Schedule & recovery details** control expands +routine backup information without hiding warnings or recovery actions. + Summoned from the bar (or with SUPER+SHIFT+B after an install using `SIA_INSTALL_KEYBINDING=1`); leaves with **Esc**, ✕, or `omarchy-shell shell hide khephri.sia`. @@ -371,8 +399,13 @@ the result responsive but legible. Nodes glow when freshly touched. gaps mark the snapshot partial in SOURCE HEALTH. If graph publication throws, the pulse exposes the error and signs `PULSE:ingest ... graph-fail` rather than reporting successful graph publication. Only a structurally exact - partial envelope remains a diagnostic graph: an open or malformed envelope, - or a status/graph publication mismatch, withdraws every current graph claim. + partial envelope remains a diagnostic graph: an open or malformed envelope + withdraws every current graph claim. The displayed graph is always the + generation the displayed status names: while a newer graph publication + waits for the status that will name it (the brainstem writes `graph.json` + seconds before `status.json`), the cockpit keeps the named generation on + screen and the snapshot line says `newer graph publication awaiting its + status`; a status naming neither generation withdraws the graph. - **The graph window is incrementally projected.** Publication advances a durable no-follow corpus directory cursor, retains only the capped cockpit candidates, and rereads only those selected pages under their observed diff --git a/docs/WHITEPAPER.md b/docs/WHITEPAPER.md index dcf7678..7090d5c 100644 --- a/docs/WHITEPAPER.md +++ b/docs/WHITEPAPER.md @@ -3,7 +3,7 @@ “Brain” is a product metaphor for auditable local machine memory; it is not a biological brain and does not establish cognition or neuroscience. -**Khephri Labs · open source (MIT) · 2026-09-19 · v1.8.0** +**Khephri Labs · open source (MIT) · 2026-09-20 · v1.8.1** *Measurements and deployment details herein are from the reference deployment: an Omarchy Linux 4.0 (aarch64) machine running the full optional-integration set.* diff --git a/install.sh b/install.sh index 6de113b..c8543b6 100755 --- a/install.sh +++ b/install.sh @@ -8790,7 +8790,7 @@ SIA_RELEASE_FILES=( bin/sialifetime.py manifest.json preview.png Panel.qml Cockpit.qml LiveView.qml Model.js README.md ROADMAP.md LICENSE SECURITY.md CHANGELOG.md GBRAIN_PIN GBRAIN_OVERLAY.patch config.example.json install.sh - uninstall.sh bin/sia bin/sia-setup bin/sia-brainstem bin/sia-ledger + uninstall.sh bin/sia bin/sia-setup bin/sia-intent-review bin/sia-brainstem bin/sia-ledger bin/sia-mcp bin/sia-continuity-worker bin/siabench.py bin/siabackup.py bin/siacapsule.py bin/sialib.py bin/siagraph.py bin/siathought.py bin/siasenses.py bin/siarestoreadmit.py bin/siamind.py bin/siaqueue.py bin/siarelease.py bin/siaactivation.py diff --git a/manifest.json b/manifest.json index 855f873..6858ded 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "khephri.sia", "name": "SIA", - "version": "1.8.0", + "version": "1.8.1", "author": "Khephri Labs", "license": "MIT", "description": "SIA — the Omarchy Brain: a persistent, associative, self-consolidating memory for your Linux desktop. Local embeddings, typed knowledge graph, deterministic cognitive core, outcome learning with an audited judge, mission-control cockpit, MCP for every agent.", diff --git a/scripts/collect_github_metrics.py b/scripts/collect_github_metrics.py new file mode 100644 index 0000000..128fe60 --- /dev/null +++ b/scripts/collect_github_metrics.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Archive GitHub's aggregate repository metrics, never installer telemetry.""" +import argparse +import datetime +import json +import os +from pathlib import Path +import subprocess +import tempfile + + +def fetch(repository, endpoint): + result = subprocess.run( + ["gh", "api", f"repos/{repository}/{endpoint}"], + capture_output=True, text=True, timeout=120, check=False) + if result.returncode: + raise RuntimeError(f"GitHub {endpoint} request failed; check gh authentication and repository access") + return json.loads(result.stdout) + + +def collect(repository): + traffic = {} + for endpoint in ("clones", "views"): + value = fetch(repository, "traffic/" + endpoint) + if (not isinstance(value, dict) + or type(value.get("count")) is not int + or type(value.get("uniques")) is not int + or not isinstance(value.get(endpoint), list)): + raise ValueError(f"Invalid {endpoint} response") + traffic[endpoint] = value + releases = fetch(repository, "releases?per_page=100") + if not isinstance(releases, list): + raise ValueError("Invalid releases response") + assets = [{"tag": release["tag_name"], "assets": [ + {"id": asset["id"], "name": asset["name"], + "download_count": asset["download_count"]} + for asset in release["assets"]]} for release in releases] + return { + "schema": "sia-github-metrics-v1", + "repository": repository, + "observed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "traffic": traffic, + "recent_releases": assets, + "limitations": [ + "Traffic reports GitHub's rolling 14-day window, not lifetime totals.", + "Overlapping windows and unique counts must not be summed.", + "Clones and downloads do not establish successful installations or distinct people.", + "Release data covers the latest API page (up to 100 releases); source archives are not uploaded assets.", + "This collector contacts GitHub only; installed SIA clients send it no reports.", + ], + } + + +def save(snapshot, directory): + directory = Path(directory).expanduser() + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + # A unique name preserves repeated samples without replacing older history. + stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H%M%SZ") + fd, name = tempfile.mkstemp(prefix=stamp + "-", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w") as stream: + json.dump(snapshot, stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + target = Path(name).with_suffix(".json") + os.rename(name, target) + return target + finally: + if os.path.exists(name): + os.unlink(name) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", default="AnubisQuantumCipher/sia") + parser.add_argument("--output", default="~/.local/state/sia-maintainer/github-traffic") + args = parser.parse_args() + try: + snapshot = collect(args.repository) + print(save(snapshot, args.output)) + except (OSError, ValueError, KeyError, TypeError, RuntimeError, + subprocess.SubprocessError) as error: + parser.exit(1, f"Metrics collection failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_checkpoint_bootstrap.py b/tests/test_checkpoint_bootstrap.py index cda544c..ec4bf51 100644 --- a/tests/test_checkpoint_bootstrap.py +++ b/tests/test_checkpoint_bootstrap.py @@ -229,7 +229,12 @@ def test_retirement_releases_lane_authority_under_a_receipt(self): # segment's initial batch, not the retired lane in flight. fresh = {k: v for k, v in held.items() if k != "controller_source_committed"} self.assertIsNone(self.cycle.retirement_pending(vars(owner), fresh)) - live_files = [owner.LIVE_STATE_PATH, owner.LIVE_CANDIDATE_PATH] + live_files = [owner.LIVE_STATE_PATH, owner.LIVE_CANDIDATE_PATH, + os.path.join(owner.STATE, "live-view.json")] + import sialiveview + sialiveview.publish_cache(vars(owner)) + self.assertTrue(os.path.exists(os.path.join(owner.STATE, "live-view.json")), + "the fixture published no cached live view") present = [path for path in live_files if os.path.exists(path)] self.assertTrue(present, "the fixture published no live generation") @@ -256,6 +261,7 @@ def test_retirement_releases_lane_authority_under_a_receipt(self): self.assertFalse(owner._controller_source_present(durable)) self.assertFalse(owner._live_started(durable), "a retired live lineage still counts as started") + self.assertIn("live_view", receipt["retired_files"]) for label, row in receipt["retired_files"].items(): self.assertFalse(os.path.exists(row["path"])) self.assertTrue(os.path.isfile(row["retained_as"])) diff --git a/tests/test_cli.py b/tests/test_cli.py index bcdacf4..ad16f53 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1186,6 +1186,42 @@ def test_status_surfaces_config_health_without_a_prior_pulse(self): self.assertEqual(sia.cmd_status(), 1) self.assertIn("config-invalid-json", output.getvalue()) + def test_status_omits_the_live_view_when_the_opt_in_lane_is_off(self): + """On the released lane `sia status` said "live refused · + live-view-cache-unavailable" followed by seven boundary lines: an + absence described as a failure. With the opt-in off and nothing + retained it now prints one line; a retained lane still reports.""" + import sialiveview + status = _current_status_fixture() + with tempfile.TemporaryDirectory() as state, \ + mock.patch.object(sia.sialib, "STATE", state), \ + mock.patch.object(sia.sialib, "corpus_owner", + return_value=contextlib.nullcontext()), \ + mock.patch.object(sia, "_corpus_owner_nowait", + return_value=contextlib.nullcontext()), \ + mock.patch.object(sia.sialib, "load_thoughts", return_value={"thoughts": []}), \ + mock.patch.object(sia.sialib, "memory_readiness", return_value=(True, "")), \ + mock.patch.object(sia.sialib, "_controller_source_enabled", return_value=False): + with mock.patch.object(sia.sialib, "load_memo", return_value={"pulse_seq": 8}), \ + mock.patch.object(sia.sialib, "read_state_json", + side_effect=(copy.deepcopy(status), _current_graph_fixture(), None)): + output = io.StringIO() + with contextlib.redirect_stdout(output): + sia.cmd_status() + text = output.getvalue() + self.assertIn("live off", text) + self.assertNotIn("live-view-cache-unavailable", text) + self.assertNotIn("boundary This view reports", text) + with mock.patch.object(sia.sialib, "load_memo", + return_value={"pulse_seq": 8, "live_loop_committed": {}}), \ + mock.patch.object(sia.sialib, "read_state_json", + side_effect=(copy.deepcopy(status), _current_graph_fixture(), None)): + output = io.StringIO() + with contextlib.redirect_stdout(output): + sia.cmd_status() + self.assertIn("refused", output.getvalue().lower()) + self.assertNotIn("live off", output.getvalue()) + def test_status_latest_thought_carries_origin(self): status = _current_status_fixture() status["thought"] = { diff --git a/tests/test_cockpit_boundary_horizon.py b/tests/test_cockpit_boundary_horizon.py index 9038c5d..9e16141 100644 --- a/tests/test_cockpit_boundary_horizon.py +++ b/tests/test_cockpit_boundary_horizon.py @@ -586,7 +586,7 @@ def test_current_status_shape_matches_backend_bounds(self): malformed.append(candidate) malformed.append(self._snapshot(workspace=["x" * 4097])) malformed.append(self._snapshot(intents=[{ - "id": "0123456789", "text": "x" * 70 + "y", + "id": "0123456789", "text": "x" * 300 + "y", "due": "2026-09-04", "days_left": 0, }])) malformed.append(self._snapshot(intents=[{ @@ -667,10 +667,10 @@ def test_stamped_status_publication_ids_match_the_backend_envelope(self): def test_vitals_withdraw_graph_counts_without_a_graph_generation(self): cockpit = " ".join(_read("Cockpit.qml").split()) self.assertIn( - 'root.currentGraph ? String(root.currentStatus.pages) : "—"', + 'root.currentStatus && root.currentGraph ? String(root.currentStatus.pages) : "—"', cockpit) self.assertIn( - 'root.currentGraph ? String(root.currentStatus.graph_edges) : "—"', + 'root.currentStatus && root.currentGraph ? String(root.currentStatus.graph_edges) : "—"', cockpit) frozen = self._snapshot() diff --git a/tests/test_cockpit_integrity_boundaries.py b/tests/test_cockpit_integrity_boundaries.py index 91504fe..d827127 100644 --- a/tests/test_cockpit_integrity_boundaries.py +++ b/tests/test_cockpit_integrity_boundaries.py @@ -631,6 +631,131 @@ def test_snapshot_rejection_withdraws_prior_live_results(self): self.assertIn("readyProc.cancel()", block) self.assertIn("root.clearReadyCheck()", block) + def test_current_graph_gate_keeps_the_generation_the_status_names(self): + # The brainstem republishes graph.json several times a pulse and names + # the final one in status.json seconds later. A newer publication on + # disk never displaces the generation the current status still names: + # that pair is the combined snapshot claim until a status names another. + cockpit = _read("Cockpit.qml") + gate = _qml_function(cockpit, "currentGraphSnapshot") + wrapper = """ +var Model = { snapshotGenerationsMatch: snapshotGenerationsMatch } +var root = { + currentStatus: null, graph: null, graphBoundary: "", admittedGraph: null, + currentGraphSnapshot: currentGraphSnapshot +} +function shown(status, graph, boundary, admitted) { + root.currentStatus = status + root.graph = graph + root.graphBoundary = boundary + root.admittedGraph = admitted + return root.currentGraphSnapshot() +} +""" + gate + admitted = {"publication_id": "a" * 32, "nodes": [], "edges": [], + "snapshot": {"complete": True}} + newer = {"publication_id": "b" * 32, "nodes": [], "edges": [], + "snapshot": {"complete": False}} + names_admitted = {"graph_publication_id": "a" * 32} + pending = "last good graph; newer graph snapshot pending validation" + for status, graph, boundary, expected in ( + # validated newer publication, not yet named by any status + (names_admitted, newer, "", admitted), + # newer publication still pending validation + (names_admitted, None, pending, admitted), + # a burst of change events for one rename: the second event + # found `graph` already withdrawn by the first + (names_admitted, None, + "resident graph snapshot pending validation", admitted), + # the status advanced to name the newer publication + ({"graph_publication_id": "b" * 32}, newer, "", newer)): + with self.subTest(boundary=boundary, status=status): + self.assertEqual(self._run( + wrapper, "shown", [status, graph, boundary, admitted], + sources=("Model.js",)), expected) + for status, graph, boundary, retained in ( + # a rejected or vanished resident graph withdraws the claim + (names_admitted, newer, + "last good graph; latest graph rejected", admitted), + (names_admitted, None, + "last good graph; resident graph snapshot unavailable", + admitted), + # a status naming neither generation, or no status at all + ({"graph_publication_id": "c" * 32}, newer, "", admitted), + (None, newer, "", admitted), + ({"graph_publication_id": ""}, newer, "", admitted), + # nothing was ever admitted + (names_admitted, newer, "", None), + (names_admitted, None, pending, None)): + with self.subTest(boundary=boundary, status=status, + retained=retained): + self.assertIsNone(self._run( + wrapper, "shown", [status, graph, boundary, retained], + sources=("Model.js",))) + + def test_only_a_named_generation_is_admitted_or_touches_the_layout(self): + cockpit = _read("Cockpit.qml") + apply_graph = _qml_function(cockpit, "applyGraph") + # The layout singleton (rings, adjacency, tsNorm) belongs to the + # generation on screen; an unnamed newer publication must not rebuild + # it underneath the admitted graph. + self.assertIn( + "var named = Model.snapshotGenerationsMatch(root.currentStatus, g)", + apply_graph) + self.assertIn("if (named && graphCanvas.width > 0", apply_graph) + self.assertIn("if (named) root.admittedGraph = g", apply_graph) + rejection = apply_graph[:apply_graph.index("var named")] + self.assertIn("root.admittedGraph = null", rejection) + self.assertIn( + "root.admittedGraph = null", + apply_graph[apply_graph.index("catch (e)"):]) + apply_status = _qml_function(cockpit, "applyStatus") + self.assertIn("root.admittedGraph = root.graph", apply_status) + self.assertIn( + "Model.snapshotGenerationsMatch(root.status, root.graph)", + apply_status) + graph_file = _qml_element(cockpit, "id: graphFile") + failed = graph_file[graph_file.index("onLoadFailed:"): + graph_file.index("onFileChanged:")] + self.assertIn("root.admittedGraph = null", failed) + changed = graph_file[graph_file.index("onFileChanged:"):] + self.assertNotIn("root.admittedGraph =", changed) + self.assertIn("root.graph || root.admittedGraph", changed) + # The admitted generation stays on screen through the change, so a + # locked selection survives it. + self.assertNotIn("root.selectedId", changed) + summary = _qml_function(cockpit, "graphSnapshotText") + self.assertIn("shown !== root.graph", summary) + self.assertIn("newer graph publication awaiting its status", summary) + + def test_snapshot_settle_waits_are_one_beat(self): + cockpit = _read("Cockpit.qml") + for timer_id in ("id: statusApply", "id: graphApply", + "id: thoughtsApply"): + with self.subTest(timer=timer_id): + self.assertIn("interval: 60", _qml_element(cockpit, timer_id)) + + def test_revalidation_boundaries_are_context_not_alarms(self): + cockpit = _read("Cockpit.qml") + tone = _qml_function(cockpit, "boundaryColor") + self.assertIn("/pending validation$/.test(text)", tone) + self.assertIn("root.urgent", tone) + self.assertIn("color: root.boundaryColor(root.thoughtsBoundary)", + cockpit) + self.assertIn("root.boundaryColor(root.graphBoundary)", cockpit) + for boundary in ( + "last good graph; newer graph snapshot pending validation", + "last good status; newer resident status pending validation", + "last good generated-entry stream; newer stream pending validation", + "last good continuity status; newer update pending validation"): + self.assertIn(boundary, cockpit) + self.assertTrue(boundary.endswith("pending validation")) + for boundary in ( + "last good graph; latest graph rejected", + "resident graph snapshot unavailable", + "resident status unavailable"): + self.assertFalse(boundary.endswith("pending validation")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_github_metrics.py b/tests/test_github_metrics.py new file mode 100644 index 0000000..14164e1 --- /dev/null +++ b/tests/test_github_metrics.py @@ -0,0 +1,46 @@ +"""Metrics retain raw windows and publish no partial collection.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +PATH = Path(__file__).resolve().parents[1] / "scripts/collect_github_metrics.py" +spec = importlib.util.spec_from_file_location("github_metrics_test", PATH) +metrics = importlib.util.module_from_spec(spec) +spec.loader.exec_module(metrics) + + +class GithubMetrics(unittest.TestCase): + def test_preserves_raw_windows_without_claiming_installs(self): + clones = dict(count=439, uniques=212, clones=[]) + views = dict(count=494, uniques=205, views=[]) + releases = [{"tag_name": "v1.8.1", "assets": [ + {"id": 7, "name": "sia.tar.gz", "download_count": 9}]}] + with mock.patch.object(metrics, "fetch", side_effect=[clones, views, releases]): + report = metrics.collect("owner/repo") + self.assertEqual(report["traffic"], {"clones": clones, "views": views}) + self.assertNotIn("installations", report) + self.assertTrue(any("must not be summed" in text for text in report["limitations"])) + with tempfile.TemporaryDirectory() as directory: + first = metrics.save(report, directory) + second = metrics.save(report, directory) + self.assertNotEqual(first, second) + self.assertEqual(json.loads(first.read_text()), report) + self.assertEqual(first.stat().st_mode & 0o777, 0o600) + self.assertEqual(list(Path(directory).glob("*.tmp")), []) + + def test_failed_fetch_does_not_write_a_partial_snapshot(self): + with mock.patch.object(metrics, "fetch", side_effect=RuntimeError("unavailable")), \ + mock.patch.object(metrics, "save") as save, \ + mock.patch("sys.argv", ["collect_github_metrics.py"]): + with self.assertRaises(SystemExit) as stopped: + metrics.main() + self.assertEqual(stopped.exception.code, 1) + save.assert_not_called() + + def test_malformed_traffic_is_not_recorded_as_zero(self): + with mock.patch.object(metrics, "fetch", return_value={"message": "denied"}): + with self.assertRaises(ValueError): + metrics.collect("owner/repo") diff --git a/tests/test_graph_layout.py b/tests/test_graph_layout.py index 4f8588e..5513f4e 100644 --- a/tests/test_graph_layout.py +++ b/tests/test_graph_layout.py @@ -136,6 +136,164 @@ def test_replay_reseeds_growth_and_hidden_nodes_do_not_settle_early(self): metrics["startRadius"] + 100, metrics) self.assertTrue(metrics["deterministic"], metrics) + def test_long_frames_settle_instead_of_running_forever(self): + """The frame loop stops when the layout settles; long frames must let it. + + The physics was tuned per 40 ms tick and scaled by the elapsed frame + time. Fed a 100 ms frame (the step scale's 2.5 cap, routine under a + software renderer), the explicit integrator overshot its own springs + and never crossed the settle threshold, so the cockpit's frame loop + ran at full rate forever: a graph sitting still cost 64% of a core. + Time is now spent in sub-ticks of at most one tuned tick, so a + 100 ms frame settles in the same wall time as a 17 ms one, and a + frame of exactly 2.5 ticks follows the trajectory of 2.5 sub-ticks. + """ + metrics = self._run_model(r''' +function build() { + const nodes = [{id: "sia/cortex", t: "organ", ts: "", deg: 1}] + const edges = [] + for (let o = 0; o < 8; o++) { + nodes.push({id: "organs/o" + o, t: "organ", ts: "", deg: 12}) + edges.push({s: "organs/o" + o, d: "sia/cortex", t: "mentions"}) + for (let k = 0; k < 60; k++) { + const id = "events/o" + o + "/d" + k + nodes.push({id, t: "event-day", deg: 2, + ts: "2026-09-" + String(1 + (k % 28)).padStart(2, "0") + "T00:00:00Z"}) + edges.push({s: id, d: "organs/o" + o, t: "mentions"}) + if (k) edges.push({s: id, d: "events/o" + o + "/d" + (k - 1), t: "mentions"}) + // One cross-organ link per memory: the density at which a 2.5-tick + // step made the old integrator oscillate forever. + edges.push({s: id, d: "events/o" + ((o + 1) % 8) + "/d" + ((k * 7) % 60), t: "mentions"}) + } + } + return {nodes, edges} +} +function scatter(graph) { + // Seeding places nodes near their targets; a real pulse's fresh nodes and + // a resized canvas do not. Start every memory far from where it belongs, + // deterministically, so the layout has real distance to travel. + let k = 0 + for (const id in L.pos) { + if (id === "sia/cortex") continue + L.pos[id].x = 60 + ((k * 587) % 1880) + L.pos[id].y = 60 + ((k * 733) % 1680) + L.pos[id].vx = 0; L.pos[id].vy = 0 + k++ + } +} +function settleTime(dtMs) { + const graph = build() + // Each run seeds from nothing; the layout state is module-global. + L.pos = {}; L.seeded = false; L.maxSpeed = 0 + syncGraph(graph, 2000, 1800) + for (let tick = 1; tick <= 5000; tick++) { + step(graph, 2000, 1800, 1.0, dtMs) + if (settled()) return tick * dtMs / 1000 + } + return -1 +} +const walls = {fast: settleTime(16.7), tick: settleTime(40), slow: settleTime(100)} +return {walls, nodes: build().nodes.length} +''') + walls = metrics["walls"] + for name in ("fast", "tick", "slow"): + self.assertGreater(walls[name], 0, metrics) + self.assertLess(walls[name], 30, metrics) + # Same order of wall-clock settle time across frame rates; the 100 ms + # case used to be -1 (never) on this exact graph. + self.assertLess(max(walls.values()), 3 * min(walls.values()), metrics) + + def test_zero_size_canvas_never_seeds_the_layout(self): + """A canvas reports width before height; a zero dimension must not seed. + + Seeding against height 0 placed every memory on one point (a ring of + radius zero). The layout then spent minutes of violent repulsion + escaping it, and on the shell's 240 ms software frames the frame loop + ran through every pulse: the cockpit sat at 63% of a core. Model now + refuses a zero-size canvas and the first real size seeds as fresh. + """ + metrics = self._run_model(r""" +function build() { + const nodes = [{id: "sia/cortex", t: "organ", ts: "", deg: 1}] + const edges = [] + for (let o = 0; o < 6; o++) { + nodes.push({id: "organs/o" + o, t: "organ", ts: "", deg: 12}) + edges.push({s: "organs/o" + o, d: "sia/cortex", t: "mentions"}) + for (let k = 0; k < 30; k++) { + const id = "events/o" + o + "/d" + k + nodes.push({id, t: "event-day", deg: 2, + ts: "2026-09-" + String(1 + (k % 28)).padStart(2, "0") + "T00:00:00Z"}) + edges.push({s: id, d: "organs/o" + o, t: "mentions"}) + if (k) edges.push({s: id, d: "events/o" + o + "/d" + (k - 1), t: "mentions"}) + } + } + return {nodes, edges} +} +function settleTicks(graph) { + for (let tick = 1; tick <= 5000; tick++) { + step(graph, 1040, 919, 1.0, 40) + if (settled()) return tick + } + return -1 +} +const graph = build() +L.pos = {}; L.seeded = false; L.maxSpeed = 0 +syncGraph(graph, 1040, 0) +const zero = {seeded: L.seeded, positions: Object.keys(L.pos).length} +step(graph, 1040, 919, 1.0, 40) +zero.settledWhileUnseeded = settled() +syncGraph(graph, 1040, 919) +const ys = new Set(Object.values(L.pos).map(p => p.y.toFixed(0))) +const afterResize = {seeded: L.seeded, distinctY: ys.size, ticks: settleTicks(graph)} +const fresh = build() +L.pos = {}; L.seeded = false; L.maxSpeed = 0 +syncGraph(fresh, 1040, 919) +return {zero, afterResize, freshTicks: settleTicks(fresh)} +""") + self.assertFalse(metrics["zero"]["seeded"], metrics) + self.assertEqual(metrics["zero"]["positions"], 0, metrics) + # An unseeded layout has nothing to move, so the frame loop rests. + self.assertTrue(metrics["zero"]["settledWhileUnseeded"], metrics) + self.assertTrue(metrics["afterResize"]["seeded"], metrics) + self.assertGreater(metrics["afterResize"]["distinctY"], 20, metrics) + self.assertGreater(metrics["afterResize"]["ticks"], 0, metrics) + self.assertEqual( + metrics["afterResize"]["ticks"], metrics["freshTicks"], metrics) + + def test_pair_loop_keys_each_node_once_per_tick(self): + """The physics keys positions once per node, not once per node pair. + + Building a string key inside the all-pairs repulsion loop allocated + two strings per pair (67 000 per tick on a 260-memory graph), and + under the shell's interpreter that allocation was most of a 240 ms + frame. The trajectory is unchanged; only the keying is hoisted. + """ + metrics = self._run_model(r""" +const graph = {nodes: [{id: "sia/cortex", t: "organ", ts: "", deg: 1}], edges: []} +for (let o = 0; o < 4; o++) { + graph.nodes.push({id: "organs/o" + o, t: "organ", ts: "", deg: 8}) + graph.edges.push({s: "organs/o" + o, d: "sia/cortex", t: "mentions"}) + for (let k = 0; k < 50; k++) { + const id = "events/o" + o + "/d" + k + graph.nodes.push({id, t: "event-day", deg: 2, + ts: "2026-09-" + String(1 + (k % 28)).padStart(2, "0") + "T00:00:00Z"}) + graph.edges.push({s: id, d: "organs/o" + o, t: "mentions"}) + } +} +L.pos = {}; L.seeded = false; L.maxSpeed = 0 +syncGraph(graph, 1040, 919) +const original = graphMapKey +let calls = 0 +graphMapKey = function (value) { calls++; return original(value) } +step(graph, 1040, 919, 1.0, 40) +graphMapKey = original +return {calls, nodes: graph.nodes.length, edges: graph.edges.length} +""") + self.assertGreater(metrics["calls"], 0, metrics) + self.assertLessEqual( + metrics["calls"], + metrics["nodes"] + 2 * metrics["edges"], metrics) + def test_label_candidates_begin_outward_and_ui_uses_collision_guard(self): candidates = self._run_model(r''' return { @@ -148,7 +306,23 @@ def test_label_candidates_begin_outward_and_ui_uses_collision_guard(self): cockpit = _read("Cockpit.qml") self.assertIn("Model.replayLayout", cockpit) - self.assertIn("root.revealT)", cockpit) + self.assertIn("root.revealT, ms)", cockpit) + # A slow frame advances the layout by the time it took (to 250 ms), + # never by one tick. + self.assertIn("else if (ms > 250) ms = 250", cockpit) + self.assertNotIn("ms > 100) ms = 40", cockpit) + # The breath repaints only the glow layer, which follows every graph + # frame; a settled graph never repaints its 260-node canvas to breathe. + self.assertIn("id: glowCanvas", cockpit) + self.assertIn("onPainted: glowCanvas.requestPaint()", cockpit) + breath = cockpit[cockpit.index("id: graphBreath"):] + breath = breath[:breath.index("onPaint:")] + self.assertIn("glowCanvas.requestPaint()", breath) + self.assertNotIn("graphCanvas.requestPaint()", breath) + # Model refuses a zero-size canvas; the resize handlers wait for both. + self.assertIn( + "onHeightChanged: if (root.currentGraph && width > 0 && height > 0)", + cockpit) self.assertIn("nodeObstacles", cockpit) self.assertIn("placedLabels", cockpit) self.assertIn("Model.labelCandidates", cockpit) diff --git a/tests/test_intent_review.py b/tests/test_intent_review.py new file mode 100644 index 0000000..90429b1 --- /dev/null +++ b/tests/test_intent_review.py @@ -0,0 +1,108 @@ +"""Commitment review never turns a reminder click into a completion.""" +import contextlib +import io +import json +import os +import shutil +import subprocess +import unittest +from unittest import mock + +try: + import sia_test_home # noqa: F401; isolate runtime paths before imports +except ModuleNotFoundError: + from tests import sia_test_home # noqa: F401 + +from tests.test_cli import _load_script, sia +import siatakes + +REPO = os.path.dirname(os.path.dirname(__file__)) +review = _load_script('sia_intent_review_test', os.path.join(REPO, 'bin/sia-intent-review')) +IID = '5e1a06cc61' +ROW = dict(id=IID, text='Review ' + 'the supporting record ' * 8, + due='2026-09-08', holder='sia', created='2026-08-29T16:58:03Z') + + +def result(code=0, stdout=''): + return subprocess.CompletedProcess([], code, stdout, '') + + +class IntentReview(unittest.TestCase): + def test_list_exposes_identity_usable_by_show(self): + output = io.StringIO() + with mock.patch.object(siatakes, 'open_intents', return_value=[dict(ROW, days_left=-12)]), contextlib.redirect_stdout(output): + self.assertEqual(sia.cmd_intend(['--list']), 0) + self.assertIn(IID, output.getvalue()) + + def test_show_full_task_without_internal_paths(self): + output = io.StringIO() + with mock.patch.object(siatakes, 'open_intents', return_value=[dict(ROW, path='/private', days_left=-12)]), contextlib.redirect_stdout(output): + self.assertEqual(sia.cmd_intend(['--show', IID]), 0) + self.assertEqual(json.loads(output.getvalue()), ROW) + + def test_show_rejects_missing_and_ambiguous(self): + for rows in ([], [ROW, ROW]): + with mock.patch.object(siatakes, 'open_intents', return_value=rows), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(sia.cmd_intend(['--show', IID]), 1) + + def test_show_rejects_option_and_shell_shaped_identity(self): + for iid in ('--help', '$(touch /tmp/no)', IID[:6]): + with mock.patch.object(siatakes, 'open_intents') as read, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(sia.cmd_intend(['--show', iid]), 2) + read.assert_not_called() + + def run_review(self, answers, responses=None): + output = io.StringIO() + with mock.patch.object(review, 'run_cli', side_effect=responses or [result(stdout=json.dumps(ROW))]) as run, mock.patch('builtins.input', side_effect=answers), contextlib.redirect_stdout(output): + code = review.review(IID) + return code, run.call_args_list, output.getvalue() + + def test_cancel_empty_outcome_and_final_cancel_never_write(self): + for answers in ([''], ['done', ''], ['done', 'x' * 201], ['done', 'Reviewed evidence', 'no']): + _, calls, _ = self.run_review(answers) + self.assertEqual(calls, [mock.call(['--show', IID])]) + + def test_close_requires_outcome_and_explicit_confirmation(self): + code, calls, output = self.run_review(['done', 'Checked retained records', 'close'], [result(stdout=json.dumps(ROW)), result(stdout='done')]) + self.assertEqual(code, 0) + self.assertEqual(calls[-1], mock.call(['--done', IID, 'Checked retained records'])) + self.assertIn('next successful publication', output) + + def test_stale_close_never_reports_success(self): + code, _, output = self.run_review(['done', 'Reviewed', 'close'], [result(stdout=json.dumps(ROW)), result(1, 'no unique open intent')]) + self.assertEqual(code, 1) + self.assertNotIn('Closed.', output) + + def test_failed_read_never_prompts_or_writes(self): + code, calls, _ = self.run_review([], [result(1, 'not ready')]) + self.assertEqual(code, 1) + self.assertEqual(calls, [mock.call(['--show', IID])]) + + def test_response_identity_mismatch_refuses(self): + with self.assertRaises(ValueError): + self.run_review([], [result(stdout=json.dumps(dict(ROW, id='aaaaaaaaaa')))]) + + def test_terminal_control_codes_removed(self): + self.assertNotIn('\x1b', review.display('\x1b[31mred\ntext')) + + def test_noninteractive_review_never_calls_cli(self): + with mock.patch.object(review.sys.stdin, 'isatty', return_value=False), mock.patch.object(review, 'run_cli') as run, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(review.main([IID]), 2) + run.assert_not_called() + + def test_full_text_status_contract_matches_qml(self): + self.assertTrue(sia.sialib._status_intents_shape([dict(id=IID, text='x' * 300, due='2026-09-08', days_left=-12)])) + self.assertFalse(sia.sialib._status_intents_shape([dict(id=IID, text='x' * 301, due='2026-09-08', days_left=-12)])) + script = "const fs=require('fs'),vm=require('vm');let s=fs.readFileSync('Model.js','utf8').replace(/^\\.pragma.*$/mg,'');let c={};vm.createContext(c);vm.runInContext(s,c);for(let n of [300,301])console.log(c.residentIntentShape([{id:'5e1a06cc61',text:'x'.repeat(n),due:'2026-09-08',days_left:-12}]));" + node = shutil.which("node") + if node is None: + # Every other JS-contract test in this repo skips when Node is + # absent rather than failing: a toolchain that is missing is not a + # contract that broke. + self.skipTest("Node is unavailable for executable Model.js logic") + run = subprocess.run([node, '-e', script], cwd=REPO, capture_output=True, text=True, check=True) + self.assertEqual(run.stdout.splitlines(), ['true', 'false']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_marketplace_first_light.py b/tests/test_marketplace_first_light.py index 119675b..f2e20fd 100644 --- a/tests/test_marketplace_first_light.py +++ b/tests/test_marketplace_first_light.py @@ -379,8 +379,12 @@ def test_ready_generation_stays_visible_during_file_refresh(self): cockpit = _read("Cockpit.qml") open_body = _balanced_body(cockpit, "function open(payloadJson)") - self.assertIn("statusFile.reload()", open_body) - self.assertIn("installCompletionFile.reload()", open_body) + self.assertIn("presentationHold.restart()", open_body) + refresh_body = _balanced_body(cockpit, "id: presentationHold") + self.assertIn("statusFile.reload()", refresh_body) + self.assertIn("installCompletionFile.reload()", refresh_body) + self.assertNotIn("statusResolved = false", refresh_body) + self.assertNotIn("installCompletionResolved = false", refresh_body) self.assertNotIn("statusResolved = false", open_body) self.assertNotIn("installCompletionResolved = false", open_body) self.assertIn("function loadedReleaseVersion(ignored)", cockpit) diff --git a/tests/test_pulse_sync.py b/tests/test_pulse_sync.py index f4a2452..25db3eb 100644 --- a/tests/test_pulse_sync.py +++ b/tests/test_pulse_sync.py @@ -342,7 +342,7 @@ def test_current_status_nested_contract_is_exact_and_bounded(self): mutations.append(bad) bad = copy.deepcopy(valid) bad["intents"] = [{ - "id": "0123456789", "text": "x" * 70 + "y", + "id": "0123456789", "text": "x" * 300 + "y", "due": "2026-08-30", "days_left": 0, }] mutations.append(bad) diff --git a/tests/test_siatakes_hardening.py b/tests/test_siatakes_hardening.py index 9a3cf5c..56d29db 100644 --- a/tests/test_siatakes_hardening.py +++ b/tests/test_siatakes_hardening.py @@ -53,6 +53,61 @@ def test_only_canonical_real_event_and_epoch_paths_are_admitted(self): self.assertFalse( siatakes._admitted_evidence_slug(slug)) + def test_judge_config_roster_admits_every_shipped_top_level_key(self): + """The judge reads config.json with its own top-level key roster and + fails closed to no judge on any unknown key. The shipped example + config carries a "mind" section the roster lacked, so grading + silently never ran on the maintainer machine (and any machine on + the example config): `sia grade` said "judge unavailable" with a + working Claude CLI. The roster must admit every key the runtime's + own loader admits and every key the example ships.""" + example = json.load(open(os.path.join(REPO, "config.example.json"), + encoding="utf-8")) + self.assertEqual(set(example) - siatakes.CONFIG_TOP_LEVEL_KEYS, set()) + self.assertEqual(sialib._CONFIG_TOP_LEVEL_KEYS - siatakes.CONFIG_TOP_LEVEL_KEYS, + set()) + + def test_unrecallable_claim_reports_a_read_that_did_not_complete(self): + """A claim the engine CLI would read as a flag is never sent to it, and + the recall is reported as NOT completed. + + This assertion is the inverse of the one it replaces, which required + `completed is True` for a corpus search that never ran. That closed the + take as UNRESOLVABLE on half an evidence lane, while the published page + still said it had been judged against a signed evidence snapshot. + `GradingEvidenceUnavailable` states the rule: "only a completed evidence + read may be judged UNRESOLVABLE".""" + with mock.patch.object(sialib, "gbrain", + side_effect=AssertionError("engine queried")): + for claim in ("--help", " -h", "", " "): + recall = siatakes._recall(claim) + self.assertFalse(recall.completed, claim) + self.assertEqual(recall.text, "") + self.assertEqual(recall.citations, frozenset()) + self.assertTrue(recall.reason, claim) + + def test_a_negative_number_claim_still_reaches_the_engine(self): + """`-1 regressions will land by March` is a legitimate take, which + `sia take -- …` exists to let a caller register, and the engine runs it + as an ordinary search. The guard used to reject every claim opening + with a dash, silently removing the semantic recall lane from takes the + CLI deliberately supports.""" + self.assertFalse(siatakes._option_shaped("-1 regressions will land")) + self.assertFalse(siatakes._option_shaped("-.5 drop in latency")) + self.assertTrue(siatakes._option_shaped("--help")) + self.assertTrue(siatakes._option_shaped(" -h")) + + seen = [] + + def _fake_gbrain(args, **kw): + seen.append(args) + raise RuntimeError("stop after dispatch") + + with mock.patch.object(sialib, "gbrain", _fake_gbrain): + siatakes._recall("-1 regressions will land by March") + self.assertTrue(seen, "the claim must reach the engine") + self.assertIn("-1 regressions will land by March", seen[0]) + def test_model_and_jackal_traversal_refuse_before_judging(self): aliases = ( "events/../takes/model",