@kitman-sh/software-engineering 0.0.0-stage → 0.10.54

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/src/errand.ts ADDED
@@ -0,0 +1,142 @@
1
+ import {
2
+ agentWorkOnce,
3
+ ask,
4
+ call,
5
+ changeRequest,
6
+ ChangeRequestResult,
7
+ defineLoop,
8
+ verify,
9
+ writeBack
10
+ } from '@kitman-sh/sdk/loop'
11
+ import * as Effect from 'effect/Effect'
12
+ import * as Schema from 'effect/Schema'
13
+ import build, { implementAdaptation, SuggestedCheck } from './build'
14
+ import { evidenceTable } from './change-request-body'
15
+
16
+ const Input = Schema.Struct({
17
+ /** What to just do. */
18
+ words: Schema.String,
19
+ taskId: Schema.optional(Schema.String),
20
+ repoBindingId: Schema.optional(Schema.String),
21
+ /** The base branch; the repository's default branch when left out. */
22
+ branch: Schema.optional(Schema.String)
23
+ })
24
+
25
+ const Done = Schema.Struct({
26
+ /** done: finished; blocked: something stopped it; too_big: it is a feature, not an errand, and nothing changed. */
27
+ status: Schema.Literals(['done', 'blocked', 'too_big']),
28
+ /** What it did or found, or what stopped it, for the person who asked. */
29
+ summary: Schema.String,
30
+ /** Whether it changed any file. */
31
+ changed: Schema.Boolean,
32
+ /** The ChangeRequest's title, when it changed files. */
33
+ title: Schema.optional(Schema.String),
34
+ /** Checks to run when the repository has none approved. */
35
+ checks: Schema.optional(Schema.Array(SuggestedCheck))
36
+ })
37
+
38
+ /** How long an errand may take before it is a feature. */
39
+ const budget = '1 hour'
40
+
41
+ const choices = ['Open a ChangeRequest', 'Hand to Build for a full review', 'Discard'] as const
42
+
43
+ const Output = Schema.Struct({
44
+ status: Schema.Literals(['done', 'blocked', 'too_big', 'opened', 'handed_to_build', 'discarded']),
45
+ summary: Schema.String,
46
+ changeRequest: Schema.optional(ChangeRequestResult)
47
+ })
48
+
49
+ const prompt = (words: string) =>
50
+ [
51
+ `Just do this: ${words}`,
52
+ implementAdaptation,
53
+ 'It is an errand: a rename, a bump, regenerated fixtures, an answer to a question. Do it directly, run the tests of what you touch, and commit nothing you were not asked for.',
54
+ 'If it turns out to be a feature (several parts, design choices, or more than an hour of work), change nothing and answer too_big with why: a Build will do it with a full review. If something stops you, answer blocked with what.',
55
+ 'Answer with a summary for the person who asked, whether you changed files, a ChangeRequest title if you did, and checks to run if the repository has none approved.'
56
+ ].join('\n\n')
57
+
58
+ /**
59
+ * An errand: one agent just does a small thing with the words, with no review rally. A change is verified and then,
60
+ * by answer or by default, opened as a ChangeRequest marked unreviewed, handed to Build for a full review, or
61
+ * discarded. A Run that changed nothing ends with the agent's summary; an errand that is really a feature ends by
62
+ * offering Build with the same words.
63
+ */
64
+ export default defineLoop({
65
+ id: 'errand',
66
+ version: '1.0.0',
67
+ title: 'Errand',
68
+ input: Input,
69
+ output: Output,
70
+ agents: { implementer: 'implementer' },
71
+ checkout: 'write',
72
+ offers: [{ id: 'build', title: 'Build it with a full review', loop: 'build' }],
73
+ capabilities: ['agent_work', 'verify', 'change_request', 'write_back', 'ask'],
74
+ calls: () => [build],
75
+ run: input =>
76
+ Effect.gen(function* () {
77
+ const done = yield* agentWorkOnce('errand', {
78
+ agent: 'implementer',
79
+ skill: 'implement',
80
+ prompt: prompt(input.words),
81
+ output: Done,
82
+ timeout: budget
83
+ }).pipe(
84
+ // Kitman fails a Step that runs past its time budget with this reason.
85
+ Effect.catchIf(
86
+ error => error._tag === 'StepFailed' && error.reason.includes('time budget'),
87
+ () =>
88
+ Effect.succeed({
89
+ status: 'too_big' as const,
90
+ summary: `It took longer than an errand's ${budget}, so it is a feature: Build can do it with a full review.`,
91
+ changed: false
92
+ })
93
+ )
94
+ )
95
+
96
+ if (done.status !== 'done' || !done.changed) return { status: done.status, summary: done.summary }
97
+
98
+ const head = yield* verify('verify', { suggested: done.checks ?? [] })
99
+
100
+ const answer = yield* ask('next', {
101
+ prompt: `${done.summary}\n\nThe checks ${head.passed ? 'pass' : 'fail'}. What next?`,
102
+ options: [
103
+ { value: 'Open a ChangeRequest', description: 'Deliver it as it is, unreviewed.' },
104
+ { value: 'Hand to Build for a full review', description: 'Build reviews and verifies it before delivering.' },
105
+ { value: 'Discard', description: 'Drop the change.' }
106
+ ] satisfies { readonly value: (typeof choices)[number]; readonly description: string }[],
107
+ recommended: head.passed ? 'Open a ChangeRequest' : 'Hand to Build for a full review',
108
+ recommendationReason: head.passed
109
+ ? 'The checks pass, and an errand is small enough to review on the ChangeRequest.'
110
+ : 'The checks fail, so it needs the fixes and review a Build gives before anyone merges it.',
111
+ default: 'Open a ChangeRequest',
112
+ timeout: '1 hour'
113
+ })
114
+
115
+ if (answer === 'Discard') return { status: 'discarded' as const, summary: done.summary }
116
+
117
+ if (answer === 'Hand to Build for a full review') {
118
+ const built = yield* call('build', build, {
119
+ words: input.words,
120
+ revision: head.revision,
121
+ ...(input.taskId === undefined ? undefined : { taskId: input.taskId }),
122
+ ...(input.branch === undefined ? undefined : { branch: input.branch })
123
+ })
124
+
125
+ return { status: 'handed_to_build' as const, summary: done.summary, changeRequest: built.changeRequest }
126
+ }
127
+
128
+ const opened = yield* changeRequest('change-request', {
129
+ title: done.title ?? input.words.slice(0, 120),
130
+ body: [
131
+ done.summary,
132
+ '> **Unreviewed errand.** One agent did this with no review; ask for a Build or a Review if it needs one.',
133
+ '## Evidence',
134
+ evidenceTable(head)
135
+ ].join('\n\n')
136
+ })
137
+
138
+ yield* writeBack('write-back', { milestone: 'change_request_opened', changeRequestUrl: opened.url })
139
+
140
+ return { status: 'opened' as const, summary: done.summary, changeRequest: opened }
141
+ })
142
+ })
@@ -0,0 +1,30 @@
1
+ import { createTasks, defineLoop, PlanTickets } from '@kitman-sh/sdk/loop'
2
+ import * as Effect from 'effect/Effect'
3
+ import * as Schema from 'effect/Schema'
4
+
5
+ export const FileTasksInput = Schema.Struct({
6
+ tickets: PlanTickets,
7
+ /** The Project the Tasks land in; the Project of the Run's Task when left out. */
8
+ projectId: Schema.optional(Schema.String)
9
+ })
10
+
11
+ const Output = Schema.Struct({
12
+ tasks: Schema.Array(Schema.Struct({ key: Schema.String, taskId: Schema.String }))
13
+ })
14
+
15
+ /** Files tickets as Tasks, with their blockers as dependencies: what another Loop's "File as Tasks" offer starts. */
16
+ export default defineLoop({
17
+ id: 'file-tasks',
18
+ version: '1.0.0',
19
+ title: 'File Tasks',
20
+ input: FileTasksInput,
21
+ output: Output,
22
+ agents: {},
23
+ checkout: 'none',
24
+ capabilities: ['create_tasks'],
25
+ run: input =>
26
+ createTasks('tasks', {
27
+ tickets: input.tickets,
28
+ ...(input.projectId === undefined ? undefined : { projectId: input.projectId })
29
+ }).pipe(Effect.map(({ tasks }) => ({ tasks })))
30
+ })
@@ -0,0 +1,232 @@
1
+ import { agentWork, alertTask, artifact, ask, call, defineLoop, note } from '@kitman-sh/sdk/loop'
2
+ import * as Effect from 'effect/Effect'
3
+ import * as Predicate from 'effect/Predicate'
4
+ import * as Schema from 'effect/Schema'
5
+ import build from './build'
6
+
7
+ const Input = Schema.Struct({
8
+ /** The alert as the monitoring Connection received it. */
9
+ alert: Schema.Record(Schema.String, Schema.Json),
10
+ /** Where a fix is built; the Team's default repository when left out. */
11
+ repoBindingId: Schema.optional(Schema.String),
12
+ /** How long a conclusion stands: a repeat within it is folded in, not investigated again; 6 when left out. */
13
+ windowHours: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(720))),
14
+ /**
15
+ * Starts a fix unasked when the cause is in code and the conclusion is confident. A Team opts in with `values` on the
16
+ * When Clause of the Kit that declares Firewatch for it.
17
+ */
18
+ autoFix: Schema.optional(Schema.Boolean)
19
+ })
20
+
21
+ type Alert = (typeof Input.Type)['alert']
22
+
23
+ const Conclusion = Schema.Struct({
24
+ summary: Schema.String,
25
+ cause: Schema.String,
26
+ /** high: the evidence shows it; medium: it fits but something is unchecked; low: a best guess. */
27
+ confidence: Schema.Literals(['high', 'medium', 'low']),
28
+ /** Logs, metrics, commits or code that support the conclusion. */
29
+ sources: Schema.NonEmptyArray(Schema.String),
30
+ /** Each telemetry source tried, and whether it could be read. */
31
+ telemetry: Schema.Array(
32
+ Schema.Struct({ source: Schema.String, reached: Schema.Boolean, tried: Schema.optional(Schema.String) })
33
+ ),
34
+ /** Whether the cause is in code Kitman can change. */
35
+ inCode: Schema.Boolean,
36
+ /** The fix, when the cause is in code. */
37
+ fix: Schema.optional(Schema.String)
38
+ })
39
+
40
+ type Conclusion = typeof Conclusion.Type
41
+
42
+ const FixRequest = Schema.Struct({ words: Schema.String, outcome: Schema.Literal('bug') })
43
+
44
+ const Output = Schema.Struct({
45
+ /** folded: a repeat joined earlier work; resolved: it resolved with nothing to investigate; concluded. */
46
+ status: Schema.Literals(['folded', 'resolved', 'concluded']),
47
+ taskId: Schema.optional(Schema.String),
48
+ /** The Run this alert was folded into. */
49
+ joinedRunId: Schema.optional(Schema.String),
50
+ conclusionArtifactId: Schema.optional(Schema.String),
51
+ /** It resolved on its own while it was being investigated. */
52
+ resolvedDuring: Schema.Boolean,
53
+ fix: Schema.Literals(['none', 'declined', 'started', 'failed']),
54
+ changeRequestUrl: Schema.optional(Schema.String),
55
+ /** The fix a person can still start from the Run's offer. */
56
+ fixRequest: Schema.optional(FixRequest)
57
+ })
58
+
59
+ type Output = typeof Output.Type
60
+
61
+ const firstText = (alert: Alert, ...keys: readonly string[]) =>
62
+ keys.flatMap(key => {
63
+ const value = alert[key]
64
+
65
+ return Predicate.isString(value) && value.trim() !== '' ? [value] : []
66
+ })[0]
67
+
68
+ const alertTitle = (alert: Alert) => firstText(alert, 'title', 'name', 'summary') ?? 'Alert'
69
+
70
+ /** Names the alert across repeats: its fingerprint, else its title. */
71
+ const fingerprintOf = (alert: Alert) => firstText(alert, 'fingerprint', 'groupKey') ?? alertTitle(alert)
72
+
73
+ const isResolved = (alert: Alert) => firstText(alert, 'status', 'state')?.toLowerCase() === 'resolved'
74
+
75
+ const priorities = new Map(
76
+ Object.entries({
77
+ critical: 'urgent',
78
+ p1: 'urgent',
79
+ urgent: 'urgent',
80
+ high: 'high',
81
+ error: 'high',
82
+ p2: 'high',
83
+ warning: 'medium',
84
+ medium: 'medium',
85
+ p3: 'medium',
86
+ low: 'low',
87
+ info: 'low',
88
+ p4: 'low'
89
+ } as const)
90
+ )
91
+
92
+ /** The alert's severity as a Task priority; high when it gives none Kitman knows. */
93
+ const priorityOf = (alert: Alert) => {
94
+ const severity = firstText(alert, 'severity', 'priority', 'level')?.toLowerCase()
95
+
96
+ return (severity === undefined ? undefined : priorities.get(severity)) ?? 'high'
97
+ }
98
+
99
+ const investigatePrompt = (alert: Alert) =>
100
+ [
101
+ 'Investigate this alert and conclude what happened and why.',
102
+ '```json',
103
+ JSON.stringify(alert, null, 2),
104
+ '```',
105
+ 'Cite as sources the logs, metrics, commits or code that support your conclusion. List every telemetry source you tried and whether you could read it, with what you tried for one you could not; say how confident you are, lower when a source was out of reach.',
106
+ 'Say whether the cause is in code Kitman can change, and propose a fix only then.'
107
+ ].join('\n\n')
108
+
109
+ const conclusionText = (conclusion: Conclusion, resolvedDuring: boolean) =>
110
+ [
111
+ conclusion.summary,
112
+ ...(resolvedDuring ? ['It resolved on its own while it was being investigated.'] : []),
113
+ `Cause: ${conclusion.cause} (confidence ${conclusion.confidence})`,
114
+ 'Sources:',
115
+ ...conclusion.sources.map(source => `- ${source}`),
116
+ ...(conclusion.telemetry.some(source => !source.reached)
117
+ ? [
118
+ 'Out of reach:',
119
+ ...conclusion.telemetry
120
+ .filter(source => !source.reached)
121
+ .map(source => `- ${source.source}${source.tried === undefined ? '' : `: ${source.tried}`}`)
122
+ ]
123
+ : []),
124
+ ...(conclusion.fix === undefined ? [] : [`Fix: ${conclusion.fix}`])
125
+ ].join('\n')
126
+
127
+ /**
128
+ * Turns an alert into a cited cause on its Bug Task, found by fingerprint or made. A repeat while the alert is being
129
+ * investigated, or soon after, is folded into that Run as another occurrence. A resolve during the investigation is
130
+ * stated in the report. A fix in code is offered with a default, so the Run never waits on it: started unasked only
131
+ * when the Team opted in and the conclusion is confident, else left as an offer on the Run.
132
+ */
133
+ export default defineLoop({
134
+ id: 'firewatch',
135
+ version: '1.0.0',
136
+ title: 'Firewatch',
137
+ input: Input,
138
+ output: Output,
139
+ outcome: 'investigation',
140
+ agents: { investigator: 'investigator' },
141
+ checkout: 'read',
142
+ when: [{ event: 'monitoring.alert', input: { alert: 'alert' } }],
143
+ offers: [{ id: 'build-fix', title: 'Build the fix', loop: 'build', from: 'fixRequest' }],
144
+ capabilities: ['agent_work', 'ask', 'create_tasks'],
145
+ calls: () => [build],
146
+ run: input =>
147
+ Effect.gen(function* () {
148
+ const fingerprint = fingerprintOf(input.alert)
149
+ const resolved = isResolved(input.alert)
150
+ const windowHours = input.windowHours ?? 6
151
+ const request = { fingerprint, title: alertTitle(input.alert), windowHours, priority: priorityOf(input.alert) }
152
+
153
+ const placed = yield* alertTask('task', { ...request, ...(resolved ? { resolved } : undefined) })
154
+ const taskId = placed.taskId === undefined ? undefined : { taskId: placed.taskId }
155
+
156
+ if (placed.joined !== undefined)
157
+ return {
158
+ status: 'folded',
159
+ ...taskId,
160
+ joinedRunId: placed.joined.runId,
161
+ resolvedDuring: false,
162
+ fix: 'none'
163
+ } satisfies Output
164
+
165
+ if (resolved) return { status: 'resolved', ...taskId, resolvedDuring: false, fix: 'none' } satisfies Output
166
+
167
+ const conclusion = yield* agentWork('investigate', {
168
+ agent: 'investigator',
169
+ prompt: investigatePrompt(input.alert),
170
+ output: Conclusion
171
+ })
172
+
173
+ // A resolve that arrived while investigating was folded into this Run's Task.
174
+ const resolvedDuring = (yield* alertTask('task:check', request)).resolved === true
175
+
176
+ const stored = yield* artifact('conclusion', {
177
+ kind: 'investigation_conclusion',
178
+ title: request.title,
179
+ text: conclusionText(conclusion, resolvedDuring),
180
+ data: { alert: input.alert, sources: conclusion.sources, confidence: conclusion.confidence }
181
+ })
182
+
183
+ yield* note('summary', { text: conclusion.summary, artifactId: stored.artifactId })
184
+
185
+ const concluded = {
186
+ status: 'concluded',
187
+ ...taskId,
188
+ conclusionArtifactId: stored.artifactId,
189
+ resolvedDuring
190
+ } as const
191
+
192
+ if (conclusion.fix === undefined || !conclusion.inCode) return { ...concluded, fix: 'none' } satisfies Output
193
+
194
+ const fixRequest = {
195
+ words: `${conclusion.fix}\n\n${conclusionText(conclusion, resolvedDuring)}`,
196
+ outcome: 'bug' as const
197
+ }
198
+
199
+ const confident = conclusion.confidence === 'high' && !resolvedDuring
200
+
201
+ const answer = yield* ask('start-fix', {
202
+ prompt: `${conclusion.summary}\n\nStart a Build that fixes it as a bug: ${conclusion.fix}?`,
203
+ options: [
204
+ { value: 'Start a fix', description: 'A Build fixes it as a bug and opens a ChangeRequest.' },
205
+ { value: 'Not now', description: 'Keeps the finding on the Task.' }
206
+ ],
207
+ recommended: confident ? 'Start a fix' : 'Not now',
208
+ recommendationReason: confident
209
+ ? 'Firewatch is confident of the cause and the alert is still firing.'
210
+ : resolvedDuring
211
+ ? 'The alert resolved while Firewatch looked, so a fix can wait for a person to judge.'
212
+ : 'Firewatch is not confident of the cause, so a fix could aim at the wrong thing.',
213
+ default: input.autoFix === true && confident ? 'Start a fix' : 'Not now',
214
+ timeout: '1 hour'
215
+ })
216
+
217
+ if (answer === 'Not now') return { ...concluded, fix: 'declined', fixRequest } satisfies Output
218
+
219
+ return yield* call('fix', build, {
220
+ ...fixRequest,
221
+ ...taskId,
222
+ ...(input.repoBindingId === undefined ? undefined : { repoBindingId: input.repoBindingId })
223
+ }).pipe(
224
+ Effect.map(
225
+ fixed => ({ ...concluded, fix: 'started', changeRequestUrl: fixed.changeRequest.url }) satisfies Output
226
+ ),
227
+ Effect.catchTag('ChildFailed', () =>
228
+ Effect.succeed({ ...concluded, fix: 'failed', fixRequest } satisfies Output)
229
+ )
230
+ )
231
+ })
232
+ })