n8n execution 1700: mode=trigger, status=success, started 2026-10-16 19:00Z
(06:00 AEDT 2026-10-17). 8/8 rows 27-34 delivered, scans ok for both children.
make verify-n8n exits 0. Documents the acceptance evidence.
- New /codes dashboard page (behind the session login, linked in the top bar):
lists every mapping by kind with Add, inline Edit+Save and Delete. Sam can put
real teacher surnames in without a rebuild (FR-17).
- Decode timetable EVENTS through the same map as periods, so an ensemble reads
'JAZZ ENSEMBLE - Ensemble Room - Mr KWO' instead of raw codes.
- teacherName splits comma- or space-separated codes ('HHL, OFG').
- SeedCodes is a one-time bootstrap while the table is empty, so a correction or
a delete is never overwritten or restored.
- Tests: decode Label/Event, the /codes page (save, refuse, csrf, delete, auth),
store re-seed behaviour. go test ./internal/... passes.
CombinedText now applies OrderChildren itself, so the CLI preview, the
dashboard and the Hub render Finn's block then Harry's exactly like the
email. Previously `notify preview --combined` showed the DB order (Harry
first). Adds TestCombinedTextKeepsBriefOrder with a reversed input.
make verify-n8n / deploy/verify-n8n-scheduled-run.sh checks the workflow is
active, reads the newest scheduled (mode=trigger) execution from the n8n
database, and prints the notify rows. Exit 1 until a scheduled run exists and
is green, so it distinguishes 'not yet' from 'failed'. Never runs the workflow
and never sends. Document the first-run time and the re-armed monitor.
- Parse each child's own "Today" day widget for the timetable (FR-16); drop the
school-wide week calendar's lesson grid and clean the leaked routine rows.
- Add the editable codes map (FR-17): subjects, rooms, teachers and period
labels, seeded, an unknown code falls back to the raw code; `school codes` CLI.
- Brief: Finn's block first, then Harry's, four sections always present (FR-9);
periods decoded into plain English; the undated tail is one counted line (FR-18).
- Calendar: write periods + work due + work overdue + major events, per child,
on every pass. Identity is the stable per-item key, so a re-run never re-inserts
(FR-8). morning-brief.sh now runs scan -> calendar push -> notify.
- Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods.
Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and
separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Imported the SSH credential and schoolMorningBrf, published it, restarted
n8n once, and confirmed the activation log line (no Unrecognized node type).
A hand run through the published workflow finished green and delivered 8/8
(Harry, Finn, combined Jo + Sam). Record the evidence and the
N8N_RUNNERS_BROKER_PORT workaround for 'n8n execute'.
n8n 2.x disables n8n-nodes-base.executeCommand by default (nodes.exclude), so the
first workflow shape could not activate: 'Unrecognized node type'. Replace the
Execute Command node with the SSH node, which is enabled, and keep the restricted
trigger key: the server forced command runs exactly scripts/morning-brief.sh.
- deploy/n8n-morning-brief.json: schedule + optional manual trigger -> SSH node
-> Code check that makes a non-zero exit red (FR-12).
- deploy/import-n8n-morning-brief.sh: builds the sshPrivateKey credential from the
key (id schoolScanKey, mode-0600 temp, removed after import), imports, publishes,
restarts once, and confirms activation in the n8n log.
- scripts/morning-brief.sh: default IMAGE 0.6.1-phase8 so the scheduled job uses
the deployed image.
- docs/phase-7a-n8n-schedule.md, deploy/DEPLOYMENT.md: document the design and the
throwaway-n8n rehearsal (activation + success + failure path).
The Hub forwards `person` equal to the asker (`PersonOrDefault`), so the spoken
question "what does Harry have on today at school?" arrives with person "sam"
and returned "no data for that person" (found by the Hub agent on AUD-116).
resolveChild now falls back to the question text when the requested person is
not one of this project's children: whole-word, case-insensitive match against
the child names this service already publishes in /capabilities. An explicit
person still wins, two named children still return answer:null, and the
permission check still runs on the resolved child. No model, no free text.
Live end-to-end through the Hub, with no person field:
POST http://192.168.20.13:8788/ask
{"text":"what does Harry have on today at school?","asker":"sam","source":"console"}
200
{"status":"ok",
"answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
"route":{"kind":"project","project":"compass_nhs"}}
Image 0.6.1-phase8; docs/phase-8-hub-notes.md records the fallback and the
end-to-end transcript.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
n8n 2.18.5 rejects an import whose workflow JSON has no `id`
(SQLITE_CONSTRAINT: NOT NULL constraint failed: workflow_entity.id). Give the
workflow a fixed id and record that the import -> publish -> active flow was
rehearsed in a throwaway n8n container before asking for approval.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The 06:00 workflow is now one schedule-trigger -> Execute Command pair that
runs scripts/morning-brief.sh on the host over a restricted SSH key. The
earlier scan -> HTTP shape could not work: the n8n container cannot resolve
the `remote` ssh alias (its ~/.ssh/config is a dangling nix symlink) and a
split scan node hides scan failures before the notify node runs.
- deploy/n8n-morning-brief.json: two-node workflow, scheduleTrigger v1.3
- deploy/setup-n8n-scan-key.sh: restricted (restrict,command=) trigger key
- deploy/import-n8n-morning-brief.sh: import + publish + restart + verify
- deploy/DEPLOYMENT.md, Makefile: document and expose both scripts (dry run)
- docs/phase-7a-n8n-schedule.md: design, verification, failure drill
Import into the shared n8n service still needs Sam's approval.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:
GET /capabilities the routing topics the Hub reads, project "compass_nhs"
POST /ask one short, read-only answer about a child's school work
POST /notify accept the contract's notification shape, deliver via Apprise
All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".
/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.
Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.
/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.
Live proof on .13 (deploy/verify-phase8.sh):
/health 200, ok true
/capabilities 200, project compass_nhs; unsigned 401
/ask 200, the sentence above, duration 1ms
/notify 400 on a bad level (auth proven, nothing sent), then 200 with
a real delivery to Sam on email and WhatsApp
The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The morning briefs go out through the house Apprise API on `.35:8210`: one
brief per boy and one combined brief to Jo and Sam (FR-9, FR-14). The service
names an Apprise tag (`harry_email`, `joanna_whatsapp`, ...) and never holds an
address, a phone number or an SMTP password (AGENTS.md §10 rule 3).
- internal/apprise: keyed and stateless client, tag filtering, and error
redaction so nothing sensitive reaches a log, a row or a screen.
- internal/brief: builds the per-boy and the combined brief, delivers on email
and WhatsApp, records every attempt in `notifications` (migration 004 adds
scope, channel, tag, ok and error) and fails loudly.
- Send panel (§5 screen 4): recipients, channels, an exact preview, Send now,
plus the Send action on a stored draft (FR-10). The dashboard shows a
Morning briefs banner and the Sends list.
- POST /notify/morning: the bearer-token endpoint the 6:00am n8n schedule
calls. 207 when a channel or a scan failed; `dry_run=1` checks the wiring
without messaging the family.
- scripts/morning-brief.sh: scan both children, then send. It is the n8n job;
deploy/n8n-morning-brief.json is the ready-to-import workflow (06:00 daily).
- FR-12: a failed scan is never "nothing due" -- the brief says so, and the run
exits non-zero. A failed delivery is recorded with no sent_at.
- The long no-due-date tail is capped in the sent brief so it stays brief
(§10 rule 6); overdue work is never capped.
Verified live: 8 deliveries recorded (4 email, 4 WhatsApp) with HTTP 200 and
sent_at, and the endpoint answers 401/200/207 as documented. Evidence in
docs/phase-7-notify-notes.md.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The first live run against the new calendar.school entity failed loudly twice,
and both causes are fixed here.
- The Local Calendar rejects `return_response` on calendar.create_event. The
client retries the call without it and ignores the changed-states array that
Home Assistant returns instead.
- Home Assistant then reports no event id. The item keeps the description
marker ([compass-nhs:<id>]) as its reference, so it leaves the candidate set
and is never written twice. The live retry proved it: both events already in
the calendar were adopted by their markers, with no duplicate.
- calendar.get_events reports `start` / `end` (a date-time or a bare date), not
`start_date_time`; the reader now accepts all three shapes.
A model must no longer classify events. The calendar write depends on the
school-event rule, and the LLM labelled every event "normal"; events now keep
the deterministic rule under both classifiers.
Also fix the Phase 5 screenshot script: the calendar banner shares the `.scan`
class, so select each banner by its labelled section.
Docs record the accepted decision: Sam chose a Home Assistant Local Calendar,
so the target is calendar.school.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Two corrections found while preparing the first live run:
- A scan that runs late in the day still records today's school events; the
candidate window now starts at the local start of day instead of "now".
- The Compass week calendar is school-wide, so one event is stored against each
child. Write it once, with no child prefix in the title, and record the same
Home Assistant event id on every child's item. A per-child event still keeps
the child's name.
Also report pending events as events, not rows, in `school calendar status`.
Tests cover the window and the de-duplication.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Add the Home Assistant REST calendar client and the idempotent sync that
writes important school events to Sam's Home Assistant calendar (FR-8). The
school stays read-only; the only write is to the calendar (rule 7).
- internal/calendar: HA REST client (states, create_event, get_events,
delete_event) and the sync that selects important future events, adopts an
existing event by its description marker, records the HA event id on the
item, and records every attempt.
- internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and
the append-only calendar_pushes table), candidates, mark-synced and the
push log.
- internal/parse: a school event is important; a routine class period is not.
- cmd/school: `school calendar entities|status|push|events`.
- web: an "Add to calendar" item action and a fail-loud calendar banner, so a
failed write is never swallowed (FR-12).
- scripts/calendar-push.sh, Makefile target, env example, docs and tests.
The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no
non-Google writable calendar today, so the entity is asked on AUD-110.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- internal/dashboard: classify items into overdue / due today / due this week /
no due date / messages; render the plain-text brief (FR-6, FR-7, FR-9).
- internal/store: ItemsForChild, ItemByID, DeleteItemByID, ScanSummaryForChild
and the stored-brief CRUD helpers (FR-10).
- internal/web: routes /?child=, /items/{id}, POST /items/{id}/delete,
POST /summaries/build, POST /summaries/{id}/delete, plus a per-child scan
banner (FR-12) and a phone-first server-rendered template.
- Calendar events are never "overdue": a past lesson is history, not work.
- cmd/school brief build|list; scripts/screenshot-phase5.mjs for phone evidence.
- Tests: bucket classification, stored-brief round-trip, and HTTP routes
including the NO SCAN / SCAN FAILED / healthy banner behaviour.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Fetch and parse Harry and Finn with a stored Compass session per child and
store every item against the right children.id.
- cmd/school/session.go + internal/store/session.go: read and write the
per-child harness session state in the SQLite `session` table. `show`
prints metadata only; the state is never printed.
- scripts/compass-fetch-patchright.mjs: reuse the stored session, refresh it
after login, resolve the child's userId from the parent page by name, and
keep the shared calendar out of the per-child run (it would leak the
eldest's timetable into the younger child).
- scripts/scan-compass.sh + make scan: scan one child or both; restore the
session, fetch, persist the session, then parse. A failing child is
recorded in `scans` with ok = 0 and the script exits non-zero (FR-12).
- cmd/school/scan.go: `school scan record` writes a fetch failure to the
scans table. cmd/school/items.go: `school items purge` removes rows that a
shared page stored against the wrong child.
- internal/store/store_test.go: prove items and sessions are separated by
child. docs/phase-4-both-children-notes.md; README + image tag 0.3.0-phase4.
Live evidence: Harry 70 tasks / 1 message / 35 events (6 overdue); Finn
42 tasks / 1 message / 0 events (2 overdue). Finn's subjects are Year 7,
Harry's are Year 11. Both sessions stored.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The Learning Tasks grid is capped at 20 rows and, on the parent page, mixes
both children, so it cannot answer "what is overdue". The parent home page
reported 6 overdue tasks for Harry while the stored grid held 1.
Fetch the per-child task list (read-only) from
POST /Services/LearningTasks.svc/GetAllLearningTasksByUserId, parse it into
task rows, and refuse to store it when the overdue count disagrees with the
home page summary (FR-12).
- fetcher: store learning-tasks-all.json + meta (userId, status, task count)
- parse: new learning-tasks-all page, JSON parser, home-page reconciliation
- store: drop superseded grid-derived task rows before the upsert
- docs: docs/phase-4a-overdue-tasks.md; close the Phase 3 known gap
Verified with live runs: Harry 70 tasks / 6 overdue, Finn 42 tasks / 2 overdue,
both equal to the home page; a tampered home page stops the parse.
Note: the fetcher and store.go files also carry the per-child session work
that was already uncommitted in this shared workspace (AUD-108).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Turn the raw pages stored by Phase 2 into rows in the SQLite items table
(AGENTS.md §11 Phase 3, FR-3/FR-4/FR-5/FR-12).
- internal/parse: dependency-free HTML reader plus extractors for the ExtJS
learning-task grid (subject, title, category, kind, due date, status), the
home news feed (author, posted time, title, body) and the calendar week grid
(day column + grid offset give the time; label gives title/room/teacher).
- internal/parse: fail loudly. A challenge page, login page, changed layout or
unknown due-date format is an explicit error, never an empty day.
- FR-4: structure is parsed deterministically; importance is classified by an
OmniRoute model (default openrouter/google/gemini-3.5-flash-lite) with a
deterministic fallback. The classifier used is printed and recorded.
- internal/llm: small OpenAI-compatible OmniRoute client (handles SSE replies).
- internal/store: migration 002 adds source_page, importance and external_ref
with a unique (child_id, source, type, external_ref) index; UpsertItems,
CountItemsByType, SampleItems and ChildByName.
- cmd/school parse: reads var/raw/<child>/<run>, upserts items, records the scan
row (ok=0 plus the error on failure) and prints counts and samples.
- scripts/parse-compass.sh and make parse run it in a one-off container against
the service's own volume without restarting the service.
- docs/phase-3-parse-notes.md records the approach, the FR-4 decision and the
known gaps.
Evidence on the live database: 56 rows for Harry (20 task, 1 message, 35 event).
Re-running the same raw pages inserts 0 and updates 56.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- §9 Technology: scraper is patchright (headless Playwright); the
pi-browser-harness gets a Cloudflare 403 managed challenge on the login POST.
- §13 Resolved: add the patchright decision and the 2FA finding (the Compass
parent login required no two-factor or one-time code during the Phase 2 proof).
- §13 Remaining: Teams two-factor auth is still unverified.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- add scripts/compass-fetch-patchright.mjs: headless patchright logs in with the
parent account (no 2FA) and stores the per-child pages with a sha256 manifest
- add scripts/package.json + lockfile for the patchright dependency
- docs: the harness clears the Cloudflare GET but not the login POST; patchright
passes it; record the real per-child URLs and the overdue counts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- retry the login on a fresh browser context (stale cf_chl_rc_ni cookies loop
the next challenge) and fail loudly with cloudflare_login_post_challenge
- detect the Compass login-rejection message and second-factor prompts
- docs: record that Cloudflare's managed challenge blocks the headless login
POST (GET passes), what was tried, and the unblock options
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- scripts/compass-fetch.mjs drives pi-browser-harness (daemon socket) to log
in once and store raw Compass pages with a sha256/byte-count manifest
- clear the Cloudflare managed challenge via a desktop-UA override plus an
isolated browser context (the default HeadlessChrome UA loops the challenge)
- docs/phase-2-harness-notes.md records the harness entrypoint, the Compass
login form, and the stored-page evidence
- gitignore local runtime state (var/, sqlite files)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Phase 1 of AGENTS.md §11: the app runs on .13 in its own container on port
8792, behind Caddy at school.lab.audasmedia.com.au, with its own session login
and GET /health.
- Go service (Go 1.25, modernc SQLite, no cgo) with its own image.
- SQLite schema per AGENTS.md §7: children, items, scans, notifications,
session (internal/store/migrations/001_init.sql).
- Separate session login (signed cookie, CSRF double-submit), not SSO.
- HTMX server-rendered shell using the Family Home Lab tokens.css: canvas
#f6f5f4, action blue #0075de, Inter, 8px grid, hairline borders, pill CTAs,
dark mode, phone-friendly.
- Fail loudly (FR-12): a missing or failed scan shows as an explicit warning or
error banner, never as an empty day.
- Dockerfile + docker-compose.yml (own container, own port 8792; uses
network_mode bridge because .13 has no free default Docker address pool).
- Caddy snippet for .35 in deploy/caddy/, deployment notes, and
deploy/verify-phase1.sh.
No school scan in this phase; the Compass login and fetch are Phase 2.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Record the verified Phase 1-2 prerequisites from AUD-102: Go toolchain
(missing), Docker + compose, port 8792, the Caddy route mechanism,
pi-browser-harness, Compass URL and reachability, the credential/2FA
state, and Apprise. Mark the unverified items.
Co-Authored-By: Paperclip <noreply@paperclip.ing>