@zhchxiao123/dsh-devflow 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zhchxiao123
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/devflow/devflow/README.md
5
+ README.md: 81a9147155d182c5fea6e7b2aebc3c6dd1cf1b9c
6
+ README.zh.md: 8613bd2b93adf3b35f59ef39b4e61be1d1927813
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @zhchxiao123/dsh-devflow
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Service Definition for the **`ctx.devflow` capability seam**: file-backed task cards moving through a fixed development pipeline. This package owns the card vocabulary (`DevCard`, `DevStage`, journal entry types, the branded `DevflowCardId`) and the journal decode/replay shared by every consumer. Storage belongs to a provider such as [`dsh-devflow-filesystem`](../devflow-filesystem/README.md); the model-facing tools are [`dsh-tool-devflow`](../tool-devflow/README.md).
6
+
7
+ ## Service
8
+
9
+ `DevflowStore` is an abstract Cordis `Service` on `ctx.devflow` (one implementation per context; a second registration throws).
10
+
11
+ Every operation carries an explicit **devflow root** dimension: reads take an optional trailing `root`, requests carry an optional `root` field resolved into their spec, and `ClaimOptions` carries one for the lease. An omitted root falls back to the implementation's configured default, so single-root deployments never mention it; a returned `DevCard` always names the resolved `root` it belongs to, and cards with equal ids under different roots are different cards. Which root a caller passes is the caller's decision — the model tools and `/devflow` derive `<session cwd>/.devflow` from the invoking session, and the seam itself never maps workspaces to directories. The session-scoped reads are the one exception, because their caller is a browser that must never send paths: `listForSession` and `detailForSession` take the *viewing session's id* and resolve it host-side (the live or persisted session's header cwd, through the optionally composed `sessions`/`sessionPersistence` services) into the same root dimension; an unknown session is a stable rejection. `detailForSession` aggregates one card's read value, its decoded journal, and its lease holder (`DevCardDetail`) in a single round trip for the board's detail view, re-reading once when a transition tears the pair. [`dsh-devflow-web`](../devflow-web/README.md) is what puts those two on a browser channel.
12
+
13
+ | Method | Behavior |
14
+ |---|---|
15
+ | `list(filter?, root?)` | One root's cards ordered by id; `filter.stage` narrows to one current location, `filter.parent` to one card's children. |
16
+ | `read(id, root?)` | One card with journal-derived state; a missing card throws. |
17
+ | `history(id, root?)` | The card's complete decoded journal, oldest first, stream-validated like a read (a structurally invalid journal fails loudly, naming file and line). |
18
+ | `holder(id, root?)` | The card's current lease holder (`ClaimHolder`: owner plus last heartbeat), `undefined` while unclaimed; a corrupt claim record fails loudly. |
19
+ | `resolveCreate(request)` | Explicit defaulting: turns a caller `CreateRequest` (title, Markdown body, optional slug, actor, optional parent, optional root) into the fully specified `CreateSpec` — the slug derived from the title when omitted, the root resolved, plus the creation timestamp. |
20
+ | `create(spec)` | Creates one card: parent validation → sequence-number allocation (continuing past archived cards, so an id is never reissued) → exclusive directory creation → the journal's first `created` entry (the only commit point) → projection write → `devflow/card-created`. Domain rejections resolve `ok: false` with a stable code (`empty-title`, `invalid-slug`, `exists`, `unknown-parent`, `nested-parent`, `parent-settled`); only infrastructure failures reject. |
21
+ | `resolve(request)` | Explicit defaulting: turns a caller `TransitionRequest` into the fully specified `TransitionSpec` with its resolved root and commit timestamp. |
22
+ | `transition(spec)` | Commits one move: revision CAS → edge check → `devflow/transition` waterfall → journal append (the only commit point) → projection rewrite → `devflow/stage-changed`. Domain rejections resolve `ok: false` with a stable code (`revision-mismatch`, `illegal-edge`, `reason-required`, `vetoed`); only infrastructure failures reject. |
23
+ | `claim(id, owner, options?)` | Takes the card's exclusive lease; a held lease resolves with the current holder, unless `options.staleAfterMs` marks its heartbeat lapsed — then the lease is taken over with a journaled `claim-expired` entry. |
24
+ | `attachArtifact(request)` | Registers a stage deliverable in the journal against the current stage; rejected while `blocked` or `done`, with the same revision check as `transition`. |
25
+ | `archiveDone(root?)` | Moves every archivable `done` card of one root out of the active set into that root's archive, keyed by the month of its last journal entry; a decomposed requirement archives as one family (a done child waits for its parent, then joins the parent's month bucket). Archived cards leave `list` but keep their complete journal. Returns the archived ids in id order. |
26
+
27
+ Current state always comes from journal replay; a card file's frontmatter is a rebuildable projection. Implementations must fail a read loudly on a structurally invalid journal (naming file and line), warn-and-override on projection drift, and publish state and notifications only after the journal committed. Legal edges (`isLegalTransition`): the pipeline order, rework from `reviewing`/`testing` back to `developing`, `blocked` entry from any non-terminal location, and recovery only to the exact interrupted stage. A rework edge (`isReworkEdge`) without a `reason` is rejected `reason-required`, so the next holder always learns what to fix.
28
+
29
+ ## Stages and journal
30
+
31
+ `DevStage` is the closed union `draft | designing | ready | developing | reviewing | testing | done`; `blocked` is a bypass location that remembers the stage it interrupted (`CardLocation = DevStage | 'blocked'`). The journal entry union is `created | transition | artifact | claim-expired`, decoded by `decodeJournalEntry` (the durable-boundary validator) and folded by `foldJournal`, which enforces: contiguous revisions from 1, `created` first and only first, transitions departing the current location, and blocked recovery returning exactly to the remembered stage.
32
+
33
+ A requirement too big for one card becomes a **parent card plus one child card per slice**. The edge is the `created` entry's `parent`, so it is fixed at creation, replayable, and never re-pointed; `foldJournal` surfaces it as `DevCard.parent` and the frontmatter `parent:` is its projection. The breakdown is one level deep — a card carrying `parent` is never itself a parent — and parent and children always share a root. Which cards may take children is the provider's creation-time decision (`unknown-parent`, `nested-parent`, `parent-settled`); the seam holds no rule about how a parent's own stage relates to its children's.
34
+
35
+ ## Events
36
+
37
+ | Event | Mode | Meaning |
38
+ |---|---|---|
39
+ | `devflow/transition` | `waterfall` | Single-decision pipeline before the commit, dispatched with the complete `TransitionAttempt` (spec plus departure); a policy listener that owns the decision returns `{ allowed: false, reason }` without calling `next()`. [`dsh-devflow-gates`](../devflow-gates/README.md) runs command policies here. |
40
+ | `devflow/card-created` | `emit` | A new card entered the active set: its journal committed the first `created` entry. |
41
+ | `devflow/stage-changed` | `emit` | A card settled at a new location after a committed transition. |
42
+
43
+ The invariant companion validates the emit streams: `card-created` announces only fresh drafts at revision 1 for never-seen ids and never hangs a card under one the stream already knows to be a child, and per card, `stage-changed` revisions strictly increase while every notification reports an actual move.
44
+
45
+ ## Model Experience
46
+
47
+ Indirectly, through the model-facing tools in dsh-tool-devflow: the service interface itself registers no prompt or schema.
48
+
49
+ #### KV Cache effect
50
+
51
+ None; this package neither assembles nor sends a provider request.
52
+
53
+ ## Known Limitations and Deferred Work
54
+
55
+ - **The archive is write-only** — `archiveDone` removes done cards from the active set; no seam operation lists or restores archived cards.
56
+ - **No card editing after creation** — `create` fixes the title and body once; changing a card's content remains a direct edit of its `card.md` in the provider's on-disk format.
package/README.zh.md ADDED
@@ -0,0 +1,56 @@
1
+ # @zhchxiao123/dsh-devflow
2
+
3
+ [English](README.md) | 中文
4
+
5
+ **`ctx.devflow` 能力缝的 Service Definition**:以文件为载体、沿固定研发流水线移动的任务卡。本包拥有卡片词汇(`DevCard`、`DevStage`、journal 条目类型、branded 的 `DevflowCardId`)与供所有消费者共用的 journal 解码/回放。存储属于 [`dsh-devflow-filesystem`](../devflow-filesystem/README.zh.md) 这样的 Provider;模型工具是 [`dsh-tool-devflow`](../tool-devflow/README.zh.md)。
6
+
7
+ ## 服务
8
+
9
+ `DevflowStore` 是注册在 `ctx.devflow` 上的抽象 Cordis `Service`(每个 context 只允许一个实现;重复注册抛错)。
10
+
11
+ 每个操作都携带显式的 **devflow root** 维度:读取带可选的尾参 `root`,请求携带可选的 `root` 字段并解析进各自的 spec,`ClaimOptions` 为租约携带一个。省略的 root 回退到实现配置的默认根,单根部署因此完全不用提它;返回的 `DevCard` 永远标明其所属的已解析 `root`,不同根下 id 相同的卡是不同的卡。调用方传哪个根由调用方决定——模型工具与 `/devflow` 从发起会话推导 `<会话 cwd>/.devflow`,缝本身从不做工作区到目录的映射。按会话取值的那两个读是唯一的例外,因为它们的调用方是绝不能发送路径的浏览器:`listForSession` 与 `detailForSession` 接收*查看会话的 id*,在 host 侧(经可选组合的 `sessions`/`sessionPersistence` 服务读取活跃或持久化会话头部的 cwd)解析成同一个 root 维度;未知会话是稳定拒绝。`detailForSession` 把一张卡的读值、已解码 journal 与租约持有者(`DevCardDetail`)聚合为一次往返,供看板的详情视图使用,并在流转撕开这对读取时重读一次。把这两个读搬到浏览器通道上的是 [`dsh-devflow-web`](../devflow-web/README.zh.md)。
12
+
13
+ | 方法 | 行为 |
14
+ |---|---|
15
+ | `list(filter?, root?)` | 一个根的卡片,按 id 排序;`filter.stage` 收窄到一个当前位置,`filter.parent` 收窄到一张卡的子卡。 |
16
+ | `read(id, root?)` | 一张带 journal 推导状态的卡片;卡片缺失抛错。 |
17
+ | `history(id, root?)` | 卡片完整的已解码 journal,从旧到新,流校验与读取一致(结构非法的 journal 指明文件与行号 fail-loud)。 |
18
+ | `holder(id, root?)` | 卡片当前租约持有者(`ClaimHolder`:owner 加最后心跳),未认领为 `undefined`;损坏的 claim 记录 fail-loud。 |
19
+ | `resolveCreate(request)` | 显式默认值补全:把调用方的 `CreateRequest`(标题、Markdown 正文、可选 slug、actor、可选 parent、可选 root)变成完全确定的 `CreateSpec`——slug 省略时由标题推导,root 解析定型,并盖上创建时间戳。 |
20
+ | `create(spec)` | 创建一张卡:父卡校验 → 顺序号分配(越过归档卡续排,id 永不复用)→ 独占目录创建 → journal 首条 `created`(唯一提交点)→ 投影写入 → `devflow/card-created`。领域拒绝以稳定 code(`empty-title`、`invalid-slug`、`exists`、`unknown-parent`、`nested-parent`、`parent-settled`)解析为 `ok: false`;仅基础设施故障才 reject。 |
21
+ | `resolve(request)` | 显式默认值补全:把调用方的 `TransitionRequest` 变成完全确定的 `TransitionSpec`,带已解析的 root 与提交时间戳。 |
22
+ | `transition(spec)` | 提交一次移动:revision CAS → 边合法性 → `devflow/transition` waterfall → journal 追加(唯一提交点)→ 投影重写 → `devflow/stage-changed`。领域拒绝以稳定 code(`revision-mismatch`、`illegal-edge`、`reason-required`、`vetoed`)解析为 `ok: false`;仅基础设施故障才 reject。 |
23
+ | `claim(id, owner, options?)` | 取得卡片的独占租约;租约已被持有时解析出当前持有者——除非 `options.staleAfterMs` 判定其心跳已过期,此时接管租约并以 `claim-expired` 条目入 journal。 |
24
+ | `attachArtifact(request)` | 按当前阶段在 journal 登记一个阶段产物;`blocked` 或 `done` 时拒绝,revision 检查与 `transition` 相同。 |
25
+ | `archiveDone(root?)` | 把一个根中每张可归档的 `done` 卡按其最后一条 journal 的月份移出活跃集合、归入该根的档案;拆分需求以族为单位归档(已完成的子卡等待父卡,随后并入父卡的月份桶)。归档卡从 `list` 消失但保留完整 journal。按 id 顺序返回归档的 id。 |
26
+
27
+ 当前状态永远来自 journal 回放;卡片文件的 frontmatter 是可重建的投影。实现必须在 journal 结构非法时读取即失败(指明文件与行号),在投影漂移时告警并覆盖,且只在 journal 提交之后发布状态与通知。合法边(`isLegalTransition`):流水线顺序、`reviewing`/`testing` 打回 `developing`、任意非终态进入 `blocked`、且只能恢复到被打断的那个阶段。无 `reason` 的打回边(`isReworkEdge`)以 `reason-required` 拒绝,下一个持有者永远知道要修什么。
28
+
29
+ ## 阶段与 journal
30
+
31
+ `DevStage` 是闭合联合 `draft | designing | ready | developing | reviewing | testing | done`;`blocked` 是记住被打断阶段的旁路位置(`CardLocation = DevStage | 'blocked'`)。journal 条目联合为 `created | transition | artifact | claim-expired`,由 `decodeJournalEntry`(持久化边界校验器)解码、`foldJournal` 折叠,后者强制:revision 从 1 连续、`created` 必须且只能是首条、transition 必须从当前位置出发、blocked 恢复必须回到被记住的阶段。
32
+
33
+ 一张卡装不下的大需求拆成**一张父卡加每个切片一张子卡**。这条边就是 `created` 条目的 `parent`,因此创建时即固定、可回放、永不改指;`foldJournal` 把它折出为 `DevCard.parent`,frontmatter 的 `parent:` 是其投影。拆分只有一层——带 `parent` 的卡自身永远不会成为父卡——父卡与子卡始终同根。哪些卡可以接子卡是 provider 的创建期决策(`unknown-parent`、`nested-parent`、`parent-settled`);缝本身不持有"父卡阶段与子卡阶段如何关联"的任何规则。
34
+
35
+ ## 事件
36
+
37
+ | 事件 | 模式 | 含义 |
38
+ |---|---|---|
39
+ | `devflow/transition` | `waterfall` | 提交前的单决策管线,以完整 `TransitionAttempt`(spec 加出发位置)分发;拥有决策的策略监听器不调 `next()` 直接返回 `{ allowed: false, reason }`。[`dsh-devflow-gates`](../devflow-gates/README.zh.md) 在此运行命令策略。 |
40
+ | `devflow/card-created` | `emit` | 一张新卡进入活跃集合:其 journal 提交了首条 `created`。 |
41
+ | `devflow/stage-changed` | `emit` | 一次已提交的流转后,卡片落在新位置。 |
42
+
43
+ 不变量伴生插件校验 emit 流:`card-created` 只宣告从未见过的 id、处于 draft 且 revision 为 1 的新卡,且绝不把新卡挂到流中已知为子卡的卡下;每张卡的 `stage-changed` revision 严格递增,且每次通知都报告真实移动。
44
+
45
+ ## Model Experience
46
+
47
+ Indirectly, through the model-facing tools in dsh-tool-devflow: the service interface itself registers no prompt or schema.
48
+
49
+ #### KV Cache effect
50
+
51
+ None; this package neither assembles nor sends a provider request.
52
+
53
+ ## Known Limitations and Deferred Work
54
+
55
+ - **档案只写不读** — `archiveDone` 把 done 卡移出活跃集合;没有缝操作能列出或恢复归档卡。
56
+ - **创建后无卡片编辑** — `create` 一次性固定标题与正文;改动卡片内容仍是按 Provider 磁盘格式直接编辑其 `card.md`。
package/lib/index.js ADDED
@@ -0,0 +1,325 @@
1
+ import { join } from "node:path";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ //#region packages/devflow/src/stages.ts
4
+ /** The pipeline stages in flow order; `blocked` is a bypass, not a member. */
5
+ const DEV_STAGES = [
6
+ "draft",
7
+ "designing",
8
+ "ready",
9
+ "developing",
10
+ "reviewing",
11
+ "testing",
12
+ "done"
13
+ ];
14
+ /**
15
+ * Narrow an unknown value to a pipeline stage.
16
+ * @param value - the candidate value.
17
+ * @returns `true` when `value` is one of {@link DEV_STAGES}.
18
+ */
19
+ function isDevStage(value) {
20
+ return typeof value === "string" && DEV_STAGES.includes(value);
21
+ }
22
+ /**
23
+ * Narrow an unknown value to a card location (a stage or `blocked`).
24
+ * @param value - the candidate value.
25
+ * @returns `true` when `value` is a stage or the `blocked` bypass.
26
+ */
27
+ function isCardLocation(value) {
28
+ return value === "blocked" || isDevStage(value);
29
+ }
30
+ /**
31
+ * Brand a raw string as a {@link DevflowCardId}. The id equals the card's
32
+ * directory name; construction lives here because this package owns the brand.
33
+ * @param value - the card directory name.
34
+ * @returns the branded id.
35
+ */
36
+ function DevflowCardId(value) {
37
+ return value;
38
+ }
39
+ /** Forward and rework edges of the pipeline; `blocked` legality lives in {@link isLegalTransition}. */
40
+ const FLOW = {
41
+ draft: ["designing"],
42
+ designing: ["ready"],
43
+ ready: ["developing"],
44
+ developing: ["reviewing"],
45
+ reviewing: ["testing", "developing"],
46
+ testing: ["done", "developing"],
47
+ done: []
48
+ };
49
+ /**
50
+ * Whether one stage move is a legal edge of the state machine.
51
+ *
52
+ * Main flow follows the pipeline order; `reviewing` and `testing` may rework
53
+ * to `developing`; any non-terminal location may enter `blocked`; a blocked
54
+ * card may only recover to the exact stage it interrupted.
55
+ * @param from - the card's current location.
56
+ * @param to - the requested target location.
57
+ * @param blockedFrom - the remembered origin stage while `from` is `blocked`.
58
+ * @returns `true` when the move is a legal edge.
59
+ */
60
+ function isLegalTransition(from, to, blockedFrom) {
61
+ if (from === to) return false;
62
+ if (from === "blocked") return to === blockedFrom;
63
+ if (to === "blocked") return from !== "done";
64
+ return FLOW[from].includes(to);
65
+ }
66
+ /**
67
+ * Whether a legal edge moves the card backwards (a rework). Rework edges
68
+ * require a recorded `reason` so the next holder knows what to fix.
69
+ * @param from - the departing location.
70
+ * @param to - the target location.
71
+ * @returns `true` for `reviewing -> developing` and `testing -> developing`.
72
+ */
73
+ function isReworkEdge(from, to) {
74
+ return to === "developing" && (from === "reviewing" || from === "testing");
75
+ }
76
+ //#endregion
77
+ //#region packages/devflow/src/journal.ts
78
+ /**
79
+ * Journal decoding and replay for the devflow seam. The journal is the
80
+ * authoritative card history; the card file's frontmatter is a rebuildable
81
+ * projection. Both the filesystem provider and the invariant companion fold
82
+ * entries through this module so every consumer derives identical state.
83
+ * @module @zhchxiao123/dsh-devflow/src/journal
84
+ */
85
+ /**
86
+ * Decode one parsed journal value into a {@link DevflowJournalEntry}.
87
+ *
88
+ * This is the durable-boundary validator: journal lines come from a file that
89
+ * humans and other processes may write, so every field is checked and a bad
90
+ * entry throws instead of being skipped.
91
+ * @param value - one JSON-parsed journal line.
92
+ * @returns the validated entry.
93
+ * @throws {Error} naming the first violated field.
94
+ */
95
+ function decodeJournalEntry(value) {
96
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("journal entry must be a JSON object");
97
+ const entry = value;
98
+ const rev = entry.rev;
99
+ if (typeof rev !== "number" || !Number.isInteger(rev) || rev < 1) throw new Error("journal entry field \"rev\" must be a positive integer");
100
+ if (typeof entry.at !== "string" || entry.at.length === 0) throw new Error("journal entry field \"at\" must be a non-empty string");
101
+ switch (entry.type) {
102
+ case "created": return {
103
+ rev,
104
+ at: entry.at,
105
+ type: "created",
106
+ by: decodeActor(entry.by),
107
+ ...decodeOptionalCardId(entry, "parent")
108
+ };
109
+ case "transition":
110
+ if (!isCardLocation(entry.from)) throw new Error("transition field \"from\" must be a stage or \"blocked\"");
111
+ if (!isCardLocation(entry.to)) throw new Error("transition field \"to\" must be a stage or \"blocked\"");
112
+ return {
113
+ rev,
114
+ at: entry.at,
115
+ type: "transition",
116
+ from: entry.from,
117
+ to: entry.to,
118
+ ...entry.by !== void 0 ? { by: decodeActor(entry.by) } : {},
119
+ ...decodeOptionalString(entry, "reason"),
120
+ ...entry.gate !== void 0 ? { gate: decodeGate(entry.gate) } : {}
121
+ };
122
+ case "artifact":
123
+ if (typeof entry.path !== "string" || entry.path.length === 0) throw new Error("artifact field \"path\" must be a non-empty string");
124
+ if (!isDevStage(entry.stage)) throw new Error(`artifact field "stage" must be one of ${DEV_STAGES.join(", ")}`);
125
+ return {
126
+ rev,
127
+ at: entry.at,
128
+ type: "artifact",
129
+ path: entry.path,
130
+ stage: entry.stage,
131
+ ...entry.by !== void 0 ? { by: decodeActor(entry.by) } : {}
132
+ };
133
+ case "claim-expired":
134
+ if (entry.previousOwner === void 0) throw new Error("claim-expired field \"previousOwner\" is required");
135
+ return {
136
+ rev,
137
+ at: entry.at,
138
+ type: "claim-expired",
139
+ previousOwner: decodeActor(entry.previousOwner),
140
+ by: decodeActor(entry.by)
141
+ };
142
+ default: throw new Error(`journal entry field "type" must be created, transition, artifact, or claim-expired (got ${JSON.stringify(entry.type)})`);
143
+ }
144
+ }
145
+ /**
146
+ * Replay a complete journal into the card's current state.
147
+ *
148
+ * Validates the structural invariants of the durable stream: revisions are the
149
+ * contiguous sequence 1..n, the first entry is `created`, every transition
150
+ * departs from the current location, a move to `blocked` remembers its origin,
151
+ * and the matching recovery returns exactly there.
152
+ * @param entries - decoded entries in file order.
153
+ * @returns the folded card state.
154
+ * @throws {Error} naming the first violated invariant and its entry revision.
155
+ */
156
+ function foldJournal(entries) {
157
+ if (entries.length === 0) throw new Error("journal is empty; every card starts with a \"created\" entry");
158
+ const state = {
159
+ stage: "draft",
160
+ revision: 0,
161
+ artifacts: []
162
+ };
163
+ for (const [index, entry] of entries.entries()) {
164
+ if (entry.rev !== index + 1) throw new Error(`journal entry ${index + 1} carries rev ${entry.rev}; revisions must be contiguous from 1`);
165
+ if (index === 0) {
166
+ if (entry.type !== "created") throw new Error("journal entry 1 must be \"created\"");
167
+ if (entry.parent !== void 0) state.parent = entry.parent;
168
+ state.revision = entry.rev;
169
+ continue;
170
+ }
171
+ switch (entry.type) {
172
+ case "created": throw new Error(`journal entry rev ${entry.rev} repeats "created"`);
173
+ case "transition":
174
+ if (entry.from !== state.stage) throw new Error(`transition rev ${entry.rev} departs from "${entry.from}" but the card is at "${state.stage}"`);
175
+ if (entry.to === state.stage) throw new Error(`transition rev ${entry.rev} does not move the card (already at "${entry.to}")`);
176
+ if (entry.to === "blocked") state.blockedFrom = entry.from;
177
+ else if (state.stage === "blocked") {
178
+ if (entry.to !== state.blockedFrom) throw new Error(`transition rev ${entry.rev} recovers to "${entry.to}" but the card blocked from "${state.blockedFrom}"`);
179
+ delete state.blockedFrom;
180
+ }
181
+ state.stage = entry.to;
182
+ state.revision = entry.rev;
183
+ break;
184
+ case "artifact":
185
+ state.artifacts.push(entry.path);
186
+ state.revision = entry.rev;
187
+ break;
188
+ case "claim-expired": state.revision = entry.rev;
189
+ }
190
+ }
191
+ return state;
192
+ }
193
+ function decodeGate(value) {
194
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("transition field \"gate\" must be a JSON object");
195
+ const gate = value;
196
+ if (gate.approvedBy === void 0) throw new Error("transition field \"gate\" requires \"approvedBy\"");
197
+ return { approvedBy: decodeActor(gate.approvedBy) };
198
+ }
199
+ function decodeActor(value) {
200
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("actor must be a JSON object");
201
+ const actor = value;
202
+ switch (actor.kind) {
203
+ case "human": return {
204
+ kind: "human",
205
+ ...decodeOptionalString(actor, "name")
206
+ };
207
+ case "agent": return {
208
+ kind: "agent",
209
+ ...decodeOptionalString(actor, "session")
210
+ };
211
+ case "command": return {
212
+ kind: "command",
213
+ ...decodeOptionalString(actor, "name")
214
+ };
215
+ default: throw new Error(`actor field "kind" must be human, agent, or command (got ${JSON.stringify(actor.kind)})`);
216
+ }
217
+ }
218
+ function decodeOptionalCardId(record, key) {
219
+ const value = record[key];
220
+ if (value === void 0) return {};
221
+ if (typeof value !== "string" || value.length === 0) throw new Error(`field "${key}" must be a non-empty card id when present`);
222
+ return { [key]: DevflowCardId(value) };
223
+ }
224
+ function decodeOptionalString(record, key) {
225
+ const value = record[key];
226
+ if (value === void 0) return {};
227
+ if (typeof value !== "string" || value.length === 0) throw new Error(`field "${key}" must be a non-empty string when present`);
228
+ return { [key]: value };
229
+ }
230
+ //#endregion
231
+ //#region packages/devflow/src/index.ts
232
+ /**
233
+ * Service Definition of the `ctx.devflow` capability seam: file-backed task
234
+ * cards whose stage moves through a fixed pipeline. This package owns the card
235
+ * vocabulary and the journal decode/replay used by every consumer. Storage
236
+ * mechanics belong to a provider such as `@zhchxiao123/dsh-devflow-filesystem`;
237
+ * model-facing tools belong to `@zhchxiao123/dsh-devflow-tool`.
238
+ * @module @zhchxiao123/dsh-devflow
239
+ */
240
+ /**
241
+ * Abstract task-card store registered as `ctx.devflow` (one implementation per
242
+ * context; loading a second throws, cordis' standard duplicate-service
243
+ * behavior). Subclass, implement the abstract methods, and load the subclass
244
+ * as a plugin.
245
+ *
246
+ * Implementations must honor these read-side semantics:
247
+ * - Current state comes from journal replay ({@link foldJournal}); the card
248
+ * file's frontmatter is a projection. On disagreement the journal wins and
249
+ * the drift is warned, never silently adopted.
250
+ * - A structurally invalid journal fails the read loudly, naming the file and
251
+ * line; a card is never silently skipped.
252
+ */
253
+ var DevflowStore = class extends Service {
254
+ constructor(ctx) {
255
+ super(ctx, "devflow");
256
+ }
257
+ /**
258
+ * {@link list} scoped to a viewing session's workspace, the face every
259
+ * browser channel reads through.
260
+ * @param filter - optional narrowing; omitted lists every card.
261
+ * @param sessionId - the viewing session; its workspace resolves host-side
262
+ * to the devflow root, so the wire never carries a file path. Omitted
263
+ * lists the default root.
264
+ * @returns cards ordered by id.
265
+ */
266
+ async listForSession(filter, sessionId) {
267
+ return this.list(filter, await this.sessionRoot(sessionId));
268
+ }
269
+ /**
270
+ * One card's detail scoped to a viewing session's workspace: the read value,
271
+ * its complete decoded journal, and the current lease holder in one round
272
+ * trip.
273
+ * @param id - the card id (its directory name).
274
+ * @param sessionId - the viewing session; resolved like {@link listForSession}.
275
+ * @returns the aggregated detail; `holder` is absent while the card is unclaimed.
276
+ */
277
+ async detailForSession(id, sessionId) {
278
+ const root = await this.sessionRoot(sessionId);
279
+ let card = await this.read(id, root);
280
+ let entries = await this.history(id, root);
281
+ if (entries.at(-1)?.rev !== card.stageRevision) {
282
+ card = await this.read(id, root);
283
+ entries = await this.history(id, root);
284
+ }
285
+ const holder = await this.holder(id, root);
286
+ return {
287
+ card,
288
+ entries,
289
+ ...holder === void 0 ? {} : { holder }
290
+ };
291
+ }
292
+ /**
293
+ * Resolve a viewing session into its workspace devflow root: the live or
294
+ * persisted session's header cwd maps to `<cwd>/.devflow`, and a session
295
+ * without a cwd derives no root (the implementation default applies). The
296
+ * browser sends only the session id — this host-side step is what keeps
297
+ * a root off the wire the browser can choose.
298
+ * @param sessionId - the viewing session, or `undefined` for the default root.
299
+ * @returns the derived root, or `undefined` when none derives.
300
+ * @throws {Error} for an unknown session, or when no session service is composed.
301
+ */
302
+ async sessionRoot(sessionId) {
303
+ if (sessionId === void 0) return void 0;
304
+ const id = sessionId;
305
+ const live = this.ctx.get("sessions")?.get(id);
306
+ if (live !== void 0) return rootOfCwd(live.header.cwd);
307
+ const persistence = this.ctx.get("sessionPersistence");
308
+ if (persistence === void 0) throw new Error(`devflow: cannot resolve session ${sessionId}: no session service is composed`);
309
+ let cwd;
310
+ try {
311
+ cwd = (await persistence.inspect(id)).meta.cwd;
312
+ } catch (error) {
313
+ throw new Error(`devflow: unknown session ${sessionId}`, { cause: error });
314
+ }
315
+ return rootOfCwd(cwd);
316
+ }
317
+ };
318
+ /** The workspace's devflow root for a session cwd; no cwd derives no root. */
319
+ function rootOfCwd(cwd) {
320
+ return cwd === void 0 ? void 0 : join(cwd, ".devflow");
321
+ }
322
+ //#endregion
323
+ export { DEV_STAGES, DevflowCardId, DevflowStore, DevflowStore as default, decodeJournalEntry, foldJournal, isCardLocation, isDevStage, isLegalTransition, isReworkEdge };
324
+
325
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,42 @@
1
+ //#region packages/devflow/src/invariant.ts
2
+ const PACKAGE_NAME = "@zhchxiao123/dsh-devflow";
3
+ /** Cordis companion plugin name. */
4
+ const name = "devflow-invariant";
5
+ /** Service required before the companion can reserve package ownership. */
6
+ const inject = ["invariants"];
7
+ /**
8
+ * Validate the devflow notification streams: `devflow/card-created` announces
9
+ * only fresh drafts at revision 1 for ids the stream has never seen and never
10
+ * nests a breakdown two levels deep, and per card, `devflow/stage-changed`
11
+ * revisions strictly increase while every notification reports an actual move.
12
+ */
13
+ const install = (ctx, fail) => {
14
+ const lastRevision = /* @__PURE__ */ new Map();
15
+ const children = /* @__PURE__ */ new Set();
16
+ const key = (card) => `${card.root} ${card.id}`;
17
+ ctx.on("devflow/card-created", (card) => {
18
+ if (card.stage !== "draft" || card.stageRevision !== 1) fail(`devflow/card-created for card ${card.id} reports "${card.stage}" at rev ${card.stageRevision}; a card must enter the board as "draft" at revision 1`);
19
+ if (lastRevision.has(key(card))) fail(`devflow/card-created repeats card ${card.id} of root ${card.root}; an id is never reissued`);
20
+ if (card.parent !== void 0) {
21
+ if (children.has(`${card.root} ${card.parent}`)) fail(`devflow/card-created hangs card ${card.id} under ${card.parent}, which is itself a child; the breakdown is one level deep`);
22
+ children.add(key(card));
23
+ }
24
+ lastRevision.set(key(card), card.stageRevision);
25
+ }, { global: true });
26
+ ctx.on("devflow/stage-changed", (card, from) => {
27
+ if (card.stage === from) fail(`devflow/stage-changed for card ${card.id} reports no move (still at "${from}")`);
28
+ const previous = lastRevision.get(key(card));
29
+ if (previous !== void 0 && card.stageRevision <= previous) fail(`devflow/stage-changed for card ${card.id} carries rev ${card.stageRevision} after rev ${previous}; revisions must strictly increase`);
30
+ lastRevision.set(key(card), card.stageRevision);
31
+ }, { global: true });
32
+ };
33
+ /**
34
+ * Register this package's invariant companion.
35
+ * @param ctx - Cordis context carrying the invariant service.
36
+ * @returns the installed registration's disposer after setup succeeds.
37
+ */
38
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
39
+ //#endregion
40
+ export { apply, inject, name };
41
+
42
+ //# sourceMappingURL=invariant.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Client-namespace projection of the devflow domain: a pure re-export of the
3
+ * package's types outlet. Client code imports ONLY the client namespace
4
+ * (repo discipline), so `./client` projects the same single-source content
5
+ * `./types` serves to host consumers — zero duplication.
6
+ *
7
+ * @module @zhchxiao123/dsh-devflow/client
8
+ */
9
+ export type * from './types.ts';
10
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Client-namespace projection of the devflow domain: a pure re-export of the
3
+ * package's types outlet. Client code imports ONLY the client namespace
4
+ * (repo discipline), so `./client` projects the same single-source content
5
+ * `./types` serves to host consumers — zero duplication.
6
+ *
7
+ * @module @zhchxiao123/dsh-devflow/client
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=client.js.map