Watch
1
0
Fork
You've already forked SouveraineOS
0

project-state: weekly evidence, carried state, and an agent's door

Five weeks of forge evidence per week rather than a rolling window, so a
finding survives under one key and "closed after three weeks" is computable.
Rules run deterministically; agent.py hands the packet to whoever writes the
prose and never calls a model itself.

Model choice fails closed — no default, no substitute, and the exact named
model must be served or the pass refuses to run.
This commit is contained in:
Fimeg 2026-08-18 19:53:19 -04:00
commit a8caf27c14
26 changed files with 56303 additions and 0 deletions

View file

@ -0,0 +1,212 @@
# TASK-79 — project state: what became true, and what stopped being true
**Status:** first slice built and exercised 2026-08-18 over five weeks,
2026-W30 to 2026-W34. Internal; nothing publishes.
**Repos:** `SouveraineOS` (the tools and the store). Reads the forge and the
local clones; writes nothing outside this repo.
**Connects to:** `saf/state.md` (the hand-written spine this reads and never
writes), `docs/tasks/README.md` (the declared state it diffs),
`~/Projects/Gitea Pipeline/TASK-02-the-ledger-pipeline.md` D4/D5 (the narrator
and the quality pass — same seam, different question),
`~/Projects/Web/CaseyTunturi.com/scripts/ledger/collect.py` (the ancestor).
## The question this answers, and the one it does not
The ledger on caseytunturi.com answers **what happened** — day-bucketed, 30-day
rolling, overwritten in place. It is a record of a man at work and it is good at
that. It cannot answer what *became true*, because nothing carries across the
window: every week is an island, and a 31-day-old finding is simply gone.
This answers **what is true now, what stopped being true, and how long it has
been unresolved.** Same forge, different shape: evidence is kept per week and
never overwritten, and a finding survives from one week to the next under a
stable key. Statements the five-week run can already make:
- *task 8 left the index, held W30 to W32, closed after two weeks.*
- *task 15, Idle coordinator, has been Blocked or gated for four weeks.*
- *souveraine#1 has been unmerged for 38 days and is the oldest decision open.*
- *souveraine CI was regressed in W33 and mixed in W34* — the finding turned.
## Why here and not in the ledger's repo
The ledger's output repo is the public site. Its CI builds and deploys on every
push, so an analytical artifact living there is one careless import away from
being published. SouveraineOS already owns project state — `saf/state.md` is the
resume anchor and `docs/tasks/README.md` is the work index — and it deploys
nowhere. The forge answers 403 to anonymous requests, so the store is behind a
login even though the repo is not marked private.
Promotion to either public surface stays a separate, deliberate act:
`caseytunturi.com` for engineering history, `souveraineai.com` for current
capability. One evidence base, two editorial surfaces, neither automatic.
## Shape
```
forge API + local clones
↓ collect.py deterministic, no model
project-state/evidence/<week>.json
↓ synthesize.py rules first, model second
project-state/state.json durable, carried forward
project-state/history/<week>.json
project-state/reports/<week>.md prose, disposable
```
Nothing schedules it. The tools name their own gaps instead:
```
collect.py --gaps [--since 2026-W30] weeks holding no evidence
collect.py --backfill --since 2026-W30 collect every one of them, in order
synthesize.py --pending weeks collected but not passed over
synthesize.py --catch-up pass over them oldest first
```
Order matters — carry-forward is sequential, so `--catch-up` is the entry point
and `--week` warns when it would skip ahead of the state.
The deterministic/model split is the point. A model is never asked to discover a
fact code can read; it gets the computed findings and looks only for what
crosses between them. Its findings are marked `by: model`, live in their own
section of the report, and are dropped unless every evidence id they cite exists
in that week's evidence and spans two kinds of it.
The prose is regenerable — `--report-only` rewrites any week's report from the
stored state and evidence, so a future model can rewrite history without the
original report ever having been authoritative.
## Evidence, measured 2026-08-18
| | W30 | W31 | W32 | W33 | W34 (partial) |
|---|---|---|---|---|---|
| Commits | 226 | 287 | 201 | 260 | 43 |
| CI runs | 98 | 133 | 143 | 185 | 25 |
| Declared changes | 255 | 56 | 18 | 37 | 116 |
16 repos listed, ~95 API calls a week, five weeks collected in 20 s, zero disk.
Three things the collector inherits from the ledger because they were paid for
once already: the `limit=50` page clamp (Gitea ignores a larger limit and hid 24
of 74 repos for months), the branch-tip prefilter (a branch whose tip predates
the window cannot hold work inside it), and per-repo SHA dedup.
Two it does not:
- **The Actions API.** `/repos/{repo}/actions/runs` ignores `limit` and `page`
and returns the whole history, so it is fetched once and sliced. Paging it
loops on the same rows. `02-the-ledger.md` lists CI state as an unbuilt later
increment; this is that increment, joined to commits by `head_sha`.
- **Redaction.** Evidence is stored raw because it never leaves the forge.
`--redact` applies the ledger's patterns for anything promoted outward.
## Dimensions
The SAF's own sections, plus the two it has no page for:
`memory` `runtime` `reflection` `identity` `surface` `sensorium` `release`
`reliability` `federation` `adoption`
A commit is placed by scoring its changed paths and its subject against a
keyword table, weighted alike — paths alone file `compaction: target the active
conversation` under the server module it edits, and the subject is the author
saying which subsystem he thinks he moved. On W33: 198 by paths, 29 by subject,
22 by repository default, 11 placed nothing and are reported as `unassigned`
rather than guessed at. Every row keeps its `basis` and its paths, so any
placement can be argued with.
One dimension per commit is a simplification. A crash fix in the shell is both
`surface` and `reliability` and counts once.
## State vocabulary, and the resolution rule
`milestone_reached` `regressed` `blocked` `decision_required` `advanced`
`unchanged` — assigned by rule:
- a task's section in the index (Live / In progress / Blocked or gated / Open)
gives its standing state; a move backwards is `regressed`, and leaving the
index entirely is `milestone_reached`;
- `decision_required` comes from Casey's own phrasing in the row — *verdict
owed*, *decisions are Casey's*, *scope and deliverable unanswered*;
- a repository whose last run of the week failed is `regressed`;
- a pull unmerged past fourteen days is `decision_required`.
**A finding the week says nothing about stays open.** Only positive evidence
closes one — a task gone from the index, a pull merged. The first two-week run
resolved four CI findings because the second week had no runs for those repos,
which is the difference between a state document and a diff.
Findings that describe *one week* — a CI week, a dimension's activity, a release
cut — do not carry at all; they lapse with their week. Findings that describe a
*thing* — a task, a pull, an issue — carry until something closes them.
Task findings track the index snapshot, not only its diff. Tracking the diff
alone made every restatement a permanent finding and the task itself invisible;
the index is the state, the diff is the movement, and `weeks_in_section` is how
"this remains the oldest blocker" gets computed.
## Provenance
Each finding carries `provenance` — deterministic rule name or model, endpoint
locality resolved from the URL rather than asserted, timestamp, and the week its
evidence came from — plus a `human` block (`verdict`, `note`, `edited`) that is
empty and exists so a promote/reject pass has somewhere to write.
`synthesize.py --explain <id>` prints a finding and every evidence row it cites,
in full. That is the trace path, and it is one command.
## The analyst, measured
`ANALYST_URL` / `ANALYST_KEY` / `ANALYST_MODEL`, any OpenAI-compatible endpoint.
No URL means no model pass and a complete deterministic report — the same "never
a failed run" contract the ledger's narrator has.
Against Fable on llama-swap, 2026-08-18:
- **Warm, it answers in 6 s.** The 14 s empty first call recorded in TASK-02 D4
was **not** llama-swap loading the model. Fable is a reasoning model: it fills
`reasoning_content` first and leaves `content` empty when the budget runs out
before it concludes. At `max_tokens: 250` — what `collect.py`'s `narrate()`
sets — it can never answer. **The ledger's narrator has that bug now.**
- The full week as a brief is 39k characters; Fable spent 3000 tokens reasoning
over it, returned nothing, and took 323 s. Sampling ten commit subjects per
dimension cuts the brief to 20k and the run to ~200 s.
- Reasoning length varies run to run and can still eat the budget, so a
truncated answer is salvaged object by object — every complete finding before
the cut is kept.
- **Quality is the open problem.** On W33 it returned 21 findings, of which ~15
were commentary on the deterministic findings rather than on the project. A
tightened charter and a gate requiring two kinds of evidence cut that to four.
Of those four: one good (CI runs that neither pass nor fail nearly equal the
passes, which the counts support), one arguable, two wrong — one cites a
success run as evidence of failure, another cites an unrelated doc commit.
A 9B is not a good analyst for this. The seam works; the model is Casey's call.
## Acceptance
- [x] Weeks collected deterministically, every row carrying a stable id.
- [x] A durable state file carrying a finding across weeks under one key, with
`first_seen`, `weeks_open`, `weeks_in_section`, and resolution only on
positive evidence.
- [x] Carry-forward exercised over five real weeks, not designed.
- [x] A human-readable report where each finding names its evidence.
- [x] One command traces a finding back to every row it rests on.
- [x] The tools name their own unfilled weeks rather than assuming a schedule.
- [ ] An analyst whose findings survive review, or the pass explicitly parked.
- [ ] A cadence decision: on demand, or a Monday job on the same runner.
- [ ] A human verdict written into a finding — the seam exists, nothing uses it.
## Open, and Casey's to answer
- **Which model holds the analyst.** Fable is honest and slow and wrong about
half the time on this. The substrate's own config already reaches bifrost,
deepseek, zai and claude; the deterministic half needs none of them.
- **Where a promoted finding goes.** The seam exists; the editorial call does
not.
- **Whether the store is committed here.** It is project state, it belongs with
`saf/state.md`, and it is behind the forge login — but it is a machine-written
tree in a repository whose other files are hand-written.
## Noticed while building this, not part of it
`docs/tasks/README.md` publishes `10.10.20.123`, `172.16.42.1` and an ssh key
path into this repo. Behind the forge login, so not the leak TASK-02 D8 closed —
same class, and it sits in the file this task diffs every week.

View file

@ -38,6 +38,7 @@ Four that are cheap relative to what they unblock:
| # | Task | What's left |
|---|------|-------------|
| 79 | [Project state: what became true, and what stopped being true](79-project-state.md) | First slice runs 2026-08-18 — `project-state/` holds W33 and W34 evidence, a carry-forward `state.json`, and a report where every finding traces to its rows. Left: which model holds the analyst (Fable reasons past any budget on this brief), a cadence decision, and whether the machine-written store belongs in this repo. |
| 77 | [The lens: a human-first garden over a git vault](77-lens-garden.md) | Built + packaged 2026-08-15 — folder/subcategory support and inbox capture landed (`61cf534`), publisher chmod fix (`c71c7e4`). Left: lens CI green + `edge` asset published, `pacman -U` on the laptop, then edge-repo DB wiring. |
| 65 | [A stalled subconscious wedges the surface](65-subconscious-stall-wedges-the-surface.md) | Reproduced and measured, not diagnosed. The reply commits and `PrimaryComplete` fires, then the N+1 pass stalls at round 3 of 10 with a fully idle runtime — and because it runs inside `run_turn`, the SSE stream never closes and Surfaces spins forever. Lead is the `todo` tool. Needs a backtrace; the stream-lifetime coupling is a fix in its own right. |
| 63 | [The second body: SouveraineOS on the iPhone 7](63-d10-second-body.md) | **The shell is on the glass 2026-08-09** — viewtop, sessiond and `qs -c souveraine` all run on a T8010 with no GPU, drawn by llvmpipe, over one USB cable. Hoolock is the bootloader and nothing else. The build half of this is TASK-64. |

25
project-state/README.md Normal file
View file

@ -0,0 +1,25 @@
# project-state — what became true, week by week
Machine-written. Internal. Nothing here publishes anywhere.
`saf/state.md` says what is true right now, in Casey's hand. This says how it
got there and what has been unresolved for how long.
```
evidence/<week>.json one ISO week of forge evidence; never overwritten
state.json the carry-forward — findings under stable keys
history/<week>.json state.json as it stood after that week's pass
reports/<week>.md the human read; disposable, regenerable from the two above
```
Written by `tools/project-state/collect.py` and `synthesize.py`. The reasoning
and the open decisions are in `docs/tasks/79-project-state.md`.
```
tools/project-state/collect.py --week last | current | 2026-W33
tools/project-state/synthesize.py --week 2026-W33 [--interpret]
tools/project-state/synthesize.py --explain f-0022
```
A finding cites evidence ids and nothing else. `--explain` prints the finding
and every row it rests on.

View file

@ -0,0 +1,11 @@
{
"week": "2026-W33",
"author": "Annie",
"model": "claude-opus-5",
"recorded_at": "2026-08-18T23:24:37Z",
"state_updated": "2026-08-18T22:48:32Z",
"findings_open": 81,
"evidence": "project-state/evidence/2026-W33.json",
"published": false,
"source": "/tmp/claude-1000/-home-casey/0c2cbaa2-dc6e-402b-9cb7-87cf56b4d4ea/scratchpad/draft.md"
}

View file

@ -0,0 +1,4 @@
# Souveraine — 2026-W33 (draft)
Placeholder written to exercise the handoff, not a real reading.
souveraine ended the week red (f-0037, ci:souveraine:1658).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
# Souveraine project state — 2026-W30
2026-07-20 to 2026-07-26 (America/Toronto). Internal. Generated from `project-state/evidence/2026-W30.json`; the prose is disposable, the evidence and `state.json` are not.
## The week, counted
- 226 commits across 8 repos, 98 CI runs, 255 declared state changes.
- Issues: 6 open, 0 filed, 0 closed. Pulls: 2 open, 0 merged.
- Dimension basis: 154 by paths, 41 by subject, 19 by none, 12 by repo (`paths` read the diff, `subject` read the commit subject, `repo` fell back to the repository, `none` placed nothing).
## ◆ milestone
**f-0033 · identity · task 11 left the index: souveraine-secrets on the phone**
Dropped from “Index” — closed work moves to archive/.
<sub>task-delisted · evidence: `d:task-11`</sub>
**f-0039 · release · souveraine released edge**
93 assets published 2026-07-25.
<sub>release-cut · evidence: `r:souveraine:edge`</sub>
**f-0030 · reliability · task 5 left the index: Crash reporter surfacing**
Dropped from “Index” — closed work moves to archive/.
<sub>task-delisted · evidence: `d:task-5`</sub>
**f-0029 · runtime · task 4 left the index: Notification server (org.freedesktop.Notifications)**
Dropped from “Index” — closed work moves to archive/.
<sub>task-delisted · evidence: `d:task-4`</sub>
**f-0028 · unassigned · task 1 left the index: Favorites — wife's name on her number**
Dropped from “Index” — closed work moves to archive/.
<sub>task-delisted · evidence: `d:task-1`</sub>
**f-0031 · unassigned · task 6 left the index: qtpim aarch64 build → contacts store**
Dropped from “Index” — closed work moves to archive/.
<sub>task-delisted · evidence: `d:task-6`</sub>
**f-0032 · unassigned · task 10 left the index: Boot chain: pmOS init → mkinitcpio**
Dropped from “Index” — closed work moves to archive/.
<sub>task-delisted · evidence: `d:task-10`</sub>
## ▼ regressed
**f-0036 · reliability · pocketboot ends the week red**
The last run of the week on pocketboot failed; 2 of 2 runs failed.
<sub>ci-week · evidence: `ci:pocketboot:834`, `ci:pocketboot:833`</sub>
## ? decision
**f-0004 · identity · task 8 restated: Device state manager / power profiles**
What's left was rewritten under “Index”. actuating 07-25 | clock + actuators; lock-before-blank is an invariant and hypridle's listeners are deleted; (b) charge real, (c)(d) open, **(e) lease still missing**, **(f) `sensors_degraded` reaches no surface**, **(g) the confidence gate
<sub>task-restated · evidence: `d:task-8`</sub>
## ▲ advanced
**f-0016 · federation · task 23 entered the index: culver locked-notification nudge**
Filed under “Index”. open | designed 07-22; needs `SyncBurst()` + IdleCoordinator schedule
<sub>task-listed · evidence: `d:task-23`</sub>
**f-0048 · federation · federation moved**
2 commit(s). Declared: listed culver locked-notification nudge; added docs/substrate/FEDERATION_SKETCH.md; added docs/substrate/tasks/archive/federation-seed-id.md; added docs/substrate/tasks/federation-summon.md.
<sub>dimension-activity · evidence: `c:culver:bb6385d1`, `c:Pixel3Arch:e760a39f`, `d:task-23`, `d:docs/substrate/FEDERATION_SKETCH.md`, `d:docs/substrate/tasks/archive/federation-seed-id.md`, `d:docs/substrate/tasks/federation-summon.md`</sub>
**f-0009 · identity · task 15 entered the index: Idle coordinator: freeze, doze, scheduled wakeups**
Filed under “Index”. blocked | freezing `app.slice` would freeze sessiond+shell+secrets; needs slice separation first (measured 07-25)
<sub>task-listed · evidence: `d:task-15`</sub>
**f-0020 · identity · task 28 entered the index: Handover on upgrade**
Filed under “Index”. open | half-upgrades move authority silently; blocked-by TASK-27 packaging
<sub>task-listed · evidence: `d:task-28`</sub>
**f-0027 · identity · task 35 entered the index: sessiond answers nothing: handler threads park forever**
Filed under “Index”. **open, live** | 347 threads in futex_wait, 691/1024 fds; accept loop fine, no request ever answered
<sub>task-listed · evidence: `d:task-35`</sub>
**f-0043 · identity · identity moved**
35 commit(s). Declared: restated Device state manager / power profiles; delisted souveraine-secrets on the phone; listed Idle coordinator: freeze, doze, scheduled wakeups; listed Handover on upgrade.
<sub>dimension-activity · evidence: `c:culver:d6201130`, `c:souveraine:d824ca7f`, `c:SouveraineOS:ae6e2be5`, `c:SouveraineOS:19475878`, `c:souveraine:b4b30b12`, `c:souveraine:261d7467`, `c:souveraine:07d45c10`, `c:souveraine:91c0cf80` …</sub>
**f-0040 · memory · memory moved**
1 commit(s). Declared: added docs/substrate/COMPACTION_STRATEGIES.md; added docs/substrate/tasks/archive/compaction-rebuild.md; added docs/substrate/tasks/archive/in-session-compaction-implementation.md; added docs/substrate/tasks/archive/n100-archivist-compression.md.
<sub>dimension-activity · evidence: `c:souveraine:687cfeb5`, `d:docs/substrate/COMPACTION_STRATEGIES.md`, `d:docs/substrate/tasks/archive/compaction-rebuild.md`, `d:docs/substrate/tasks/archive/in-session-compaction-implementation.md`, `d:docs/substrate/tasks/archive/n100-archivist-compression.md`</sub>
**f-0042 · reflection · reflection moved**
1 commit(s). Declared: added docs/substrate/subconscious-evolution/2026-05-19_subconscious_mandate_enrichment.md; added docs/substrate/tasks/archive/n25-reflection-four-elements.md; added docs/substrate/tasks/archive/rename-aster-to-subconscious.md; added docs/substrate/tasks/archive/scope-4-n25-reflection.md.
<sub>dimension-activity · evidence: `c:Pixel3Arch:d39c1fdc`, `d:docs/substrate/subconscious-evolution/2026-05-19_subconscious_mandate_enrichment.md`, `d:docs/substrate/tasks/archive/n25-reflection-four-elements.md`, `d:docs/substrate/tasks/archive/rename-aster-to-subconscious.md`, `d:docs/substrate/tasks/archive/scope-4-n25-reflection.md`</sub>
**f-0014 · release · task 20 entered the index: Souveraine Player video + agent control**
Filed under “Index”. open | video/snapshot/manifest; preserve music
<sub>task-listed · evidence: `d:task-20`</sub>
**f-0018 · release · task 25 entered the index: One repo, all packages**
Filed under “Index”. parked 07-25 | four blockers before `repo-add --sign`; see also TASK-27
<sub>task-listed · evidence: `d:task-25`</sub>
**f-0019 · release · task 27 entered the index: Gitea pipeline audit**
Filed under “Index”. open | overlay is not a package; no `primary` branch; poisoned build sysroot
<sub>task-listed · evidence: `d:task-27`</sub>
**f-0046 · release · release moved**
32 commit(s). Declared: listed Souveraine Player video + agent control; listed One repo, all packages; listed Gitea pipeline audit; added docs/archive/DUMP-pacman-pipeline-2026-07-24.md.
<sub>dimension-activity · evidence: `c:culver:2963a794`, `c:souveraine-player:3ad5a504`, `c:Pixel3Arch:a970742a`, `c:Pixel3Arch:693cde44`, `c:Pixel3Arch:9135d3fc`, `c:Pixel3Arch:80bb0c86`, `c:Pixel3Arch:42aedb2e`, `c:Pixel3Arch:36268f61` …</sub>
**f-0026 · reliability · task 34 entered the index: SLPI bring-up sequencing regression**
Filed under “Index”. open | old sequencing error; doorbell dies on SSR and stays dead; blocks TASK-13 step 2
<sub>task-listed · evidence: `d:task-34`</sub>
**f-0038 · reliability · souveraine-updater CI clean**
6 runs on souveraine-updater, none failed.
<sub>ci-week · evidence: `ci:souveraine-updater:885`</sub>
**f-0047 · reliability · reliability moved**
12 commit(s). Declared: delisted Crash reporter surfacing; listed SLPI bring-up sequencing regression.
<sub>dimension-activity · evidence: `c:souveraine-player:2ed0389f`, `c:SouveraineOS:651f24d2`, `c:souveraine:a799bf1c`, `c:souveraine:f620a5d5`, `c:souveraine:155288d9`, `c:culver:b28b15f8`, `c:souveraine:044c373c`, `c:souveraine-updater:ec1f42e8` …</sub>
**f-0012 · runtime · task 18 entered the index: Selection/highlight action menu**
Filed under “Index”. open | design captured 07-21; component-first, Read Aloud gated on TTS server
<sub>task-listed · evidence: `d:task-18`</sub>
**f-0041 · runtime · runtime moved**
15 commit(s). Declared: delisted Notification server (org.freedesktop.Notifications); listed Selection/highlight action menu; added docs/substrate/ANI_PRESSURE_PHENOMENOLOGY.md; added docs/substrate/ARCHITECTURE_v3.md.
<sub>dimension-activity · evidence: `c:Pixel3Arch:2b3351d6`, `c:souveraine-player:9ec06edc`, `c:souveraine-player:15be14ae`, `c:souveraine-player:313481db`, `c:souveraine-player:d3bfbcd4`, `c:SouveraineOS:95808d9a`, `c:Pixel3Arch:67aeb254`, `c:SouveraineOS:2d95f374` …</sub>
**f-0007 · sensorium · task 13 entered the index: Active Edge (squeeze) as a sensor input**
Filed under “Index”. open, wanted | rail is UP and held from boot (07-26); step 2 now blocked on TASK-34 (SLPI doorbell)
<sub>task-listed · evidence: `d:task-13`</sub>
**f-0025 · sensorium · task 33 entered the index: Battery and charging belong to the device state machine**
Filed under “Index”. open | last raw feed going straight to the glass; the charge ceiling is writable today
<sub>task-listed · evidence: `d:task-33`</sub>
**f-0045 · sensorium · sensorium moved**
47 commit(s). Declared: listed Active Edge (squeeze) as a sensor input; listed Battery and charging belong to the device state machine; added docs/AUDIO-PRIVACY.md.
<sub>dimension-activity · evidence: `c:SouveraineOS:7ec2479f`, `c:linux-blueline:4580513b`, `c:linux-blueline:22f9f080`, `c:Pixel3Arch:b564b2ab`, `c:Pixel3Arch:c8557cfa`, `c:Pixel3Arch:46855d89`, `c:Pixel3Arch:2a3aeaa5`, `c:Pixel3Arch:4760853c` …</sub>
**f-0008 · surface · task 14 entered the index: Overview/app grid + Auxo multitasking**
Filed under “Index”. in progress | shell-side; Home path + app cards
<sub>task-listed · evidence: `d:task-14`</sub>
**f-0010 · surface · task 16 entered the index: culver: usable messenger + contacts as Personal-class store**
Filed under “Index”. in progress | A/B/D landed; C contact card next; F is the folded-in TASK-01
<sub>task-listed · evidence: `d:task-16`</sub>
**f-0011 · surface · task 17 entered the index: Keyboard (stevia) + dictation polish**
Filed under “Index”. shipped-partial | OSK + mic key + STT live; **no word completion** — stevia asks hunspell for `en-us`, the dict is `en_US` (07-25)
<sub>task-listed · evidence: `d:task-17`</sub>
**f-0017 · surface · task 24 entered the index: Keyboard layout rethink**
Filed under “Index”. open | start from squeekboard `us+apple`; Casey decides where the mic key goes
<sub>task-listed · evidence: `d:task-24`</sub>
**f-0022 · surface · task 30 entered the index: Agent reach: verb tables across the surfaces**
Filed under “Index”. half built 07-26 | sessiond has describe + refusal codes + intent; dock/apps/shell/settings do not
<sub>task-listed · evidence: `d:task-30`</sub>
**f-0023 · surface · task 31 entered the index: The radial dial**
Filed under “Index”. open | component before contents; entries come from the verb tables, not a list in QML
<sub>task-listed · evidence: `d:task-31`</sub>
**f-0024 · surface · task 32 entered the index: The pill and the keyboard, as owned apps**
Filed under “Index”. open | both erroneous daily; neither has an owner or a shipping story
<sub>task-listed · evidence: `d:task-32`</sub>
**f-0044 · surface · surface moved**
62 commit(s). Declared: listed Overview/app grid + Auxo multitasking; listed culver: usable messenger + contacts as Personal-class store; listed Keyboard (stevia) + dictation polish; listed Keyboard layout rethink.
<sub>dimension-activity · evidence: `c:souveraine:a2320641`, `c:Pixel3Arch:9c64e9ee`, `c:souveraine-player:6dc8ee6c`, `c:Pixel3Arch:2e762c49`, `c:souveraine:e3cf50bf`, `c:souveraine:d0272510`, `c:culver:c1088d2b`, `c:souveraine-player:0d693b9d` …</sub>
**f-0002 · unassigned · task 3 restated: Boot timing: fade splash → lock, no extra fixes**
What's left was rewritten under “Index”. diagnosed | EBUSY handoff race is the last timing bug; splash text killed, holder added
<sub>task-restated · evidence: `d:task-3`</sub>
**f-0003 · unassigned · task 7 restated: WCD9340 mic: functional-pinned; unpin + fidelity**
What's left was rewritten under “Index”. functional 07-20 | defect: power pin; kernel `mic-race-fix`
<sub>task-restated · evidence: `d:task-7`</sub>
**f-0006 · unassigned · task 12 entered the index: Face auth as a capability factor (Gaze reference)**
Filed under “Index”. open | per-target: phone needs front-cam bring-up
<sub>task-listed · evidence: `d:task-12`</sub>
**f-0013 · unassigned · task 19 entered the index: First-party Settings control center**
Filed under “Index”. in progress | guarded standalone launch + owned pages; absorbed TASK-22's polish sweep
<sub>task-listed · evidence: `d:task-19`</sub>
**f-0015 · unassigned · task 21 entered the index: Reboot-to-Android confirmation**
Filed under “Index”. open | scoped 07-22; one-tap irreversible slot flip still bare in app search
<sub>task-listed · evidence: `d:task-21`</sub>
**f-0021 · unassigned · task 29 entered the index: SouveraineOS Updater**
Filed under “Index”. started 07-25 | rust rewrite of pachub; doesn't build yet; first repo to build *into* the pipeline
<sub>task-listed · evidence: `d:task-29`</sub>
## · unchanged
**f-0049 · adoption · adoption did not move**
No commits, and no week before this one to compare against.
<sub>dimension-activity · evidence: `w:2026-W30`</sub>
**f-0034 · reliability · Pixel3Arch CI mixed**
On Pixel3Arch, 3 of 5 runs failed.
<sub>ci-week · evidence: `ci:Pixel3Arch:930`, `ci:Pixel3Arch:909`, `ci:Pixel3Arch:911`, `ci:Pixel3Arch:929`</sub>
**f-0035 · reliability · culver CI mixed**
On culver, 13 of 14 runs failed.
<sub>ci-week · evidence: `ci:culver:862`, `ci:culver:813`, `ci:culver:815`, `ci:culver:816`, `ci:culver:817`, `ci:culver:818`</sub>
**f-0037 · reliability · souveraine CI mixed**
On souveraine, 10 of 71 runs failed and 21 never concluded.
<sub>ci-week · evidence: `ci:souveraine:931`, `ci:souveraine:839`, `ci:souveraine:840`, `ci:souveraine:842`, `ci:souveraine:843`, `ci:souveraine:844`</sub>
## Standing longest without moving
2 task(s) sat in their section untouched this week. The ten that have sat longest:
- **f-0001 · surface · task 2: Lockscreen-as-Rust-system: glance + swipe** — “Index” for 1 week(s), since 2026-W30.
- **f-0005 · unassigned · task 9: Suspend-resume FTS calibration race** — “Index” for 1 week(s), since 2026-W30.
## Provenance
- Evidence: `project-state/evidence/2026-W30.json`, collected 2026-08-18T22:46:26Z by `tools/project-state/collect.py` v1 against gitea 1.25.5 in 101 API calls.
- Deterministic rules: ci-week, dimension-activity, release-cut, task-delisted, task-listed, task-restated, task-standing.
- Analyst: none run; every finding here is deterministic.
- Trace one: `tools/project-state/synthesize.py --explain <id>`.

View file

@ -0,0 +1,281 @@
# Souveraine project state — 2026-W31
2026-07-27 to 2026-08-02 (America/Toronto). Internal. Generated from `project-state/evidence/2026-W31.json`; the prose is disposable, the evidence and `state.json` are not.
## The week, counted
- 287 commits across 9 repos, 133 CI runs, 51 declared state changes.
- Issues: 6 open, 0 filed, 0 closed. Pulls: 2 open, 0 merged.
- Dimension basis: 204 by paths, 45 by repo, 30 by subject, 8 by none (`paths` read the diff, `subject` read the commit subject, `repo` fell back to the repository, `none` placed nothing).
## ◆ milestone
**f-0027 · identity · task 35 left the index: sessiond answers nothing: handler threads park forever**
Dropped from “Index” — closed work moves to archive/.
<sub>task-delisted · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-35`</sub>
**f-0070 · release · Pixel3Arch released kernel-latest**
2 assets published 2026-07-28.
<sub>release-cut · evidence: `r:Pixel3Arch:kernel-latest`</sub>
**f-0007 · sensorium · task 13 left the index: Active Edge (squeeze) as a sensor input**
Dropped from “Index” — closed work moves to archive/.
<sub>task-delisted · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-13`</sub>
## ▼ regressed
**f-0021 · adoption · task 29 moved: SouveraineOS Updater**
Index → Live. Ships and installs by `pacman -Syu`. **Read-only on device** until something runs a polkit agent — that is the shell's job. Also owns TASK-42's layer 2.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-29`</sub>
**f-0016 · federation · task 23 moved: culver locked-notification nudge**
Index → Open. Needs `SyncBurst()` + an IdleCoordinator schedule (TASK-15).
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-23`</sub>
**f-0004 · identity · task 8 moved: Device state manager / power profiles**
Index → In progress. Largest task; sub-divide before starting. (e) lease missing, (f) **surfaced 2026-07-31** — health, evidence and the decision trail render on the Device page; last-seen and grip still absent from `device_state`. (g) confidence gates need a d
<sub>task-moved · open 2 weeks since 2026-W30 · was decision required · evidence: `d:task-8`</sub>
**f-0009 · identity · task 15 moved: Idle coordinator**
Index → Blocked or gated. Freezing `app.slice` would freeze sessiond, shell and secrets. Needs slice separation first (measured 07-25).
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-15`</sub>
**f-0014 · release · task 20 moved: Player: video + agent control**
Index → Open. Video, snapshot, manifest. Preserve the music path.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-20`</sub>
**f-0018 · release · task 25 moved: One repo, all packages**
Index → Blocked or gated. Parked 07-25 — four blockers before `repo-add --sign`. See TASK-27.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-25`</sub>
**f-0019 · release · task 27 moved: Gitea pipeline audit**
Index → Open. The fixes. Overlay is not a package; no `primary` branch; poisoned build sysroot.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-27`</sub>
**f-0006 · sensorium · task 12 moved: Face auth as a capability factor**
Index → Open. Per-target. Phone needs front-camera bring-up — **that is TASK-46's IMX355 half.**
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-12`</sub>
**f-0025 · sensorium · task 33 moved: Battery belongs to the state machine**
Index → Open. Last raw feed going straight to the glass. The charge ceiling is writable today.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-33`</sub>
**f-0026 · sensorium · task 34 moved: SLPI FastRPC session wedge**
Index → Open. Re-diagnose. The real symptom: a DSP session survives its host daemon's death and only a cold boot clears it. **No longer blocks grip.**
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-34`</sub>
**f-0001 · surface · task 2 moved: Lockscreen-as-Rust-system**
Index → In progress. sessiond's fallback should render the glance, not a second PIN pad. Stopgap shipped 07-17.
<sub>task-moved · open 2 weeks since 2026-W30 · was unchanged · evidence: `d:task-2`</sub>
**f-0008 · surface · task 14 moved: Overview → phone app grid**
Index → In progress. Paged app grid replacing the desktop workspace pane; Auxo-style running cards; unconditional Home path.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-14`</sub>
**f-0010 · surface · task 16 moved: culver: messenger + contacts**
Index → In progress. A/B/D landed. C (contact card) builds and awaits phone acceptance. F is the folded-in TASK-01.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-16`</sub>
**f-0011 · surface · task 17 moved: Keyboard + dictation polish**
Index → Open. No word completion (stevia asks hunspell for `en-us`, the dict is `en_US`); no recording indicator on the mic key. The layout half moved to TASK-24. **TTS now exists** (`10.10.20.123:7863`) — the settings field can be filled.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-17`</sub>
**f-0013 · surface · task 19 moved: First-party Settings control center**
Index → In progress. State-machine + source-health readout **built 2026-07-31**, unverified on glass. 34 and 42's layer 3 still surface here.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-19`</sub>
**f-0017 · surface · task 24 moved: Keyboard layout rethink**
Index → Open. Redesign from scratch, not more patches. Start from squeekboard `us+apple`. Casey decides where the mic key goes.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-24`</sub>
**f-0022 · surface · task 30 moved: Agent reach: verb tables**
Index → In progress. sessiond has describe + refusal codes + intent. Dock, apps, shell and settings do not. One session per surface.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-30`</sub>
**f-0023 · surface · task 31 moved: The radial dial**
Index → In progress. Component, gestures and squeeze invocation are live. **Entries are still a list in `DialHost.qml`** — they must come from the verb tables.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-31`</sub>
**f-0024 · surface · task 32 moved: Pill and keyboard as owned apps**
Index → Open. Both erroneous daily; neither has an owner or a shipping story.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-32`</sub>
**f-0002 · unassigned · task 3 moved: Boot timing: splash → lock**
Index → Open. The EBUSY handoff race is the last timing bug. Splash text killed, holder added.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-3`</sub>
**f-0003 · unassigned · task 7 moved: WCD9340 mic**
Index → Open. Functional since 07-20. One defect: the power pin that makes it work. Kernel branch `mic-race-fix`.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-7`</sub>
**f-0005 · unassigned · task 9 moved: Suspend-resume FTS calibration race**
Index → Open. Kernel-side real fix; interim fix shipped. Cold-boot only.
<sub>task-moved · open 2 weeks since 2026-W30 · was unchanged · evidence: `d:task-9`</sub>
**f-0012 · unassigned · task 18 moved: Selection/highlight action menu**
Index → Open. Component first, fluid before functional. **Read Aloud is unblocked** — TTS is live.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-18`</sub>
**f-0015 · unassigned · task 21 moved: Reboot-to-Android confirmation**
Index → Open. Scoped 07-22. A one-tap irreversible slot flip still sits bare in app search.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-21`</sub>
**f-0020 · unassigned · task 28 moved: Handover on upgrade**
Index → Blocked or gated. TASK-27's packaging. Also owns TASK-13's outstanding cold-boot handshake check.
<sub>task-moved · open 2 weeks since 2026-W30 · was advanced · evidence: `d:task-28`</sub>
## ■ blocked
**f-0054 · runtime · task 40 entered the index: Inference tiering for the sensors**
Filed under “Blocked or gated”. Nothing — **it is the gate.** P1 applied to sensord/grip/dial; gates the grip raw stream and TASK-31's IPC. Decisions first, then small code.
<sub>task-listed · evidence: `d:task-40`</sub>
## ? decision
**f-0069 · identity · souveraine#1 unmerged 17d: machined: system-tier machine identity daemon**
Opened 2026-07-16, never merged or closed.
<sub>pull-aging · evidence: `p:souveraine:1`</sub>
**f-0058 · sensorium · task 44 entered the index: Fingerprint (FPC1020)**
Filed under “Open”. Piece 1 (wake source / input) is a session — DT + shim already extracted. Piece 2 (biometrics) is a TrustZone question, answer it before scheduling it.
<sub>task-listed · evidence: `d:task-44`</sub>
## ▲ advanced
**f-0049 · adoption · adoption moved**
1 commit(s), 0 the week before. Declared: moved SouveraineOS Updater.
<sub>dimension-activity · open 2 weeks since 2026-W30 · was unchanged · evidence: `c:Pixel3Arch:3090440a`, `d:task-29`</sub>
**f-0048 · federation · federation moved**
0 commit(s), 2 the week before. Declared: moved culver locked-notification nudge.
<sub>dimension-activity · open 2 weeks since 2026-W30 · evidence: `d:task-23`</sub>
**f-0043 · identity · identity moved**
25 commit(s), 35 the week before. Declared: moved Device state manager / power profiles; moved Idle coordinator; delisted sessiond answers nothing: handler threads park forever.
<sub>dimension-activity · open 2 weeks since 2026-W30 · evidence: `c:souveraine:3d934005`, `c:souveraine:4ed8f3dd`, `c:souveraine-viewtop:c6f74d0f`, `c:souveraine-viewtop:d1b7a16f`, `c:souveraine:c4873762`, `c:souveraine-viewtop:3ef1bc43`, `c:souveraine:c8507e92`, `c:Pixel3Arch:230d7dd7` …</sub>
**f-0042 · reflection · reflection moved**
1 commit(s), 1 the week before. Declared: added docs/SHELL-SURFACES.md.
<sub>dimension-activity · open 2 weeks since 2026-W30 · evidence: `c:souveraine:15af38ae`, `d:docs/SHELL-SURFACES.md`</sub>
**f-0046 · release · release moved**
30 commit(s), 32 the week before. Declared: moved Player: video + agent control; moved One repo, all packages; moved Gitea pipeline audit; added docs/DUMP-packages-publish-2026-07-30.md.
<sub>dimension-activity · open 2 weeks since 2026-W30 · evidence: `c:Pixel3Arch:84adb458`, `c:Pixel3Arch:c8b0287b`, `c:Pixel3Arch:a07801b5`, `c:Pixel3Arch:ab09c5ea`, `c:Pixel3Arch:1561d216`, `c:Pixel3Arch:2dbe9c4f`, `c:Pixel3Arch:b4a45ff9`, `c:Pixel3Arch:c18be31f` …</sub>
**f-0038 · reliability · souveraine-updater CI clean**
1 runs on souveraine-updater, none failed.
<sub>ci-week · open 2 weeks since 2026-W30 · evidence: `ci:souveraine-updater:996`</sub>
**f-0066 · reliability · hexagonrpc CI clean**
6 runs on hexagonrpc, none failed.
<sub>ci-week · evidence: `ci:hexagonrpc:962`</sub>
**f-0067 · reliability · smithay CI clean**
2 runs on smithay, none failed.
<sub>ci-week · evidence: `ci:smithay:1066`</sub>
**f-0041 · runtime · runtime moved**
12 commit(s), 15 the week before. Declared: listed Inference tiering for the sensors; listed Camera: **the front camera works**; added docs/substrate/VPN-WIUF.md.
<sub>dimension-activity · open 2 weeks since 2026-W30 · evidence: `c:hexagonrpc:37437453`, `c:SouveraineOS:04831db5`, `c:SouveraineOS:4287d876`, `c:souveraine-speech:48b5caa5`, `c:souveraine:e9bf33e6`, `c:SouveraineOS:051da9e0`, `c:souveraine-speech:f10e34b9`, `c:souveraine:426dcf63` …</sub>
**f-0045 · sensorium · sensorium moved**
89 commit(s), 47 the week before. Declared: moved Face auth as a capability factor; delisted Active Edge (squeeze) as a sensor input; moved Battery belongs to the state machine; moved SLPI FastRPC session wedge.
<sub>dimension-activity · open 2 weeks since 2026-W30 · evidence: `c:hexagonrpc:6c0fa9cb`, `c:hexagonrpc:55c0d186`, `c:hexagonrpc:c314f76f`, `c:hexagonrpc:8d76b8f5`, `c:hexagonrpc:0aa66cf8`, `c:hexagonrpc:c875a342`, `c:SouveraineOS:3abf45a5`, `c:SouveraineOS:ccf02e3b` …</sub>
**f-0044 · surface · surface moved**
108 commit(s), 62 the week before. Declared: moved Lockscreen-as-Rust-system; moved Overview → phone app grid; moved culver: messenger + contacts; moved Keyboard + dictation polish.
<sub>dimension-activity · open 2 weeks since 2026-W30 · evidence: `c:souveraine:53dcb40c`, `c:SouveraineOS:78c62a60`, `c:souveraine:2170ba68`, `c:souveraine:deda4a7b`, `c:souveraine:68b60252`, `c:souveraine-viewtop:36b6b2e7`, `c:souveraine-viewtop:e1c93a5e`, `c:souveraine-viewtop:d1a9d73e` …</sub>
**f-0057 · surface · task 43 entered the index: viewtop: the Souveraine compositor**
Filed under “Live”. Runs on the phone. Next lane is the ext-session-lock admission gate. The two-client lock handoff is the thing not to get wrong.
<sub>task-listed · evidence: `d:task-43`</sub>
**f-0056 · unassigned · task 42 entered the index: Build outcomes are invisible**
Filed under “In progress”. Layer 1 built (`tools/ci-status.sh` + push hook). Layer 2 is TASK-29's, layer 3 is TASK-19's. Pin-rot prevention unbuilt.
<sub>task-listed · evidence: `d:task-42`</sub>
## · unchanged
**f-0040 · memory · memory moved**
2 commit(s), 1 the week before.
<sub>dimension-activity · open 2 weeks since 2026-W30 · was advanced · evidence: `c:linux-blueline:4d52867f`, `c:linux-blueline:a8bac15b`</sub>
**f-0034 · reliability · Pixel3Arch CI mixed**
On Pixel3Arch, 2 of 14 runs failed and 1 never concluded.
<sub>ci-week · open 2 weeks since 2026-W30 · evidence: `ci:Pixel3Arch:1085`, `ci:Pixel3Arch:992`, `ci:Pixel3Arch:1035`</sub>
**f-0037 · reliability · souveraine CI mixed**
On souveraine, 17 of 65 runs failed and 11 never concluded.
<sub>ci-week · open 2 weeks since 2026-W30 · evidence: `ci:souveraine:1089`, `ci:souveraine:934`, `ci:souveraine:937`, `ci:souveraine:942`, `ci:souveraine:950`, `ci:souveraine:952`</sub>
**f-0047 · reliability · reliability moved**
11 commit(s), 12 the week before.
<sub>dimension-activity · open 2 weeks since 2026-W30 · was advanced · evidence: `c:souveraine:7eb21359`, `c:Pixel3Arch:84673db7`, `c:souveraine:25d1e192`, `c:souveraine:684d1f9f`, `c:souveraine:21b602a7`, `c:souveraine-speech:19c0e307`, `c:souveraine:3bc363a9`, `c:souveraine-updater:1540559a`</sub>
**f-0068 · reliability · souveraine-viewtop CI mixed**
On souveraine-viewtop, 10 of 45 runs failed.
<sub>ci-week · evidence: `ci:souveraine-viewtop:1090`, `ci:souveraine-viewtop:954`, `ci:souveraine-viewtop:955`, `ci:souveraine-viewtop:958`, `ci:souveraine-viewtop:991`, `ci:souveraine-viewtop:1005`</sub>
**f-0060 · runtime · task 46 entered the index: Camera: **the front camera works****
Filed under “Open”. **First frame captured 2026-07-29** on the running kernel — no reboot, no DT change. Both front IMX355 were already bound; nobody had tried streaming. Recipe + the stride/packing gotchas are in the task. Next: exposure controls, then libcam
<sub>task-listed · evidence: `d:task-46`</sub>
**f-0050 · sensorium · task 36 entered the index: CHRE nanoapp host for the SLPI**
Filed under “Open”. Three methods to add; method table recovered 07-27. No longer on grip's critical path — wanted for DozeDeep detection and `elmyra_haptics_control`.
<sub>task-listed · evidence: `d:task-36`</sub>
**f-0053 · sensorium · task 39 entered the index: Audio levels as tracked state**
Filed under “Open”. Defaults + roles, **not** a save bug — WirePlumber persistence measured working 07-28. Do not add a second writer.
<sub>task-listed · evidence: `d:task-39`</sub>
**f-0059 · sensorium · task 45 entered the index: Haptics (CS40L20)**
Filed under “Open”. **First: which chip actually drives the motor**`pmi8998_haptics` is what the dial's detents use today. Then port `cs40l2x` from Kirisakura; firmware blobs are committed.
<sub>task-listed · evidence: `d:task-45`</sub>
**f-0061 · sensorium · task 47 entered the index: Auto-brightness: anchors, none of it built**
Filed under “Open”. §12 reads as settled and has **zero** code behind its actuation half — no brightness `Action`, no anchors, `grep anchor src/` is empty. The evidence half (lux in, health tracked) is live.
<sub>task-listed · evidence: `d:task-47`</sub>
**f-0051 · surface · task 37 entered the index: "Back" as a first-class verb**
Filed under “Open”. Our `Gestures.qml` is already the compositor half; `can_go_back` is the dial's enabled/reason.
<sub>task-listed · evidence: `d:task-37`</sub>
**f-0052 · surface · task 38 entered the index: Pill swipe-to-max swaps the keyboard**
Filed under “Open”. Micro. The only route to a terminal keyboard since stevia dropped its layout.
<sub>task-listed · evidence: `d:task-38`</sub>
**f-0055 · surface · task 41 entered the index: Producers must be attested**
Filed under “Open”. Design first. Demonstrated live 07-28 — a squeeze and a dial-open both accepted from an SSH shell.
<sub>task-listed · evidence: `d:task-41`</sub>
**f-0062 · surface · task 48 entered the index: Shell dies on any reload that owes a lock**
Filed under “Open”. Fully diagnosed 07-29, 11 crashes since 07-17. A reload re-requests ext-session-lock while the outgoing instance still holds it → `FATAL: Tried to show lockscreen surfaces without active lock`. **Sync with TASK-43 before touching the lock p
<sub>task-listed · evidence: `d:task-48`</sub>
**f-0063 · surface · task 50 entered the index: Her hand on the glass**
Filed under “Open”. Every scene intent reaches the compositor now **except touch** — so she can move, scale, dim and close a window and cannot press anything inside one. The receiving end is built (`Origin::Agent`, the disjoint slot namespace, the evidence pre
<sub>task-listed · evidence: `d:task-50`</sub>
**f-0064 · surface · task 51 entered the index: viewtop to daily driver**
Filed under “Open”. The punch list between *runs* and *stable*, all of it found by using the phone: windows stack with nothing to tell them apart, screen capture is written and unmerged (so no UI change can be verified), volume buttons are recognised and dropp
<sub>task-listed · evidence: `d:task-51`</sub>
**f-0065 · surface · task 52 entered the index: Atmosphere**
Filed under “Open”. Attention as a compositor capability — the fifteen-technique vocabulary. Two are **already landed** (`expose` is negative space, `pose` is gravity wells), and `compile_custom_pixel_shader` + `PixelShaderElement` are already in our smithay,
<sub>task-listed · evidence: `d:task-52`</sub>
## Closed this week
- **f-0033 · task 11 left the index: souveraine-secrets on the phone** — held from 2026-W30 to 2026-W30, 1 week(s).
- **f-0030 · task 5 left the index: Crash reporter surfacing** — held from 2026-W30 to 2026-W30, 1 week(s).
- **f-0029 · task 4 left the index: Notification server (org.freedesktop.Notifications)** — held from 2026-W30 to 2026-W30, 1 week(s).
- **f-0028 · task 1 left the index: Favorites — wife's name on her number** — held from 2026-W30 to 2026-W30, 1 week(s).
- **f-0031 · task 6 left the index: qtpim aarch64 build → contacts store** — held from 2026-W30 to 2026-W30, 1 week(s).
- **f-0032 · task 10 left the index: Boot chain: pmOS init → mkinitcpio** — held from 2026-W30 to 2026-W30, 1 week(s).
## Provenance
- Evidence: `project-state/evidence/2026-W31.json`, collected 2026-08-18T22:46:32Z by `tools/project-state/collect.py` v1 against gitea 1.25.5 in 99 API calls.
- Deterministic rules: ci-week, dimension-activity, pull-aging, release-cut, task-delisted, task-listed, task-moved.
- Analyst: none run; every finding here is deterministic.
- Trace one: `tools/project-state/synthesize.py --explain <id>`.

View file

@ -0,0 +1,166 @@
# Souveraine project state — 2026-W32
2026-08-03 to 2026-08-09 (America/Toronto). Internal. Generated from `project-state/evidence/2026-W32.json`; the prose is disposable, the evidence and `state.json` are not.
## The week, counted
- 201 commits across 5 repos, 143 CI runs, 18 declared state changes.
- Issues: 6 open, 0 filed, 0 closed. Pulls: 2 open, 0 merged.
- Dimension basis: 158 by paths, 19 by subject, 14 by none, 10 by repo (`paths` read the diff, `subject` read the commit subject, `repo` fell back to the repository, `none` placed nothing).
## ▼ regressed
**f-0034 · reliability · Pixel3Arch ends the week red**
The last run of the week on Pixel3Arch failed; 12 of 36 runs failed.
<sub>ci-week · open 3 weeks since 2026-W30 · was unchanged · evidence: `ci:Pixel3Arch:1256`, `ci:Pixel3Arch:1137`, `ci:Pixel3Arch:1138`, `ci:Pixel3Arch:1139`, `ci:Pixel3Arch:1140`, `ci:Pixel3Arch:1141`</sub>
## ■ blocked
**f-0009 · identity · task 15: Idle coordinator**
Standing under “Blocked or gated”. Freezing `app.slice` would freeze sessiond, shell and secrets. Needs slice separation first (measured 07-25).
<sub>task-standing · open 3 weeks since 2026-W30 · was regressed · evidence: `d:task-15`</sub>
**f-0018 · release · task 25: One repo, all packages**
Standing under “Blocked or gated”. Parked 07-25 — four blockers before `repo-add --sign`. See TASK-27.
<sub>task-standing · open 3 weeks since 2026-W30 · was regressed · evidence: `d:task-25`</sub>
**f-0054 · runtime · task 40: Inference tiering for the sensors**
Standing under “Blocked or gated”. Nothing — **it is the gate.** P1 applied to sensord/grip/dial; gates the grip raw stream and TASK-31's IPC. Decisions first, then small code.
<sub>task-standing · open 2 weeks since 2026-W31 · evidence: `d:task-40`</sub>
**f-0020 · unassigned · task 28: Handover on upgrade**
Standing under “Blocked or gated”. TASK-27's packaging. Also owns TASK-13's outstanding cold-boot handshake check.
<sub>task-standing · open 3 weeks since 2026-W30 · was regressed · evidence: `d:task-28`</sub>
## ? decision
**f-0069 · identity · souveraine#1 unmerged 24d: machined: system-tier machine identity daemon**
Opened 2026-07-16, never merged or closed.
<sub>pull-aging · open 2 weeks since 2026-W31 · evidence: `p:souveraine:1`</sub>
**f-0077 · surface · task 60 entered the index: Multitasking is a place, not a transition**
Filed under “Live”. One-owner compositor and shell halves are built; strand repro and measured landing pass on glass. `16c6ee1` is pushed and CI 1236 is green. What remains is Casey's thumb on the settle/fade and a real split-zone card check.
<sub>task-listed · evidence: `d:task-60`</sub>
## ▲ advanced
**f-0043 · identity · identity moved**
14 commit(s), 25 the week before. Declared: listed viewtop audit: what it left open; listed Full USB KVM; added docs/DUMP-bugs-2026-08-06.md.
<sub>dimension-activity · open 3 weeks since 2026-W30 · evidence: `c:souveraine:6cb24549`, `c:souveraine:1f028c12`, `c:souveraine:91ea54eb`, `c:souveraine:133daa94`, `c:SouveraineOS:271e22cf`, `c:souveraine:bc094803`, `c:souveraine:425693a2`, `c:souveraine:620c7d31` …</sub>
**f-0071 · identity · task 53 entered the index: viewtop audit: what it left open**
Filed under “Live”. All ten fixed and **packaged onto the phone** (`r74.g6c9614c20471`). The cold boot then exposed a *third*: a lost sessiond race had left the power button dead — discovery waits now. Hardware found two more the gates did not — `delivered` re
<sub>task-listed · evidence: `d:task-53`</sub>
**f-0078 · identity · task 62 entered the index: Full USB KVM**
Filed under “In progress”. GUD + HID + NCM + smoo and the signed package path are green. Still open: install and cable/glass acceptance; real Session Authority gates (peer identity, probe lease, step-up, D-Bus enforcement); cold-boot SSH-over-NCM proof. Type-C/TCPM h
<sub>task-listed · evidence: `d:task-62`</sub>
**f-0067 · reliability · smithay CI clean**
2 runs on smithay, none failed.
<sub>ci-week · open 2 weeks since 2026-W31 · evidence: `ci:smithay:1207`</sub>
**f-0041 · runtime · runtime moved**
14 commit(s), 12 the week before. Declared: listed Her face on the glass: a Live2D presence; added docs/davids-tasks/slimbus-def-act-chan.md.
<sub>dimension-activity · open 3 weeks since 2026-W30 · evidence: `c:souveraine:1a3c24fa`, `c:SouveraineOS:ec552a82`, `c:souveraine:d4f9213a`, `c:souveraine:7f294a5f`, `c:souveraine:14c3c80b`, `c:Pixel3Arch:6a757981`, `c:Pixel3Arch:e8bb2bff`, `c:Pixel3Arch:b2a1d2c4` …</sub>
**f-0045 · sensorium · sensorium moved**
20 commit(s), 89 the week before. Declared: restated Audio levels as tracked state; listed Host power: the SMB2 OTG boost; added docs/DUMP-fingerprint-2026-08-09.md; revised docs/README.md.
<sub>dimension-activity · open 3 weeks since 2026-W30 · evidence: `c:souveraine:6b67512c`, `c:souveraine:fe48bb76`, `c:Pixel3Arch:9b1afd1b`, `c:SouveraineOS:4aa61d73`, `c:SouveraineOS:721b0c7a`, `c:Pixel3Arch:68dca16c`, `c:Pixel3Arch:f30063b5`, `c:Pixel3Arch:13888ed3` …</sub>
**f-0044 · surface · surface moved**
119 commit(s), 108 the week before. Declared: restated Keyboard + dictation polish; listed The hand's verbs: a window action sheet; listed The island, and the bar's honest face; listed The Pi dock head.
<sub>dimension-activity · open 3 weeks since 2026-W30 · evidence: `c:SouveraineOS:5c9a652d`, `c:souveraine-viewtop:ded525a7`, `c:souveraine-viewtop:243a8373`, `c:souveraine-viewtop:9bcc0138`, `c:souveraine:ca44b0be`, `c:souveraine-viewtop:24d743bf`, `c:souveraine:c21bd1d7`, `c:SouveraineOS:384cd945` …</sub>
## · unchanged
**f-0049 · adoption · adoption moved**
2 commit(s), 1 the week before.
<sub>dimension-activity · open 3 weeks since 2026-W30 · was advanced · evidence: `c:SouveraineOS:67923009`, `c:Pixel3Arch:39242795`</sub>
**f-0048 · federation · federation did not move**
A second quiet week.
<sub>dimension-activity · open 3 weeks since 2026-W30 · was advanced · evidence: `w:2026-W32`</sub>
**f-0040 · memory · memory moved**
3 commit(s), 2 the week before.
<sub>dimension-activity · open 3 weeks since 2026-W30 · evidence: `c:souveraine:0c9db098`, `c:souveraine:9ceaf61f`, `c:SouveraineOS:5cc4020c`</sub>
**f-0042 · reflection · reflection did not move**
No commits, after 1 the week before.
<sub>dimension-activity · open 3 weeks since 2026-W30 · was advanced · evidence: `w:2026-W32`</sub>
**f-0046 · release · release moved**
14 commit(s), 30 the week before.
<sub>dimension-activity · open 3 weeks since 2026-W30 · was advanced · evidence: `c:Pixel3Arch:f234928f`, `c:Pixel3Arch:dfb2763d`, `c:Pixel3Arch:aee40a07`, `c:Pixel3Arch:af17845b`, `c:Pixel3Arch:64495406`, `c:Pixel3Arch:a3d5df9a`, `c:SouveraineOS:71f6ce59`, `c:Pixel3Arch:258c995b`</sub>
**f-0037 · reliability · souveraine CI mixed**
On souveraine, 10 of 68 runs failed and 25 never concluded.
<sub>ci-week · open 3 weeks since 2026-W30 · evidence: `ci:souveraine:1259`, `ci:souveraine:1132`, `ci:souveraine:1158`, `ci:souveraine:1183`, `ci:souveraine:1184`, `ci:souveraine:1185`</sub>
**f-0047 · reliability · reliability moved**
1 commit(s), 11 the week before.
<sub>dimension-activity · open 3 weeks since 2026-W30 · evidence: `c:souveraine-viewtop:875e97ed`</sub>
**f-0068 · reliability · souveraine-viewtop CI mixed**
On souveraine-viewtop, 4 of 37 runs failed and 1 never concluded.
<sub>ci-week · open 2 weeks since 2026-W31 · evidence: `ci:souveraine-viewtop:1234`, `ci:souveraine-viewtop:1124`, `ci:souveraine-viewtop:1131`, `ci:souveraine-viewtop:1133`, `ci:souveraine-viewtop:1165`</sub>
**f-0076 · runtime · task 59 entered the index: Her face on the glass: a Live2D presence**
Filed under “Open”. **Phase 1 passed on hardware 2026-08-05: ~58 fps, rig verified drawing (`painted_px` 23.6k/24k), 283 MB WebKit RSS.** Scoped from a Live2D board-girl found in a cloned blog. The reference streams its chat over **SSE terminated by `[DONE]`**
<sub>task-listed · evidence: `d:task-59`</sub>
**f-0053 · sensorium · task 39 restated: Audio levels as tracked state**
What's left was rewritten under “Open”. Open policy: roles, defaults, lock tiers, and state-driven profile intent. WirePlumber persistence works; do not add a second writer.
<sub>task-restated · open 2 weeks since 2026-W31 · evidence: `d:task-39`</sub>
**f-0075 · sensorium · task 58 entered the index: Host power: the SMB2 OTG boost**
Filed under “Open”. Host data works; the boost isn't. The SMB2 5 V boost exists in silicon and mainline's `qcom-smbx-charger` never registers it. Investigation first (which tree builds the running kernel, does smb2 touch the boost, does `qcom_pmic_typec` bind
<sub>task-listed · evidence: `d:task-58`</sub>
**f-0011 · surface · task 17 restated: Keyboard + dictation polish**
What's left was rewritten under “Open”. Dictation works in daily use. Open: no word completion (`en-us` vs `en_US`) and the stateful mic-key indicator is written but not built. Layout moved to TASK-24. **TTS now exists** (`10.10.20.123:7863`).
<sub>task-restated · open 3 weeks since 2026-W30 · was regressed · evidence: `d:task-17`</sub>
**f-0072 · surface · task 55 entered the index: The hand's verbs: a window action sheet**
Filed under “Open”. **Sheet built and deployed 2026-08-05.** The tap carries its window now (the centroid was being dropped, which is why it could only reach the overview); `Kill` added because `Close` is a request a hung client never reads; `ViewtopControl.qm
<sub>task-listed · evidence: `d:task-55`</sub>
**f-0073 · surface · task 56 entered the index: The island, and the bar's honest face**
Filed under “Open”. Bar clears a region for sprawling notification banners, agent-controlled (Part A: design questions). Part B is buildable now: drop the LTE suffix, tap-to-cycle the carrier name (Fido → Casey → Fimeg), and a network-type indicator that shows
<sub>task-listed · evidence: `d:task-56`</sub>
**f-0074 · surface · task 57 entered the index: The Pi dock head**
Filed under “Open”. Phone stays a gadget; the Pi 4 drives the monitor + dock. NCM link is default, Venus encoder is present, compositor capture landed — the unproven seams are `zwlr_screencopy` vs our own capture IPC, and the Pi's own bring-up ("major malfunct
<sub>task-listed · evidence: `d:task-57`</sub>
**f-0003 · unassigned · task 7 restated: WCD9340 mic**
What's left was rewritten under “Open”. Functional and in daily STT use. One defect: the permanent sysclk + MICB2 power pin. Kernel branch `mic-race-fix`.
<sub>task-restated · open 3 weeks since 2026-W30 · was regressed · evidence: `d:task-7`</sub>
## Closed this week
- **f-0027 · task 35 left the index: sessiond answers nothing: handler threads park forever** — held from 2026-W30 to 2026-W31, 2 week(s).
- **f-0007 · task 13 left the index: Active Edge (squeeze) as a sensor input** — held from 2026-W30 to 2026-W31, 2 week(s).
## Standing longest without moving
38 task(s) sat in their section untouched this week. The ten that have sat longest:
- **f-0001 · surface · task 2: Lockscreen-as-Rust-system** — “In progress” for 2 week(s), since 2026-W31.
- **f-0002 · unassigned · task 3: Boot timing: splash → lock** — “Open” for 2 week(s), since 2026-W31.
- **f-0004 · identity · task 8: Device state manager / power profiles** — “In progress” for 2 week(s), since 2026-W31.
- **f-0005 · unassigned · task 9: Suspend-resume FTS calibration race** — “Open” for 2 week(s), since 2026-W31.
- **f-0006 · sensorium · task 12: Face auth as a capability factor** — “Open” for 2 week(s), since 2026-W31.
- **f-0008 · surface · task 14: Overview → phone app grid** — “In progress” for 2 week(s), since 2026-W31.
- **f-0010 · surface · task 16: culver: messenger + contacts** — “In progress” for 2 week(s), since 2026-W31.
- **f-0012 · unassigned · task 18: Selection/highlight action menu** — “Open” for 2 week(s), since 2026-W31.
- **f-0013 · surface · task 19: First-party Settings control center** — “In progress” for 2 week(s), since 2026-W31.
- **f-0014 · release · task 20: Player: video + agent control** — “Open” for 2 week(s), since 2026-W31.
## Provenance
- Evidence: `project-state/evidence/2026-W32.json`, collected 2026-08-18T22:46:38Z by `tools/project-state/collect.py` v1 against gitea 1.25.5 in 91 API calls.
- Deterministic rules: ci-week, dimension-activity, pull-aging, task-listed, task-restated, task-standing.
- Analyst: none run; every finding here is deterministic.
- Trace one: `tools/project-state/synthesize.py --explain <id>`.

View file

@ -0,0 +1,201 @@
# Souveraine project state — 2026-W33
2026-08-10 to 2026-08-16 (America/Toronto). Internal. Generated from `project-state/evidence/2026-W33.json`; the prose is disposable, the evidence and `state.json` are not.
## The week, counted
- 260 commits across 7 repos, 185 CI runs, 37 declared state changes.
- Issues: 6 open, 6 filed, 0 closed. Pulls: 2 open, 0 merged.
- Dimension basis: 198 by paths, 29 by subject, 22 by repo, 11 by none (`paths` read the diff, `subject` read the commit subject, `repo` fell back to the repository, `none` placed nothing).
## ▼ regressed
**f-0037 · reliability · souveraine ends the week red**
The last run of the week on souveraine failed; 16 of 111 runs failed and 50 never concluded.
<sub>ci-week · open 4 weeks since 2026-W30 · was unchanged · evidence: `ci:souveraine:1658`, `ci:souveraine:1268`, `ci:souveraine:1307`, `ci:souveraine:1328`, `ci:souveraine:1400`, `ci:souveraine:1403`</sub>
## ■ blocked
**f-0009 · identity · task 15: Idle coordinator**
Standing under “Blocked or gated”. Freezing `app.slice` would freeze sessiond, shell and secrets. Needs slice separation first (measured 07-25).
<sub>task-standing · open 4 weeks since 2026-W30 · evidence: `d:task-15`</sub>
**f-0018 · release · task 25: One repo, all packages**
Standing under “Blocked or gated”. Parked 07-25 — four blockers before `repo-add --sign`. See TASK-27.
<sub>task-standing · open 4 weeks since 2026-W30 · evidence: `d:task-25`</sub>
**f-0054 · runtime · task 40: Inference tiering for the sensors**
Standing under “Blocked or gated”. Nothing — **it is the gate.** P1 applied to sensord/grip/dial; gates the grip raw stream and TASK-31's IPC. Decisions first, then small code.
<sub>task-standing · open 3 weeks since 2026-W31 · evidence: `d:task-40`</sub>
**f-0020 · unassigned · task 28: Handover on upgrade**
Standing under “Blocked or gated”. TASK-27's packaging. Also owns TASK-13's outstanding cold-boot handshake check.
<sub>task-standing · open 4 weeks since 2026-W30 · evidence: `d:task-28`</sub>
## ? decision
**f-0069 · identity · souveraine#1 unmerged 31d: machined: system-tier machine identity daemon**
Opened 2026-07-16, never merged or closed.
<sub>pull-aging · open 3 weeks since 2026-W31 · evidence: `p:souveraine:1`</sub>
**f-0095 · identity · souveraine#2 unmerged 19d: sessiond: squeeze is input, and a pocket can veto it**
Opened 2026-07-28, never merged or closed.
<sub>pull-aging · evidence: `p:souveraine:2`</sub>
**f-0083 · memory · task 67 entered the index: The caching landed blind, and compaction drifted**
Filed under “Open”. Prompt caching works — one turn shows `cache_write=22585` then `cache_read=22585` — and **nothing reads the counters**: `assistant_with_usage` has zero callers, so the only proof is a log line. Separately, the live config hands the subconsc
<sub>task-listed · evidence: `d:task-67`</sub>
**f-0088 · release · task 73 entered the index: One spine, two skins: the self-dashboard**
Filed under “Open”. Designed 2026-08-11, nothing built. RedFlag Desktop (Tauri, public) + house Gatehouse (wry + QML glance, lens skeleton), spine is agent-side pacman closure resolution that retires souveraine-updater's `pkexec --noconfirm`. Name and home-rep
<sub>task-listed · evidence: `d:task-73`</sub>
**f-0087 · sensorium · task 71 entered the index: QmlMobileScanner as a components app candidate**
Filed under “Open”. Recon 2026-08-11, verdict owed. GPL-3.0 Qt Quick scanner+writer (zxing-cpp/QZXing/zint, SQLite history). Compatible inside AGPL; camera gate is TASK-46; ffmpeg backend needs a freedreno check; fontawesome-qml likely **not** needed (no FontL
<sub>task-listed · evidence: `d:task-71`</sub>
## ▲ advanced
**f-0049 · adoption · adoption moved**
5 commit(s), 2 the week before. Declared: listed Provisioning belongs to a package.
<sub>dimension-activity · open 4 weeks since 2026-W30 · was unchanged · evidence: `c:souveraine-lens:c2830abc`, `c:SouveraineOS:6e2f3f80`, `c:souveraine-lens:0f34f557`, `c:souveraine-lens:1bbda8df`, `c:souveraine-lens:cbe8f672`, `d:task-76`</sub>
**f-0043 · identity · identity moved**
14 commit(s), 14 the week before. Declared: listed Shared household state: tasks first; added docs/SHARED-STATE-ARCHITECTURE.md; revised docs/session-authority-boot-order.md.
<sub>dimension-activity · open 4 weeks since 2026-W30 · evidence: `c:souveraine:c367a4db`, `c:souveraine:f4837c52`, `c:souveraine:715080bb`, `c:souveraine:c31fa10e`, `c:souveraine:af801b88`, `c:souveraine:0dd8aaab`, `c:souveraine:24086b35`, `c:SouveraineOS:4cd894a5` …</sub>
**f-0040 · memory · memory moved**
11 commit(s), 3 the week before. Declared: listed The caching landed blind, and compaction drifted.
<sub>dimension-activity · open 4 weeks since 2026-W30 · was unchanged · evidence: `c:SouveraineOS:f4683a6f`, `c:souveraine-lens:531e7e75`, `c:SouveraineOS:22adbe11`, `c:souveraine-lens:d0f2fabd`, `c:souveraine:ce6741bf`, `c:souveraine:e498e72b`, `c:SouveraineOS:3bf40c80`, `c:souveraine:aae9344f` …</sub>
**f-0042 · reflection · reflection moved**
7 commit(s), 0 the week before. Declared: listed One SouveraineOS build for the iPhone.
<sub>dimension-activity · open 4 weeks since 2026-W30 · was unchanged · evidence: `c:SouveraineOS:287acdff`, `c:souveraine:3175700e`, `c:souveraine:9149c409`, `c:souveraine:dfc72d72`, `c:souveraine:837e0242`, `c:souveraine:63ef8e5b`, `c:souveraine:4662fa1c`, `d:task-64`</sub>
**f-0046 · release · release moved**
38 commit(s), 14 the week before. Declared: listed One spine, two skins: the self-dashboard; listed The lens: a human-first garden over a git vault; revised docs/README.md; revised docs/archive/DUMP-pacman-pipeline-2026-07-24.md.
<sub>dimension-activity · open 4 weeks since 2026-W30 · was unchanged · evidence: `c:SouveraineOS:d1312cbb`, `c:SouveraineOS:6cc89ef2`, `c:SouveraineOS:a1931fd6`, `c:SouveraineOS:38409222`, `c:SouveraineOS:5cf7bbc5`, `c:SouveraineOS:fff06182`, `c:souveraine-viewtop:1bcbfa51`, `c:Pixel3Arch:b272d1b3` …</sub>
**f-0092 · release · task 77 entered the index: The lens: a human-first garden over a git vault**
Filed under “In progress”. Built + packaged 2026-08-15 — folder/subcategory support and inbox capture landed (`61cf534`), publisher chmod fix (`c71c7e4`). Left: lens CI green + `edge` asset published, `pacman -U` on the laptop, then edge-repo DB wiring.
<sub>task-listed · evidence: `d:task-77`</sub>
**f-0067 · reliability · smithay CI clean**
3 runs on smithay, none failed.
<sub>ci-week · open 3 weeks since 2026-W31 · evidence: `ci:smithay:1645`</sub>
**f-0093 · reliability · SouveraineOS CI clean**
7 runs on SouveraineOS, none failed.
<sub>ci-week · evidence: `ci:SouveraineOS:1555`</sub>
**f-0041 · runtime · runtime moved**
47 commit(s), 14 the week before. Declared: listed A stalled subconscious wedges the surface; listed souveraineai.com overhaul; listed Agent usage, one spine: the session daemon; added docs/FACE-VEIL-ASSETS.md.
<sub>dimension-activity · open 4 weeks since 2026-W30 · evidence: `c:souveraine:a10ef5e3`, `c:souveraine:dfc89d09`, `c:souveraine:eee26fcf`, `c:souveraine:30e1c34a`, `c:souveraine:6eda4914`, `c:Pixel3Arch:e06aa70b`, `c:souveraine-speech:f89f7f9d`, `c:souveraine-speech:f6ec2a28` …</sub>
**f-0081 · runtime · task 65 entered the index: A stalled subconscious wedges the surface**
Filed under “In progress”. Reproduced and measured, not diagnosed. The reply commits and `PrimaryComplete` fires, then the N+1 pass stalls at round 3 of 10 with a fully idle runtime — and because it runs inside `run_turn`, the SSE stream never closes and Surfaces spi
<sub>task-listed · evidence: `d:task-65`</sub>
**f-0082 · runtime · task 66 entered the index: souveraineai.com overhaul**
Filed under “Live”. Rework landed 2026-08-10, waiting on push. Nested Astro docs repo folded into `souveraine-website` with history; the static `public/*.html` shell archived, not deleted. Left: `astro build` green in the merged repo and the server rebuilding
<sub>task-listed · evidence: `d:task-66`</sub>
**f-0045 · sensorium · sensorium moved**
15 commit(s), 20 the week before. Declared: listed QmlMobileScanner as a components app candidate; listed LibrePods: an admitted accessory; revised docs/AUDIO-PRIVACY.md; revised docs/DEVICE-STATE-MACHINE.md.
<sub>dimension-activity · open 4 weeks since 2026-W30 · evidence: `c:SouveraineOS:9b54eb41`, `c:Pixel3Arch:5927aa7f`, `c:SouveraineOS:a1c184d3`, `c:Pixel3Arch:f52a57b9`, `c:souveraine:29ec9fe2`, `c:souveraine-speech:ea43970b`, `c:souveraine-speech:be9219dc`, `c:Pixel3Arch:b2281f02` …</sub>
**f-0044 · surface · surface moved**
105 commit(s), 119 the week before. Declared: restated Squeekboard + dictation polish; restated Keyboard layout rethink; restated Pill and keyboard as owned apps; restated Pill swipe-to-max swaps the keyboard.
<sub>dimension-activity · open 4 weeks since 2026-W30 · evidence: `c:souveraine-viewtop:72608e91`, `c:smithay:e57116a0`, `c:souveraine-viewtop:f49af4af`, `c:souveraine:902a7248`, `c:souveraine:658217e7`, `c:souveraine:cb7dd73d`, `c:souveraine:28ed6f21`, `c:souveraine:ec87e6a9` …</sub>
**f-0079 · surface · task 63 entered the index: The second body: SouveraineOS on the iPhone 7**
Filed under “In progress”. **The shell is on the glass 2026-08-09** — viewtop, sessiond and `qs -c souveraine` all run on a T8010 with no GPU, drawn by llvmpipe, over one USB cable. Hoolock is the bootloader and nothing else. The build half of this is TASK-64.
<sub>task-listed · evidence: `d:task-63`</sub>
## · unchanged
**f-0091 · adoption · task 76 entered the index: Provisioning belongs to a package**
Filed under “Open”. Five hand-placed files own no package on the two live devices, and `/etc/systemd/system/souveraine-machined.service` shadows the packaged signer unit on **both**. Activation is two unversioned lua lines. `blueline-edge-sense` already ships
<sub>task-listed · evidence: `d:task-76`</sub>
**f-0048 · federation · federation did not move**
A second quiet week.
<sub>dimension-activity · open 4 weeks since 2026-W30 · evidence: `w:2026-W33`</sub>
**f-0084 · identity · task 68 entered the index: Shared household state: tasks first**
Filed under “Open”. Canonical plan landed 2026-08-10. First slice is an admitted local task trail + materialized QML app/sidebar view; Culver is the Personal-class client, not the authority.
<sub>task-listed · evidence: `d:task-68`</sub>
**f-0080 · reflection · task 64 entered the index: One SouveraineOS build for the iPhone**
Filed under “Open”. Scoped 2026-08-10, not started. `linux-pauli` is not a repository, reaches the phone by `scp`, and CI has never built it. The display work that day is worth more than its code: the raster is **derived, not guessed** — 888×1845 @ 60 off `adp
<sub>task-listed · evidence: `d:task-64`</sub>
**f-0034 · reliability · Pixel3Arch CI mixed**
On Pixel3Arch, 14 of 30 runs failed and 5 never concluded.
<sub>ci-week · open 4 weeks since 2026-W30 · was regressed · evidence: `ci:Pixel3Arch:1705`, `ci:Pixel3Arch:1264`, `ci:Pixel3Arch:1265`, `ci:Pixel3Arch:1284`, `ci:Pixel3Arch:1289`, `ci:Pixel3Arch:1294`</sub>
**f-0047 · reliability · reliability moved**
7 commit(s), 1 the week before.
<sub>dimension-activity · open 4 weeks since 2026-W30 · evidence: `c:souveraine-viewtop:f45f459f`, `c:souveraine-viewtop:991f7664`, `c:souveraine-viewtop:0f82700d`, `c:souveraine:8bc9c9a7`, `c:souveraine:2fa92210`, `c:souveraine-lens:cd646e09`, `c:souveraine-lens:3f0f4de4`</sub>
**f-0068 · reliability · souveraine-viewtop CI mixed**
On souveraine-viewtop, 1 of 20 runs failed.
<sub>ci-week · open 3 weeks since 2026-W31 · evidence: `ci:souveraine-viewtop:1663`, `ci:souveraine-viewtop:1271`</sub>
**f-0094 · reliability · souveraine-lens CI mixed**
On souveraine-lens, 3 of 14 runs failed.
<sub>ci-week · evidence: `ci:souveraine-lens:1692`, `ci:souveraine-lens:1632`, `ci:souveraine-lens:1634`, `ci:souveraine-lens:1679`</sub>
**f-0085 · runtime · task 69 entered the index: Agent usage, one spine: the session daemon**
Filed under “Open”. Recon 2026-08-11, nothing built. One collector tracks Souveraine + Claude Code + Codex from their local truth (`~/.codex/sessions` JSONL, `~/.claude/projects` JSONL, substrate `core/session`). Two gaps are one shape: the substrate records u
<sub>task-listed · evidence: `d:task-69`</sub>
**f-0089 · sensorium · task 74 entered the index: LibrePods: an admitted accessory**
Filed under “Open”. **Scoped 2026-08-13.** AirPods protocol stays in LibrePods; sessiond must admit/audit presentation events and Quickshell projects the card. First gate: capture a redacted case-open fixture — connection is not proof of opening.
<sub>task-listed · evidence: `d:task-74`</sub>
**f-0011 · surface · task 17 restated: Squeekboard + dictation polish**
What's left was rewritten under “Open”. Sole OSK installed at pkgrel 4. Pkgrel 5 adds Daily's visible mode key and chooser for the separate five-row Terminal board; CI, install, repeated reopen, mic-state, and side-panel focus proof remain. Native completion follows.
<sub>task-restated · open 4 weeks since 2026-W30 · evidence: `d:task-17`</sub>
**f-0017 · surface · task 24 restated: Keyboard layout rethink**
What's left was rewritten under “Open”. Superseded by TASK-17's reference-led Daily/Terminal Squeekboard layouts. Retained as design evidence; do not resume its Stevia patch plan.
<sub>task-restated · open 4 weeks since 2026-W30 · evidence: `d:task-24`</sub>
**f-0024 · surface · task 32 restated: Pill and keyboard as owned apps**
What's left was rewritten under “Open”. Keyboard now has one runtime/package owner and no dual-OSK provisioning; glass proof remains. Pill ownership and package-owned shell delivery remain open.
<sub>task-restated · open 4 weeks since 2026-W30 · evidence: `d:task-32`</sub>
**f-0052 · surface · task 38 restated: Pill swipe-to-max swaps the keyboard**
What's left was rewritten under “Open”. Superseded: Squeekboard's own visible mode chooser selects Daily/Terminal inside one process. Archive after TASK-17 glass acceptance.
<sub>task-restated · open 3 weeks since 2026-W31 · evidence: `d:task-38`</sub>
**f-0086 · surface · task 70 entered the index: The island: agent surfacing for the shell**
Filed under “Open”. Recon 2026-08-11. "Open Agent Island" — end-4 fork (same ancestor as ii-base) with a morphing notch, Claude Code status, and permission Allow/Deny from the notch. Take the mechanisms (AgentService + bridge, 13/13 safety) onto ii-base, not t
<sub>task-listed · evidence: `d:task-70`</sub>
**f-0090 · surface · task 75 entered the index: The shell must have a memory budget**
Filed under “Open”. **Scoped 2026-08-13.** QuickShell's resident core must stay small; the AI panel is the first separately supervised, genuinely unloadable surface. Measure before choosing a full native-Rust shell rewrite.
<sub>task-listed · evidence: `d:task-75`</sub>
## Standing longest without moving
45 task(s) sat in their section untouched this week. The ten that have sat longest:
- **f-0001 · surface · task 2: Lockscreen-as-Rust-system** — “In progress” for 3 week(s), since 2026-W31.
- **f-0002 · unassigned · task 3: Boot timing: splash → lock** — “Open” for 3 week(s), since 2026-W31.
- **f-0003 · unassigned · task 7: WCD9340 mic** — “Open” for 3 week(s), since 2026-W31.
- **f-0004 · identity · task 8: Device state manager / power profiles** — “In progress” for 3 week(s), since 2026-W31.
- **f-0005 · unassigned · task 9: Suspend-resume FTS calibration race** — “Open” for 3 week(s), since 2026-W31.
- **f-0006 · sensorium · task 12: Face auth as a capability factor** — “Open” for 3 week(s), since 2026-W31.
- **f-0008 · surface · task 14: Overview → phone app grid** — “In progress” for 3 week(s), since 2026-W31.
- **f-0010 · surface · task 16: culver: messenger + contacts** — “In progress” for 3 week(s), since 2026-W31.
- **f-0012 · unassigned · task 18: Selection/highlight action menu** — “Open” for 3 week(s), since 2026-W31.
- **f-0013 · surface · task 19: First-party Settings control center** — “In progress” for 3 week(s), since 2026-W31.
## Provenance
- Evidence: `project-state/evidence/2026-W33.json`, collected 2026-08-18T22:46:44Z by `tools/project-state/collect.py` v1 against gitea 1.25.5 in 92 API calls.
- Deterministic rules: ci-week, dimension-activity, pull-aging, task-listed, task-restated, task-standing.
- Analyst: none run; every finding here is deterministic.
- Trace one: `tools/project-state/synthesize.py --explain <id>`.

View file

@ -0,0 +1,173 @@
# Souveraine project state — 2026-W34
2026-08-17 to 2026-08-23 (America/Toronto). Internal. Generated from `project-state/evidence/2026-W34.json`; the prose is disposable, the evidence and `state.json` are not.
## The week, counted
- 43 commits across 4 repos, 25 CI runs, 116 declared state changes.
- Issues: 6 open, 0 filed, 0 closed. Pulls: 2 open, 0 merged.
- Dimension basis: 33 by paths, 7 by subject, 3 by repo (`paths` read the diff, `subject` read the commit subject, `repo` fell back to the repository, `none` placed nothing).
## ◆ milestone
**f-0082 · runtime · task 66 left the index: souveraineai.com overhaul**
Dropped from “Live” — closed work moves to archive/.
<sub>task-delisted · open 2 weeks since 2026-W33 · was advanced · evidence: `d:task-66`</sub>
**f-0085 · runtime · task 69 left the index: Agent usage, one spine: the session daemon**
Dropped from “Open” — closed work moves to archive/.
<sub>task-delisted · open 2 weeks since 2026-W33 · was unchanged · evidence: `d:task-69`</sub>
**f-0017 · surface · task 24 left the index: Keyboard layout rethink**
Dropped from “Open” — closed work moves to archive/.
<sub>task-delisted · open 5 weeks since 2026-W30 · was unchanged · evidence: `d:task-24`</sub>
**f-0052 · surface · task 38 left the index: Pill swipe-to-max swaps the keyboard**
Dropped from “Open” — closed work moves to archive/.
<sub>task-delisted · open 4 weeks since 2026-W31 · was unchanged · evidence: `d:task-38`</sub>
**f-0072 · surface · task 55 left the index: The hand's verbs: a window action sheet**
Dropped from “Open” — closed work moves to archive/.
<sub>task-delisted · open 3 weeks since 2026-W32 · was unchanged · evidence: `d:task-55`</sub>
**f-0086 · surface · task 70 left the index: The island: agent surfacing for the shell**
Dropped from “Open” — closed work moves to archive/.
<sub>task-delisted · open 2 weeks since 2026-W33 · was unchanged · evidence: `d:task-70`</sub>
**f-0090 · surface · task 75 left the index: The shell must have a memory budget**
Dropped from “Open” — closed work moves to archive/.
<sub>task-delisted · open 2 weeks since 2026-W33 · was unchanged · evidence: `d:task-75`</sub>
## ■ blocked
**f-0009 · identity · task 15: Idle coordinator**
Standing under “Blocked or gated”. Freezing `app.slice` would freeze sessiond, shell and secrets. Needs slice separation first (measured 07-25).
<sub>task-standing · open 5 weeks since 2026-W30 · evidence: `d:task-15`</sub>
**f-0018 · release · task 25: One repo, all packages**
Standing under “Blocked or gated”. Parked 07-25 — four blockers before `repo-add --sign`. See TASK-27.
<sub>task-standing · open 5 weeks since 2026-W30 · evidence: `d:task-25`</sub>
**f-0054 · runtime · task 40: Inference tiering for the sensors**
Standing under “Blocked or gated”. Nothing — **it is the gate.** P1 applied to sensord/grip/dial; gates the grip raw stream and TASK-31's IPC. Decisions first, then small code.
<sub>task-standing · open 4 weeks since 2026-W31 · evidence: `d:task-40`</sub>
**f-0020 · unassigned · task 28: Handover on upgrade**
Standing under “Blocked or gated”. TASK-27's packaging. Also owns TASK-13's outstanding cold-boot handshake check.
<sub>task-standing · open 5 weeks since 2026-W30 · evidence: `d:task-28`</sub>
## ? decision
**f-0069 · identity · souveraine#1 unmerged 38d: machined: system-tier machine identity daemon**
Opened 2026-07-16, never merged or closed.
<sub>pull-aging · open 4 weeks since 2026-W31 · evidence: `p:souveraine:1`</sub>
**f-0095 · identity · souveraine#2 unmerged 26d: sessiond: squeeze is input, and a pocket can veto it**
Opened 2026-07-28, never merged or closed.
<sub>pull-aging · open 2 weeks since 2026-W33 · evidence: `p:souveraine:2`</sub>
## ▲ advanced
**f-0049 · adoption · adoption moved**
0 commit(s), 5 the week before. Declared: restated Provisioning belongs to a package; added saf/device/evidence/first_provision_capture.md; added saf/device/evidence/qcril_full_provisioning.md.
<sub>dimension-activity · open 5 weeks since 2026-W30 · evidence: `d:task-76`, `d:saf/device/evidence/first_provision_capture.md`, `d:saf/device/evidence/qcril_full_provisioning.md`</sub>
**f-0048 · federation · federation moved**
0 commit(s), 0 the week before. Declared: moved docs/substrate/tasks/archive/federation-summon.md; added saf/archive/atproto-federation.md; added saf/federation/01-node-enrollment.md.
<sub>dimension-activity · open 5 weeks since 2026-W30 · was unchanged · evidence: `d:docs/substrate/tasks/archive/federation-summon.md`, `d:saf/archive/atproto-federation.md`, `d:saf/federation/01-node-enrollment.md`</sub>
**f-0043 · identity · identity moved**
13 commit(s), 14 the week before. Declared: listed The claude login belongs to souveraine, not to `claude`; revised docs/SESSION-AUTHORITY-DOCTRINE.md; revised docs/souveraine-components/INVENTORY.md; revised docs/souveraine-components/audit-status.md.
<sub>dimension-activity · open 5 weeks since 2026-W30 · evidence: `c:souveraine:21ef10e8`, `c:souveraine:91ab8053`, `c:SouveraineOS:9815185c`, `c:souveraine:af60d9e4`, `c:souveraine:ee496a7c`, `c:SouveraineOS:341a2fe0`, `c:souveraine:14298e73`, `c:souveraine:0f8fe22a` …</sub>
**f-0040 · memory · memory moved**
5 commit(s), 11 the week before. Declared: added saf/archive/per-agent-compaction-provider.md; added saf/memory/01-compaction.md; added saf/memory/02-replay.md.
<sub>dimension-activity · open 5 weeks since 2026-W30 · evidence: `c:souveraine:06a1a035`, `c:souveraine:707a2772`, `c:souveraine:49c6a6a8`, `c:souveraine:04ceac79`, `c:souveraine:5f7f23f3`, `d:saf/archive/per-agent-compaction-provider.md`, `d:saf/memory/01-compaction.md`, `d:saf/memory/02-replay.md`</sub>
**f-0042 · reflection · reflection moved**
3 commit(s), 7 the week before. Declared: revised docs/substrate/tasks/archive/rename-aster-to-subconscious.md; deleted docs/substrate/tasks/rename-aster-to-subconscious.md.
<sub>dimension-activity · open 5 weeks since 2026-W30 · evidence: `c:souveraine:5a5b647b`, `c:souveraine:9bee0e8a`, `c:souveraine:727c4969`, `d:docs/substrate/tasks/archive/rename-aster-to-subconscious.md`, `d:docs/substrate/tasks/rename-aster-to-subconscious.md`</sub>
**f-0068 · reliability · souveraine-viewtop CI clean**
2 runs on souveraine-viewtop, none failed.
<sub>ci-week · open 4 weeks since 2026-W31 · was unchanged · evidence: `ci:souveraine-viewtop:1746`</sub>
**f-0041 · runtime · runtime moved**
7 commit(s), 47 the week before. Declared: delisted souveraineai.com overhaul; restated The caching landed blind, and compaction drifted; delisted Agent usage, one spine: the session daemon; revised docs/substrate/FELT_STATE_ARCHITECTURE.md.
<sub>dimension-activity · open 5 weeks since 2026-W30 · evidence: `c:souveraine:b3bbfeea`, `c:souveraine:dcd44a0c`, `c:souveraine:7b45054f`, `c:souveraine:7089dfb8`, `c:souveraine:9e147093`, `c:souveraine:f32f00c6`, `c:souveraine:76e12045`, `d:task-66` …</sub>
**f-0045 · sensorium · sensorium moved**
1 commit(s), 15 the week before. Declared: restated Battery belongs to the state machine; revised docs/AUDIO-PRIVACY.md; added saf/device/audio.md; added saf/device/evidence/android_modem_bringup_capture.md.
<sub>dimension-activity · open 5 weeks since 2026-W30 · evidence: `c:SouveraineOS:bde961c6`, `d:task-33`, `d:docs/AUDIO-PRIVACY.md`, `d:saf/device/audio.md`, `d:saf/device/evidence/android_modem_bringup_capture.md`</sub>
**f-0044 · surface · surface moved**
12 commit(s), 105 the week before. Declared: delisted Keyboard layout rethink; delisted Pill swipe-to-max swaps the keyboard; restated Shell dies on any reload that owes a lock; listed Bearer selection and DNS.
<sub>dimension-activity · open 5 weeks since 2026-W30 · evidence: `c:souveraine:3b119033`, `c:souveraine:da8d69af`, `c:souveraine:5237039c`, `c:souveraine:799f0650`, `c:souveraine:59b52a81`, `c:souveraine:2bd61ec4`, `c:souveraine:184a4497`, `c:SouveraineOS:7fc623c5` …</sub>
**f-0071 · surface · task 53 restated: viewtop audit: what it left open**
What's left was rewritten under “Live”. The original ten and cold-boot follow-ups are fixed. Firefox's 504/540 mismatch and the false touch-frame storm are source-fixed and pushed (`3431223`, `41f07b1`), awaiting package/glass proof. Open: overshoot clamp, persistent control conn
<sub>task-restated · open 3 weeks since 2026-W32 · was unchanged · evidence: `d:task-53`</sub>
**f-0077 · surface · task 60 restated: Multitasking is a place, not a transition**
What's left was rewritten under “Live”. One-owner carry, measured card geometry, velocity-seeded spring, tray, Home, and the strand repro are on glass. What remains is a real split-zone card check; the inert 1.22 overshoot clamp is a TASK-53 compositor defect.
<sub>task-restated · open 3 weeks since 2026-W32 · was unchanged · evidence: `d:task-60`</sub>
**f-0096 · surface · task 49 entered the index: Bearer selection and DNS**
Filed under “In progress”. Machine evidence, settle window, `PreferLink`, tunnel-underlay pinning, readout, and the first two hardware checks landed. Open: split DNS through resolved, wifi/LTE transition, and honest deaf-tunnel proof.
<sub>task-listed · evidence: `d:task-49`</sub>
## · unchanged
**f-0091 · adoption · task 76 restated: Provisioning belongs to a package**
What's left was rewritten under “Open”. The viewtop-owned session edge and legal wrapper targets landed. Still open: package-owned QuickShell content, commission-time activation on each device, removal of `/etc` and user-unit shadows, and fresh-install proof.
<sub>task-restated · open 2 weeks since 2026-W33 · evidence: `d:task-76`</sub>
**f-0097 · identity · task 78 entered the index: The claude login belongs to souveraine, not to `claude`**
Filed under “Open”. Scoped 2026-08-18, nothing built. Headless PKCE `souveraine login` (flow verified), a souveraine-owned credential file outside the phone/laptop sync, and a Panel notice when no login can speak. Full Providers subsection in the Panel is a la
<sub>task-listed · evidence: `d:task-78`</sub>
**f-0046 · release · release moved**
2 commit(s), 38 the week before.
<sub>dimension-activity · open 5 weeks since 2026-W30 · was advanced · evidence: `c:souveraine-viewtop:e8e3f9b1`, `c:SouveraineOS:b1c9494f`</sub>
**f-0037 · reliability · souveraine CI mixed**
On souveraine, 6 of 23 runs failed and 9 never concluded.
<sub>ci-week · open 5 weeks since 2026-W30 · was regressed · evidence: `ci:souveraine:1829`, `ci:souveraine:1729`, `ci:souveraine:1753`, `ci:souveraine:1790`, `ci:souveraine:1795`, `ci:souveraine:1799`</sub>
**f-0047 · reliability · reliability did not move**
No commits, after 7 the week before.
<sub>dimension-activity · open 5 weeks since 2026-W30 · evidence: `w:2026-W34`</sub>
**f-0083 · runtime · task 67 restated: The caching landed blind, and compaction drifted**
What's left was rewritten under “Open”. Prompt caching is measured and per-turn usage now persists through `assistant_with_usage`; context occupancy reaches both TUI and Panel. Still open: render the provider cache split, regression-test a stable second-round read, settle the wid
<sub>task-restated · open 2 weeks since 2026-W33 · was decision required · evidence: `d:task-67`</sub>
**f-0025 · sensorium · task 33 restated: Battery belongs to the state machine**
What's left was rewritten under “Open”. Charge now enters as `SensorSource::Charge` through sensord and sessiond interprets it. Open: UPower supplier-walk threshold write support and the step-up-gated charge policy.
<sub>task-restated · open 5 weeks since 2026-W30 · evidence: `d:task-33`</sub>
**f-0062 · surface · task 48 restated: Shell dies on any reload that owes a lock**
What's left was rewritten under “Open”. **Unblocked.** Viewtop's lock admission/restore and two-client handoff now exist. The QML root still lacks the reload identity its children carry; the locked reload acceptance has not been rerun.
<sub>task-restated · open 4 weeks since 2026-W31 · evidence: `d:task-48`</sub>
## Standing longest without moving
50 task(s) sat in their section untouched this week. The ten that have sat longest:
- **f-0001 · surface · task 2: Lockscreen-as-Rust-system** — “In progress” for 4 week(s), since 2026-W31.
- **f-0002 · unassigned · task 3: Boot timing: splash → lock** — “Open” for 4 week(s), since 2026-W31.
- **f-0003 · unassigned · task 7: WCD9340 mic** — “Open” for 4 week(s), since 2026-W31.
- **f-0004 · identity · task 8: Device state manager / power profiles** — “In progress” for 4 week(s), since 2026-W31.
- **f-0005 · unassigned · task 9: Suspend-resume FTS calibration race** — “Open” for 4 week(s), since 2026-W31.
- **f-0006 · sensorium · task 12: Face auth as a capability factor** — “Open” for 4 week(s), since 2026-W31.
- **f-0008 · surface · task 14: Overview → phone app grid** — “In progress” for 4 week(s), since 2026-W31.
- **f-0010 · surface · task 16: culver: messenger + contacts** — “In progress” for 4 week(s), since 2026-W31.
- **f-0011 · surface · task 17: Squeekboard + dictation polish** — “Open” for 4 week(s), since 2026-W31.
- **f-0012 · unassigned · task 18: Selection/highlight action menu** — “Open” for 4 week(s), since 2026-W31.
## Provenance
- Evidence: `project-state/evidence/2026-W34.json`, collected 2026-08-18T22:46:47Z by `tools/project-state/collect.py` v1 against gitea 1.25.5 in 85 API calls.
- Deterministic rules: ci-week, dimension-activity, pull-aging, task-delisted, task-listed, task-restated, task-standing.
- Analyst: none run; every finding here is deterministic.
- Trace one: `tools/project-state/synthesize.py --explain <id>`.

3377
project-state/state.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
{
"pull/souveraine#1": {
"verdict": "promote",
"note": "machined has sat a month; decide or close it",
"by": "casey",
"at": "2026-08-18T23:27:09Z",
"edited": false,
"week": "2026-W34",
"finding_id": "f-0069",
"title": "souveraine#1 unmerged 38d: machined: system-tier machine identity daemon"
}
}

View file

@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""The agent's door into project state. No inference happens here, ever.
The deterministic layer establishes what is true; the agent reading this writes
the prose. Nothing in this file selects, calls, or configures a model the
agent invoking it is the model, and it already has the identity, the context and
the tools.
agent.py status what is collected, what is missing
agent.py packet --week 2026-W33 everything needed to write the draft
agent.py explain f-0069 one finding and every row it rests on
agent.py draft --week 2026-W33 --file draft.md --author Annie
agent.py verdict f-0069 promote --note "worth a task"
`packet` is the contract. It carries the week's state, what turned, what has
stood longest, and an `investigate` list naming where the deterministic layer
knows it is thin go read those repositories rather than trusting the packet.
See docs/tasks/79-project-state.md.
"""
import argparse
import datetime as dt
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import common as c
import collect
import synthesize as s
STORE = s.STORE
CONTRACT = {
"you_are": "the agent that writes Souveraine's weekly project-state draft",
"the_split": (
"Everything under `counted`, `movement`, `standing`, `carried` and `closed` was "
"computed from the forge by deterministic code and is authoritative. Your draft is "
"downstream of it and is disposable — it can be regenerated from this packet and "
"the stored state at any time."
),
"produce": (
"A markdown draft answering: what became true about Souveraine this week, what "
"stopped being true, what advanced, what regressed, what remains unresolved, what "
"decisions are waiting, and which milestones were crossed or are approaching."
),
"rules": [
"Cite finding ids (f-0069) and evidence ids (c:, ci:, d:, i:, p:, r:) for every claim.",
"Do not invent a shipment, release, decision, or outcome that is not in the evidence.",
"'Nothing moved here this week' is a legal sentence.",
"Where the packet's `investigate` list says the evidence is thin, go read the "
"repository before asserting; say so inline if you did not.",
"The report is internal. It publishes nowhere until a human promotes it.",
],
"when_the_packet_is_not_enough": (
"Read the repositories directly. `evidence.file` holds every row including full "
"commit bodies and changed paths; the working trees named in `repos` are checked "
"out locally. `agent.py explain <id>` prints any finding with its rows in full."
),
"return_with": "agent.py draft --week <week> --file <path> --author <you> [--model <id>]",
"no_inference_here": (
"This tool never calls a model. Model choice belongs to whoever invokes it and is "
"recorded, not decided, by --model."
),
}
EVIDENCE_KINDS = {
"c:": "commit — repo, sha, subject, body, changed paths, dimension and the basis that chose it",
"ci:": "Gitea Actions run — workflow, branch, head sha, conclusion; joins to a commit id",
"d:": "declared change — a task-index row or a SAF/docs file that moved",
"i:": "issue", "p:": "pull request", "r:": "release", "w:": "the week's rollup",
}
def load_state():
return s.load(os.path.join(STORE, "state.json"), {"findings": []})
def load_evidence(week):
return s.load(os.path.join(STORE, "evidence", f"{week}.json"))
def status():
state = load_state()
held = collect.held()
return {
"weeks_held": held,
"latest_synthesized": state.get("latest_week"),
"gaps": collect.gaps(),
"pending": s.pending(state),
"open_findings": sum(1 for f in state.get("findings", []) if f["status"] == "open"),
"drafts": sorted(os.listdir(os.path.join(STORE, "drafts")))
if os.path.isdir(os.path.join(STORE, "drafts")) else [],
"next": next_action(state, held),
}
def next_action(state, held):
if collect.gaps():
return "collect.py --backfill — weeks are missing evidence"
if s.pending(state):
return "synthesize.py --catch-up — weeks are collected but not passed over"
if not held:
return "collect.py --backfill --since <week> — nothing collected yet"
return f"agent.py packet --week {state.get('latest_week')} — state is current; write the draft"
def thin_spots(ev, findings):
"""Where the deterministic layer knows it did not see enough.
This is the half of the packet that asks for work: each entry says what is
weak, and what reading it would take to settle.
"""
out = []
unplaced = [r for r in ev["commits"] if r["dimension"] == c.UNASSIGNED]
if unplaced:
out.append({
"why": f"{len(unplaced)} commit(s) matched no dimension keyword and were not guessed at",
"how": "read these commits in their repositories and place them yourself",
"rows": [{"id": r["id"], "repo": r["repo"], "subject": r["subject"],
"paths": r["paths"][:6]} for r in unplaced],
})
blind = [name for name, meta in ev["collector"]["repos"].items()
if not meta["checkout"] and meta["commits"]]
if blind:
out.append({
"why": f"no local checkout for {', '.join(blind)}, so changed paths were unavailable "
f"and those commits were placed by subject or repository alone",
"how": "clone or fetch those repos if a dimension call there matters this week",
"rows": [],
})
single = [f for f in findings if len(f["evidence"]) == 1
and f["provenance"]["rule"] not in ("task-standing", "pull-aging", "issue-aging")]
if single:
out.append({
"why": f"{len(single)} finding(s) rest on exactly one evidence row",
"how": "agent.py explain <id>, then read the surrounding work before leaning on it",
"rows": [{"id": f["id"], "title": f["title"], "evidence": f["evidence"]}
for f in single[:12]],
})
declared_only = [dim for dim, r in ev["rollup"]["by_dimension"].items()
if r.get("declared") and not r.get("commits")]
if declared_only:
out.append({
"why": f"{', '.join(declared_only)} changed in the documentation with no commits behind it",
"how": "a doc can lead the code or lag it — check which, before calling it advanced",
"rows": [{"id": r["id"], "file": r.get("file"), "title": r.get("title"),
"change": r["change"]}
for r in ev["declared"] if r["dimension"] in declared_only][:12],
})
notes = ev["collector"].get("notes") or []
if notes:
out.append({"why": "the collector reported problems", "how": "treat these areas as incomplete",
"rows": [{"note": n} for n in notes]})
return out
def packet(week, brief=False):
label, monday, sunday = c.parse_week(week)
ev = load_evidence(label)
if not ev:
return None, f"no evidence for {label} — collect.py --week {label}"
state = s.load(os.path.join(STORE, "history", f"{label}.json")) or load_state()
if label not in state.get("weeks", []):
return None, f"{label} is collected but not synthesized — synthesize.py --catch-up"
findings = state["findings"]
this_week = [f for f in findings if f["last_seen"] == label and f["status"] == "open"]
quiet = [f for f in this_week if f["provenance"]["rule"] == "task-standing"
and f["movement"] != "blocked"]
moved = [f for f in this_week if f not in quiet]
carried = [f for f in findings if f["status"] == "open" and f["last_seen"] != label]
closed = [f for f in findings if f.get("resolved_in") == label]
turned = [f for f in moved if f.get("was") and f["was"]["movement"] != f["movement"]]
def slim(f):
row = {"id": f["id"], "key": f["key"], "dimension": f["dimension"],
"movement": f["movement"], "title": f["title"], "statement": f["statement"],
"evidence": f["evidence"], "rule": f["provenance"]["rule"],
"first_seen": f["first_seen"], "weeks_open": f.get("weeks_open")}
for extra in ("was", "weeks_in_section", "section_since", "task_state", "resolved_in"):
if f.get(extra):
row[extra] = f[extra]
if (f.get("human") or {}).get("verdict"):
row["human"] = f["human"]
return row
commits = [{"id": r["id"], "repo": r["repo"], "at": r["at"], "dimension": r["dimension"],
"basis": r["basis"], "subject": r["subject"],
**({} if brief else {"detail": r["detail"], "paths": r["paths"][:12]})}
for r in ev["commits"]]
# Only a finding that carries can be the oldest one; a CI week or a
# dimension's activity describes one week and lapses with it.
oldest = max((f for f in findings if f["status"] == "open"
and f["provenance"]["rule"] in s.CARRIES),
key=lambda f: (f.get("weeks_open", 1), f["id"]), default=None)
return {
"contract": CONTRACT,
"week": label,
"range": ev["range"],
"completeness": {
"weeks_held": collect.held(),
"gaps": collect.gaps(),
"pending": s.pending(load_state()),
"collected_at": ev["collected_at"],
"collector": {k: ev["collector"][k] for k in ("tool", "version", "forge", "api_calls",
"redacted")},
},
"counted": ev["rollup"],
"movement": [slim(f) for f in moved],
"standing": [slim(f) for f in sorted(quiet, key=lambda f: -f.get("weeks_in_section", 1))],
"carried": [slim(f) for f in sorted(carried, key=lambda f: -f.get("weeks_open", 1))],
"closed": [slim(f) for f in closed],
"continuity": {
"weeks_available": state.get("weeks", []),
"turned_this_week": [{"id": f["id"], "title": f["title"],
"from": f["was"]["movement"], "to": f["movement"],
"since": f["was"]["week"]} for f in turned],
"oldest_open": slim(oldest) if oldest else None,
},
"investigate": thin_spots(ev, this_week),
# --brief keeps every row the counts cannot replace and drops the rest:
# green CI runs are already a tally, and the task index is `standing`.
"evidence": {
"file": f"project-state/evidence/{label}.json",
"explain": "tools/project-state/agent.py explain <finding-id>",
"kinds": EVIDENCE_KINDS,
"commits": commits,
"declared": ev["declared"],
"ci": [r for r in ev["ci"] if r["conclusion"] != "success"] if brief else ev["ci"],
"ci_note": "green runs omitted; counts are under `counted.ci`" if brief else None,
"issues": ev["issues"],
"pulls": ev["pulls"],
"releases": ev["releases"],
**({} if brief else {"task_index": ev.get("index", {})}),
},
"repos": {name: {"commits": meta["commits"],
"checkout": os.path.join(c.PROJECTS, name) if meta["checkout"] else None}
for name, meta in ev["collector"]["repos"].items() if meta["commits"]},
}, None
def record_draft(week, path, author, model, note):
label, _, _ = c.parse_week(week)
try:
body = open(path).read()
except OSError as e:
note(f"cannot read {path}: {e}")
return 2
state = s.load(os.path.join(STORE, "history", f"{label}.json")) or load_state()
folder = os.path.join(STORE, "drafts")
os.makedirs(folder, exist_ok=True)
slug = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in author).strip("-").lower()
stem = os.path.join(folder, f"{label}--{slug}")
open(stem + ".md", "w").write(body)
# The draft names the state it was written against, so a later reader can
# tell whether it still describes the findings that exist.
open(stem + ".json", "w").write(json.dumps({
"week": label,
"author": author,
"model": model,
"recorded_at": c.iso_z(dt.datetime.now(dt.timezone.utc)),
"state_updated": state.get("updated"),
"findings_open": sum(1 for f in state.get("findings", []) if f["status"] == "open"),
"evidence": f"project-state/evidence/{label}.json",
"published": False,
"source": os.path.abspath(path),
}, indent=2, ensure_ascii=False) + "\n")
note(f"{label}: draft by {author}"
+ (f" ({model})" if model else "")
+ f" -> project-state/drafts/{label}--{slug}.md; nothing published")
return 0
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
sub = ap.add_subparsers(dest="command", required=True)
sub.add_parser("status", help="what is collected, synthesized, and missing")
p = sub.add_parser("packet", help="everything needed to write the week's draft")
p.add_argument("--week", default="last")
p.add_argument("--brief", action="store_true", help="drop commit bodies and paths")
p.add_argument("--out", help="write here instead of stdout")
e = sub.add_parser("explain", help="one finding and every evidence row it cites")
e.add_argument("finding")
e.add_argument("--json", action="store_true")
d = sub.add_parser("draft", help="record a draft written by an agent or a person")
d.add_argument("--week", default="last")
d.add_argument("--file", required=True)
d.add_argument("--author", required=True)
d.add_argument("--model", help="the model that wrote it, recorded not chosen")
v = sub.add_parser("verdict", help="a person's word on a finding")
v.add_argument("finding")
v.add_argument("verdict", choices=s.VERDICTS)
v.add_argument("--note")
v.add_argument("--by")
args = ap.parse_args()
def note(message):
print(f"[project-state] {message}", file=sys.stderr)
if args.command == "status":
print(json.dumps(status(), indent=2, ensure_ascii=False))
return 0
if args.command == "packet":
data, problem = packet(args.week, args.brief)
if problem:
note(problem)
return 2
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
if args.out:
open(args.out, "w").write(text)
note(f"{data['week']}: packet -> {args.out} ({len(text):,} chars)")
else:
sys.stdout.write(text)
return 0
if args.command == "explain":
if not args.json:
return s.explain(load_state(), args.finding)
state = load_state()
f = next((x for x in state["findings"]
if x["id"] == args.finding or x["key"] == args.finding), None)
if not f:
note(f"no finding {args.finding}")
return 1
week = f["provenance"].get("evidence_week", f["last_seen"])
ev = load_evidence(week)
index = {row["id"]: row for group in ("commits", "ci", "issues", "pulls",
"releases", "declared")
for row in ev.get(group, [])}
index[f"w:{week}"] = ev.get("rollup", {})
print(json.dumps({"finding": f, "week": week,
"evidence": {eid: index.get(eid, "MISSING") for eid in f["evidence"]}},
indent=2, ensure_ascii=False))
return 0
if args.command == "draft":
return record_draft(args.week, args.file, args.author, args.model, note)
if args.command == "verdict":
return s.write_verdict(f"{args.finding}={args.verdict}", note, args.note, args.by)
return 2
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,475 @@
#!/usr/bin/env python3
"""Weekly evidence for the project-state pass. Deterministic; no model runs here.
Reads the forge for one ISO week commits, Actions runs, issues, pulls,
releases and the local SouveraineOS clone for what the task index and the SAF
declared, then writes project-state/evidence/<week>.json. Every row carries a
stable id; a finding cites those ids and nothing else.
tools/project-state/collect.py --week last
tools/project-state/collect.py --week 2026-W33 --out -
Env: GITEA_URL, GITEA_TOKEN (falls back to ~/.git-credentials).
See docs/tasks/79-project-state.md.
"""
import argparse
import datetime as dt
import json
import os
import re
import sys
import urllib.error
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import common as c
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
VERSION = 1
def commits(forge, repo, start, end, note):
"""Commits in the window, every branch, deduped by SHA.
A branch whose tip predates the window cannot hold work inside it, so it is
never walked the same prefilter the ledger's collector uses.
"""
full = f"{c.OWNER}/{repo}"
edge = c.iso_z(start)
rows, seen = [], set()
try:
branches = forge.pages(f"repos/{full}/branches")
except urllib.error.HTTPError as e:
note(f"{repo}: branches unavailable ({e.code})")
return []
live = [b["name"] for b in branches
if ((b.get("commit") or {}).get("timestamp") or "") >= edge]
for branch in live:
path = (f"repos/{full}/commits?sha={branch}&since={edge}&until={c.iso_z(end)}"
f"&stat=false&verification=false&files=false")
try:
page = forge.pages(path)
except urllib.error.HTTPError as e:
note(f"{repo}@{branch}: commits unavailable ({e.code})")
continue
for row in page:
sha = row.get("sha", "")
if sha in seen or len((row.get("parents") or [])) > 1:
continue
seen.add(sha)
commit = row.get("commit") or {}
author = commit.get("author") or {}
subject, _, body = (commit.get("message") or "").partition("\n")
paths = c.commit_paths(repo, sha)
dimension, basis = c.classify(repo, subject.strip(), paths)
rows.append({
"id": f"c:{repo}:{sha[:8]}",
"repo": repo,
"sha": sha,
"branch": branch,
"at": author.get("date", ""),
"author": author.get("email", ""),
"subject": subject.strip(),
"detail": re.split(r"\n\s*\n", body.strip(), maxsplit=1)[0].strip(),
"dimension": dimension,
"basis": basis,
"paths": paths[:40],
"url": row.get("html_url", ""),
})
return rows
def runs(forge, repo, start, end, note):
"""Actions runs started inside the window.
This endpoint ignores limit and page and hands back the whole history, so it
is fetched once and sliced here paging it would loop on the same rows.
"""
try:
payload = forge.get(f"repos/{c.OWNER}/{repo}/actions/runs")
except urllib.error.HTTPError as e:
if e.code != 404:
note(f"{repo}: actions unavailable ({e.code})")
return []
rows = []
for run in payload.get("workflow_runs", []) if isinstance(payload, dict) else payload:
started = c.parse_iso(run.get("started_at") or run.get("run_started_at"))
if not started or not (start <= started < end):
continue
rows.append({
"id": f"ci:{repo}:{run.get('id')}",
"repo": repo,
"run": run.get("id"),
"workflow": os.path.basename(run.get("path") or ""),
"branch": run.get("head_branch", ""),
"sha": run.get("head_sha", ""),
"commit": f"c:{repo}:{(run.get('head_sha') or '')[:8]}",
"conclusion": run.get("conclusion") or run.get("status") or "",
"started": run.get("started_at", ""),
"completed": run.get("completed_at", ""),
"title": run.get("display_title", ""),
})
return rows
def issues(forge, repo, start, end, note):
"""Every issue, open or closed — aging needs the ones that predate the week."""
try:
rows = forge.pages(f"repos/{c.OWNER}/{repo}/issues?state=all&type=issues")
except urllib.error.HTTPError as e:
if e.code != 404:
note(f"{repo}: issues unavailable ({e.code})")
return []
out = []
for i in rows:
created, closed = c.parse_iso(i.get("created_at")), c.parse_iso(i.get("closed_at"))
out.append({
"id": f"i:{repo}:{i.get('number')}",
"repo": repo,
"number": i.get("number"),
"title": i.get("title", ""),
"state": i.get("state", ""),
"labels": [l.get("name") for l in (i.get("labels") or [])],
"created": i.get("created_at", ""),
"closed": i.get("closed_at"),
"age_days": (end - created).days if created else None,
"opened_this_week": bool(created and start <= created < end),
"closed_this_week": bool(closed and start <= closed < end),
"url": i.get("html_url", ""),
})
return out
def pulls(forge, repo, start, end, note):
try:
rows = forge.pages(f"repos/{c.OWNER}/{repo}/pulls?state=all")
except urllib.error.HTTPError as e:
if e.code != 404:
note(f"{repo}: pulls unavailable ({e.code})")
return []
out = []
for p in rows:
created, merged = c.parse_iso(p.get("created_at")), c.parse_iso(p.get("merged_at"))
out.append({
"id": f"p:{repo}:{p.get('number')}",
"repo": repo,
"number": p.get("number"),
"title": p.get("title", ""),
"state": p.get("state", ""),
"created": p.get("created_at", ""),
"merged": p.get("merged_at"),
"age_days": (end - created).days if created else None,
"opened_this_week": bool(created and start <= created < end),
"merged_this_week": bool(merged and start <= merged < end),
"url": p.get("html_url", ""),
})
return out
def releases(forge, repo, start, end, note):
try:
rows = forge.pages(f"repos/{c.OWNER}/{repo}/releases")
except urllib.error.HTTPError as e:
if e.code != 404:
note(f"{repo}: releases unavailable ({e.code})")
return []
out = []
for r in rows:
published = c.parse_iso(r.get("published_at") or r.get("created_at"))
out.append({
"id": f"r:{repo}:{r.get('tag_name')}",
"repo": repo,
"tag": r.get("tag_name", ""),
"name": r.get("name", ""),
"published": r.get("published_at") or r.get("created_at", ""),
"assets": len(r.get("assets") or []),
"draft": bool(r.get("draft")),
"cut_this_week": bool(published and start <= published < end),
"age_days": (end - published).days if published else None,
"url": r.get("html_url", ""),
})
return out
# ---- declared state --------------------------------------------------------
TASK_ROW = re.compile(r"^\|\s*(\d+)\s*\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|\s*(.*?)\s*\|\s*$")
def parse_index(text):
"""The task index as {number: {section, title, left}} — the hand-written state."""
section, out = "", {}
for line in text.splitlines():
if line.startswith("## "):
section = line[3:].split("")[0].strip()
continue
m = TASK_ROW.match(line)
if m:
# The index writes both `| 08 |` and `| 8 |` for task 8; unnormalised,
# one task carried two keys and closed itself while still standing.
out[str(int(m.group(1)))] = {
"section": section,
"title": m.group(2).strip(),
"file": m.group(3).strip(),
"left": m.group(4).strip(),
}
return out
def blob_at(path, sha, filename):
return c.git(path, "show", f"{sha}:{filename}")
def boundary(path, when, branch="main"):
"""Newest commit at or before an instant — the index as it stood then."""
out = c.git(path, "rev-list", "-1", f"--before={c.iso_z(when)}", branch)
return out.strip()
def declared(start, end, note, snapshot):
"""What the task index and the SAF said before and after the week."""
path = c.checkout("SouveraineOS")
if not path:
note("SouveraineOS is not checked out — declared state skipped")
return []
before, after = boundary(path, start), boundary(path, end)
if not before or not after:
note("no SouveraineOS commit on one side of the week — declared state skipped")
return []
index_file = "docs/tasks/README.md"
was = parse_index(blob_at(path, before, index_file))
now = parse_index(blob_at(path, after, index_file))
snapshot.update(now)
rows = []
for number in sorted(set(was) | set(now), key=int):
old, new = was.get(number), now.get(number)
if old and new and old == new:
continue
if not old:
change, detail = "listed", new["section"]
elif not new:
change, detail = "delisted", old["section"]
elif old["section"] != new["section"]:
change, detail = "moved", f"{old['section']}{new['section']}"
else:
change, detail = "restated", "what's left rewritten"
rows.append({
"id": f"d:task-{number}",
"kind": "task-index",
"task": number,
"title": (new or old)["title"],
"file": (new or old)["file"],
"change": change,
"detail": detail,
"section": (new or old)["section"],
"was": old["left"] if old else None,
"now": new["left"] if new else None,
"dimension": c.classify_text(
f"{(new or old)['title']} {(new or old)['file']} {(new or old)['left']}")[0],
})
status = {"A": "added", "M": "revised", "D": "deleted", "R": "moved", "C": "copied"}
changed = c.git(path, "diff", "--name-status", before, after, "--", "saf/", "docs/")
for line in changed.splitlines():
fields = line.split("\t")
if len(fields) < 2:
continue
filename = fields[-1]
if filename.startswith("docs/tasks/"):
continue
log = c.git(path, "log", "--format=%h|%aI|%s", f"{before}..{after}", "--", filename)
touches = [entry.split("|", 2) for entry in log.splitlines() if entry]
rows.append({
"id": f"d:{filename}",
"kind": "document",
"file": filename,
"change": status.get(fields[0][0], fields[0]),
"detail": f"{len(touches)} commit(s)",
"commits": [t[0] for t in touches],
"subjects": [t[2] for t in touches],
"dimension": c.classify_text(f"{filename} {' '.join(t[2] for t in touches)}")[0],
})
if not rows:
note("declared state did not move this week")
return rows
# ---- rollup ----------------------------------------------------------------
def rollup(evidence, start, end):
by_dim = {}
for row in evidence["commits"]:
d = by_dim.setdefault(row["dimension"], {"commits": 0, "repos": [], "authors": []})
d["commits"] += 1
for key, value in (("repos", row["repo"]), ("authors", row["author"])):
if value and value not in d[key]:
d[key].append(value)
for row in evidence["declared"]:
d = by_dim.setdefault(row["dimension"], {"commits": 0, "repos": [], "authors": []})
d["declared"] = d.get("declared", 0) + 1
ci = {}
for row in evidence["ci"]:
r = ci.setdefault(row["repo"], {"runs": 0, "success": 0, "failure": 0, "other": 0})
r["runs"] += 1
key = row["conclusion"] if row["conclusion"] in ("success", "failure") else "other"
r[key] += 1
for repo, r in ci.items():
newest = max((row for row in evidence["ci"] if row["repo"] == repo),
key=lambda row: row["started"], default=None)
r["newest"] = {"id": newest["id"], "conclusion": newest["conclusion"]} if newest else None
open_issues = [i for i in evidence["issues"] if i["state"] == "open"]
open_pulls = [p for p in evidence["pulls"] if p["state"] == "open"]
basis = {}
for row in evidence["commits"]:
basis[row["basis"]] = basis.get(row["basis"], 0) + 1
return {
"by_dimension": by_dim,
"ci": ci,
"issues": {
"open": len(open_issues),
"opened_this_week": sum(i["opened_this_week"] for i in evidence["issues"]),
"closed_this_week": sum(i["closed_this_week"] for i in evidence["issues"]),
"ever_closed": sum(1 for i in evidence["issues"] if i["closed"]),
"oldest_open": max(open_issues, key=lambda i: i["age_days"] or 0)["id"] if open_issues else None,
"oldest_open_days": max((i["age_days"] or 0) for i in open_issues) if open_issues else 0,
},
"pulls": {
"open": len(open_pulls),
"merged_this_week": sum(p["merged_this_week"] for p in evidence["pulls"]),
"oldest_open_days": max((p["age_days"] or 0) for p in open_pulls) if open_pulls else 0,
},
"releases": {
"cut_this_week": [r["id"] for r in evidence["releases"] if r["cut_this_week"]],
"newest_age_days": min((r["age_days"] or 0) for r in evidence["releases"]) if evidence["releases"] else None,
},
"dimension_basis": basis,
}
def held():
"""Weeks that already have evidence on disk."""
path = os.path.join(ROOT, "project-state", "evidence")
return sorted(name[:-5] for name in os.listdir(path)) if os.path.isdir(path) else []
def gaps(since=None):
"""Weeks with no evidence, from the first week held (or --since) to now.
Nothing runs this on a schedule. The tool has to be able to say which weeks
are unfilled, or a missed Monday quietly becomes a hole in the record.
"""
have = held()
current, _, _ = c.parse_week("current")
first = since or (have[0] if have else current)
return [week for week in c.week_span(first, current) if week not in have]
def collect_week(week, args, forge=None):
label, monday, sunday = c.parse_week(week)
start, end = c.week_bounds_utc(monday, sunday)
repos = [r.strip() for r in args.repos.split(",")] if args.repos else c.REPOS
forge = forge or c.Forge()
notes = []
def note(message):
notes.append(message)
print(f"[project-state] {message}", file=sys.stderr)
evidence = {"commits": [], "ci": [], "issues": [], "pulls": [], "releases": []}
repo_summary = {}
before = forge.calls
for repo in repos:
rows = commits(forge, repo, start, end, note)
evidence["commits"] += rows
evidence["ci"] += runs(forge, repo, start, end, note)
evidence["issues"] += issues(forge, repo, start, end, note)
evidence["pulls"] += pulls(forge, repo, start, end, note)
evidence["releases"] += releases(forge, repo, start, end, note)
repo_summary[repo] = {"commits": len(rows), "checkout": bool(c.checkout(repo))}
index = {}
evidence["declared"] = declared(start, end, note, index)
evidence["index"] = index
for key in ("commits", "ci"):
evidence[key].sort(key=lambda row: row.get("at") or row.get("started") or "")
if args.redact:
for row in evidence["commits"]:
row["subject"] = c.redact(row["subject"])
row["detail"] = c.redact(row["detail"])
out = {
"week": label,
"range": {
"start": monday.isoformat(), "end": sunday.isoformat(), "tz": str(c.LOCAL),
"start_utc": c.iso_z(start), "end_utc": c.iso_z(end),
},
"collected_at": c.iso_z(dt.datetime.now(dt.timezone.utc)),
"collector": {
"tool": "tools/project-state/collect.py",
"version": VERSION,
"forge": f"gitea {forge.version()}",
"api_calls": forge.calls - before,
"redacted": bool(args.redact),
"repos": repo_summary,
"notes": notes,
},
**evidence,
}
out["rollup"] = rollup(out, start, end)
return out
def write(out, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "w").write(json.dumps(out, indent=2, ensure_ascii=False) + "\n")
print(f"[project-state] {out['week']}: {len(out['commits'])} commits, {len(out['ci'])} runs, "
f"{len(out['declared'])} declared changes, {out['collector']['api_calls']} api calls "
f"-> {path}", file=sys.stderr)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--week", default="last", help="2026-W33 | current | last (default)")
ap.add_argument("--repos", help="comma-separated override of the repo list")
ap.add_argument("--out", help="output path, '-' for stdout")
ap.add_argument("--redact", action="store_true",
help="mask hosts and home paths — for evidence leaving the forge")
ap.add_argument("--gaps", action="store_true", help="name the weeks holding no evidence")
ap.add_argument("--backfill", action="store_true", help="collect every week --gaps names")
ap.add_argument("--since", help="earliest week --gaps and --backfill consider")
args = ap.parse_args()
if args.gaps or args.backfill:
missing = gaps(args.since)
if not missing:
print(f"[project-state] no gaps; {len(held())} week(s) held", file=sys.stderr)
return 0
if args.gaps:
print("\n".join(missing))
return 0
forge = c.Forge()
if not forge.token:
print("project-state: no forge token (set GITEA_TOKEN)", file=sys.stderr)
return 2
for week in missing:
write(collect_week(week, args, forge),
os.path.join(ROOT, "project-state", "evidence", f"{week}.json"))
return 0
forge = c.Forge()
if not forge.token:
print("project-state: no forge token (set GITEA_TOKEN)", file=sys.stderr)
return 2
out = collect_week(args.week, args, forge)
if args.out == "-":
sys.stdout.write(json.dumps(out, indent=2, ensure_ascii=False) + "\n")
return 0
write(out, args.out or os.path.join(ROOT, "project-state", "evidence", f"{out['week']}.json"))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,276 @@
"""Shared pieces for the project-state pass: forge client, weeks, dimensions.
See docs/tasks/79-project-state.md for what this is for and why it is shaped
this way.
"""
import datetime as dt
import json
import os
import re
import subprocess
import urllib.parse
import urllib.request
from zoneinfo import ZoneInfo
LOCAL = ZoneInfo("America/Toronto")
OWNER = "Fimeg"
PROJECTS = os.path.expanduser("~/Projects")
# Listed, not discovered. A repo joining Souveraine is a deliberate addition
# here, and one that goes quiet should be visible as a quiet row rather than
# silently stop being read. Same reasoning as tools/ci-status.sh.
REPOS = [
"souveraine", "SouveraineOS", "Pixel3Arch", "souveraine-viewtop",
"souveraine-updater", "souveraine-lens", "souveraine-speech",
"souveraine-player", "souveraine-usb", "culver", "linux-blueline",
"hexagonrpc", "smithay", "pocketboot", "libcmatrix", "upower",
]
# The SAF sections, plus the two the pipeline owns and SAF has no page for.
DIMENSIONS = [
"memory", "runtime", "reflection", "identity", "surface",
"sensorium", "release", "reliability", "federation", "adoption",
]
UNASSIGNED = "unassigned"
# Longer keywords score higher, so "device state" beats a bare "state" and a
# path full of a subsystem's name beats one incidental word in a subject.
KEYWORDS = {
"memory": ["memory", "memfs", "compaction", "archivist", "recall", "vault", "memoir"],
"runtime": ["server", "handler", "runtime", "substrate", "provider", "tool call",
"stream", "sse", "prompt", "context", "turn", "inference", "token"],
"reflection": ["reflection", "subconscious", "cadence", "n+1", "aster", "introspect"],
"identity": ["identity", "authority", "sessiond", "principal", "secrets", "machined",
"trust", "login", "credential", "permission", "polkit", "device state",
"device_state", "admission"],
"surface": ["shell", "panel", "tui", "surface", "qml", "dock", "dial", "osk",
"keyboard", "island", "overview", "compositor", "viewtop", "window",
"gesture", "lockscreen", "card", "render"],
"sensorium": ["sensor", "grip", "battery", "charge", "audio", "microphone", "camera",
"haptic", "bluetooth", "airpods", "librepods", "modem", "bearer",
"brightness", "fingerprint", "touch", "squeeze"],
"release": ["release", "package", "pkgbuild", "pacman", "repo-add", "distribution",
"artifact", "signing", "manifest"],
"reliability": ["crash", "stall", "deadlock", "leak", "regression", "revert",
"hotfix", "clippy", "flake", "panic", "timeout", "retry", "workflow"],
"federation": ["federation", "enrol", "enroll", "peer", "mirror", "distributed",
"multi-device", "household", "sync"],
"adoption": ["license", "licence", "install", "provision", "onboard",
"quickstart", "getting started", "contributor"],
}
# Two path shapes that no keyword scan should have to guess at.
PATH_RULES = [
("reliability", r"(^|/)\.gitea/"),
("release", r"(^|/)(packaging|pkgs?)/|PKGBUILD"),
]
# Only for a commit that neither its paths nor its subject place. A repo whose
# whole purpose is one dimension answers for it.
REPO_DEFAULT = {
"souveraine": "runtime",
"Pixel3Arch": "sensorium",
"linux-blueline": "sensorium",
"souveraine-viewtop": "surface",
"souveraine-lens": "adoption",
"souveraine-updater": "release",
"souveraine-player": "surface",
"souveraine-speech": "sensorium",
"souveraine-usb": "federation",
"culver": "surface",
"hexagonrpc": "sensorium",
"smithay": "surface",
"pocketboot": "release",
"libcmatrix": "surface",
"upower": "sensorium",
}
# Commit subjects are written for the man who owns the machines. Evidence stays
# raw because it never leaves the forge; this is here for whatever gets promoted
# to a public surface later. Same patterns as the ledger's collector.
REDACTIONS = [
(re.compile(r'\b(?:10|127)(?:\.\d{1,3}){2,3}(?::\d+)?\b'), "[host]"),
(re.compile(r'\b192\.168(?:\.\d{1,3}){1,2}(?::\d+)?\b'), "[host]"),
(re.compile(r'\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){1,2}(?::\d+)?\b'), "[host]"),
(re.compile(r'\b(?:WIUF[-\w]*|wiufph|archdev)\b', re.I), "[host]"),
(re.compile(r'/home/casey|/root\b'), "~"),
]
def redact(text):
for pattern, mask in REDACTIONS:
text = pattern.sub(mask, text)
return text
def score(text):
"""Dimension scores for any text; ties go to the earlier dimension."""
low = (text or "").lower()
hits = {d: sum(len(k) for k in words if k in low) for d, words in KEYWORDS.items()}
hits = {d: s for d, s in hits.items() if s}
if not hits:
return None, {}
best = max(hits, key=lambda d: (hits[d], -DIMENSIONS.index(d)))
return best, hits
def classify_text(text):
"""For a task row or a document: what is this about, or nothing."""
best, _ = score(text)
return (best, "text") if best else (UNASSIGNED, "none")
def classify(repo, subject, paths=None):
"""One dimension for a commit, and the basis that chose it.
Paths and subject are both read and weighted alike. Paths alone put
`compaction: target the active conversation` under the server module it
edits; the subject is the author saying which subsystem he thinks he moved.
"""
joined = " ".join(paths or [])
_, from_paths = score(joined)
_, from_subject = score(subject)
total = {d: from_paths.get(d, 0) + from_subject.get(d, 0)
for d in set(from_paths) | set(from_subject)}
if total:
best = max(total, key=lambda d: (total[d], -DIMENSIONS.index(d)))
return best, "paths" if from_paths.get(best, 0) else "subject"
for dim, pattern in PATH_RULES:
if joined and re.search(pattern, joined, re.I):
return dim, "paths"
if repo in REPO_DEFAULT:
return REPO_DEFAULT[repo], "repo"
return UNASSIGNED, "none"
# ---- weeks -----------------------------------------------------------------
def parse_week(spec, today=None):
"""'2026-W33', 'current', 'last' -> (label, monday, sunday) as local dates."""
today = today or dt.datetime.now(LOCAL).date()
if spec in ("current", "last", None):
monday = today - dt.timedelta(days=today.isoweekday() - 1)
if spec == "last":
monday -= dt.timedelta(days=7)
else:
m = re.fullmatch(r"(\d{4})-?W(\d{1,2})", spec.upper())
if not m:
raise ValueError(f"week must look like 2026-W33, got {spec!r}")
monday = dt.date.fromisocalendar(int(m.group(1)), int(m.group(2)), 1)
year, week, _ = monday.isocalendar()
return f"{year}-W{week:02d}", monday, monday + dt.timedelta(days=6)
def week_bounds_utc(monday, sunday):
"""Local week -> UTC instants, so a Sunday-evening commit stays in its week."""
start = dt.datetime.combine(monday, dt.time.min, LOCAL).astimezone(dt.timezone.utc)
end = dt.datetime.combine(sunday + dt.timedelta(days=1), dt.time.min, LOCAL).astimezone(dt.timezone.utc)
return start, end
def week_monday(label):
year, week = label.split("-W")
return dt.date.fromisocalendar(int(year), int(week), 1)
def label_for(monday):
y, w, _ = monday.isocalendar()
return f"{y}-W{w:02d}"
def previous_week(label):
return label_for(week_monday(label) - dt.timedelta(days=7))
def week_span(first, last):
"""Every ISO week label from first to last inclusive, in order."""
monday, end, out = week_monday(first), week_monday(last), []
while monday <= end:
out.append(label_for(monday))
monday += dt.timedelta(days=7)
return out
def iso_z(when):
return when.astimezone(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def parse_iso(value):
if not value:
return None
try:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
# ---- forge -----------------------------------------------------------------
class Forge:
# Gitea clamps a page to MAX_RESPONSE_ITEMS (50) and ignores a larger limit
# without saying so. The ledger lost 24 of 74 repos to this for months.
PAGE = 50
def __init__(self, base=None, token=None):
self.base = (base or os.environ.get("GITEA_URL") or "http://10.10.20.120:4455").rstrip("/")
self.token = token or os.environ.get("GITEA_TOKEN") or self._stored_token()
self.calls = 0
def _stored_token(self):
path = os.path.expanduser("~/.git-credentials")
host = urllib.parse.urlsplit(self.base).hostname or ""
try:
for line in open(path):
parsed = urllib.parse.urlsplit(line.strip())
if parsed.hostname and parsed.hostname.split("%")[0] == host:
return parsed.password or ""
except OSError:
pass
return ""
def get(self, path):
req = urllib.request.Request(f"{self.base}/api/v1/{path}")
if self.token:
req.add_header("Authorization", f"token {self.token}")
self.calls += 1
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def pages(self, path, max_pages=40):
sep = "&" if "?" in path else "?"
out = []
for page in range(1, max_pages + 1):
chunk = self.get(f"{path}{sep}limit={self.PAGE}&page={page}")
if isinstance(chunk, dict):
chunk = chunk.get("workflow_runs") or chunk.get("data") or []
if not chunk:
return out
out += chunk
if len(chunk) < self.PAGE:
return out
raise RuntimeError(f"{path}: {max_pages} pages and still going")
def version(self):
return self.get("version").get("version", "?")
# ---- local checkouts -------------------------------------------------------
def checkout(repo):
path = os.path.join(PROJECTS, repo)
return path if os.path.isdir(os.path.join(path, ".git")) else None
def git(path, *args):
proc = subprocess.run(["git", "-C", path, *args], capture_output=True, text=True)
return proc.stdout if proc.returncode == 0 else ""
def commit_paths(repo, sha):
"""Changed paths from the local clone, empty when it doesn't have the SHA."""
path = checkout(repo)
if not path:
return []
out = git(path, "show", "--name-only", "--format=", "--no-renames", sha)
return [line for line in out.splitlines() if line.strip()]

View file

@ -0,0 +1,821 @@
#!/usr/bin/env python3
"""Turn a week of evidence plus last week's state into this week's state.
Deterministic rules run first and produce every finding that can be computed.
A model, when one is configured, only reads those findings and the rollup and
adds cross-cutting observations; it is never asked to discover a fact. Model
findings that cite an evidence id the week does not contain are dropped.
tools/project-state/synthesize.py --week 2026-W33
tools/project-state/synthesize.py --week 2026-W33 --interpret
tools/project-state/synthesize.py --explain f-0007
Env: ANALYST_URL, ANALYST_KEY, ANALYST_MODEL any OpenAI-compatible endpoint.
See docs/tasks/79-project-state.md.
"""
import argparse
import datetime as dt
import ipaddress
import re
import json
import os
import sys
import urllib.parse
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import common as c
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
STORE = os.path.join(ROOT, "project-state")
VERSION = 1
STALE_DAYS = 14
SAMPLE_PER_DIMENSION = 10
MODEL_MAX = 6
MOVEMENTS = ["milestone_reached", "regressed", "blocked", "decision_required",
"advanced", "unchanged"]
# Rules whose findings survive a week that says nothing about them. The rest
# describe one week and lapse with it.
CARRIES = {"task-standing", "task-listed", "task-moved", "task-restated",
"task-delisted", "pull-aging", "issue-aging", "issue-tracker-unused"}
CHARTER = """You read one week of evidence from the Souveraine project. Deterministic code has
already counted everything countable and its findings are included so you do not repeat
them. Your job is the three things code cannot do:
1. Work in one subsystem that changes what is true in another.
2. A contradiction between what the repository declares about itself and what its
commits, CI runs or pull requests show.
3. An assumption this week invalidated.
Rules, and a finding that breaks one is discarded unread:
- Write about the project, never about the findings list. "The deterministic finding
says X" is not a finding. Never count anything.
- Cite at least two evidence ids of different kinds a commit id (c:) with a declared
change (d:), a CI run (ci:) with a pull request (p:), and so on. One kind alone is a
restatement, not a connection.
- Never say that two commits share a dimension, or that one id appears in two samples.
That is an artefact of how the evidence was sampled, not a fact about the project.
- At most six findings. Fewer is better. An empty list is a legal answer.
- One or two plain sentences. No hype, no praise, no summary of the week.
- Do not invent releases, decisions, people, or outcomes.
Answer with JSON only: {"findings": [{"dimension": ..., "movement": ..., "title": ...,
"statement": ..., "evidence": ["id", ...]}]}
Movements: milestone_reached, regressed, blocked, decision_required, advanced, unchanged."""
def load(path, default=None):
try:
return json.load(open(path))
except (FileNotFoundError, json.JSONDecodeError):
return default if default is not None else {}
def finding(key, dimension, title, movement, statement, evidence, rule):
return {
"key": key,
"dimension": dimension,
"title": title,
"movement": movement,
"statement": statement,
"evidence": evidence,
"provenance": {"by": "deterministic", "rule": rule},
}
# ---- deterministic rules ---------------------------------------------------
def ci_findings(ev, prior_ev):
out = []
for repo, stat in sorted(ev["rollup"]["ci"].items()):
runs, failed = stat["runs"], stat["failure"]
was = (prior_ev.get("rollup", {}).get("ci", {}) or {}).get(repo)
rate = failed / runs if runs else 0
was_rate = (was["failure"] / was["runs"]) if was and was["runs"] else None
newest = stat.get("newest") or {}
cite = [newest["id"]] if newest.get("id") else []
cite += [row["id"] for row in ev["ci"]
if row["repo"] == repo and row["conclusion"] == "failure"
and row["id"] not in cite][:5]
tally = f"{failed} of {runs} runs failed"
if stat["other"]:
tally += f" and {stat['other']} never concluded"
if newest.get("conclusion") == "failure":
movement, title = "regressed", f"{repo} ends the week red"
statement = f"The last run of the week on {repo} failed; {tally}."
elif was_rate is not None and rate > was_rate + 0.15:
movement, title = "regressed", f"{repo} CI failure rate rose"
statement = f"{tally} on {repo}{rate:.0%}, up from {was_rate:.0%}."
elif failed == 0 and runs:
movement, title = "advanced", f"{repo} CI clean"
statement = f"{runs} runs on {repo}, none failed."
else:
movement, title = "unchanged", f"{repo} CI mixed"
statement = f"On {repo}, {tally}."
out.append(finding(f"ci/{repo}", "reliability", title, movement,
statement, cite, "ci-week"))
return out
def dimension_findings(ev, prior_ev):
out = []
now = ev["rollup"]["by_dimension"]
was = prior_ev.get("rollup", {}).get("by_dimension", {})
for dim in c.DIMENSIONS:
here = now.get(dim, {})
commits = here.get("commits", 0)
declared = [row for row in ev["declared"] if row["dimension"] == dim]
before = was.get(dim, {}).get("commits", 0) if was else None
cite = [row["id"] for row in ev["commits"] if row["dimension"] == dim][:8]
cite += [row["id"] for row in declared][:4]
if not commits and not declared:
if before:
statement = f"No commits, after {before} the week before."
elif before == 0:
statement = "A second quiet week."
else:
statement = "No commits, and no week before this one to compare against."
out.append(finding(f"activity/{dim}", dim, f"{dim} did not move",
"unchanged", statement, cite or [f"w:{ev['week']}"],
"dimension-activity"))
continue
parts = [f"{commits} commit(s)"]
if before is not None:
parts.append(f"{before} the week before")
statement = ", ".join(parts) + "."
if declared:
named = "; ".join(f"{row['change']} {row.get('title') or row['file']}"
for row in declared[:4])
statement += f" Declared: {named}."
out.append(finding(f"activity/{dim}", dim, f"{dim} moved",
"advanced" if declared else "unchanged", statement,
cite, "dimension-activity"))
return out
BACKWARD = {"Live": 3, "In progress": 2, "Open": 1, "Blocked or gated": 0}
# Casey writes the open question into the row itself. These are his phrasings,
# read off the index on 2026-08-18 — not a general vocabulary of hesitation.
DECISION = re.compile(
r"verdict owed|decisions? (?:are|first|before)|needs a decision|open decision"
r"|unanswered|undecided|scope and deliverable|answer it before|product call"
r"|Casey'?s(?: call| own)?\b|owed\b", re.I)
def movement_for(row):
text = row.get("now") or row.get("was") or ""
if row["section"] == "Blocked or gated":
return "blocked"
if DECISION.search(text):
return "decision_required"
return "unchanged" if row["section"] == "Open" else "advanced"
def task_findings(ev):
"""One finding per task standing in the index, moved by this week's diff.
Tracking only the diff made every restatement a permanent finding and the
task itself invisible; the index is the state, the diff is the movement.
"""
changes = {row["task"]: row for row in ev["declared"] if row["kind"] == "task-index"}
out = []
for number, row in sorted(ev.get("index", {}).items(), key=lambda kv: int(kv[0])):
change = changes.get(number)
dim = c.classify_text(f"{row['title']} {row['file']} {row['left']}")[0]
key, section, left = f"task/{number}", row["section"], row["left"][:240]
cite = [change["id"]] if change else [f"d:task-{number}"]
state = {"section": section, "left": row["left"], "file": row["file"]}
if not change:
f = finding(key, dim, f"task {number}: {row['title']}",
"blocked" if section == "Blocked or gated" else "unchanged",
f"Standing under “{section}”. {left}", cite, "task-standing")
elif change["change"] == "listed":
f = finding(key, dim, f"task {number} entered the index: {row['title']}",
movement_for(change), f"Filed under “{section}”. {left}",
cite, "task-listed")
elif change["change"] == "moved":
old, new = change["detail"].split("")
back = BACKWARD.get(new, 9) < BACKWARD.get(old, 9)
f = finding(key, dim, f"task {number} moved: {row['title']}",
"regressed" if back else movement_for(change),
f"{old}{new}. {left}", cite, "task-moved")
else:
f = finding(key, dim, f"task {number} restated: {row['title']}",
movement_for(change),
f"What's left was rewritten under “{section}”. {left}",
cite, "task-restated")
f["task_state"] = state
out.append(f)
for number, change in sorted(changes.items(), key=lambda kv: int(kv[0])):
if change["change"] != "delisted":
continue
out.append(finding(f"task/{number}", change["dimension"],
f"task {number} left the index: {change['title']}",
"milestone_reached",
f"Dropped from “{change['detail']}” — closed work moves to archive/.",
[change["id"]], "task-delisted"))
return out
def aging_findings(ev):
out = []
for issue in ev["issues"]:
if issue["state"] != "open" or (issue["age_days"] or 0) < STALE_DAYS:
continue
out.append(finding(f"issue/{issue['repo']}#{issue['number']}",
c.classify_text(issue["title"])[0],
f"{issue['repo']}#{issue['number']} open {issue['age_days']}d: {issue['title'][:70]}",
"unchanged",
f"Filed {issue['created'][:10]}, still open.",
[issue["id"]], "issue-aging"))
for pull in ev["pulls"]:
if pull["state"] != "open" or (pull["age_days"] or 0) < STALE_DAYS:
continue
out.append(finding(f"pull/{pull['repo']}#{pull['number']}",
c.classify_text(pull["title"])[0],
f"{pull['repo']}#{pull['number']} unmerged {pull['age_days']}d: {pull['title'][:70]}",
"decision_required",
f"Opened {pull['created'][:10]}, never merged or closed.",
[pull["id"]], "pull-aging"))
for rel in ev["releases"]:
if rel["cut_this_week"]:
out.append(finding(f"release/{rel['repo']}", "release",
f"{rel['repo']} released {rel['tag']}", "milestone_reached",
f"{rel['assets']} assets published {rel['published'][:10]}.",
[rel["id"]], "release-cut"))
issues = ev["rollup"]["issues"]
if issues["open"] and not issues["ever_closed"] and issues["oldest_open_days"] >= STALE_DAYS:
oldest = next((i for i in ev["issues"] if i["id"] == issues["oldest_open"]), None)
out.append(finding("issues/never-closed", "adoption",
"No issue has ever been closed", "unchanged",
f"{issues['open']} open, none closed since the tracker was first used"
+ (f"; the oldest is {oldest['repo']}#{oldest['number']}, "
f"{oldest['age_days']}d." if oldest else "."),
[i["id"] for i in ev["issues"] if i["state"] == "open"][:8],
"issue-tracker-unused"))
return out
def deterministic(ev, prior_ev):
return (task_findings(ev) + ci_findings(ev, prior_ev)
+ aging_findings(ev) + dimension_findings(ev, prior_ev))
# ---- model pass ------------------------------------------------------------
def locality(url):
host = urllib.parse.urlsplit(url).hostname or ""
try:
return "local" if ipaddress.ip_address(host).is_private else "remote"
except ValueError:
return "local" if host in ("localhost", "127.0.0.1") else "remote"
def parse_findings(text):
"""The findings, and whether they were salvaged from a truncated answer.
A reasoning model's answer arrives after its thinking, so a budget that runs
out cuts the array mid-object. Every complete object before the cut is still
a whole finding, and throwing them away because the closing bracket is
missing loses real work.
"""
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
try:
return json.loads(text[start:end + 1]).get("findings", []), False
except json.JSONDecodeError:
pass
out, depth, begin = [], 0, None
for i, ch in enumerate(text[text.find("[") + 1:] if "[" in text else ""):
if ch == "{":
if depth == 0:
begin = i
depth += 1
elif ch == "}" and depth:
depth -= 1
if depth == 0:
fragment = text[text.find("[") + 1:][begin:i + 1]
try:
out.append(json.loads(fragment))
except json.JSONDecodeError:
pass
return (out, True) if out else (None, False)
class AnalystUnavailable(Exception):
"""The named model cannot be run. Never a reason to run a different one."""
def require_model(url, model, note):
"""Confirm the endpoint serves the exact model named, or refuse to proceed.
There is no default model and no fallback. A convenient default made the
choice feel fungible, and on 2026-08-18 that ended in three other models
being tried after one had been named.
"""
req = urllib.request.Request(f"{url.rstrip('/')}/v1/models")
key = os.environ.get("ANALYST_KEY")
if key:
req.add_header("Authorization", f"Bearer {key}")
try:
with urllib.request.urlopen(req, timeout=30) as r:
served = [m.get("id") for m in (json.load(r).get("data") or [])]
except Exception as e:
raise AnalystUnavailable(f"{url} did not answer /v1/models: {e}")
if model not in served:
near = [m for m in served if model.split("-")[0].lower() in (m or "").lower()][:4]
raise AnalystUnavailable(
f"{url} does not serve {model!r}"
+ (f"; it does serve {near} — name one of those explicitly if you want it"
if near else f"; {len(served)} models served, none of them that one"))
note(f"analyst model {model} confirmed served by {url}")
def interpret(ev, findings, prior_state, note):
url = os.environ.get("ANALYST_URL")
model = os.environ.get("ANALYST_MODEL")
if not url or not model:
raise AnalystUnavailable(
"--interpret needs both ANALYST_URL and ANALYST_MODEL; there is no default model")
require_model(url, model, note)
ids = {row["id"] for group in ("commits", "ci", "issues", "pulls", "releases", "declared")
for row in ev[group]}
# The whole week is 39k characters of commit subjects. A local 9B spends its
# entire budget reading that and never answers (measured 2026-08-18, 323s,
# nothing returned). Sampling per dimension is what makes the pass affordable
# — and reading every subject is a job for the deterministic half anyway.
sample = {}
for row in ev["commits"]:
seen = sample.setdefault(row["dimension"], [])
if len(seen) < SAMPLE_PER_DIMENSION:
seen.append({"id": row["id"], "subject": row["subject"]})
brief = {
"week": ev["week"],
"counts": {"by_dimension": ev["rollup"]["by_dimension"], "ci": ev["rollup"]["ci"],
"issues": ev["rollup"]["issues"], "pulls": ev["rollup"]["pulls"]},
"deterministic_findings": [
{"dimension": f["dimension"], "movement": f["movement"], "title": f["title"]}
for f in findings],
"commit_sample": sample,
"declared": [{"id": row["id"], "change": row["change"], "detail": row["detail"],
"title": row.get("title") or row.get("file")} for row in ev["declared"]],
"open_from_last_week": [
{"key": f["key"], "title": f["title"], "weeks_open": f.get("weeks_open")}
for f in prior_state.get("findings", []) if f.get("status") == "open"][:30],
}
payload = {
"model": model,
"messages": [{"role": "system", "content": CHARTER},
{"role": "user", "content": json.dumps(brief, ensure_ascii=False)}],
# Fable is a reasoning model: it fills `reasoning_content` first and
# leaves `content` empty if the budget runs out before it concludes.
# Measured 2026-08-18 — 3000 tokens went entirely to reasoning and
# returned nothing, and a 250-token cap (what the ledger's narrator
# uses) cannot ever answer.
"max_tokens": int(os.environ.get("ANALYST_MAX_TOKENS", 6000)),
"temperature": 0.3,
}
headers = {"Content-Type": "application/json"}
key = os.environ.get("ANALYST_KEY")
if key:
headers["Authorization"] = f"Bearer {key}"
started = dt.datetime.now(dt.timezone.utc)
try:
req = urllib.request.Request(f"{url.rstrip('/')}/v1/chat/completions",
data=json.dumps(payload).encode(), headers=headers)
with urllib.request.urlopen(req, timeout=900) as r:
body = json.load(r)
except Exception as e:
raise AnalystUnavailable(
f"{model} failed after {(dt.datetime.now(dt.timezone.utc) - started).seconds}s: {e}")
choice = (body.get("choices") or [{}])[0]
message = choice.get("message", {})
text = (message.get("content") or "").strip()
took = (dt.datetime.now(dt.timezone.utc) - started).seconds
meta = {"model": model, "endpoint": locality(url), "ok": True, "seconds": took,
"usage": body.get("usage"), "finish": choice.get("finish_reason"),
"reasoning_chars": len(message.get("reasoning_content") or "")}
# An empty findings list is a legal answer. No answer at all is a failure of
# the model that was named, and stays that.
if not text:
raise AnalystUnavailable(
f"{model} returned no answer in {took}s — {meta['reasoning_chars']} chars of "
f"reasoning, finish={meta['finish']}; the budget went entirely to thinking")
parsed, meta["salvaged"] = parse_findings(text)
if parsed is None:
raise AnalystUnavailable(
f"{model} produced {len(text)} chars that are not findings JSON "
f"(finish={meta['finish']})")
out, dropped, seen = [], 0, set()
for i, row in enumerate(parsed):
cited = [e for e in (row.get("evidence") or []) if e in ids]
kinds = {e.split(":", 1)[0] for e in cited}
signature = frozenset(cited)
# A connection spans two kinds of evidence. One kind, or a citation set
# already used, is the model restating itself.
if (len(cited) < 2 or len(kinds) < 2 or signature in seen
or row.get("movement") not in MOVEMENTS or len(out) >= MODEL_MAX):
dropped += 1
continue
seen.add(signature)
title = str(row.get("title") or "")[:120]
out.append({
"key": f"model/{ev['week']}/{i}",
"dimension": row.get("dimension") if row.get("dimension") in c.DIMENSIONS else c.UNASSIGNED,
"title": title,
"movement": row["movement"],
"statement": str(row.get("statement") or "")[:600],
"evidence": cited,
"provenance": {"by": "model", "rule": "cross-cutting", **meta},
})
meta["accepted"], meta["dropped"] = len(out), dropped
note(f"analyst: {len(out)} findings kept, {dropped} dropped for uncited or bad movement, {took}s")
return out, meta
# ---- carry forward ---------------------------------------------------------
def carry(prior, produced, week, analyst, closes):
"""This week's findings over last week's, with the resolution rule explicit.
A finding the week says nothing about is still open. Only positive evidence
closes one a task leaving the index, a pull merging. Absence of evidence
resolved four CI findings on the first two-week run, which is the difference
between a state document and a diff.
"""
by_key = {f["key"]: f for f in prior.get("findings", [])}
held = verdicts()
seq = max((int(f["id"][2:]) for f in prior.get("findings", []) if f.get("id", "").startswith("f-")),
default=0)
stamp = c.iso_z(dt.datetime.now(dt.timezone.utc))
weeks = prior.get("weeks", [])
if week not in weeks:
weeks = sorted(weeks + [week])
out, seen = [], set()
for new in produced:
old = by_key.get(new["key"])
seen.add(new["key"])
if old:
new["id"] = old["id"]
new["first_seen"] = old.get("first_seen", week)
new["was"] = {"movement": old.get("movement"), "week": old.get("last_seen")}
else:
seq += 1
new["id"] = f"f-{seq:04d}"
new["first_seen"] = week
new["human"] = held.get(new["key"], {"verdict": None, "note": None, "edited": False})
new["last_seen"] = week
# A model reading belongs to the week that produced it and is never
# carried as project state; it has no human verdict behind it.
new["status"] = "reading" if new["provenance"]["by"] == "model" else "open"
new["weeks_open"] = sum(1 for w in weeks if w >= new["first_seen"])
if "task_state" in new:
same_section = old and (old.get("task_state") or {}).get("section") == new["task_state"]["section"]
new["section_since"] = old.get("section_since", week) if same_section else week
new["weeks_in_section"] = sum(1 for w in weeks if w >= new["section_since"])
new["provenance"].setdefault("at", stamp)
new["provenance"].setdefault("evidence_week", week)
out.append(new)
for key, old in by_key.items():
if key in seen:
continue
if old.get("status") == "open":
if key in closes:
old["status"] = "resolved"
old["resolved_in"] = week
elif old["provenance"]["rule"] not in CARRIES:
# A CI week, a dimension's activity, a release cut: each states
# something about one week and says nothing about the next.
old["status"] = "lapsed"
old["lapsed_in"] = week
else:
old["weeks_open"] = sum(1 for w in weeks if w >= old.get("first_seen", week))
old["carried"] = True
out.append(old)
out.sort(key=lambda f: (f["status"] != "open", MOVEMENTS.index(f["movement"])
if f["movement"] in MOVEMENTS else 9, f["dimension"], f["id"]))
return {
"version": VERSION,
"updated": stamp,
"latest_week": week,
"weeks": weeks,
"analysis": {"week": week, "analyst": analyst,
"deterministic_rules": sorted({f["provenance"]["rule"] for f in produced
if f["provenance"]["by"] == "deterministic"})},
"findings": out,
}
# ---- report ----------------------------------------------------------------
BADGE = {"milestone_reached": "◆ milestone", "regressed": "▼ regressed",
"blocked": "■ blocked", "decision_required": "? decision",
"advanced": "▲ advanced", "unchanged": "· unchanged"}
def report(state, ev, week):
lines = [f"# Souveraine project state — {week}",
"",
f"{ev['range']['start']} to {ev['range']['end']} ({ev['range']['tz']}). "
f"Internal. Generated from `project-state/evidence/{week}.json`; "
f"the prose is disposable, the evidence and `state.json` are not.",
""]
r = ev["rollup"]
lines += ["## The week, counted", "",
f"- {len(ev['commits'])} commits across "
f"{len({row['repo'] for row in ev['commits']})} repos, "
f"{len(ev['ci'])} CI runs, {len(ev['declared'])} declared state changes.",
f"- Issues: {r['issues']['open']} open, {r['issues']['opened_this_week']} filed, "
f"{r['issues']['closed_this_week']} closed. "
f"Pulls: {r['pulls']['open']} open, {r['pulls']['merged_this_week']} merged.",
"- Dimension basis: "
+ ", ".join(f"{n} by {b}" for b, n in sorted(r["dimension_basis"].items(),
key=lambda kv: -kv[1]))
+ " (`paths` read the diff, `subject` read the commit subject, "
"`repo` fell back to the repository, `none` placed nothing).",
""]
open_now = [f for f in state["findings"] if f["status"] == "open"]
current = [f for f in open_now if f["last_seen"] == week]
standing = [f for f in open_now if f["last_seen"] != week]
resolved = [f for f in state["findings"] if f.get("resolved_in") == week]
read = [f for f in state["findings"]
if f["status"] == "reading" and f["last_seen"] == week]
# A task that simply stands under its section is state, not news; it belongs
# in Standing, not in a movement group that would be 60 rows long.
quiet = [f for f in current if f["provenance"]["rule"] == "task-standing"]
current = [f for f in current if f["provenance"]["rule"] != "task-standing"
or f["movement"] == "blocked"]
for movement in MOVEMENTS:
group = [f for f in current if f["movement"] == movement]
if not group:
continue
lines += [f"## {BADGE[movement]}", ""]
for f in group:
age = f" · open {f['weeks_open']} weeks since {f['first_seen']}" if f["weeks_open"] > 1 else ""
was = f["was"]["movement"] if f.get("was") else None
turn = f" · was {was.replace('_', ' ')}" if was and was != movement else ""
mark = "model" if f["provenance"]["by"] == "model" else f["provenance"]["rule"]
lines += [f"**{f['id']} · {f['dimension']} · {f['title']}** ",
f"{f['statement']} ",
f"<sub>{mark}{age}{turn} · evidence: "
f"{', '.join('`' + e + '`' for e in f['evidence'][:8])}"
f"{'' if len(f['evidence']) > 8 else ''}</sub>", ""]
if read:
analyst = state["analysis"]["analyst"] or {}
lines += ["## Read by the analyst — unreviewed", "",
f"Written by {analyst.get('model', 'a model')}, not computed. Every id below "
f"exists in the week's evidence and every finding spans two kinds of it; "
f"neither check makes the reading correct. Nothing here has a human verdict.",
""]
for f in read:
lines += [f"**{f['id']} · {f['dimension']} · {BADGE[f['movement']]} · {f['title']}** ",
f"{f['statement']} ",
f"<sub>evidence: {', '.join('`' + e + '`' for e in f['evidence'])}</sub>", ""]
if resolved:
lines += ["## Closed this week", ""]
for f in resolved:
lines += [f"- **{f['id']} · {f['title']}** — held from {f['first_seen']} "
f"to {f['last_seen']}, {f['weeks_open']} week(s)."]
lines.append("")
held = sorted((f for f in quiet if f["movement"] != "blocked"),
key=lambda f: (-f.get("weeks_in_section", 1), int(f["key"].split("/")[1])))
if held:
lines += ["## Standing longest without moving", "",
f"{len(quiet)} task(s) sat in their section untouched this week. "
f"The ten that have sat longest:", ""]
for f in held[:10]:
lines.append(f"- **{f['id']} · {f['dimension']} · {f['title']}** — "
f"{f['task_state']['section']}” for "
f"{f.get('weeks_in_section', 1)} week(s), since {f.get('section_since')}.")
lines.append("")
if standing:
lines += ["## Still open, unmentioned by this week's evidence", "",
"Nothing closed these; the week simply said nothing about them.", ""]
for f in sorted(standing, key=lambda f: -f["weeks_open"]):
lines.append(f"- **{f['id']} · {f['dimension']} · {f['title']}** — "
f"last evidence {f['last_seen']}, open {f['weeks_open']} week(s) "
f"since {f['first_seen']}.")
lines.append("")
analyst = state["analysis"]["analyst"]
lines += ["## Provenance", "",
f"- Evidence: `project-state/evidence/{week}.json`, collected "
f"{ev['collected_at']} by `{ev['collector']['tool']}` v{ev['collector']['version']} "
f"against {ev['collector']['forge']} in {ev['collector']['api_calls']} API calls.",
f"- Deterministic rules: {', '.join(state['analysis']['deterministic_rules'])}.",
(f"- Analyst: {analyst['model']} ({analyst['endpoint']}), "
f"{analyst.get('accepted', 0)} findings kept, {analyst.get('dropped', 0)} dropped."
if analyst else "- Analyst: none run; every finding here is deterministic."),
f"- Trace one: `tools/project-state/synthesize.py --explain <id>`.", ""]
if ev["collector"]["notes"]:
lines += ["Collector notes: " + "; ".join(ev["collector"]["notes"]), ""]
return "\n".join(lines)
def explain(state, finding_id):
f = next((x for x in state.get("findings", []) if x["id"] == finding_id or x["key"] == finding_id), None)
if not f:
print(f"no finding {finding_id}", file=sys.stderr)
return 1
print(json.dumps({k: v for k, v in f.items() if k != "evidence"}, indent=2, ensure_ascii=False))
week = f["provenance"].get("evidence_week", f["last_seen"])
ev = load(os.path.join(STORE, "evidence", f"{week}.json"))
index = {row["id"]: (group, row)
for group in ("commits", "ci", "issues", "pulls", "releases", "declared")
for row in ev.get(group, [])}
index[f"w:{week}"] = ("rollup", ev.get("rollup", {}))
print(f"\nevidence ({len(f['evidence'])} rows from {week}):")
for eid in f["evidence"]:
group, row = index.get(eid, (None, None))
if not row:
print(f" {eid}: MISSING from {week} evidence")
continue
print(f"\n [{group}] {eid}")
print(" " + json.dumps(row, indent=2, ensure_ascii=False).replace("\n", "\n "))
return 0
VERDICTS = ("confirm", "promote", "reject", "edit")
VERDICT_FILE = "verdicts.json"
def verdicts():
"""A person's word on a finding, keyed by finding key.
Kept outside state.json because state is derived and gets regenerated a
verdict written into it vanished the next time the week was passed over.
This file is the one thing here a machine does not write.
"""
return load(os.path.join(STORE, VERDICT_FILE), {})
def write_verdict(spec, note, text=None, by=None):
finding_id, _, verdict = spec.partition("=")
if verdict not in VERDICTS:
note(f"verdict must be one of {', '.join(VERDICTS)}, not {verdict!r}")
return 2
state = load(os.path.join(STORE, "state.json"))
target = next((f for f in state.get("findings", [])
if f["id"] == finding_id or f["key"] == finding_id), None)
if not target:
note(f"no finding {finding_id}")
return 1
held = verdicts()
held[target["key"]] = {
"verdict": verdict,
"note": text,
"by": by or os.environ.get("USER") or "unknown",
"at": c.iso_z(dt.datetime.now(dt.timezone.utc)),
"edited": verdict == "edit",
"week": state.get("latest_week"),
"finding_id": target["id"],
"title": target["title"],
}
path = os.path.join(STORE, VERDICT_FILE)
open(path, "w").write(json.dumps(held, indent=2, ensure_ascii=False) + "\n")
target["human"] = held[target["key"]]
open(os.path.join(STORE, "state.json"), "w").write(
json.dumps(state, indent=2, ensure_ascii=False) + "\n")
note(f"{target['id']} {verdict} by {held[target['key']]['by']}{target['title'][:60]}")
return 0
def evidence_weeks():
path = os.path.join(STORE, "evidence")
return sorted(name[:-5] for name in os.listdir(path)) if os.path.isdir(path) else []
def pending(state):
"""Evidence weeks the state has not yet passed over, oldest first."""
done = set(state.get("weeks", []))
return [week for week in evidence_weeks() if week not in done]
def run_week(label, prior, args, note):
ev = load(os.path.join(STORE, "evidence", f"{label}.json"))
if not ev:
note(f"no evidence for {label} — run collect.py --week {label}")
return None, None
prior_ev = load(os.path.join(STORE, "evidence", f"{c.previous_week(label)}.json"))
produced = deterministic(ev, prior_ev)
model_rows, analyst = interpret(ev, produced, prior, note) if getattr(args, "interpret", False) else ([], None)
# A task closes when it is gone from the index, not when it merely goes
# unmentioned. The delisting week still shows its milestone; the week after
# is when it stops being open state.
listed = set(ev.get("index", {}))
closes = {f["key"] for f in prior.get("findings", [])
if f["key"].startswith("task/") and f["key"].split("/", 1)[1] not in listed}
return carry(prior, produced + model_rows, label, analyst, closes), ev
def persist(state, ev, label):
text = report(state, ev, label)
for folder in ("reports", "history"):
os.makedirs(os.path.join(STORE, folder), exist_ok=True)
open(os.path.join(STORE, "reports", f"{label}.md"), "w").write(text)
blob = json.dumps(state, indent=2, ensure_ascii=False) + "\n"
open(os.path.join(STORE, "history", f"{label}.json"), "w").write(blob)
open(os.path.join(STORE, "state.json"), "w").write(blob)
return text
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--week", default="last")
ap.add_argument("--catch-up", action="store_true",
help="pass over every collected week the state has not seen, in order")
ap.add_argument("--pending", action="store_true", help="name those weeks and stop")
ap.add_argument("--interpret", action="store_true",
help="run the model pass; needs ANALYST_URL and ANALYST_MODEL, "
"fails closed if that exact model is not served")
ap.add_argument("--verdict", help="a person's word on a finding, as f-0069=promote")
ap.add_argument("--note", help="text to store with --verdict")
ap.add_argument("--by", help="who is giving the verdict (default $USER)")
ap.add_argument("--explain", help="print a finding and every evidence row it cites")
ap.add_argument("--dry-run", action="store_true", help="print the report, write nothing")
ap.add_argument("--report-only", action="store_true",
help="rewrite the report from the stored state, running nothing")
args = ap.parse_args()
state_path = os.path.join(STORE, "state.json")
prior = load(state_path, {"findings": []})
if args.explain:
return explain(prior, args.explain)
notes = []
def note(message):
notes.append(message)
print(f"[project-state] {message}", file=sys.stderr)
if args.verdict:
return write_verdict(args.verdict, note, args.note, args.by)
if args.pending:
weeks = pending(prior)
print("\n".join(weeks) if weeks else "", end="\n" if weeks else "")
note(f"{len(weeks)} week(s) collected but not yet passed over" if weeks
else f"state is current through {prior.get('latest_week', 'nothing')}")
return 0
if args.catch_up:
weeks = pending(prior)
if not weeks:
note(f"nothing pending; state is current through {prior.get('latest_week')}")
return 0
for label in weeks:
state, ev = run_week(label, prior, args, note)
if not state:
return 2
persist(state, ev, label)
note(f"{label}: {sum(1 for f in state['findings'] if f['status'] == 'open')} open")
prior = state
return 0
label, _, _ = c.parse_week(args.week)
ev = load(os.path.join(STORE, "evidence", f"{label}.json"))
if not ev:
note(f"no evidence for {label} — run collect.py --week {label}")
return 2
if args.report_only:
state = load(os.path.join(STORE, "history", f"{label}.json")) or prior
text = report(state, ev, label)
if args.dry_run:
sys.stdout.write(text)
return 0
open(os.path.join(STORE, "reports", f"{label}.md"), "w").write(text)
note(f"{label}: report rewritten from stored state")
return 0
# Re-running a week already in the state starts from the week before it, so
# a second pass does not compound onto its own output.
if label in prior.get("weeks", []):
prior = load(os.path.join(STORE, "history", f"{c.previous_week(label)}.json"),
{"findings": []})
elif prior.get("latest_week") and c.previous_week(label) != prior["latest_week"]:
note(f"state stops at {prior['latest_week']} and this is {label}"
f"{len(pending(prior))} week(s) unseen; --catch-up runs them in order")
state, ev = run_week(label, prior, args, note)
if not state:
return 2
if args.dry_run:
sys.stdout.write(report(state, ev, label))
return 0
persist(state, ev, label)
produced = [f for f in state["findings"] if f["last_seen"] == label]
note(f"{label}: {len(produced)} findings this week, "
f"{sum(1 for f in state['findings'] if f['status'] == 'open')} open "
f"-> project-state/reports/{label}.md")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except AnalystUnavailable as failure:
# Nothing was written. The named model did not run, and no other one
# stands in for it.
print(f"[project-state] analyst unavailable: {failure}", file=sys.stderr)
sys.exit(3)