@pmelab/gtd 20.1.0 → 20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "20.1.0",
3
+ "version": "20.3.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -72,6 +72,8 @@
72
72
  "*": "oxfmt --no-error-on-unmatched-pattern --write"
73
73
  },
74
74
  "devDependencies": {
75
+ "@commitlint/cli": "^21.2.3",
76
+ "@commitlint/config-conventional": "^21.2.3",
75
77
  "@effect/language-service": "^0.72.0",
76
78
  "@radix-ui/colors": "^3.0.0",
77
79
  "@semantic-release/exec": "^7.1.0",
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import {
3
+ FLOWS_PROTOCOL,
4
+ FlowsProtocolError,
5
+ head,
6
+ installContext,
7
+ protocolMismatch,
8
+ type FlowContext,
9
+ } from "./runtime.js"
10
+
11
+ afterEach(() => installContext(undefined))
12
+
13
+ const context = { head: () => "h1" } as unknown as FlowContext
14
+ const globals = globalThis as Record<symbol, unknown>
15
+ const CONTEXT_KEY = Symbol.for("@pmelab/gtd/flow-context")
16
+ const PROTOCOL_KEY = Symbol.for("@pmelab/gtd/flow-protocol")
17
+
18
+ describe("the flows protocol handshake", () => {
19
+ it("reads the context when the engine installed the facade's protocol", () => {
20
+ installContext(context)
21
+ expect(head()).toBe("h1")
22
+ })
23
+
24
+ it("tells a newer engine to have the workflow's package upgraded", () => {
25
+ installContext(context)
26
+ globals[PROTOCOL_KEY] = FLOWS_PROTOCOL + 1
27
+ expect(() => head()).toThrow(FlowsProtocolError)
28
+ expect(() => head()).toThrow(
29
+ `gtd: the workflow speaks flows protocol ${FLOWS_PROTOCOL}, but the engine installed protocol ${FLOWS_PROTOCOL + 1} — upgrade the @pmelab/gtd the workflow imports`,
30
+ )
31
+ expect(protocolMismatch()).toBeInstanceOf(FlowsProtocolError)
32
+ })
33
+
34
+ it("tells an older engine to be upgraded", () => {
35
+ installContext(context)
36
+ globals[PROTOCOL_KEY] = FLOWS_PROTOCOL - 1
37
+ expect(() => head()).toThrow(
38
+ `engine installed protocol ${FLOWS_PROTOCOL - 1} — upgrade the gtd that runs it`,
39
+ )
40
+ })
41
+
42
+ // Must go red once FLOWS_PROTOCOL passes 1: a pre-handshake engine is v1.
43
+ it("accepts an engine that installs no protocol number", () => {
44
+ delete globals[PROTOCOL_KEY]
45
+ globals[CONTEXT_KEY] = context
46
+ expect(head()).toBe("h1")
47
+ })
48
+
49
+ it("keeps the outside-a-replay error when nothing is installed", () => {
50
+ expect(() => head()).toThrow(
51
+ new Error("gtd: a workflow step or helper was called outside a gtd replay"),
52
+ )
53
+ })
54
+ })
@@ -159,20 +159,66 @@ export interface FlowContext {
159
159
  readonly accessFor: (localName: string, file?: string) => AccessDef
160
160
  }
161
161
 
162
- const CONTEXT_KEY = Symbol.for("@pmelab/gtd/flow-context")
162
+ /**
163
+ * Bump only when the installed `FlowContext` object changes shape or semantics;
164
+ * type-only authoring-API changes don't count. A bump past 1 must keep v1
165
+ * members working or move the context to a new symbol key — pre-handshake
166
+ * facades never read the protocol.
167
+ */
168
+ export const FLOWS_PROTOCOL = 1
169
+
170
+ export class FlowsProtocolError extends Error {
171
+ constructor(engine: number, flows: number) {
172
+ const upgrade =
173
+ engine < flows
174
+ ? "upgrade the gtd that runs it"
175
+ : "upgrade the @pmelab/gtd the workflow imports"
176
+ super(
177
+ `gtd: the workflow speaks flows protocol ${flows}, but the engine installed protocol ${engine} — ${upgrade}`,
178
+ )
179
+ this.name = "FlowsProtocolError"
180
+ }
181
+ }
163
182
 
164
- type ContextHolder = { [CONTEXT_KEY]?: FlowContext | undefined }
183
+ const CONTEXT_KEY = Symbol.for("@pmelab/gtd/flow-context")
184
+ // Separate from the context so a facade older than the protocol still reads
185
+ // the context it expects.
186
+ const PROTOCOL_KEY = Symbol.for("@pmelab/gtd/flow-protocol")
187
+ // The first mismatch, kept for the engine: workflow code may catch what ctx()
188
+ // throws, and the refusal must still win.
189
+ const MISMATCH_KEY = Symbol.for("@pmelab/gtd/flow-protocol-mismatch")
190
+
191
+ type ContextHolder = {
192
+ [CONTEXT_KEY]?: FlowContext | undefined
193
+ [PROTOCOL_KEY]?: number | undefined
194
+ [MISMATCH_KEY]?: Error | undefined
195
+ }
165
196
 
166
- /** Engine-only: install the replay context a flow run reads. */
197
+ /** Engine-only. */
167
198
  export const installContext = (context: FlowContext | undefined): void => {
168
- ;(globalThis as ContextHolder)[CONTEXT_KEY] = context
199
+ const holder = globalThis as ContextHolder
200
+ holder[CONTEXT_KEY] = context
201
+ holder[PROTOCOL_KEY] = FLOWS_PROTOCOL
202
+ holder[MISMATCH_KEY] = undefined
169
203
  }
170
204
 
205
+ /** Engine-only: the mismatch a facade copy hit during this replay, if any. */
206
+ export const protocolMismatch = (): Error | undefined => (globalThis as ContextHolder)[MISMATCH_KEY]
207
+
171
208
  const ctx = (): FlowContext => {
172
- const context = (globalThis as ContextHolder)[CONTEXT_KEY]
209
+ const holder = globalThis as ContextHolder
210
+ const context = holder[CONTEXT_KEY]
173
211
  if (context === undefined) {
174
212
  throw new Error("gtd: a workflow step or helper was called outside a gtd replay")
175
213
  }
214
+ // An engine older than the handshake installs no number but the context
215
+ // protocol 1 describes.
216
+ const protocol = holder[PROTOCOL_KEY] ?? 1
217
+ if (protocol !== FLOWS_PROTOCOL) {
218
+ const error = new FlowsProtocolError(protocol, FLOWS_PROTOCOL)
219
+ holder[MISMATCH_KEY] ??= error
220
+ throw error
221
+ }
176
222
  return context
177
223
  }
178
224