lalph 0.0.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 +21 -0
- package/README.md +28 -0
- package/dist/cli.mjs +132396 -0
- package/package.json +32 -0
- package/src/Kvs.ts +3 -0
- package/src/Linear/TokenManager.ts +183 -0
- package/src/Linear.ts +149 -0
- package/src/Prd.ts +188 -0
- package/src/PromptGen.ts +64 -0
- package/src/Runner.ts +52 -0
- package/src/Settings.ts +70 -0
- package/src/cli.ts +69 -0
- package/src/domain/CliAgent.ts +43 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lalph",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"bin": {
|
|
9
|
+
"lalph": "./dist/cli.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Tim Smart <hello@timsmart.co>",
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@changesets/changelog-github": "^0.5.2",
|
|
19
|
+
"@changesets/cli": "^2.29.8",
|
|
20
|
+
"@effect/language-service": "^0.64.1",
|
|
21
|
+
"@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@bc26c98",
|
|
22
|
+
"@linear/sdk": "^69.0.0",
|
|
23
|
+
"effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@bc26c98",
|
|
24
|
+
"prettier": "^3.7.4",
|
|
25
|
+
"tsdown": "^0.19.0",
|
|
26
|
+
"typescript": "^5.9.3"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"check": "tsc --noEmit && prettier --check .",
|
|
30
|
+
"build": "tsdown"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/Kvs.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DateTime,
|
|
3
|
+
Deferred,
|
|
4
|
+
Effect,
|
|
5
|
+
Layer,
|
|
6
|
+
Option,
|
|
7
|
+
Schedule,
|
|
8
|
+
Schema,
|
|
9
|
+
ServiceMap,
|
|
10
|
+
} from "effect"
|
|
11
|
+
import {
|
|
12
|
+
FetchHttpClient,
|
|
13
|
+
HttpClient,
|
|
14
|
+
HttpClientRequest,
|
|
15
|
+
HttpClientResponse,
|
|
16
|
+
HttpRouter,
|
|
17
|
+
HttpServerRequest,
|
|
18
|
+
HttpServerResponse,
|
|
19
|
+
} from "effect/unstable/http"
|
|
20
|
+
import { Base64Url } from "effect/encoding"
|
|
21
|
+
import { NodeHttpServer } from "@effect/platform-node"
|
|
22
|
+
import { createServer } from "node:http"
|
|
23
|
+
import { KeyValueStore } from "effect/unstable/persistence"
|
|
24
|
+
import { layerKvs } from "../Kvs.ts"
|
|
25
|
+
|
|
26
|
+
const clientId = "852ed0906088135c1f591d234a4eaa4b"
|
|
27
|
+
|
|
28
|
+
export class TokenManager extends ServiceMap.Service<TokenManager>()(
|
|
29
|
+
"lalph/Linear/TokenManager",
|
|
30
|
+
{
|
|
31
|
+
make: Effect.gen(function* () {
|
|
32
|
+
const kvs = yield* KeyValueStore.KeyValueStore
|
|
33
|
+
const tokenStore = KeyValueStore.toSchemaStore(kvs, AccessToken)
|
|
34
|
+
|
|
35
|
+
const httpClient = (yield* HttpClient.HttpClient).pipe(
|
|
36
|
+
HttpClient.filterStatusOk,
|
|
37
|
+
HttpClient.retryTransient({
|
|
38
|
+
schedule: Schedule.spaced(1000),
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
let currentToken = yield* Effect.orDie(tokenStore.get("accessToken"))
|
|
43
|
+
const set = (token: AccessToken) =>
|
|
44
|
+
Effect.orDie(tokenStore.set("accessToken", token))
|
|
45
|
+
const clear = Effect.orDie(tokenStore.remove("accessToken"))
|
|
46
|
+
|
|
47
|
+
const getNoLock: Effect.Effect<AccessToken> = Effect.gen(function* () {
|
|
48
|
+
if (Option.isNone(currentToken)) {
|
|
49
|
+
const newToken = yield* pkce
|
|
50
|
+
yield* set(newToken)
|
|
51
|
+
return newToken
|
|
52
|
+
} else if (currentToken.value.isExpired()) {
|
|
53
|
+
const newToken = yield* refresh(currentToken.value)
|
|
54
|
+
if (Option.isNone(newToken)) {
|
|
55
|
+
yield* clear
|
|
56
|
+
return yield* getNoLock
|
|
57
|
+
}
|
|
58
|
+
yield* set(newToken.value)
|
|
59
|
+
currentToken = newToken
|
|
60
|
+
return newToken.value
|
|
61
|
+
}
|
|
62
|
+
return currentToken.value
|
|
63
|
+
})
|
|
64
|
+
const get = Effect.makeSemaphoreUnsafe(1).withPermit(getNoLock)
|
|
65
|
+
|
|
66
|
+
const pkce = Effect.gen(function* () {
|
|
67
|
+
const deferred = yield* Deferred.make<typeof CallbackParams.Type>()
|
|
68
|
+
|
|
69
|
+
const CallbackRoute = HttpRouter.add(
|
|
70
|
+
"GET",
|
|
71
|
+
"/callback",
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const params = yield* callbackParams
|
|
74
|
+
yield* Deferred.succeed(deferred, params)
|
|
75
|
+
return yield* HttpServerResponse.html`
|
|
76
|
+
<html>
|
|
77
|
+
<body style="font-family: sans-serif; text-align: center; margin-top: 50px;">
|
|
78
|
+
<h1>Lalph login Successful</h1>
|
|
79
|
+
<p>You can close this window now.</p>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
`
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
yield* HttpRouter.serve(CallbackRoute, {
|
|
86
|
+
disableListenLog: true,
|
|
87
|
+
disableLogger: true,
|
|
88
|
+
}).pipe(
|
|
89
|
+
Layer.provide(NodeHttpServer.layer(createServer, { port: 34338 })),
|
|
90
|
+
Layer.build,
|
|
91
|
+
)
|
|
92
|
+
const redirectUri = `http://localhost:34338/callback`
|
|
93
|
+
|
|
94
|
+
// client
|
|
95
|
+
const verifier = crypto.randomUUID()
|
|
96
|
+
const verifierSha256 = yield* Effect.promise(() =>
|
|
97
|
+
crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)),
|
|
98
|
+
)
|
|
99
|
+
const challenge = Base64Url.encode(new Uint8Array(verifierSha256))
|
|
100
|
+
|
|
101
|
+
const url = `https://linear.app/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=read,write&code_challenge=${challenge}&code_challenge_method=S256`
|
|
102
|
+
|
|
103
|
+
console.log("Open this URL to login to Linear:", url)
|
|
104
|
+
|
|
105
|
+
const params = yield* Deferred.await(deferred)
|
|
106
|
+
|
|
107
|
+
const res = yield* HttpClientRequest.post(
|
|
108
|
+
"https://api.linear.app/oauth/token",
|
|
109
|
+
).pipe(
|
|
110
|
+
HttpClientRequest.bodyUrlParams({
|
|
111
|
+
code: params.code,
|
|
112
|
+
redirect_uri: redirectUri,
|
|
113
|
+
client_id: clientId,
|
|
114
|
+
code_verifier: verifier,
|
|
115
|
+
grant_type: "authorization_code",
|
|
116
|
+
}),
|
|
117
|
+
httpClient.execute,
|
|
118
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(TokenResponse)),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return AccessToken.fromResponse(res)
|
|
122
|
+
}).pipe(Effect.scoped, Effect.orDie)
|
|
123
|
+
|
|
124
|
+
const refresh = Effect.fnUntraced(function* (token: AccessToken) {
|
|
125
|
+
const res = yield* HttpClientRequest.post(
|
|
126
|
+
"https://api.linear.app/oauth/token",
|
|
127
|
+
).pipe(
|
|
128
|
+
HttpClientRequest.bodyUrlParams({
|
|
129
|
+
refresh_token: token.refreshToken,
|
|
130
|
+
client_id: clientId,
|
|
131
|
+
grant_type: "refresh_token",
|
|
132
|
+
}),
|
|
133
|
+
httpClient.execute,
|
|
134
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(TokenResponse)),
|
|
135
|
+
)
|
|
136
|
+
return AccessToken.fromResponse(res)
|
|
137
|
+
}, Effect.option)
|
|
138
|
+
|
|
139
|
+
return { get } as const
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
) {
|
|
143
|
+
static layer = Layer.effect(this, this.make).pipe(
|
|
144
|
+
Layer.provide([layerKvs, FetchHttpClient.layer]),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class AccessToken extends Schema.Class<AccessToken>(
|
|
149
|
+
"lalph/Linear/AccessToken",
|
|
150
|
+
)({
|
|
151
|
+
token: Schema.String,
|
|
152
|
+
expiresAt: Schema.DateTimeUtc,
|
|
153
|
+
refreshToken: Schema.String,
|
|
154
|
+
}) {
|
|
155
|
+
static fromResponse(res: typeof TokenResponse.Type): AccessToken {
|
|
156
|
+
return new AccessToken({
|
|
157
|
+
token: res.access_token,
|
|
158
|
+
refreshToken: res.refresh_token,
|
|
159
|
+
expiresAt: DateTime.nowUnsafe().pipe(
|
|
160
|
+
DateTime.add({ seconds: res.expires_in }),
|
|
161
|
+
),
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
isExpired(): boolean {
|
|
166
|
+
return DateTime.isPastUnsafe(
|
|
167
|
+
this.expiresAt.pipe(DateTime.subtract({ minutes: 5 })),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const CallbackParams = Schema.Struct({
|
|
173
|
+
code: Schema.String,
|
|
174
|
+
})
|
|
175
|
+
const callbackParams = HttpServerRequest.schemaSearchParams(CallbackParams)
|
|
176
|
+
|
|
177
|
+
const TokenResponse = Schema.Struct({
|
|
178
|
+
access_token: Schema.String,
|
|
179
|
+
token_type: Schema.String,
|
|
180
|
+
expires_in: Schema.Number,
|
|
181
|
+
refresh_token: Schema.String,
|
|
182
|
+
scope: Schema.String,
|
|
183
|
+
})
|
package/src/Linear.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Effect, Stream, Layer, Schema, ServiceMap, Option } from "effect"
|
|
2
|
+
import {
|
|
3
|
+
Connection,
|
|
4
|
+
IssueLabel,
|
|
5
|
+
LinearClient,
|
|
6
|
+
Project,
|
|
7
|
+
WorkflowState,
|
|
8
|
+
} from "@linear/sdk"
|
|
9
|
+
import { TokenManager } from "./Linear/TokenManager.ts"
|
|
10
|
+
import { KeyValueStore } from "effect/unstable/persistence"
|
|
11
|
+
import { Prompt } from "effect/unstable/cli"
|
|
12
|
+
import { layerKvs } from "./Kvs.ts"
|
|
13
|
+
import { selectedLabelId, selectedTeamId, Settings } from "./Settings.ts"
|
|
14
|
+
|
|
15
|
+
export class Linear extends ServiceMap.Service<Linear>()("lalph/Linear", {
|
|
16
|
+
make: Effect.gen(function* () {
|
|
17
|
+
const tokens = yield* TokenManager
|
|
18
|
+
|
|
19
|
+
const client = new LinearClient({
|
|
20
|
+
accessToken: (yield* tokens.get).token,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const use = <A>(f: (client: LinearClient) => Promise<A>) =>
|
|
24
|
+
Effect.tryPromise({
|
|
25
|
+
try: () => f(client),
|
|
26
|
+
catch: (cause) => new LinearError({ cause }),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const stream = <A>(f: (client: LinearClient) => Promise<Connection<A>>) =>
|
|
30
|
+
Stream.paginate(
|
|
31
|
+
null as null | Connection<A>,
|
|
32
|
+
Effect.fnUntraced(function* (prev) {
|
|
33
|
+
const connection = yield* prev
|
|
34
|
+
? Effect.tryPromise({
|
|
35
|
+
try: () => prev.fetchNext(),
|
|
36
|
+
catch: (cause) => new LinearError({ cause }),
|
|
37
|
+
})
|
|
38
|
+
: use(f)
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
connection.nodes,
|
|
42
|
+
Option.some(connection).pipe(
|
|
43
|
+
Option.filter((c) => c.pageInfo.hasNextPage),
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const projects = stream((client) => client.projects())
|
|
50
|
+
const labels = stream((client) => client.issueLabels())
|
|
51
|
+
const states = yield* Stream.runFold(
|
|
52
|
+
stream((client) => client.workflowStates()),
|
|
53
|
+
() => new Map<string, WorkflowState>(),
|
|
54
|
+
(map, state) => map.set(state.id, state),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return { use, stream, projects, labels, states } as const
|
|
58
|
+
}),
|
|
59
|
+
}) {
|
|
60
|
+
static layer = Layer.effect(this, this.make).pipe(
|
|
61
|
+
Layer.provide(TokenManager.layer),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class LinearError extends Schema.ErrorClass("lalph/LinearError")({
|
|
66
|
+
_tag: Schema.tag("LinearError"),
|
|
67
|
+
cause: Schema.Defect,
|
|
68
|
+
}) {}
|
|
69
|
+
|
|
70
|
+
export class CurrentProject extends ServiceMap.Service<
|
|
71
|
+
CurrentProject,
|
|
72
|
+
Project
|
|
73
|
+
>()("lalph/Linear/CurrentProject") {
|
|
74
|
+
static store = KeyValueStore.KeyValueStore.use((_) =>
|
|
75
|
+
Effect.succeed(KeyValueStore.prefix(_, "linear.currentProjectId")),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
static select = Effect.gen(function* () {
|
|
79
|
+
const kvs = yield* CurrentProject.store
|
|
80
|
+
const linear = yield* Linear
|
|
81
|
+
|
|
82
|
+
const projects = yield* Stream.runCollect(linear.projects)
|
|
83
|
+
|
|
84
|
+
const project = yield* Prompt.select({
|
|
85
|
+
message: "Select a Linear project",
|
|
86
|
+
choices: projects.map((project) => ({
|
|
87
|
+
title: project.name,
|
|
88
|
+
value: project,
|
|
89
|
+
})),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
yield* kvs.set("", project.id)
|
|
93
|
+
|
|
94
|
+
yield* teamSelect(project)
|
|
95
|
+
yield* labelSelect
|
|
96
|
+
|
|
97
|
+
return project
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
static get = Effect.gen(function* () {
|
|
101
|
+
const linear = yield* Linear
|
|
102
|
+
const kvs = yield* CurrentProject.store
|
|
103
|
+
const projectId = yield* kvs.get("")
|
|
104
|
+
|
|
105
|
+
return projectId
|
|
106
|
+
? yield* linear
|
|
107
|
+
.use((c) => c.project(projectId))
|
|
108
|
+
.pipe(Effect.catch(() => CurrentProject.select))
|
|
109
|
+
: yield* CurrentProject.select
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
static layer = Layer.effect(this, this.get).pipe(
|
|
113
|
+
Layer.provide([Linear.layer, layerKvs, Settings.layer]),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const labelSelect = Effect.gen(function* () {
|
|
118
|
+
const linear = yield* Linear
|
|
119
|
+
const labels = yield* Stream.runCollect(linear.labels)
|
|
120
|
+
const label = yield* Prompt.select({
|
|
121
|
+
message: "Select a label to filter issues by",
|
|
122
|
+
choices: [
|
|
123
|
+
{
|
|
124
|
+
title: "No Label",
|
|
125
|
+
value: Option.none<IssueLabel>(),
|
|
126
|
+
},
|
|
127
|
+
].concat(
|
|
128
|
+
labels.map((label) => ({
|
|
129
|
+
title: label.name,
|
|
130
|
+
value: Option.some(label),
|
|
131
|
+
})),
|
|
132
|
+
),
|
|
133
|
+
})
|
|
134
|
+
yield* selectedLabelId.set(Option.map(label, (l) => l.id))
|
|
135
|
+
return label
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const teamSelect = Effect.fnUntraced(function* (project: Project) {
|
|
139
|
+
const linear = yield* Linear
|
|
140
|
+
const teams = yield* Stream.runCollect(linear.stream(() => project.teams()))
|
|
141
|
+
const teamId = yield* Prompt.select({
|
|
142
|
+
message: "Select a team for new issues",
|
|
143
|
+
choices: teams.map((team) => ({
|
|
144
|
+
title: team.name,
|
|
145
|
+
value: team.id,
|
|
146
|
+
})),
|
|
147
|
+
})
|
|
148
|
+
yield* selectedTeamId.set(Option.some(teamId))
|
|
149
|
+
})
|
package/src/Prd.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Data,
|
|
3
|
+
Effect,
|
|
4
|
+
FileSystem,
|
|
5
|
+
Layer,
|
|
6
|
+
Option,
|
|
7
|
+
Schema,
|
|
8
|
+
ServiceMap,
|
|
9
|
+
Stream,
|
|
10
|
+
SubscriptionRef,
|
|
11
|
+
} from "effect"
|
|
12
|
+
import { CurrentProject, Linear } from "./Linear.ts"
|
|
13
|
+
import { selectedLabelId, selectedTeamId, Settings } from "./Settings.ts"
|
|
14
|
+
import type { Issue } from "@linear/sdk"
|
|
15
|
+
|
|
16
|
+
export class Prd extends ServiceMap.Service<Prd>()("lalph/Prd", {
|
|
17
|
+
make: Effect.gen(function* () {
|
|
18
|
+
const fs = yield* FileSystem.FileSystem
|
|
19
|
+
const linear = yield* Linear
|
|
20
|
+
const project = yield* CurrentProject
|
|
21
|
+
const teamId = Option.getOrThrow(yield* selectedTeamId.get)
|
|
22
|
+
const labelId = yield* selectedLabelId.get
|
|
23
|
+
|
|
24
|
+
const getIssues = linear
|
|
25
|
+
.stream(() =>
|
|
26
|
+
project.issues({
|
|
27
|
+
filter: {
|
|
28
|
+
labels: {
|
|
29
|
+
id: labelId.pipe(
|
|
30
|
+
Option.map((eq) => ({ eq })),
|
|
31
|
+
Option.getOrNull,
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
state: {
|
|
35
|
+
type: { eq: "unstarted" },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
.pipe(Stream.runCollect)
|
|
41
|
+
|
|
42
|
+
const initial = yield* getIssues.pipe(Effect.map(PrdList.fromLinearIssues))
|
|
43
|
+
if (initial.issues.size === 0) {
|
|
44
|
+
return yield* new NoMoreWork({})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const current = yield* SubscriptionRef.make(initial)
|
|
48
|
+
|
|
49
|
+
const prdFile = `.lalph/prd.json`
|
|
50
|
+
|
|
51
|
+
yield* fs.writeFileString(prdFile, initial.toJson())
|
|
52
|
+
|
|
53
|
+
const sync = Effect.gen(function* () {
|
|
54
|
+
const json = yield* fs.readFileString(prdFile)
|
|
55
|
+
const currentValue = yield* SubscriptionRef.get(current)
|
|
56
|
+
const updated = PrdList.fromJson(json)
|
|
57
|
+
|
|
58
|
+
for (const issue of updated) {
|
|
59
|
+
if (issue.id === null) {
|
|
60
|
+
// create new issue
|
|
61
|
+
yield* linear.use((c) =>
|
|
62
|
+
c.createIssue({
|
|
63
|
+
teamId,
|
|
64
|
+
projectId: project.id,
|
|
65
|
+
labelIds: Option.toArray(labelId),
|
|
66
|
+
title: issue.title,
|
|
67
|
+
description: issue.description,
|
|
68
|
+
priority: issue.priority,
|
|
69
|
+
estimate: issue.estimate,
|
|
70
|
+
stateId: issue.stateId,
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
const existing = currentValue.issues.get(issue.id)
|
|
76
|
+
if (!existing || !existing.isChangedComparedTo(issue)) continue
|
|
77
|
+
|
|
78
|
+
// update existing issue
|
|
79
|
+
yield* linear.use((c) =>
|
|
80
|
+
c.updateIssue(issue.id!, {
|
|
81
|
+
description: issue.description,
|
|
82
|
+
stateId: issue.stateId,
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
yield* Effect.addFinalizer(() => Effect.orDie(sync))
|
|
89
|
+
|
|
90
|
+
yield* fs.watch(prdFile).pipe(
|
|
91
|
+
Stream.buffer({
|
|
92
|
+
capacity: 1,
|
|
93
|
+
strategy: "dropping",
|
|
94
|
+
}),
|
|
95
|
+
Stream.runForEach(() => Effect.ignore(sync)),
|
|
96
|
+
Effect.forkScoped,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return { current } as const
|
|
100
|
+
}),
|
|
101
|
+
}) {
|
|
102
|
+
static layer = Layer.effect(this, this.make).pipe(
|
|
103
|
+
Layer.provide([Linear.layer, Settings.layer, CurrentProject.layer]),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class NoMoreWork extends Schema.ErrorClass<NoMoreWork>(
|
|
108
|
+
"lalph/Prd/NoMoreWork",
|
|
109
|
+
)({
|
|
110
|
+
_tag: Schema.tag("NoMoreWork"),
|
|
111
|
+
}) {
|
|
112
|
+
readonly message = "No more work to be done!"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class PrdIssue extends Schema.Class<PrdIssue>("PrdIssue")({
|
|
116
|
+
id: Schema.NullOr(Schema.String).annotate({
|
|
117
|
+
description:
|
|
118
|
+
"The unique identifier of the issue. If null, it is considered a new issue.",
|
|
119
|
+
}),
|
|
120
|
+
title: Schema.String.annotate({
|
|
121
|
+
description: "The title of the issue",
|
|
122
|
+
}),
|
|
123
|
+
description: Schema.String.annotate({
|
|
124
|
+
description: "The description of the issue",
|
|
125
|
+
}),
|
|
126
|
+
priority: Schema.Finite.annotate({
|
|
127
|
+
description:
|
|
128
|
+
"The priority of the issue. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low.",
|
|
129
|
+
}),
|
|
130
|
+
estimate: Schema.NullOr(Schema.Finite).annotate({
|
|
131
|
+
description:
|
|
132
|
+
"The estimate of the issue in points. Null if no estimate is set.",
|
|
133
|
+
}),
|
|
134
|
+
stateId: Schema.String.annotate({
|
|
135
|
+
description: "The state ID of the issue.",
|
|
136
|
+
}),
|
|
137
|
+
}) {
|
|
138
|
+
static Array = Schema.Array(this)
|
|
139
|
+
static ArrayFromJson = Schema.toCodecJson(this.Array)
|
|
140
|
+
|
|
141
|
+
static jsonSchemaDoc = Schema.toJsonSchemaDocument(this)
|
|
142
|
+
static jsonSchema = {
|
|
143
|
+
...this.jsonSchemaDoc.schema,
|
|
144
|
+
$defs: this.jsonSchemaDoc.definitions,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static fromLinearIssue(issue: Issue): PrdIssue {
|
|
148
|
+
return new PrdIssue({
|
|
149
|
+
id: issue.id,
|
|
150
|
+
title: issue.title,
|
|
151
|
+
description: issue.description ?? "",
|
|
152
|
+
priority: issue.priority,
|
|
153
|
+
estimate: issue.estimate ?? null,
|
|
154
|
+
stateId: issue.stateId!,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
isChangedComparedTo(issue: PrdIssue): boolean {
|
|
159
|
+
return (
|
|
160
|
+
this.description !== issue.description || this.stateId !== issue.stateId
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class PrdList extends Data.Class<{
|
|
166
|
+
readonly issues: ReadonlyMap<string, PrdIssue>
|
|
167
|
+
}> {
|
|
168
|
+
static fromLinearIssues(issues: Issue[]): PrdList {
|
|
169
|
+
const map = new Map<string, PrdIssue>()
|
|
170
|
+
for (const issue of issues) {
|
|
171
|
+
const prdIssue = PrdIssue.fromLinearIssue(issue)
|
|
172
|
+
if (!prdIssue.id) continue
|
|
173
|
+
map.set(prdIssue.id, prdIssue)
|
|
174
|
+
}
|
|
175
|
+
return new PrdList({ issues: map })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
static fromJson(json: string): ReadonlyArray<PrdIssue> {
|
|
179
|
+
const issues = Schema.decodeSync(PrdIssue.ArrayFromJson)(JSON.parse(json))
|
|
180
|
+
return issues
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
toJson(): string {
|
|
184
|
+
const issuesArray = Array.from(this.issues.values())
|
|
185
|
+
const encoded = Schema.encodeSync(PrdIssue.ArrayFromJson)(issuesArray)
|
|
186
|
+
return JSON.stringify(encoded, null, 2)
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/PromptGen.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Effect, FileSystem, Layer, ServiceMap } from "effect"
|
|
2
|
+
import { Linear } from "./Linear.ts"
|
|
3
|
+
import { PrdIssue } from "./Prd.ts"
|
|
4
|
+
|
|
5
|
+
export class PromptGen extends ServiceMap.Service<PromptGen>()(
|
|
6
|
+
"lalph/PromptGen",
|
|
7
|
+
{
|
|
8
|
+
make: Effect.gen(function* () {
|
|
9
|
+
const linear = yield* Linear
|
|
10
|
+
const fs = yield* FileSystem.FileSystem
|
|
11
|
+
|
|
12
|
+
yield* Effect.scoped(
|
|
13
|
+
fs.open("PROGRESS.md", {
|
|
14
|
+
flag: "a+",
|
|
15
|
+
}),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const prompt = `# Instructions
|
|
19
|
+
|
|
20
|
+
1. Decide which single task to work on next from the prd.json file. This should
|
|
21
|
+
be the task YOU decide as the most important to work on next, not just the
|
|
22
|
+
first task in the list.
|
|
23
|
+
- **Important**: Before starting the chosen task, mark it as "in progress" by
|
|
24
|
+
updating its \`stateId\` in the prd.json file.
|
|
25
|
+
This prevents other people or agents from working on the same task simultaneously.
|
|
26
|
+
3. Run any checks / feedback loops, such as type checks, unit tests, or linting.
|
|
27
|
+
4. APPEND your progress to the PROGRESS.md file.
|
|
28
|
+
5. Make a git commit when you have made significant progress or completed the task.
|
|
29
|
+
6. Update the prd.json file to reflect any changes in task states.
|
|
30
|
+
- Add follow up tasks only if needed.
|
|
31
|
+
- Append to the \`description\` field with any notes.
|
|
32
|
+
- When a task is complete, set its \`stateId\` to the id that indicates
|
|
33
|
+
a review is required, or completion if a review state is unavailable.
|
|
34
|
+
|
|
35
|
+
Remember, only work on a single task at a time, that you decide is the most
|
|
36
|
+
important to work on next.
|
|
37
|
+
|
|
38
|
+
## prd.json format
|
|
39
|
+
|
|
40
|
+
Each item in the prd.json file represents a task for the current project.
|
|
41
|
+
|
|
42
|
+
The \`stateId\` field indicates the current state of the task. The possible states
|
|
43
|
+
are:
|
|
44
|
+
|
|
45
|
+
${Array.from(linear.states.values(), (state) => `- **${state.name}** (stateId: \`${state.id}\`)`).join("\n")}
|
|
46
|
+
|
|
47
|
+
### Adding tasks
|
|
48
|
+
|
|
49
|
+
To add a new task, append a new item to the prd.json file with the id set to
|
|
50
|
+
\`null\`.
|
|
51
|
+
|
|
52
|
+
### prd.json json schema
|
|
53
|
+
|
|
54
|
+
\`\`\`json
|
|
55
|
+
${JSON.stringify(PrdIssue.jsonSchema, null, 2)}
|
|
56
|
+
\`\`\`
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
return { prompt } as const
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
) {
|
|
63
|
+
static layer = Layer.effect(this, this.make).pipe(Layer.provide(Linear.layer))
|
|
64
|
+
}
|
package/src/Runner.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Array, Effect, Option } from "effect"
|
|
2
|
+
import { PromptGen } from "./PromptGen.ts"
|
|
3
|
+
import { Prd } from "./Prd.ts"
|
|
4
|
+
import { ChildProcess } from "effect/unstable/process"
|
|
5
|
+
import { Prompt } from "effect/unstable/cli"
|
|
6
|
+
import { allCliAgents } from "./domain/CliAgent.ts"
|
|
7
|
+
import { selectedCliAgentId } from "./Settings.ts"
|
|
8
|
+
|
|
9
|
+
export const run = Effect.gen(function* () {
|
|
10
|
+
const promptGen = yield* PromptGen
|
|
11
|
+
const cliAgent = yield* getOrSelectCliAgent
|
|
12
|
+
const cliCommand = cliAgent.command({
|
|
13
|
+
prompt: promptGen.prompt,
|
|
14
|
+
prdFilePath: ".lalph/prd.json",
|
|
15
|
+
progressFilePath: "PROGRESS.md",
|
|
16
|
+
})
|
|
17
|
+
const command = ChildProcess.make(cliCommand[0]!, cliCommand.slice(1), {
|
|
18
|
+
extendEnv: true,
|
|
19
|
+
stdout: "inherit",
|
|
20
|
+
stderr: "inherit",
|
|
21
|
+
stdin: "inherit",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const agent = yield* command
|
|
25
|
+
const exitCode = yield* agent.exitCode
|
|
26
|
+
|
|
27
|
+
yield* Effect.log(`Agent exited with code: ${exitCode}`)
|
|
28
|
+
}).pipe(Effect.scoped, Effect.provide([PromptGen.layer, Prd.layer]))
|
|
29
|
+
|
|
30
|
+
export const selectCliAgent = Effect.gen(function* () {
|
|
31
|
+
const agent = yield* Prompt.select({
|
|
32
|
+
message: "Select the CLI agent to use",
|
|
33
|
+
choices: allCliAgents.map((agent) => ({
|
|
34
|
+
title: agent.name,
|
|
35
|
+
value: agent,
|
|
36
|
+
})),
|
|
37
|
+
})
|
|
38
|
+
yield* selectedCliAgentId.set(Option.some(agent.id))
|
|
39
|
+
return agent
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const getOrSelectCliAgent = Effect.gen(function* () {
|
|
43
|
+
const selectedAgent = (yield* selectedCliAgentId.get).pipe(
|
|
44
|
+
Option.filterMap((id) =>
|
|
45
|
+
Array.findFirst(allCliAgents, (agent) => agent.id === id),
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
if (Option.isSome(selectedAgent)) {
|
|
49
|
+
return selectedAgent.value
|
|
50
|
+
}
|
|
51
|
+
return yield* selectCliAgent
|
|
52
|
+
})
|