English version. The Russian source is
concept/LogFormat.mdand remains the working document for the maintainer.
This is the technical contract between the external cron agent and the game. Any change to this format breaks compatibility and requires a format version bump (see below).
tasks.jsonl is the sole data ingestion point into the game. The user's external cron agent
appends new lines to it when completed tasks are detected. The game watches the file, reads only
the tail (offset is persisted), validates each line, and converts valid lines into TaskCompleted
events in its own events.jsonl.
The file path is configured in game settings (F-14). Default:
~/Library/Application Support/CommitPyramid/tasks.jsonl
If the file does not exist, the game creates an empty file on first launch and begins watching it.
- Encoding: UTF-8.
- Line ending: LF (
\n), not CRLF. - Each line is an independent JSON object. No wrapping array.
- The file is append-only from the cron agent side. The game never modifies the file.
- Empty lines are allowed (skipped by the game).
- Lines starting with
#are comments (skipped by the game). Use these for debug annotations from the cron agent.
| Field | Type | Description |
|---|---|---|
ts |
string (ISO8601) | Record timestamp (scan-time). Format YYYY-MM-DDThh:mm:ssZ or with timezone offset |
project |
string | Human-readable project name. The name is the identifier. Case is preserved |
title |
string | Task text. Length 1–500 characters. Used in the inspector and the log |
| Field | Type | Description |
|---|---|---|
task_id |
string | Stable task identifier. If omitted, the game generates a UUID v4. Used for deduplication in the future |
source |
string | Arbitrary source string (file path, ticket URL). Persisted and shown in the inspector |
version |
integer | Record format version. Default 1. The game rejects versions >1 (until an upgrade is released) |
Any fields not listed above are preserved as-is in events.jsonl but have no effect on game
logic. This allows the cron agent to embed service metadata without breaking the contract.
{"ts": "2026-05-21T10:30:00Z", "project": "my-website", "title": "Approved the spec with the client"}{"ts": "2026-05-21T10:30:00Z", "project": "my-website", "title": "Approved the spec with the client", "task_id": "site-001", "source": "/path/to/brief.md", "version": 1}# 2026-05-21 — tasks added for the week
{"ts": "2026-05-15T09:00:00Z", "project": "CommitPyramid", "title": "Drafted the concept"}
{"ts": "2026-05-21T10:30:00Z", "project": "my-website", "title": "Approved the spec"}
{"ts": "2026-05-21T11:00:00Z", "project": "my-website", "title": "Sent the contract"}
projectis compared case-sensitive, as-is, without trim. That is,"my-website"and"my-site"are two different districts.- To avoid accidental "duplicates" (typo in name → new district), the cron agent must use a stable canonical name.
- If the project name genuinely changes and the district needs to be "renamed", that is a migration operation — not part of the standard flow. Tracked in the backlog.
| Situation | Game behavior |
|---|---|
| Malformed JSON line | Written to errors.log with line offset; line is skipped |
| Missing required field | Written to errors.log; line is skipped |
ts in wrong format |
Written to errors.log; line is skipped |
title longer than 500 characters |
Truncated to 500 + …, written to errors.log (warning), line is processed |
Unknown version > 1 |
Written to errors.log; line is skipped |
ts in the future (> now + 1 day) |
Written to errors.log (warning); line is processed with future flag |
| File deleted / moved | Watcher keeps watching (DispatchSource). When a new file is created — resumes from offset 0 |
In the MVP deduplication is disabled. Every line is a new event, even if it looks identical to the previous one. This is a deliberate decision:
- Keeps the contract simple (the agent does not need to track "what was already written").
- If the agent writes a duplicate — that is the agent's responsibility to avoid.
In the future (see Backlog.md) deduplication by task_id may be introduced when the field is
present.
The game stores the offset of the last processed position in tasks.jsonl in the file:
~/Library/Application Support/CommitPyramid/ingestion-state.json
Format:
{"file_path": "/path/to/tasks.jsonl", "offset_bytes": 12345, "last_read_ts": "2026-05-21T11:00:00Z"}On startup the game checks:
- File size >= offset → read from offset to end.
- File size < offset → file was truncated/replaced; read from 0.
file_pathchanged in settings → read the new file from 0.
Current version: 1.
Future format changes must:
- Increment the
versionvalue in new lines. - Support backward reading of old lines (
version: 1is always readable). - Be documented in a separate section in this file with a changelog.
- The user creates
~/Library/Application Support/CommitPyramid/tasks.jsonl(or the game creates it automatically). - The user's cron agent appends a line to the file:
{"ts": "2026-05-21T10:30:00Z", "project": "Test", "title": "First task"} - The game (watcher active) detects the change within 2 seconds, reads the new line,
creates a
TaskCompletedevent inevents.jsonl, and renders a new unit in the "Test" district on the map (a fresh district is created since none existed before).
In addition to task_completed, the engine writes system events from applyTaskCompleted
and DecayEngine. All use the GameEvent structure (see Data/GameEvent.swift).
| Kind | When written | title field |
|---|---|---|
task_completed |
live ingest and from watcher | task title |
unit_built |
each completed task → a new unit is built (!silent) |
UnitKind.label |
stage_up |
district stage increase 0→…→5 (!silent) |
S<old> → S<new> |
restore |
return to a project with decayLevel 1..3 (!silent) |
nil |
decay_tick |
DecayEngine tick increasing decay level |
nil |
fire |
decay transition 2→3 (fire visualization) | nil |
ruins_cleared |
(reserved; not yet written separately) | nil |
unit_evolved |
visual unit evolution at F-16 threshold | <uid>|<from>|<to> |
task_completed → (restore optional) → unit_built → (unit_evolved × N optional) → (stage_up optional)
All four events are written within a single call to applyTaskCompleted (live tick).
In the silent: true branch (replay from log / snapshot tail) no new records are written:
events are already on disk. Idempotency is guaranteed by
apply(.unitBuilt) = apply(.stageUp) = break.
Old logs (only task_completed + decay series) replay without modification:
state aggregates (taskCount, stage, unitIds) are derived from task_completed branches.
New logs contain unit_built/stage_up, but these are no-ops on apply — state is identical.
The format version is not bumped (there is no version field in GameEvent).
GameEvent.Kind is a closed Codable enum without @unknown default. A line in
events.jsonl with an unknown kind is skipped: EventLog.readAll catches the
decoding error via try? and writes to ErrorsLog
(EventLog.swift:42-45). This means:
- An old binary reading a log with a new
kinddoes not crash, but loses those lines; - When adding a new
kind, authors must updateLogFormat.mdand maintain compatibility viaapply = breakor a dedicated no-op branch.
Added 2026-05-23
The original 12 rawValues (shack, house, villa, well, road, warehouse,
workshop, raw, market, forum, temple, obelisk) are guaranteed to be valid
in all future versions. Do not rename them or move them to different cases.
templeandobeliskremain in the.socialcategory until TASK-035 (reclassification to.religiouswill happen simultaneously with the rewrite ofUnitPlanner).- The test
testRawValuesStable12()inLegacyStateMigrationTests.swiftis the safety net against accidental renames.
- Old snapshots (
StateSnapshot.version == 1) are read by new code as-is; the version is not bumped when theUnitKindcatalog is extended. currentVersionis incremented only when the structure ofCityState/ProjectState/UnitStatechanges in a breaking way.- When adding new fields to these structures, fields must be
Optionalor have a default (e.g., via a custominit(from:)withdecodeIfPresent). - An unknown
UnitKindrawValue in a snapshot →JSONDecodercannot decode the wholeStateSnapshot→SnapshotStore.load()returnsniland writes toerrors.log→ the engine falls back to a full replay fromevents.jsonl. Partial resilience (skipping a single broken unit) is in the backlog as "partial-snapshot robustness".
UnitKindis not serialized inevents.jsonl. Thetitlefield of theunit_builtevent stores only the human-readableUnitKind.label, not the rawValue. Therefore extending the catalog from 12 to 50 cases does not require migrating the event log.- On full replay,
UnitKindis recalculated by the current version of the planner (UnitPlanner) — this is an architectural property, not a bug. A full replay after an app update may produce different kinds than were in the original state (this is acceptable and documented).
SnapshotStore.load() distinguishes three scenarios:
| Situation | Written to errors.log | Action |
|---|---|---|
| File does not exist | No | Return nil → full replay |
| File exists but cannot be read | Yes | Return nil → full replay |
| File readable, decode failed (unknown rawValue, schema mismatch) | Yes + error details | Return nil → full replay |
| Snapshot version ≠ currentVersion | Yes | Return nil → full replay |
| All OK | No | Return snapshot |