@bluewombat/work-ledger 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # WorkLedger
2
+
3
+ Kernel package: the durable store of FeatureStandards, Subtasks, states,
4
+ Attempts, and Traces. Glue writes here before it acts.
5
+
6
+ Behavioural contract: [SPECS.md](./SPECS.md). Persistence adapters live under
7
+ `packages/` (`@bluewombat/persist-fs`, `@bluewombat/persist-sqlite`). This
8
+ package owns the Port, not the bytes.
9
+
10
+ ## Stack
11
+
12
+ Repository stack — TypeScript, Node 24.20, Biome, `node:test`. See [docs/DEVELOPMENT.md](../../../docs/DEVELOPMENT.md).
13
+
14
+ ## Setup
15
+
16
+ From the repository root:
17
+
18
+ ```bash
19
+ nvm use
20
+ npm install
21
+ ```
22
+
23
+ ## Commands
24
+
25
+ | Task | Command |
26
+ | --------- | ---------------- |
27
+ | Lint | `npm run lint` |
28
+ | Format | `npm run format` |
29
+ | Typecheck | `npm run tsc` |
30
+ | Test | `npm test` |
31
+ | Build | `npm run build` |
32
+
33
+ There is no CLI in this implementation. Glue injects a persistence adapter and
34
+ calls commands on the returned ledger.
35
+
36
+ ```ts
37
+ import { openWorkLedger } from "@bluewombat/work-ledger";
38
+ import { openFilesystemPersist } from "@bluewombat/persist-fs";
39
+
40
+ const persist = await openFilesystemPersist({ root: "/absolute/path/to/ledger" });
41
+ const ledger = openWorkLedger({ persist });
42
+ ```
43
+
44
+ SQLite is the same Port:
45
+
46
+ ```ts
47
+ import { openSqlitePersist } from "@bluewombat/persist-sqlite";
48
+
49
+ const persist = await openSqlitePersist({ path: "/absolute/path/to/ledger.sqlite" });
50
+ ```
51
+
52
+ ## Layout
53
+
54
+ ```
55
+ SPECS.md behavioural contract
56
+ README.md how to build and run
57
+ src/
58
+ index.ts openWorkLedger; records; Persistence Port
59
+ records.ts persisted shapes (no behaviour)
60
+ persist/port.ts Persistence Port only
61
+ ledger/ commands and queries (imports Port, not an adapter)
62
+ ```
package/SPECS.md ADDED
@@ -0,0 +1,401 @@
1
+ # WorkLedger
2
+
3
+ Kernel package, not a Transformer. It has many operations, not one IN and one
4
+ OUT. Its code, identifiers, comments, and messages name only what it owns:
5
+ Feature records, Plans, Attempts, Traces, states, bails, and the Persistence
6
+ Port. No Transformer is named inside that perimeter, because none of them exist
7
+ there.
8
+
9
+ WorkLedger is the durable store of FeatureStandards, Subtasks, states, Attempts,
10
+ and Traces. It is the system's source of truth. Glue writes here **before** it
11
+ acts. Isolated workspaces are a consequence of that state, never the source of
12
+ it.
13
+
14
+ WorkLedger does not isolate a directory, run a Builder, fold a Child, or report
15
+ to a FeatureManager. It accepts an already-normalized FeatureStandard (or an
16
+ `invalid` verdict). It does not convert raw intentions.
17
+
18
+ Callers inject a persistence adapter. Adapters are their own packages under
19
+ `packages/` (`persist-fs`, `persist-sqlite`, later `persist-postgres`).
20
+ The domain never imports an adapter; an adapter never imports the ledger.
21
+
22
+ ## 1. Job
23
+
24
+ ```
25
+ caller WorkLedger adapter
26
+ ------ ---------- -------
27
+ command or query check, transition, persist load / save / list
28
+ Persistence Port (injected)
29
+ ← record, or refused + code
30
+ ```
31
+
32
+ **In:** a command or a query, plus an injected Persistence Port. **Out:** a
33
+ typed result. No JSON lines, no exit code, no Status protocol. Those belong to
34
+ Transformers. This package is a function set imported in process.
35
+
36
+ There is no required CLI. If a CLI appears later, it is a thin adapter over the
37
+ same functions, the way a Transformer's `cli.ts` is.
38
+
39
+ ## 2. Faces and wiring
40
+
41
+ | Face | Entry | Must not |
42
+ | ------- | -------------------------------------------------- | ------------------------------------- |
43
+ | Import | `openWorkLedger({ persist })` then command methods | Read `process.env` to pick an adapter |
44
+ | Adapter | A value that implements the Persistence Port | Live under `src/ledger/` or in this package |
45
+
46
+ Glue builds the adapter (`openFilesystemPersist({ root })` from
47
+ `@bluewombat/persist-fs`, `openSqlitePersist({ path })` from
48
+ `@bluewombat/persist-sqlite`) and passes it in. The ledger does not sniff the
49
+ environment, the working directory, or a file extension to choose a backend.
50
+ It never names a concrete adapter.
51
+
52
+ Transformers do not import this package. Conductor does. Host chooses the adapter.
53
+
54
+ ## 3. Two layers
55
+
56
+ ```
57
+ commands / queries src/ledger/ FeatureStandard states, Plan freeze,
58
+ bail, Attempt, Trace, refusals
59
+ │
60
+ │ Persistence Port (plain records)
61
+ ▼
62
+ adapters packages/plugins/persist-<name>/ bytes on disk, rows in a database
63
+ ```
64
+
65
+ | Layer | Owns | Does not own |
66
+ | ------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------- |
67
+ | Ledger | Legal transitions, idempotence, bound counters, bail | `node:fs`, `sqlite`, `pg`, paths |
68
+ | Port | The record shapes that cross the boundary, and the verbs `load` / `save` / `listSummaries` | FeatureStandard state names as a state machine |
69
+ | Adapter | How those records are stored and listed | Whether `merging` may be cancelled |
70
+
71
+ A caller never talks to an adapter. It talks to the ledger. The ledger never
72
+ talks to `node:fs`. It talks to the Port.
73
+
74
+ ## 4. Adapter isolation
75
+
76
+ Hard rules. Breaking one is a spec bug, not a refactor.
77
+
78
+ 1. **One package per adapter**, under `packages/plugins/persist-<name>/` (name is
79
+ `fs`, `sqlite`, later `postgres`, …). Nothing of an adapter leaks into
80
+ `src/ledger/` or into a sibling adapter.
81
+ 2. **`src/ledger/` does not import an adapter package**, `node:fs`, or
82
+ `node:sqlite`. It imports only the Port type (and record types). A test that
83
+ fails if `ledger` grows such an import is part of this contract.
84
+ 3. **An adapter package does not import `src/ledger/`.** It imports the Port and
85
+ the persisted record types from `@bluewombat/work-ledger`. An adapter that
86
+ needs a transition table has crossed the line.
87
+ 4. **Shared persisted shapes live in this package** (`src/records.ts`). They are
88
+ data, not behaviour.
89
+ 5. **No `if (adapter === "sqlite")` in the ledger.** New backends add a package
90
+ and a constructor. They do not edit command code.
91
+ 6. **Filesystem and SQLite both ship.** Postgres is named so the Port stays
92
+ honest. Its package does not exist until someone implements it.
93
+ 7. **Domain tests inject a Port.** They use a test double, or an adapter package
94
+ on a temp path. They do not reach into adapter internals. Adapter tests do
95
+ not run FeatureStandard transitions.
96
+
97
+ ## 5. Persistence Port
98
+
99
+ Three operations. Adapters may store one JSON file or ten SQL tables; the Port
100
+ does not care.
101
+
102
+ | Operation | In | Out |
103
+ | --------------- | ------------------------- | ------------------------------------ |
104
+ | `load` | Feature `key` | Aggregate, or missing |
105
+ | `save` | Feature `key` + aggregate | Durable replace of that key. Atomic. |
106
+ | `listSummaries` | none | One summary per known key |
107
+
108
+ `save` replaces the whole aggregate for that key. The ledger read-modify-writes.
109
+ The adapter does not merge. If `save` cannot complete, it fails; it does not
110
+ leave a half-written key that `load` could return.
111
+
112
+ `listSummaries` exists so `claim` can pick the next `received` FeatureStandard
113
+ without loading every Trace. A summary carries at least: `key`, `project`,
114
+ `state`, `priority`, `received_at`, bail expiry if any.
115
+
116
+ An adapter that cannot satisfy atomic `save` is not a WorkLedger adapter.
117
+
118
+ ## 6. Filesystem adapter (`@bluewombat/persist-fs`)
119
+
120
+ Constructor: `openFilesystemPersist({ root })`. `root` is an absolute directory.
121
+ The adapter creates it if missing. This package does not live here.
122
+
123
+ Physical layout is this adapter's private choice. `persist-fs` uses
124
+ one file per key:
125
+
126
+ ```
127
+ <root>/<encoded-key>.json
128
+ ```
129
+
130
+ `<encoded-key>` is `encodeURIComponent(key)` so a key `fake:42` is one path
131
+ segment (`fake%3A42.json`). The adapter never uses the raw key as a filename.
132
+
133
+ `save` writes a sibling temporary file in `root`, then renames onto the target
134
+ (atomic on the same filesystem). A crash during `save` leaves either the old
135
+ file or the new file, never a truncated JSON document that parses as a
136
+ different aggregate.
137
+
138
+ This adapter knows directories and JSON. It does not know `merging`, `ready`,
139
+ or Isolator.
140
+
141
+ ## 7. Adapter packages
142
+
143
+ Same Port, own package, own constructor.
144
+
145
+ | Adapter | Package | Constructor sketch |
146
+ | ---------- | ------------------------------- | ------------------------------------ |
147
+ | Filesystem | `packages/plugins/persist-fs` | `openFilesystemPersist({ root })` |
148
+ | SQLite | `packages/plugins/persist-sqlite` | `openSqlitePersist({ path })` |
149
+ | Postgres | `packages/work-ledger-postgres` | `openPostgresPersist({ url })` |
150
+
151
+ They may split an aggregate into tables. `load` / `save` still look like one
152
+ aggregate to the ledger. A Postgres adapter that exposes SQL to glue has leaked
153
+ through the Port.
154
+
155
+ ## 8. Aggregate
156
+
157
+ One aggregate per FeatureStandard `key` (`<manager>:<external_id>`). The Adapter
158
+ produced `key` and `fingerprint`; WorkLedger owns everything else.
159
+
160
+ | Part | Content |
161
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------- |
162
+ | Identity | `key`, `project`, `fingerprint` |
163
+ | Intention | FeatureStandard fields this package stores (intention, title, priority, source, …) |
164
+ | Feature state | Exactly one of §9 |
165
+ | Invalid | `code` + `reason` when state is `invalid` |
166
+ | Pending fingerprint | Set when an update arrives during `planning` / `running` / `integrating`; applied only after the in-flight work stops |
167
+ | Plan | Absent until freeze. Then frozen: Subtasks, dependencies, `planned_at`. No automatic rewrite |
168
+ | Subtask states | Per Subtask, exactly one of §9 |
169
+ | Attempts | Numbered, each with a Trace when an Attempt ran, and stamped with the round (`resumes` at the time) it belongs to |
170
+ | Bound counters | Attempts used, as this package tracks them |
171
+ | Resumes | How many `resumeReady` were taken. A round tells two escalations of one kind apart; absent reads as 0 |
172
+ | Bail | Optional. `expires_at` when a feature or Subtask is claimed |
173
+ | Declared workspaces | Optional absolute paths glue wrote **before** creating those directories |
174
+
175
+ Unknown fields from a FeatureStandard that FeatureAdapter would drop are not
176
+ invented here either. WorkLedger does not reopen the Adapter's closed shape.
177
+
178
+ `integrated` Subtasks are never removed from the Plan. An Attempt is never
179
+ rewritten; a new Attempt is appended.
180
+
181
+ ## 9. States
182
+
183
+ FeatureStandard states, copied from the product glossary. WorkLedger is the
184
+ component that actually holds them.
185
+
186
+ | State | Meaning |
187
+ | ------------- | -------------------------------------------------- |
188
+ | `received` | Admitted, not yet claimed |
189
+ | `invalid` | Stored as unusable, with a code; not silent |
190
+ | `planning` | Breakdown in progress (bail held) |
191
+ | `running` | Subtask loop (bail held) |
192
+ | `escalated` | Frozen for a human |
193
+ | `integrating` | All Subtasks `integrated`; assembly Gates |
194
+ | `submitted` | Offered to an Authority; waiting on its judgement |
195
+ | `merging` | Final fold into WorkLineStable. Point of no return |
196
+ | `done` | Terminal. In WorkLineStable |
197
+ | `cancelled` | Terminal. Abandoned before final integration |
198
+
199
+ Subtask states: `pending`, `runnable`, `running`, `escalated`, `integrated`,
200
+ `cancelled`.
201
+
202
+ A command that would require a transition not in this section is `refused`.
203
+ The aggregate is unchanged.
204
+
205
+ ## 10. Commands
206
+
207
+ Each command is a function on the opened ledger. Illegal input or an illegal
208
+ transition returns `refused` with a `code`. It does not throw for domain
209
+ reasons. It does not persist a refused command.
210
+
211
+ Idempotence is on `key` unless a row says otherwise.
212
+
213
+ | Command | Meaning | Refused when |
214
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
215
+ | `admit` | Store a FeatureStandard in `received`. Same `key` already `received` with the same fingerprint → success, no rewrite. Same `key`, different fingerprint, not in-flight → update the stored intention. Same `key` in-flight (`planning` / `running` / `integrating` / `submitted`) with the same fingerprint (or the fingerprint already pending) → success, no rewrite. A different fingerprint while in-flight is recorded as pending; the live work is not rewritten. | `key` in `merging` or `done`; `key` in `cancelled` |
216
+ | `recordInvalid` | Store `invalid` with code, reason and the fingerprint of what was received | `key` already in `merging` or `done` |
217
+ | `claim` | Take the next `received` FeatureStandard for a Project (priority then `received_at`). Hold bail. State → `planning`. No-op with `empty` when none, or when that Project already has an active FeatureStandard | That Project already has `planning`, `running`, `escalated`, `integrating`, `submitted`, or `merging` |
218
+ | `recordPlan` | Freeze the Plan. Subtasks start `pending` / `runnable`. State → `running` | Not `planning`; Plan already frozen; Plan fails the same structural checks FeatureBreakdown already applied (empty, cycle, missing definition of done, …) |
219
+ | `refusePlan` | Breakdown refused. State → `escalated` with the Breakdown code | Not `planning` |
220
+ | `startSubtask` | Named Subtask → `running`. Feature stays `running`. Bail renewed or held | Feature not `running`; Subtask not `runnable`; another Subtask already `running` |
221
+ | `recordAttempt` | Append an Attempt + Trace (`ended`, optional `report` and `refusedBy`) | That Subtask is not `running`; Attempt number not the next integer |
222
+ | `markSubtaskIntegrated` | Subtask → `integrated`. If no Subtask remains non-terminal → Feature → `integrating` | Subtask not `running`; Feature not `running` |
223
+ | `markSubmitted` | Feature → `submitted`. Records the Authority's opaque `reference`. A second call keeps the first reference and any `last_report` | Not `integrating` or `submitted` |
224
+ | `recordRefusal` | Authority / assembly Gates sent the work back. Increment `refusals`, store `last_report`, Feature → `integrating` | Not `submitted` |
225
+ | `markMerging` | Feature → `merging` | Not `integrating` or `submitted` |
226
+ | `markDone` | Feature → `done`. Records the fold's `reference` when given (the work line's own name for it, opaque). Clear bail. Clear declared Subtask workspace | Not `merging` |
227
+ | `escalate` | Feature → `escalated` (whole feature frozen). Trace required if an Attempt exists for the named Subtask. Consumes `pending_fingerprint`. | Feature already `done` or `cancelled`; escalation without Trace when an Attempt existed |
228
+ | `resumeReady` | Human `ready`. Plan escalation → `planning` (Plan unfrozen, Breakdown replayed). Subtask escalation → that Subtask `runnable` from zero, Feature `running`. Escalation from `merging`, `submitted`, or `assembly` → `integrating` | Not `escalated` or `invalid`; `invalid` without a new admissible FeatureStandard |
229
+ | `cancel` | Feature → `cancelled`. Remaining Subtasks `cancelled`. `integrated` Subtasks stay `integrated` | `merging` or `done`; an escalation that was born in `merging` |
230
+ | `declareWorkspace` | Record a WorkSpaceFeature and/or WorkSpaceSubtask path **before** glue creates it | Feature `done` or `cancelled` |
231
+ | `clearWorkspace` | Drop a declared path after glue deleted that directory | — |
232
+ | `expireBail` | Bail past expiry: Subtask `running` → `runnable` (from zero, consume an Attempt); Feature `planning` → `received` | No bail, or bail not expired |
233
+ | `renewBail` | Push bail expiry forward by the configured duration. Allowed while a bail is held | No bail |
234
+
235
+ `resumeReady` from `invalid` requires `admit` of a corrected FeatureStandard
236
+ first (`invalid` → `received` is `admit` after correction, then claim).
237
+
238
+ In-flight update (`admit` while `planning` / `running` / `integrating` /
239
+ `submitted`): same fingerprint as the live intention, or as the fingerprint
240
+ already pending → success, no rewrite (an echo of a report). A different
241
+ fingerprint is stored as pending; do not change the frozen Plan; do not
242
+ rewrite the live intention. `escalate` consumes `pending_fingerprint`.
243
+ Conductor calls `escalate({ kind: "plan" })` after a Subtask is marked
244
+ integrated, and when entering `integrating`, if the field is set. WorkLedger
245
+ does not watch a Transformer's clock.
246
+
247
+ Escalation `kind` is one of `plan`, `subtask`, `assembly`, `submitted`,
248
+ `merging`.
249
+
250
+ ## 11. Queries
251
+
252
+ | Query | Out |
253
+ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
254
+ | `get` | Aggregate for a `key`, or missing |
255
+ | `list` | One summary per known key, every state. No persist, no bail, no transition |
256
+ | `listInFlight` | The `list` subset that is mid-flight (`planning` / `running` / `integrating` / `submitted`) |
257
+ | `activeOn` | The active FeatureStandard on a Project (`planning` / `running` / `escalated` / `integrating` / `submitted` / `merging`), or none |
258
+ | `nextReceived` | What `claim` would take, without taking it |
259
+ | `nextRunnable` | A `runnable` Subtask of a Feature (ties: the one that unblocks the most others). Glue still serializes |
260
+ | `listDeclaredWorkspaces` | Keys and paths the ledger believes exist. Glue reconciles disk to this list. Orphans on disk that are not listed are glue's to destroy |
261
+
262
+ Queries do not persist. They do not take a bail.
263
+
264
+ ## 12. Bail and reconcile
265
+
266
+ While a FeatureStandard is `planning`, `running`, `integrating`, `submitted`,
267
+ or `merging`, it holds a bail (`expires_at`). There is no holder id in this
268
+ implementation. Glue renews by calling `startSubtask` / `claim`-family
269
+ commands; `renewBail` exists if expiry would otherwise pass during a long
270
+ Attempt.
271
+
272
+ Expired bail: `expireBail`. The Subtask becomes `runnable` from zero and
273
+ consumes an Attempt. Declared Subtask workspace is cleared (glue deletes the
274
+ directory). The ledger decides; glue acts.
275
+
276
+ After a process start, glue calls `listDeclaredWorkspaces` and destroys
277
+ directories that are not on that list. It does not invent Feature state from
278
+ disk.
279
+
280
+ ## 13. Refusals
281
+
282
+ A refused command leaves the store as it was. `code` is one token.
283
+
284
+ | Code | When |
285
+ | ---------------------- | -------------------------------------------------------------------------------------------------- |
286
+ | `not-found` | `key` is unknown |
287
+ | `duplicate` | Reserved; `admit` of an identical fingerprint is success, not this |
288
+ | `illegal-transition` | State does not allow this command |
289
+ | `project-busy` | `claim` while that Project already has an active FeatureStandard |
290
+ | `plan-frozen` | `recordPlan` when a Plan already exists |
291
+ | `plan-not-frozen` | A Subtask command before `recordPlan` |
292
+ | `subtask-not-runnable` | `startSubtask` on a Subtask whose dependencies are not `integrated` |
293
+ | `another-running` | `startSubtask` while one Subtask is already `running` |
294
+ | `missing-trace` | `escalate` after an Attempt with no Trace |
295
+ | `point-of-no-return` | `cancel` in `merging` or `done`, or abandon after merging-born escalation |
296
+ | `too-late` | `admit` / `recordInvalid` / intention edit in `done` |
297
+ | `persist-failed` | Adapter `save` failed. Aggregate in memory is not the source of truth; caller retries or escalates |
298
+
299
+ `persist-failed` is the only refusal that may mean "we do not know if bytes
300
+ landed". Glue treats it as unavailable, not as `invalid`.
301
+
302
+ ## 14. What is implemented
303
+
304
+ Shipped:
305
+
306
+ - Import face, `openWorkLedger({ persist })`
307
+ - Filesystem adapter (`packages/plugins/persist-fs`)
308
+ - SQLite adapter (`packages/plugins/persist-sqlite`)
309
+ - Commands and queries in §10–§11
310
+ - Isolation tests in §4
311
+
312
+ Not shipped:
313
+
314
+ - Postgres adapter
315
+ - CLI
316
+ - A human UI
317
+ - Pause / emergency-stop flags (Conductor admission, not this store)
318
+
319
+ ## 15. Layout
320
+
321
+ ```
322
+ SPECS.md this contract
323
+ README.md how to build and run
324
+ src/
325
+ index.ts openWorkLedger; re-export commands, records, Port
326
+ records.ts persisted shapes (no behaviour)
327
+ persist/
328
+ port.ts Persistence Port only
329
+ ledger/ commands, queries, transitions; imports Port, not an adapter
330
+ ```
331
+
332
+ Adapters are sibling packages under `packages/plugins/persist-<name>/`, not
333
+ directories here.
334
+
335
+ ## 16. Acceptance
336
+
337
+ 1. `admit` of a new FeatureStandard → `get` returns it in `received` with that
338
+ `key` and `fingerprint`.
339
+ 2. `admit` of the same `key` and fingerprint again → success, aggregate
340
+ unchanged (byte-for-byte on the stored fingerprint and intention).
341
+ 3. `admit` of the same `key` with a new fingerprint while `received` → stored
342
+ intention updates.
343
+ 4. `admit` while `running` with a new fingerprint → pending fingerprint set;
344
+ frozen Plan unchanged; Feature state still `running`. Same fingerprint
345
+ while `running` or `submitted` → success, no pending field.
346
+ 5. `claim` on a Project with one `received` → that Feature is `planning` and
347
+ holds a bail. A second `claim` on the same Project → `project-busy`.
348
+ 6. `recordPlan` with two Subtasks, B depending on A → A `runnable`, B
349
+ `pending`. `startSubtask(B)` → `subtask-not-runnable`.
350
+ 7. `startSubtask(A)` then `startSubtask` on any other → `another-running`.
351
+ 8. `markSubtaskIntegrated` on the last non-terminal Subtask → Feature
352
+ `integrating`.
353
+ 9. `cancel` in `merging` → `point-of-no-return`; state still `merging`.
354
+ 10. `escalate` after `recordAttempt` without a Trace → `missing-trace`; state
355
+ unchanged.
356
+ 11. `declareWorkspace` then `listDeclaredWorkspaces` includes that path.
357
+ `clearWorkspace` removes it.
358
+ 12. `save` via the filesystem adapter: after a successful `admit`, the file
359
+ `<root>/<encoded-key>.json` exists and `load` round-trips the aggregate.
360
+ 13. Filesystem `save` is atomic: a reader never observes truncated JSON.
361
+ 14. `src/ledger/` has no import of an adapter package, `node:fs`, or `node:sqlite`.
362
+ 15. `src/persist/` holds only `port.ts`.
363
+ 16. Opening the ledger with a different Persistence Port (a test double) still
364
+ passes 1–11. No filesystem is required for those.
365
+ 17. SQLite `save` then `load` round-trips the same aggregate.
366
+
367
+ ## 17. Choices
368
+
369
+ Decisions taken while writing this spec. Each one is a choice, not a
370
+ consequence: another answer was possible.
371
+
372
+ 1. **Not a Transformer.** A store with many verbs is not IN/OUT-shaped. One IN
373
+ and one OUT would force a toolbox CLI and hide the transition table.
374
+ 2. **Commands, not `set(state)`.** The product rules (plan freeze, one active
375
+ FeatureStandard per Project, no abandon in `merging`) live here or they live
376
+ nowhere.
377
+ 3. **Injected Persistence Port.** Sniffing env or a path to pick SQLite later
378
+ would couple the ledger to ops. Glue chooses; the ledger stays portable.
379
+ 4. **Adapters are sibling packages.** A directory inside this package would mix
380
+ kernel with a file format. Separate packages make a SQLite backend a new
381
+ `packages/` directory, not an edit of command code.
382
+ 5. **Port is load / save / listSummaries of one aggregate.** Fine-grained row
383
+ verbs would leak SQL into the ledger. Adapters may split storage internally.
384
+ 6. **Filesystem and SQLite both ship.** Enough to be truth on disk or in one
385
+ file. Postgres is an adapter, not a prerequisite.
386
+ 7. **Keys encoded for filenames.** FeatureAdapter keys contain `:`. Using them
387
+ raw would create extra directories or illegal paths.
388
+ 8. **Refused commands do not persist.** A failed transition must not look like
389
+ history.
390
+ 9. **`persist-failed` is not `invalid`.** Disk or database blips must not brand
391
+ a FeatureStandard unusable.
392
+ 10. **Declared workspaces are ledger facts, not discoveries.** Glue writes the
393
+ path before creating the directory, so crash reconcile has a list that
394
+ disk cannot lie about.
395
+ 11. **No CLI.** The caller is TypeScript glue. A
396
+ CLI can wait; two faces would be invented before a second caller exists.
397
+ 12. **Stack: TypeScript on Node.js 24, Biome, `node:test` run through `tsx`.**
398
+ Recorded here as a decision; the commands live in this package's README
399
+ when it exists.
400
+
401
+ How this package is built and run: [README.md](./README.md).
@@ -0,0 +1,6 @@
1
+ export type { PlanCheckResult, PlanDraft } from "./ledger/check-plan.js";
2
+ export type { ClaimResult, ClearWorkspaceInput, CommandOk, CommandRefused, CommandResult, DeclaredWorkspace, DeclareWorkspaceInput, EscalateInput, FeatureAdmission, GetResult, OpenWorkLedgerOptions, RecordAttemptInput, WorkLedger, } from "./ledger/open-work-ledger.js";
3
+ export { openWorkLedger } from "./ledger/open-work-ledger.js";
4
+ export type { PersistencePort, PersistSaveResult } from "./persist/port.js";
5
+ export type * from "./records.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACzE,YAAY,EACV,WAAW,EACX,mBAAmB,EACnB,SAAS,EACT,cAAc,EACd,aAAa,EACb,iBAAiB,EACjB,qBAAqB,EACrB,aAAa,EACb,gBAAgB,EAChB,SAAS,EACT,qBAAqB,EACrB,kBAAkB,EAClB,UAAU,GACX,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC5E,mBAAmB,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { openWorkLedger } from "./ledger/open-work-ledger.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { PlannedSubtask } from "../records.js";
2
+ export type PlanDraft = {
3
+ id: string;
4
+ intention: string;
5
+ definition_of_done: string;
6
+ depends_on: string[];
7
+ };
8
+ export type PlanCheckResult = {
9
+ ok: true;
10
+ subtasks: PlanDraft[];
11
+ } | {
12
+ ok: false;
13
+ };
14
+ export declare function checkPlan(drafts: PlanDraft[]): PlanCheckResult;
15
+ export declare function initialSubtaskState(draft: PlanDraft): PlannedSubtask["state"];
16
+ export declare function refreshRunnable(subtasks: PlannedSubtask[]): PlannedSubtask[];
17
+ //# sourceMappingURL=check-plan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check-plan.d.ts","sourceRoot":"","sources":["../../src/ledger/check-plan.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,MAAM,MAAM,SAAS,GAAG;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,SAAS,EAAE,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAA;CAAE,CAAC;AAmClF,wBAAgB,SAAS,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,eAAe,CA0B9D;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,GAAG,cAAc,CAAC,OAAO,CAAC,CAE7E;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,cAAc,EAAE,GAAG,cAAc,EAAE,CAW5E"}
@@ -0,0 +1,73 @@
1
+ function findCycle(subtasks) {
2
+ const byId = new Map(subtasks.map((st) => [st.id, st]));
3
+ const color = new Map();
4
+ for (const st of subtasks) {
5
+ color.set(st.id, "white");
6
+ }
7
+ const visit = (id) => {
8
+ color.set(id, "gray");
9
+ const node = byId.get(id);
10
+ if (node !== undefined) {
11
+ for (const dep of node.depends_on) {
12
+ const depColor = color.get(dep);
13
+ if (depColor === "gray") {
14
+ return true;
15
+ }
16
+ if (depColor === "white" && visit(dep)) {
17
+ return true;
18
+ }
19
+ }
20
+ }
21
+ color.set(id, "black");
22
+ return false;
23
+ };
24
+ for (const st of subtasks) {
25
+ if (color.get(st.id) === "white" && visit(st.id)) {
26
+ return true;
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+ export function checkPlan(drafts) {
32
+ if (drafts.length === 0) {
33
+ return { ok: false };
34
+ }
35
+ const seen = new Set();
36
+ for (const draft of drafts) {
37
+ if (draft.id.length === 0 || seen.has(draft.id)) {
38
+ return { ok: false };
39
+ }
40
+ seen.add(draft.id);
41
+ if (draft.intention.trim().length === 0 || draft.definition_of_done.trim().length === 0) {
42
+ return { ok: false };
43
+ }
44
+ }
45
+ const ids = new Set(drafts.map((d) => d.id));
46
+ for (const draft of drafts) {
47
+ for (const dep of draft.depends_on) {
48
+ if (!ids.has(dep)) {
49
+ return { ok: false };
50
+ }
51
+ }
52
+ }
53
+ if (findCycle(drafts) || drafts.every((d) => d.depends_on.length > 0)) {
54
+ return { ok: false };
55
+ }
56
+ return { ok: true, subtasks: drafts };
57
+ }
58
+ export function initialSubtaskState(draft) {
59
+ return draft.depends_on.length === 0 ? "runnable" : "pending";
60
+ }
61
+ export function refreshRunnable(subtasks) {
62
+ const integrated = new Set(subtasks.filter((st) => st.state === "integrated").map((st) => st.id));
63
+ return subtasks.map((st) => {
64
+ if (st.state !== "pending") {
65
+ return st;
66
+ }
67
+ if (st.depends_on.every((dep) => integrated.has(dep))) {
68
+ return { ...st, state: "runnable" };
69
+ }
70
+ return st;
71
+ });
72
+ }
73
+ //# sourceMappingURL=check-plan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check-plan.js","sourceRoot":"","sources":["../../src/ledger/check-plan.ts"],"names":[],"mappings":"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"}