@zhchxiao123/dsh-devflow 0.1.1 → 0.3.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.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/devflow/devflow/README.md
5
- README.md: 81a9147155d182c5fea6e7b2aebc3c6dd1cf1b9c
6
- README.zh.md: 8613bd2b93adf3b35f59ef39b4e61be1d1927813
5
+ README.md: 93ad295689a8ebb5071ee844f29db6770ed1e5fa
6
+ README.zh.md: ef5e88a7ecbd690af7fb2346a51de078b3a37d21
package/README.md CHANGED
@@ -19,24 +19,26 @@ Every operation carries an explicit **devflow root** dimension: reads take an op
19
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
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
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`. |
22
+ | `transition(spec)` | Commits one move: revision CAS → edge check → `devflow/transition` waterfall → cross-process commit lock and 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`, `write-contended`); 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 one concurrent caller journals `claim-expired` and replaces the lease under the commit lock. Lock contention leaves the observed holder in place. |
24
+ | `attachArtifact(request)` | Registers a stage deliverable in the journal against the current stage, in one of two mutually exclusive forms: the reference form records a `path` the caller already wrote under the card directory, and the store-written form hands over `kind` plus `content` for the implementation to write `artifacts/<rev>-<kind>.md` itself before the journal append — still the only commit point, so a lost commit registers nothing and its unreferenced file is overwritten by a same-revision retry. Registrations are immutable: the newest record of one kind is that kind's current content. Rejected while `blocked` or `done`, with `revision-mismatch`, `illegal-edge`, `invalid-kind`, or `write-contended`; the outcome carries the registered `ArtifactRecord`. |
25
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
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.
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 whichever stage owns the fault — `developing` for the implementation, `designing` for the design — `blocked` entry from any non-terminal location, and recovery only to the exact interrupted stage. `done` is terminal in both directions: a delivered card is not reopened, because its history may already have been archived and the seam has no operation that reads the archive. A rework edge (`isReworkEdge`) without a `reason` is rejected `reason-required`, so the next holder always learns what to fix.
28
28
 
29
29
  ## Stages and journal
30
30
 
31
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
32
 
33
+ An `artifact` entry may carry a `kind` naming the deliverable of a store-written registration; entries without one decode and fold exactly as before. `foldArtifactRecords` derives every registration as an `ArtifactRecord` (path, optional kind, journal revision, registering stage), surfaced as `DevCard.artifactRecords` with `DevCard.artifacts` remaining its path projection. A `transition` entry's `gate` records what permitted the move: the human approval signature (`approvedBy`) and/or the gate verdicts (`checks`) a policy listener attached to its permitting waterfall decision — both fields optional, so existing `{ approvedBy }` entries decode unchanged.
34
+
33
35
  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
36
 
35
37
  ## Events
36
38
 
37
39
  | Event | Mode | Meaning |
38
40
  |---|---|---|
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. |
41
+ | `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()`, and a permitting decision may carry `approvedBy` and `checks`, recorded as the committed entry's `gate`. [`dsh-devflow-gates`](../devflow-gates/README.md) runs command policies here. |
40
42
  | `devflow/card-created` | `emit` | A new card entered the active set: its journal committed the first `created` entry. |
41
43
  | `devflow/stage-changed` | `emit` | A card settled at a new location after a committed transition. |
42
44
 
package/README.zh.md CHANGED
@@ -19,24 +19,26 @@
19
19
  | `resolveCreate(request)` | 显式默认值补全:把调用方的 `CreateRequest`(标题、Markdown 正文、可选 slug、actor、可选 parent、可选 root)变成完全确定的 `CreateSpec`——slug 省略时由标题推导,root 解析定型,并盖上创建时间戳。 |
20
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
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` 相同。 |
22
+ | `transition(spec)` | 提交一次移动:revision CAS → 边合法性 → `devflow/transition` waterfall → 跨进程 commit lock 与 journal 追加(唯一提交点)→ 投影重写 → `devflow/stage-changed`。领域拒绝以稳定 code(`revision-mismatch`、`illegal-edge`、`reason-required`、`vetoed`、`write-contended`)解析为 `ok: false`;仅基础设施故障才 reject。 |
23
+ | `claim(id, owner, options?)` | 取得卡片的独占租约;租约已被持有时解析出当前持有者——除非 `options.staleAfterMs` 判定其心跳已过期,此时一个并发调用者在 commit lock 下写入 `claim-expired` 并替换租约。锁竞争让观察到的持有者保持原位。 |
24
+ | `attachArtifact(request)` | 按当前阶段在 journal 登记一个阶段产物,两种互斥形式二选一:引用形式登记调用方已写在卡目录下的 `path`;代写形式交出 `kind` 与 `content`,由实现在 journal 追加之前自行写入 `artifacts/<rev>-<kind>.md`——追加仍是唯一提交点,输掉提交则什么也没登记,其无引用文件会被同 revision 的重试覆盖。登记不可变:同一 kind 最新的记录就是该 kind 的当前内容。`blocked` 或 `done` 时拒绝,并可能返回 `revision-mismatch`、`illegal-edge`、`invalid-kind` `write-contended`;结果携带登记的 `ArtifactRecord`。 |
25
25
  | `archiveDone(root?)` | 把一个根中每张可归档的 `done` 卡按其最后一条 journal 的月份移出活跃集合、归入该根的档案;拆分需求以族为单位归档(已完成的子卡等待父卡,随后并入父卡的月份桶)。归档卡从 `list` 消失但保留完整 journal。按 id 顺序返回归档的 id。 |
26
26
 
27
- 当前状态永远来自 journal 回放;卡片文件的 frontmatter 是可重建的投影。实现必须在 journal 结构非法时读取即失败(指明文件与行号),在投影漂移时告警并覆盖,且只在 journal 提交之后发布状态与通知。合法边(`isLegalTransition`):流水线顺序、`reviewing`/`testing` 打回 `developing`、任意非终态进入 `blocked`、且只能恢复到被打断的那个阶段。无 `reason` 的打回边(`isReworkEdge`)以 `reason-required` 拒绝,下一个持有者永远知道要修什么。
27
+ 当前状态永远来自 journal 回放;卡片文件的 frontmatter 是可重建的投影。实现必须在 journal 结构非法时读取即失败(指明文件与行号),在投影漂移时告警并覆盖,且只在 journal 提交之后发布状态与通知。合法边(`isLegalTransition`):流水线顺序、`reviewing`/`testing` 打回实际拥有缺陷的阶段——实现问题回 `developing`,设计问题回 `designing`——任意非终态进入 `blocked`、且只能恢复到被打断的那个阶段。`done` 双向都是终态:已交付卡片可能已经归档,而缝没有读取归档的操作。无 `reason` 的打回边(`isReworkEdge`)以 `reason-required` 拒绝,下一个持有者永远知道要修什么。
28
28
 
29
29
  ## 阶段与 journal
30
30
 
31
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
32
 
33
+ `artifact` 条目可以携带 `kind`,指名一次代写登记的产物种类;不带 kind 的条目解码与折叠与从前完全一致。`foldArtifactRecords` 把每次登记折出为一条 `ArtifactRecord`(路径、可选 kind、journal revision、登记阶段),以 `DevCard.artifactRecords` 呈现,`DevCard.artifacts` 仍是其路径投影。`transition` 条目的 `gate` 记录放行这次移动的事实:策略监听器附在放行的 waterfall 决策上的人工审批签名(`approvedBy`)与/或门禁裁决(`checks`)——两个字段皆可选,既有的 `{ approvedBy }` 条目解码不变。
34
+
33
35
  一张卡装不下的大需求拆成**一张父卡加每个切片一张子卡**。这条边就是 `created` 条目的 `parent`,因此创建时即固定、可回放、永不改指;`foldJournal` 把它折出为 `DevCard.parent`,frontmatter 的 `parent:` 是其投影。拆分只有一层——带 `parent` 的卡自身永远不会成为父卡——父卡与子卡始终同根。哪些卡可以接子卡是 provider 的创建期决策(`unknown-parent`、`nested-parent`、`parent-settled`);缝本身不持有"父卡阶段与子卡阶段如何关联"的任何规则。
34
36
 
35
37
  ## 事件
36
38
 
37
39
  | 事件 | 模式 | 含义 |
38
40
  |---|---|---|
39
- | `devflow/transition` | `waterfall` | 提交前的单决策管线,以完整 `TransitionAttempt`(spec 加出发位置)分发;拥有决策的策略监听器不调 `next()` 直接返回 `{ allowed: false, reason }`。[`dsh-devflow-gates`](../devflow-gates/README.zh.md) 在此运行命令策略。 |
41
+ | `devflow/transition` | `waterfall` | 提交前的单决策管线,以完整 `TransitionAttempt`(spec 加出发位置)分发;拥有决策的策略监听器不调 `next()` 直接返回 `{ allowed: false, reason }`,放行的决策可携带 `approvedBy` 与 `checks`,记入已提交条目的 `gate`。[`dsh-devflow-gates`](../devflow-gates/README.zh.md) 在此运行命令策略。 |
40
42
  | `devflow/card-created` | `emit` | 一张新卡进入活跃集合:其 journal 提交了首条 `created`。 |
41
43
  | `devflow/stage-changed` | `emit` | 一次已提交的流转后,卡片落在新位置。 |
42
44
 
package/lib/index.js CHANGED
@@ -28,6 +28,31 @@ function isCardLocation(value) {
28
28
  return value === "blocked" || isDevStage(value);
29
29
  }
30
30
  /**
31
+ * The service classes, in ascending order of what they skip.
32
+ *
33
+ * Closed vocabulary rather than a plugin `Config` field, on the same grounds as
34
+ * {@link DEV_STAGES}: the board, both language documents, and the agent's
35
+ * prompts all reference these names, and a deployment-defined class would make
36
+ * every one of those references local. Letting a deployment mint its own
37
+ * shorter class is also precisely the failure mode this vocabulary exists to
38
+ * prevent — see the service-class Agent Note.
39
+ */
40
+ const SERVICE_CLASSES = [
41
+ "standard",
42
+ "express",
43
+ "emergency"
44
+ ];
45
+ /** The class of a card that declares none, on disk and in memory. */
46
+ const DEFAULT_SERVICE_CLASS = "standard";
47
+ /**
48
+ * Narrow an unknown value to a service class.
49
+ * @param value - the candidate value.
50
+ * @returns `true` when `value` is one of {@link SERVICE_CLASSES}.
51
+ */
52
+ function isServiceClass(value) {
53
+ return typeof value === "string" && SERVICE_CLASSES.includes(value);
54
+ }
55
+ /**
31
56
  * Brand a raw string as a {@link DevflowCardId}. The id equals the card's
32
57
  * directory name; construction lives here because this package owns the brand.
33
58
  * @param value - the card directory name.
@@ -36,41 +61,93 @@ function isCardLocation(value) {
36
61
  function DevflowCardId(value) {
37
62
  return value;
38
63
  }
39
- /** Forward and rework edges of the pipeline; `blocked` legality lives in {@link isLegalTransition}. */
64
+ /**
65
+ * Forward and rework edges of the pipeline; `blocked` legality lives in
66
+ * {@link isLegalTransition}.
67
+ *
68
+ * Review and verification send a card back to whichever stage owns the fault:
69
+ * `developing` when the implementation is wrong, `designing` when the design
70
+ * is. Without the second, design rework happens on a card labelled
71
+ * `developing`, and the board stops answering the one question it exists to
72
+ * answer.
73
+ *
74
+ * `developing` reaches `designing` for the same reason, from the stage that
75
+ * finds such faults most often. Its absence left `developing → reviewing →
76
+ * designing` as the only route back, which records a review that never
77
+ * happened in the authoritative journal to reach the stage owning the fault.
78
+ */
40
79
  const FLOW = {
41
80
  draft: ["designing"],
42
81
  designing: ["ready"],
43
82
  ready: ["developing"],
44
- developing: ["reviewing"],
45
- reviewing: ["testing", "developing"],
46
- testing: ["done", "developing"],
83
+ developing: ["reviewing", "designing"],
84
+ reviewing: [
85
+ "testing",
86
+ "developing",
87
+ "designing"
88
+ ],
89
+ testing: [
90
+ "done",
91
+ "developing",
92
+ "designing"
93
+ ],
47
94
  done: []
48
95
  };
49
96
  /**
97
+ * Edges each service class adds to {@link FLOW}, and the only place a class
98
+ * differs from another. Stated as additions rather than as one whole graph per
99
+ * class so "every class is a superset of `standard`" is a property of the code
100
+ * instead of a convention: a class cannot remove an edge, and therefore cannot
101
+ * make a journal that replays today stop replaying.
102
+ */
103
+ const CLASS_EXTRA = {
104
+ standard: {},
105
+ express: {
106
+ draft: ["developing"],
107
+ reviewing: ["done"]
108
+ },
109
+ emergency: {
110
+ draft: ["developing"],
111
+ developing: ["done"]
112
+ }
113
+ };
114
+ /**
50
115
  * Whether one stage move is a legal edge of the state machine.
51
116
  *
52
117
  * 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.
118
+ * to `developing` or `designing` and `developing` may rework to `designing`;
119
+ * any non-terminal location may enter `blocked`; a blocked card may only
120
+ * recover to the exact stage it interrupted. A card's service class adds the
121
+ * shortcuts in {@link CLASS_EXTRA} and takes nothing away.
122
+ *
123
+ * `blocked` legality does not vary by class: a shortcut is about which stages
124
+ * a card may skip, not about how it pauses.
55
125
  * @param from - the card's current location.
56
126
  * @param to - the requested target location.
57
- * @param blockedFrom - the remembered origin stage while `from` is `blocked`.
127
+ * @param card - the moving card's own context; omitted reads as a `standard`
128
+ * card that is not blocked.
58
129
  * @returns `true` when the move is a legal edge.
59
130
  */
60
- function isLegalTransition(from, to, blockedFrom) {
131
+ function isLegalTransition(from, to, card) {
61
132
  if (from === to) return false;
62
- if (from === "blocked") return to === blockedFrom;
133
+ if (from === "blocked") return to === card?.blockedFrom;
63
134
  if (to === "blocked") return from !== "done";
64
- return FLOW[from].includes(to);
135
+ if (FLOW[from].includes(to)) return true;
136
+ return (CLASS_EXTRA[card?.serviceClass ?? "standard"][from] ?? []).includes(to);
65
137
  }
66
138
  /**
67
139
  * 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.
140
+ * require a recorded `reason` so the next holder knows what to fix — on
141
+ * `developing -> designing` that reason is what implementing the design
142
+ * revealed about it, which is the whole point of routing the card back rather
143
+ * than redesigning in place.
69
144
  * @param from - the departing location.
70
145
  * @param to - the target location.
71
- * @returns `true` for `reviewing -> developing` and `testing -> developing`.
146
+ * @returns `true` for a move from `reviewing` or `testing` back to
147
+ * `developing` or `designing`, and for `developing` back to `designing`.
72
148
  */
73
149
  function isReworkEdge(from, to) {
150
+ if (to === "designing") return from === "developing" || from === "reviewing" || from === "testing";
74
151
  return to === "developing" && (from === "reviewing" || from === "testing");
75
152
  }
76
153
  //#endregion
@@ -104,7 +181,8 @@ function decodeJournalEntry(value) {
104
181
  at: entry.at,
105
182
  type: "created",
106
183
  by: decodeActor(entry.by),
107
- ...decodeOptionalCardId(entry, "parent")
184
+ ...decodeOptionalCardId(entry, "parent"),
185
+ ...decodeOptionalServiceClass(entry)
108
186
  };
109
187
  case "transition":
110
188
  if (!isCardLocation(entry.from)) throw new Error("transition field \"from\" must be a stage or \"blocked\"");
@@ -128,7 +206,17 @@ function decodeJournalEntry(value) {
128
206
  type: "artifact",
129
207
  path: entry.path,
130
208
  stage: entry.stage,
131
- ...entry.by !== void 0 ? { by: decodeActor(entry.by) } : {}
209
+ ...entry.by !== void 0 ? { by: decodeActor(entry.by) } : {},
210
+ ...decodeOptionalString(entry, "kind")
211
+ };
212
+ case "abandoned":
213
+ if (typeof entry.reason !== "string" || entry.reason.trim().length === 0) throw new Error("abandoned field \"reason\" must be a non-empty string; a card leaving the board without one loses the decision");
214
+ return {
215
+ rev,
216
+ at: entry.at,
217
+ type: "abandoned",
218
+ by: decodeActor(entry.by),
219
+ reason: entry.reason
132
220
  };
133
221
  case "claim-expired":
134
222
  if (entry.previousOwner === void 0) throw new Error("claim-expired field \"previousOwner\" is required");
@@ -139,7 +227,7 @@ function decodeJournalEntry(value) {
139
227
  previousOwner: decodeActor(entry.previousOwner),
140
228
  by: decodeActor(entry.by)
141
229
  };
142
- default: throw new Error(`journal entry field "type" must be created, transition, artifact, or claim-expired (got ${JSON.stringify(entry.type)})`);
230
+ default: throw new Error(`journal entry field "type" must be created, transition, artifact, abandoned, or claim-expired (got ${JSON.stringify(entry.type)})`);
143
231
  }
144
232
  }
145
233
  /**
@@ -148,7 +236,8 @@ function decodeJournalEntry(value) {
148
236
  * Validates the structural invariants of the durable stream: revisions are the
149
237
  * contiguous sequence 1..n, the first entry is `created`, every transition
150
238
  * departs from the current location, a move to `blocked` remembers its origin,
151
- * and the matching recovery returns exactly there.
239
+ * the matching recovery returns exactly there, and nothing follows an
240
+ * `abandoned` entry.
152
241
  * @param entries - decoded entries in file order.
153
242
  * @returns the folded card state.
154
243
  * @throws {Error} naming the first violated invariant and its entry revision.
@@ -158,6 +247,7 @@ function foldJournal(entries) {
158
247
  const state = {
159
248
  stage: "draft",
160
249
  revision: 0,
250
+ serviceClass: DEFAULT_SERVICE_CLASS,
161
251
  artifacts: []
162
252
  };
163
253
  for (const [index, entry] of entries.entries()) {
@@ -165,9 +255,11 @@ function foldJournal(entries) {
165
255
  if (index === 0) {
166
256
  if (entry.type !== "created") throw new Error("journal entry 1 must be \"created\"");
167
257
  if (entry.parent !== void 0) state.parent = entry.parent;
258
+ if (entry.serviceClass !== void 0) state.serviceClass = entry.serviceClass;
168
259
  state.revision = entry.rev;
169
260
  continue;
170
261
  }
262
+ if (state.abandoned === true) throw new Error(`journal entry rev ${entry.rev} follows an abandoned card; abandoning is terminal`);
171
263
  switch (entry.type) {
172
264
  case "created": throw new Error(`journal entry rev ${entry.rev} repeats "created"`);
173
265
  case "transition":
@@ -185,16 +277,57 @@ function foldJournal(entries) {
185
277
  state.artifacts.push(entry.path);
186
278
  state.revision = entry.rev;
187
279
  break;
280
+ case "abandoned":
281
+ state.abandoned = true;
282
+ state.revision = entry.rev;
283
+ break;
188
284
  case "claim-expired": state.revision = entry.rev;
189
285
  }
190
286
  }
191
287
  return state;
192
288
  }
289
+ /**
290
+ * Derive the artifact registrations of a decoded journal, in registration
291
+ * order. Kept beside {@link foldJournal} — whose `artifacts` is this list's
292
+ * path projection — so every consumer derives identical records; an entry
293
+ * without a `kind` yields a record without one.
294
+ * @param entries - decoded entries in file order.
295
+ * @returns the artifact records, oldest first.
296
+ */
297
+ function foldArtifactRecords(entries) {
298
+ const records = [];
299
+ for (const entry of entries) {
300
+ if (entry.type !== "artifact") continue;
301
+ records.push({
302
+ path: entry.path,
303
+ ...entry.kind !== void 0 ? { kind: entry.kind } : {},
304
+ rev: entry.rev,
305
+ stage: entry.stage
306
+ });
307
+ }
308
+ return records;
309
+ }
193
310
  function decodeGate(value) {
194
311
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("transition field \"gate\" must be a JSON object");
195
312
  const gate = value;
196
- if (gate.approvedBy === void 0) throw new Error("transition field \"gate\" requires \"approvedBy\"");
197
- return { approvedBy: decodeActor(gate.approvedBy) };
313
+ if (gate.approvedBy === void 0 && gate.checks === void 0) throw new Error("transition field \"gate\" requires \"approvedBy\" or \"checks\"");
314
+ return {
315
+ ...gate.approvedBy !== void 0 ? { approvedBy: decodeActor(gate.approvedBy) } : {},
316
+ ...gate.checks !== void 0 ? { checks: decodeGateChecks(gate.checks) } : {}
317
+ };
318
+ }
319
+ function decodeGateChecks(value) {
320
+ if (!Array.isArray(value)) throw new Error("transition field \"gate.checks\" must be an array");
321
+ return value.map((check) => {
322
+ if (typeof check !== "object" || check === null || Array.isArray(check)) throw new Error("gate check must be a JSON object");
323
+ const record = check;
324
+ if (record.verdict !== "allowed") throw new Error(`gate check field "verdict" must be "allowed" (got ${JSON.stringify(record.verdict)})`);
325
+ return {
326
+ by: decodeActor(record.by),
327
+ verdict: "allowed",
328
+ ...decodeOptionalString(record, "summary")
329
+ };
330
+ });
198
331
  }
199
332
  function decodeActor(value) {
200
333
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("actor must be a JSON object");
@@ -215,6 +348,12 @@ function decodeActor(value) {
215
348
  default: throw new Error(`actor field "kind" must be human, agent, or command (got ${JSON.stringify(actor.kind)})`);
216
349
  }
217
350
  }
351
+ function decodeOptionalServiceClass(record) {
352
+ const value = record.serviceClass;
353
+ if (value === void 0) return {};
354
+ if (!isServiceClass(value)) throw new Error(`created field "serviceClass" must be one of ${SERVICE_CLASSES.join(", ")} when present`);
355
+ return { serviceClass: value };
356
+ }
218
357
  function decodeOptionalCardId(record, key) {
219
358
  const value = record[key];
220
359
  if (value === void 0) return {};
@@ -237,6 +376,88 @@ function decodeOptionalString(record, key) {
237
376
  * model-facing tools belong to `@zhchxiao123/dsh-devflow-tool`.
238
377
  * @module @zhchxiao123/dsh-devflow
239
378
  */
379
+ /** JSON Schema for the Definition-owned artifact registration record. */
380
+ const ARTIFACT_RECORD_SCHEMA = {
381
+ type: "object",
382
+ additionalProperties: false,
383
+ properties: {
384
+ path: {
385
+ type: "string",
386
+ required: true
387
+ },
388
+ kind: { type: "string" },
389
+ rev: {
390
+ type: "integer",
391
+ required: true
392
+ },
393
+ stage: {
394
+ type: "string",
395
+ required: true,
396
+ enum: [...DEV_STAGES]
397
+ }
398
+ }
399
+ };
400
+ /** JSON Schema for one public artifact transition inspection. */
401
+ const ARTIFACT_TRANSITION_INSPECTION_SCHEMA = {
402
+ type: "object",
403
+ additionalProperties: false,
404
+ properties: {
405
+ from: {
406
+ type: "string",
407
+ required: true,
408
+ enum: [...DEV_STAGES, "blocked"]
409
+ },
410
+ to: {
411
+ type: "string",
412
+ required: true,
413
+ enum: [...DEV_STAGES, "blocked"]
414
+ },
415
+ requirements: {
416
+ type: "array",
417
+ required: true,
418
+ items: {
419
+ type: "object",
420
+ additionalProperties: false,
421
+ properties: {
422
+ kind: {
423
+ type: "string",
424
+ required: true
425
+ },
426
+ status: {
427
+ type: "string",
428
+ required: true,
429
+ enum: [
430
+ "missing",
431
+ "malformed",
432
+ "satisfied"
433
+ ]
434
+ },
435
+ spec: {
436
+ type: "object",
437
+ required: true,
438
+ additionalProperties: false,
439
+ properties: {
440
+ frontmatter: {
441
+ type: "array",
442
+ items: { type: "string" }
443
+ },
444
+ sections: {
445
+ type: "array",
446
+ items: { type: "string" }
447
+ }
448
+ }
449
+ },
450
+ artifact: ARTIFACT_RECORD_SCHEMA,
451
+ defects: {
452
+ type: "array",
453
+ required: true,
454
+ items: { type: "string" }
455
+ }
456
+ }
457
+ }
458
+ }
459
+ }
460
+ };
240
461
  /**
241
462
  * Abstract task-card store registered as `ctx.devflow` (one implementation per
242
463
  * context; loading a second throws, cordis' standard duplicate-service
@@ -320,6 +541,6 @@ function rootOfCwd(cwd) {
320
541
  return cwd === void 0 ? void 0 : join(cwd, ".devflow");
321
542
  }
322
543
  //#endregion
323
- export { DEV_STAGES, DevflowCardId, DevflowStore, DevflowStore as default, decodeJournalEntry, foldJournal, isCardLocation, isDevStage, isLegalTransition, isReworkEdge };
544
+ export { ARTIFACT_RECORD_SCHEMA, ARTIFACT_TRANSITION_INSPECTION_SCHEMA, DEFAULT_SERVICE_CLASS, DEV_STAGES, DevflowCardId, DevflowStore, DevflowStore as default, SERVICE_CLASSES, decodeJournalEntry, foldArtifactRecords, foldJournal, isCardLocation, isDevStage, isLegalTransition, isReworkEdge, isServiceClass };
324
545
 
325
546
  //# sourceMappingURL=index.js.map
@@ -7,11 +7,119 @@
7
7
  * @module @zhchxiao123/dsh-devflow
8
8
  */
9
9
  import { Context, Service } from '@deepseek-ai/cordis';
10
- import type { ArtifactRequest, ArtifactResult, CardFilter, ClaimHolder, ClaimOptions, ClaimResult, CreateRequest, CreateResult, CreateSpec, DevActor, DevCard, DevCardDetail, DevflowCardId, DevflowJournalEntry, TransitionRequest, TransitionResult, TransitionSpec } from './types.ts';
10
+ import type { AbandonRequest, AbandonResult, ArtifactRequest, ArtifactResult, CardFilter, ClaimHolder, ClaimOptions, ClaimResult, CreateRequest, CreateResult, CreateSpec, DevActor, DevCard, DevCardDetail, DevflowCardId, DevflowJournalEntry, TransitionRequest, TransitionResult, TransitionSpec } from './types.ts';
11
11
  export type * from './types.ts';
12
- export { DEV_STAGES, DevflowCardId, isCardLocation, isDevStage, isLegalTransition, isReworkEdge } from './stages.ts';
13
- export { decodeJournalEntry, foldJournal } from './journal.ts';
12
+ export { DEFAULT_SERVICE_CLASS, DEV_STAGES, DevflowCardId, SERVICE_CLASSES, isCardLocation, isDevStage, isLegalTransition, isReworkEdge, isServiceClass } from './stages.ts';
13
+ export type { TransitionContext } from './stages.ts';
14
+ export { decodeJournalEntry, foldArtifactRecords, foldJournal } from './journal.ts';
14
15
  export type { JournalFoldState } from './journal.ts';
16
+ /** JSON Schema for the Definition-owned artifact registration record. */
17
+ export declare const ARTIFACT_RECORD_SCHEMA: {
18
+ readonly type: "object";
19
+ readonly additionalProperties: false;
20
+ readonly properties: {
21
+ readonly path: {
22
+ readonly type: "string";
23
+ readonly required: true;
24
+ };
25
+ readonly kind: {
26
+ readonly type: "string";
27
+ };
28
+ readonly rev: {
29
+ readonly type: "integer";
30
+ readonly required: true;
31
+ };
32
+ readonly stage: {
33
+ readonly type: "string";
34
+ readonly required: true;
35
+ readonly enum: readonly ["draft", "designing", "ready", "developing", "reviewing", "testing", "done"];
36
+ };
37
+ };
38
+ };
39
+ /** JSON Schema for one public artifact transition inspection. */
40
+ export declare const ARTIFACT_TRANSITION_INSPECTION_SCHEMA: {
41
+ readonly type: "object";
42
+ readonly additionalProperties: false;
43
+ readonly properties: {
44
+ readonly from: {
45
+ readonly type: "string";
46
+ readonly required: true;
47
+ readonly enum: readonly ["draft", "designing", "ready", "developing", "reviewing", "testing", "done", "blocked"];
48
+ };
49
+ readonly to: {
50
+ readonly type: "string";
51
+ readonly required: true;
52
+ readonly enum: readonly ["draft", "designing", "ready", "developing", "reviewing", "testing", "done", "blocked"];
53
+ };
54
+ readonly requirements: {
55
+ readonly type: "array";
56
+ readonly required: true;
57
+ readonly items: {
58
+ readonly type: "object";
59
+ readonly additionalProperties: false;
60
+ readonly properties: {
61
+ readonly kind: {
62
+ readonly type: "string";
63
+ readonly required: true;
64
+ };
65
+ readonly status: {
66
+ readonly type: "string";
67
+ readonly required: true;
68
+ readonly enum: readonly ["missing", "malformed", "satisfied"];
69
+ };
70
+ readonly spec: {
71
+ readonly type: "object";
72
+ readonly required: true;
73
+ readonly additionalProperties: false;
74
+ readonly properties: {
75
+ readonly frontmatter: {
76
+ readonly type: "array";
77
+ readonly items: {
78
+ readonly type: "string";
79
+ };
80
+ };
81
+ readonly sections: {
82
+ readonly type: "array";
83
+ readonly items: {
84
+ readonly type: "string";
85
+ };
86
+ };
87
+ };
88
+ };
89
+ readonly artifact: {
90
+ readonly type: "object";
91
+ readonly additionalProperties: false;
92
+ readonly properties: {
93
+ readonly path: {
94
+ readonly type: "string";
95
+ readonly required: true;
96
+ };
97
+ readonly kind: {
98
+ readonly type: "string";
99
+ };
100
+ readonly rev: {
101
+ readonly type: "integer";
102
+ readonly required: true;
103
+ };
104
+ readonly stage: {
105
+ readonly type: "string";
106
+ readonly required: true;
107
+ readonly enum: readonly ["draft", "designing", "ready", "developing", "reviewing", "testing", "done"];
108
+ };
109
+ };
110
+ };
111
+ readonly defects: {
112
+ readonly type: "array";
113
+ readonly required: true;
114
+ readonly items: {
115
+ readonly type: "string";
116
+ };
117
+ };
118
+ };
119
+ };
120
+ };
121
+ };
122
+ };
15
123
  declare module '@deepseek-ai/cordis' {
16
124
  interface Context {
17
125
  devflow: DevflowStore;
@@ -91,27 +199,61 @@ export declare abstract class DevflowStore extends Service {
91
199
  * `devflow/transition` waterfall, the journal append (the only commit
92
200
  * point), the projection rewrite, then `devflow/stage-changed`. State and
93
201
  * notifications publish only after the journal committed.
202
+ *
203
+ * The waterfall's gate commands put real time between those checks and the
204
+ * append, so implementations must re-establish the checked revision at the
205
+ * append itself, under an exclusion another process observes. A card that
206
+ * moved in that window resolves `revision-mismatch`; a card whose commit
207
+ * stayed excluded resolves `write-contended` with nothing written.
94
208
  * @param spec - a resolved spec from {@link resolve}, never a raw request.
95
209
  * @returns the outcome; domain rejections resolve with `ok: false`.
96
210
  */
97
211
  abstract transition(spec: TransitionSpec): Promise<TransitionResult>;
98
212
  /**
99
- * Take the card's exclusive lease.
213
+ * Take the card's exclusive lease. A stale takeover journals the eviction
214
+ * under the same cross-process commit exclusion as transitions and artifact
215
+ * registration, so concurrent takeover attempts grant at most one holder.
100
216
  * @param id - the card to claim.
101
217
  * @param owner - the prospective holder, recorded in the lease.
102
218
  * @param options - staleness takeover policy and root; omitted never takes
103
219
  * over and uses the implementation's default root.
104
- * @returns the live handle, or the current holder when the lease is taken.
220
+ * @returns the live handle, or a holder read from the lease. On journal-commit
221
+ * contention that holder was observed before trying the lock, not freshly
222
+ * established as the current owner.
105
223
  */
106
224
  abstract claim(id: DevflowCardId, owner: DevActor, options?: ClaimOptions): Promise<ClaimResult>;
107
225
  /**
108
226
  * Register a stage deliverable in the card's journal against its current
109
- * stage. A blocked card cannot register artifacts, and the revision check
110
- * mirrors {@link transition}.
111
- * @param request - card, artifact path, expected revision, and actor.
112
- * @returns the outcome; domain rejections resolve with `ok: false`.
227
+ * stage, in one of two mutually exclusive forms: the reference form records
228
+ * a `path` the caller already wrote under the card directory, and the
229
+ * store-written form hands over `kind` plus `content` for the
230
+ * implementation to write `artifacts/<rev>-<kind>.md` itself before the
231
+ * journal append — which stays the only commit point, so a registration
232
+ * that loses the commit registers nothing and its unreferenced file is
233
+ * overwritten by a same-revision retry. Registrations are immutable: the
234
+ * newest record of one kind is that kind's current content. A blocked or
235
+ * done card cannot register artifacts, the revision check mirrors
236
+ * {@link transition}, and an ill-formed kind resolves `invalid-kind`.
237
+ * @param request - card, expected revision, actor, and the artifact reference or content.
238
+ * @returns the outcome carrying the registered record; domain rejections resolve with `ok: false`.
113
239
  */
114
240
  abstract attachArtifact(request: ArtifactRequest): Promise<ArtifactResult>;
241
+ /**
242
+ * Record that a card's work stops and take it off the active board.
243
+ *
244
+ * The journal append is the commit point; the card's directory then joins
245
+ * the archive. A crash between the two leaves an abandoned card under
246
+ * `tasks/`, so {@link list} must exclude it by its folded state rather than
247
+ * by where its directory sits.
248
+ *
249
+ * The reason is required — a card that disappears without one loses the
250
+ * decision it exists to record — and a `done` card is refused: a delivered
251
+ * outcome is settled by {@link archiveDone}, not overwritten by a decision
252
+ * not to deliver it.
253
+ * @param request - card, expected revision, actor, and the reason.
254
+ * @returns the outcome; domain rejections resolve with `ok: false`.
255
+ */
256
+ abstract abandon(request: AbandonRequest): Promise<AbandonResult>;
115
257
  /**
116
258
  * Move every `done` card of one root out of the active set into that root's
117
259
  * archive, keyed by the month of its last journal entry. Archived cards