@kitman-sh/software-engineering 0.10.57 → 0.10.59

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.
@@ -337,6 +337,39 @@
337
337
  "type": "integer",
338
338
  "minimum": -9007199254740991,
339
339
  "maximum": 9007199254740991
340
+ },
341
+ "release": {
342
+ "anyOf": [
343
+ {
344
+ "type": "object",
345
+ "properties": {
346
+ "runStatus": {
347
+ "type": "string"
348
+ },
349
+ "reason": {
350
+ "type": "string"
351
+ },
352
+ "tag": {
353
+ "anyOf": [
354
+ {
355
+ "type": "string"
356
+ },
357
+ {
358
+ "type": "null"
359
+ }
360
+ ]
361
+ }
362
+ },
363
+ "required": [
364
+ "runStatus",
365
+ "reason"
366
+ ],
367
+ "additionalProperties": true
368
+ },
369
+ {
370
+ "type": "null"
371
+ }
372
+ ]
340
373
  }
341
374
  },
342
375
  "required": [
@@ -418,7 +451,9 @@
418
451
  "call",
419
452
  "ask",
420
453
  "read_change_request",
421
- "merge_change_request"
454
+ "merge_change_request",
455
+ "claim",
456
+ "publish_release"
422
457
  ]
423
458
  },
424
459
  {
@@ -542,7 +577,9 @@
542
577
  "post_comment",
543
578
  "approve_change_request",
544
579
  "read_change_request",
545
- "merge_change_request"
580
+ "merge_change_request",
581
+ "claim",
582
+ "publish_release"
546
583
  ]
547
584
  },
548
585
  {
@@ -886,7 +923,9 @@
886
923
  "post_comment",
887
924
  "read_change_request",
888
925
  "merge_change_request",
889
- "approve_change_request"
926
+ "approve_change_request",
927
+ "claim",
928
+ "publish_release"
890
929
  ]
891
930
  },
892
931
  {
@@ -1068,7 +1107,9 @@
1068
1107
  "post_comment",
1069
1108
  "approve_change_request",
1070
1109
  "read_change_request",
1071
- "merge_change_request"
1110
+ "merge_change_request",
1111
+ "claim",
1112
+ "publish_release"
1072
1113
  ]
1073
1114
  },
1074
1115
  {
@@ -1360,7 +1401,9 @@
1360
1401
  "merge_change_request",
1361
1402
  "read_change_request",
1362
1403
  "post_comment",
1363
- "approve_change_request"
1404
+ "approve_change_request",
1405
+ "claim",
1406
+ "publish_release"
1364
1407
  ]
1365
1408
  },
1366
1409
  {
@@ -1509,6 +1552,7 @@
1509
1552
  "deployed",
1510
1553
  "rolled_back",
1511
1554
  "deploy_failed",
1555
+ "rollback_failed",
1512
1556
  "timed_out"
1513
1557
  ]
1514
1558
  },
@@ -1564,7 +1608,8 @@
1564
1608
  "$defs": {}
1565
1609
  },
1566
1610
  "agents": {
1567
- "releaser": "releaser"
1611
+ "releaser": "releaser",
1612
+ "deployer": "deployer"
1568
1613
  },
1569
1614
  "checkout": "read",
1570
1615
  "when": [],
@@ -1950,7 +1995,9 @@
1950
1995
  "post_comment",
1951
1996
  "approve_change_request",
1952
1997
  "read_change_request",
1953
- "merge_change_request"
1998
+ "merge_change_request",
1999
+ "claim",
2000
+ "publish_release"
1954
2001
  ],
1955
2002
  "schedule": {
1956
2003
  "rule": {
@@ -2401,7 +2448,9 @@
2401
2448
  "write_back",
2402
2449
  "read_change_request",
2403
2450
  "merge_change_request",
2404
- "approve_change_request"
2451
+ "approve_change_request",
2452
+ "claim",
2453
+ "publish_release"
2405
2454
  ]
2406
2455
  },
2407
2456
  {
@@ -2573,6 +2622,39 @@
2573
2622
  "type": "integer",
2574
2623
  "minimum": -9007199254740991,
2575
2624
  "maximum": 9007199254740991
2625
+ },
2626
+ "release": {
2627
+ "anyOf": [
2628
+ {
2629
+ "type": "object",
2630
+ "properties": {
2631
+ "runStatus": {
2632
+ "type": "string"
2633
+ },
2634
+ "reason": {
2635
+ "type": "string"
2636
+ },
2637
+ "tag": {
2638
+ "anyOf": [
2639
+ {
2640
+ "type": "string"
2641
+ },
2642
+ {
2643
+ "type": "null"
2644
+ }
2645
+ ]
2646
+ }
2647
+ },
2648
+ "required": [
2649
+ "runStatus",
2650
+ "reason"
2651
+ ],
2652
+ "additionalProperties": true
2653
+ },
2654
+ {
2655
+ "type": "null"
2656
+ }
2657
+ ]
2576
2658
  }
2577
2659
  },
2578
2660
  "required": [
@@ -2600,7 +2682,9 @@
2600
2682
  "write_back",
2601
2683
  "ask",
2602
2684
  "verify",
2603
- "change_request"
2685
+ "change_request",
2686
+ "claim",
2687
+ "publish_release"
2604
2688
  ]
2605
2689
  },
2606
2690
  {
@@ -2798,7 +2882,9 @@
2798
2882
  "post_comment",
2799
2883
  "approve_change_request",
2800
2884
  "write_back",
2801
- "ask"
2885
+ "ask",
2886
+ "claim",
2887
+ "publish_release"
2802
2888
  ],
2803
2889
  "schedule": {
2804
2890
  "rule": {
@@ -2964,6 +3050,8 @@
2964
3050
  "read_change_request",
2965
3051
  "merge_change_request",
2966
3052
  "approve_change_request",
3053
+ "claim",
3054
+ "publish_release",
2967
3055
  "create_tasks"
2968
3056
  ]
2969
3057
  }
@@ -3032,6 +3120,15 @@
3032
3120
  "shipping-and-launch"
3033
3121
  ]
3034
3122
  },
3123
+ "deployer": {
3124
+ "instructions": "You are a deployer. Release has tagged a version, and you run the repository's own deploy script for it, once.\n\n## Boundaries\n\n- Run only the command your brief names. Do not edit files, commit, push, tag, merge or retry it: the script backs up, publishes, deploys, verifies and rolls back by itself.\n- It runs for a long time. Start it once in the background with its output in a log file, and wait for it to exit.\n- Never print, copy or summarise the variables, tokens or secrets it pulls, or the production host's address.\n\n## Output\n\nSubmit your Output with the Kitman MCP `submit_output` tool, in the shape the brief's Output schema gives.",
3125
+ "effort": "low",
3126
+ "fallbacks": [
3127
+ {
3128
+ "harness": "pi"
3129
+ }
3130
+ ]
3131
+ },
3035
3132
  "investigator": {
3036
3133
  "instructions": "You are an investigator. You find out why something happened and report a conclusion you can support.\n\n## Investigation\n\n- Start from the evidence you are given: the alert, its logs, metrics and traces, and the code that produced them.\n- You have web tools: read an upstream issue, a changelog or the documentation of a dependency when the evidence points outside the repository.\n- Form a hypothesis, then look for what would prove it wrong. Report what the evidence shows, not what you expected.\n- Every conclusion cites its sources: the log line, the query, the commit or the file and line it rests on.\n- Separate what you established from what you suspect, and say how sure you are.\n- When the cause points to a code change, describe the fix precisely enough that a Build can make it, but do not make it yourself.\n\n## Boundaries\n\n- Change nothing: do not edit files, commit, push or merge. The Run owns the branch.\n- Never `git stash`: the stash stack is shared with every other worktree of the repository. To look at another revision, make a throwaway checkout with `git worktree add` and remove it after.\n- A finding written in your Output is a claim. Say what you ran and what it returned.\n\n## Output\n\nSubmit your Output with the Kitman MCP `submit_output` tool, in the shape the brief's Output schema gives. An answer with no fix is a real outcome.",
3037
3134
  "effort": "high",
@@ -3090,7 +3187,7 @@
3090
3187
  "carried": [
3091
3188
  {
3092
3189
  "name": "@kitman-sh/software-engineering",
3093
- "version": "0.10.57",
3190
+ "version": "0.10.59",
3094
3191
  "setup": "2a237548a69b0cd53ec3be7f687cfa8c2128335b3a655d34e185f2abce6a3c37",
3095
3192
  "loops": [
3096
3193
  "build",
@@ -3115,6 +3212,7 @@
3115
3212
  "quality-reviewer",
3116
3213
  "planner",
3117
3214
  "releaser",
3215
+ "deployer",
3118
3216
  "investigator",
3119
3217
  "climber",
3120
3218
  "surveyor",
package/kitman.config.ts CHANGED
@@ -73,6 +73,11 @@ export default defineKit({
73
73
  skills: ['git-workflow-and-versioning', 'shipping-and-launch'],
74
74
  instructions: { file: 'agents/releaser.md' }
75
75
  },
76
+ deployer: {
77
+ effort: 'low',
78
+ fallbacks,
79
+ instructions: { file: 'agents/deployer.md' }
80
+ },
76
81
  investigator: {
77
82
  effort: 'high',
78
83
  fallbacks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitman-sh/software-engineering",
3
- "version": "0.10.57",
3
+ "version": "0.10.59",
4
4
  "description": "Everything for building software: Loops, their Agents, the skills they use and the Ponytail plugin.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -17,12 +17,12 @@
17
17
  "tsconfig.json"
18
18
  ],
19
19
  "dependencies": {
20
- "@kitman-sh/sdk": "0.10.57",
20
+ "@kitman-sh/sdk": "0.10.59",
21
21
  "effect": "4.1.2"
22
22
  },
23
23
  "kitman": {
24
24
  "bundle": "dist/bundle.js",
25
- "digest": "14e6bb8098394b0d2278be72ca35948b40702e697f102e0432a266fc7dadad8a",
25
+ "digest": "c865bc195025830b0c1056823b84a8a14ff3315d5c68fbe5da62ca246d862fee",
26
26
  "protocolVersion": 5,
27
27
  "declarations": "dist/declarations.json",
28
28
  "setup": {
package/src/build.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  agentWork,
3
+ ask,
3
4
  agentWorkOnce,
4
5
  approveChangeRequest,
5
6
  ApproveChangeRequestResult,
@@ -516,8 +517,9 @@ export default defineLoop({
516
517
  )
517
518
  : undefined
518
519
 
520
+ // A blocking Review, or anything else left unsettled, stops here with the findings for a person, not in Shepherd.
519
521
  const landed =
520
- input.land !== false
522
+ input.land !== false && unsettled.length === 0
521
523
  ? yield* call('land', shepherd, {
522
524
  changeRequestId: opened.id,
523
525
  ...(input.taskId === undefined ? undefined : { taskId: input.taskId }),
@@ -525,6 +527,16 @@ export default defineLoop({
525
527
  })
526
528
  : undefined
527
529
 
530
+ if (input.land !== false && unsettled.length > 0)
531
+ yield* ask('unsettled', {
532
+ prompt: `The ChangeRequest ${opened.url} is open, but these still stand: ${unsettled.join('; ')}. Build will not land it.`,
533
+ options: [{ value: 'Taken over', description: 'A person fixes, rebuts or steers it from here.' }],
534
+ recommended: 'Taken over',
535
+ recommendationReason: 'Build stopped with these unsettled, so the next step is a person’s.',
536
+ // No default: an unattended Run, as full auto starts, would take it at once and finish as partial.
537
+ default: null
538
+ })
539
+
528
540
  return {
529
541
  changeRequest: opened,
530
542
  revision: last.head.revision,
package/src/plan.ts CHANGED
@@ -285,7 +285,9 @@ const buildTicket = (
285
285
  ...(taskId === undefined ? undefined : { taskId }),
286
286
  repoBindingId: head.repoBindingId,
287
287
  branch: head.branch,
288
- revision: head.revision
288
+ revision: head.revision,
289
+ // Plan merges each Build's ChangeRequest into its integration branch itself.
290
+ land: false
289
291
  }).pipe(Effect.catchTag('ChildFailed', (failure: ChildFailed) => Effect.succeed(failure)))
290
292
 
291
293
  if (built instanceof ChildFailed)
package/src/release.ts CHANGED
@@ -43,6 +43,22 @@ const Drafted = Schema.Struct({
43
43
 
44
44
  type Drafted = typeof Drafted.Type
45
45
 
46
+ /** What the repository's own deploy script did, as the releaser read it from its last line and exit code. */
47
+ const Deployed = Schema.Struct({
48
+ /** none: the repository has no `.kitman/deploy`, so the deploy is left to whatever sends the deploy Event. */
49
+ status: Schema.Literals(['deployed', 'failed', 'rolled_back', 'rollback_failed', 'none']),
50
+ detail: Schema.String
51
+ })
52
+
53
+ const deployPrompt = (tag: string, previousTag: string | undefined) =>
54
+ [
55
+ `Deploy the release ${tag}, which Kitman has just tagged and pushed.`,
56
+ `If the repository has no executable \`.kitman/deploy\` at ${tag}, change nothing and answer status none.`,
57
+ `Otherwise run \`.kitman/deploy ${tag}${previousTag === undefined ? '' : ` ${previousTag}`}\` from the checkout of ${tag} (\`git fetch --tags\` first), and do nothing else: it backs up, publishes, deploys, verifies and rolls back by itself, and may take an hour. Run it in the background with its output in a log file, and wait for it to exit, checking every few minutes; never start it twice.`,
58
+ 'Answer with the status its last line `kitman-deploy: <status> <detail>` names, and that detail. If it exited without that line, answer failed with its exit code and the end of its log.',
59
+ 'Never print, copy or summarise the variables or secrets it pulls.'
60
+ ].join('\n\n')
61
+
46
62
  const Output = Schema.Struct({
47
63
  status: Schema.Literals([
48
64
  'gate_failed',
@@ -51,6 +67,8 @@ const Output = Schema.Struct({
51
67
  'deployed',
52
68
  'rolled_back',
53
69
  'deploy_failed',
70
+ /** The deploy failed after production changed, and so did the rollback: production may be broken. */
71
+ 'rollback_failed',
54
72
  'timed_out'
55
73
  ]),
56
74
  /** Why it stopped, or what happened, in a line. */
@@ -189,7 +207,7 @@ export default defineLoop({
189
207
  title: 'Release',
190
208
  input: Input,
191
209
  output: Output,
192
- agents: { releaser: 'releaser' },
210
+ agents: { releaser: 'releaser', deployer: 'deployer' },
193
211
  checkout: 'read',
194
212
  capabilities: [
195
213
  'agent_work',
@@ -247,7 +265,9 @@ export default defineLoop({
247
265
  recommended: 'Approve',
248
266
  recommendationReason:
249
267
  'The release is proved: its checks pass, or its ChangeRequest is green, approved and mergeable.',
250
- default: null
268
+ default: null,
269
+ // A full-auto Team has handed releasing a proved change to Kitman.
270
+ auto: 'Approve'
251
271
  })
252
272
 
253
273
  if (answer === 'Approve') break
@@ -360,6 +380,35 @@ export default defineLoop({
360
380
  )
361
381
 
362
382
  const settled = { tag, notesArtifactId: notes.artifactId, revision: released.revision, url: released.url }
383
+
384
+ // The repository deploys itself from the Runner when it has a deploy script, which rolls back on its own.
385
+ const ran = yield* agentWork('run-deploy', {
386
+ agent: 'deployer',
387
+ prompt: deployPrompt(tag, drafted.previousTag),
388
+ output: Deployed,
389
+ timeout: '3 hours'
390
+ })
391
+
392
+ if (ran.status !== 'none') {
393
+ const status = ran.status === 'failed' ? 'deploy_failed' : ran.status
394
+
395
+ const outcome = {
396
+ deployed: 'deployed',
397
+ rolled_back: 'rolled back',
398
+ deploy_failed: 'failed to deploy',
399
+ rollback_failed: 'failed to deploy, and the rollback failed too'
400
+ }[status]
401
+
402
+ yield* artifact('report', {
403
+ kind: 'release_report',
404
+ title: `${tag} ${outcome}`,
405
+ text: `${tag} at ${released.revision}: ${ran.detail}. ${released.url}`,
406
+ data: { tag, status, revision: released.revision, url: released.url, detail: ran.detail }
407
+ })
408
+
409
+ return { status, reason: ran.detail, ...settled } satisfies Output
410
+ }
411
+
363
412
  let status = yield* deployed('deploy', tag)
364
413
 
365
414
  for (let round = 1; status !== 'deployed'; round++) {
package/src/review.ts CHANGED
@@ -270,14 +270,25 @@ export default defineLoop({
270
270
  const earlier = input.earlier ?? []
271
271
  const earlierIds = new Set(earlier.map(finding => finding.id))
272
272
 
273
- const followUps = followUpsOf(
273
+ const answered = followUpsOf(
274
274
  earlier,
275
275
  outputs.flatMap(([, output]) => output.followUps ?? [])
276
276
  )
277
277
 
278
- const standing = new Set(followUps.flatMap(entry => (entry.status === 'fixed' ? [] : [entry.id])))
278
+ const standing = new Set(answered.flatMap(entry => (entry.status === 'fixed' ? [] : [entry.id])))
279
279
 
280
- const carried = earlier.filter(finding => standing.has(finding.id))
280
+ const verdicts = outputs.flatMap(([, output]) => output.verdicts ?? [])
281
+ const rebutted = new Set((input.rebuttals ?? []).map(rebuttal => rebuttal.findingId))
282
+
283
+ // A rebutted finding every verdict on it accepts is withdrawn, as Build's settleRound counts it.
284
+ const withdrawn = (id: string) => {
285
+ const on = verdicts.filter(verdict => verdict.findingId === id)
286
+
287
+ return rebutted.has(id) && on.length > 0 && on.every(verdict => verdict.accepted)
288
+ }
289
+
290
+ const carried = earlier.filter(finding => standing.has(finding.id) && !withdrawn(finding.id))
291
+ const followUps = answered.filter(entry => !withdrawn(entry.id))
281
292
 
282
293
  const fresh = mergeFindings(
283
294
  Object.fromEntries(
@@ -290,14 +301,24 @@ export default defineLoop({
290
301
 
291
302
  const findings = [...carried, ...fresh]
292
303
 
304
+ const blockers = findings.filter(isBlocking)
305
+ const scope = input.files === undefined ? outputs.find(([seat]) => seat === scopeSeat)?.[1].scope : undefined
306
+ const failing = input.failing ?? []
307
+
308
+ // A seat's own summary is kept only when nothing blocks, so it cannot contradict the verdict.
293
309
  const summary =
294
- findings.length === 0
295
- ? 'No findings: nothing stands in the way of merging.'
296
- : outputs.map(([, output]) => output.summary).join(' ')
310
+ blockers.length > 0
311
+ ? `Blocking: fix ${blockers.map(finding => finding.id).join(', ')} before merging.`
312
+ : scope?.met === false
313
+ ? 'Not ready: the change does not yet do what was asked.'
314
+ : failing.length > 0
315
+ ? `Not ready: ${failing.join(', ')} fails on this head.`
316
+ : findings.length === 0
317
+ ? 'No findings: nothing stands in the way of merging.'
318
+ : ['Can merge: nothing blocks it.', ...outputs.map(([, output]) => output.summary)].join(' ')
297
319
 
298
320
  const freshIds = new Set(fresh.map(finding => finding.id))
299
321
  const text = reviewText(summary, findings, followUps)
300
- const scope = input.files === undefined ? outputs.find(([seat]) => seat === scopeSeat)?.[1].scope : undefined
301
322
 
302
323
  const stored = yield* artifact('findings', {
303
324
  kind: 'review-findings',
@@ -310,7 +331,7 @@ export default defineLoop({
310
331
  summary,
311
332
  findings,
312
333
  followUps,
313
- verdicts: outputs.flatMap(([, output]) => output.verdicts ?? []),
334
+ verdicts,
314
335
  blocking: findings.some(isBlocking),
315
336
  artifactId: stored.artifactId,
316
337
  ...(scope === undefined ? undefined : { scope })
@@ -321,7 +342,7 @@ export default defineLoop({
321
342
  if (changeRequestId === undefined || head === undefined) return reviewed
322
343
 
323
344
  const approval =
324
- reviewed.blocking || scope?.met === false || (input.failing?.length ?? 0) > 0
345
+ reviewed.blocking || scope?.met === false || failing.length > 0
325
346
  ? undefined
326
347
  : yield* approveChangeRequest('approve', { changeRequestId, head, summary }).pipe(
327
348
  Effect.map(result =>
@@ -6,5 +6,9 @@ export const ShepherdOutput = Schema.Struct({
6
6
  /** Why it stopped, in a line a person can act on. */
7
7
  reason: Schema.String,
8
8
  fixRounds: Schema.Int,
9
- replies: Schema.Int
9
+ replies: Schema.Int,
10
+ /** The Release it started after merging, when it was told to release. */
11
+ release: Schema.optional(
12
+ Schema.Struct({ runStatus: Schema.String, reason: Schema.String, tag: Schema.optional(Schema.String) })
13
+ )
10
14
  })
package/src/shepherd.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from '@kitman-sh/sdk/loop'
26
26
  import * as Effect from 'effect/Effect'
27
27
  import * as Schema from 'effect/Schema'
28
+ import release from './release'
28
29
  import review, { ReviewInput, ReviewOutput } from './review'
29
30
  import { ShepherdOutput } from './shepherd-output'
30
31
 
@@ -160,7 +161,7 @@ export default defineLoop({
160
161
  'ask',
161
162
  'verify'
162
163
  ],
163
- calls: (): readonly CalledLoop[] => [review],
164
+ calls: (): readonly CalledLoop[] => [review, release],
164
165
  run: input => {
165
166
  const changeRequestId = input.changeRequestId
166
167
  // The ChangeRequest's own target: an offer's `branch` may be the offering Run's branch, which is this one.
@@ -486,6 +487,8 @@ export default defineLoop({
486
487
  recommended: 'Merge',
487
488
  recommendationReason: 'It is approved, green and mergeable, which is everything a merge waits for.',
488
489
  default: 'Wait',
490
+ // A full-auto Team merges what its Review approved and its checks passed.
491
+ auto: 'Merge',
489
492
  timeout: '1 hour'
490
493
  })
491
494
 
@@ -500,7 +503,42 @@ export default defineLoop({
500
503
  if (merged.status !== 'merged') continue
501
504
  yield* writeBack('write-back', { milestone: 'merged' })
502
505
 
503
- return done('merged', `Merged at ${merged.revision}`)
506
+ const ship = yield* ask('ship', {
507
+ prompt: `Merged at ${merged.revision}. Release it now?`,
508
+ options: [
509
+ { value: 'Release', description: 'Starts Release on the merge: it gates, tags, publishes and deploys it.' },
510
+ { value: 'Not now', description: 'Leaves the release to a person or a later Release.' }
511
+ ],
512
+ recommended: 'Release',
513
+ recommendationReason: 'The merge is approved and green, so it is ready to ship.',
514
+ default: 'Not now',
515
+ auto: 'Release',
516
+ timeout: '1 hour'
517
+ })
518
+
519
+ if (ship === 'Not now') return done('merged', `Merged at ${merged.revision}`)
520
+
521
+ const released = yield* call('release', release, { revision: merged.revision })
522
+
523
+ // It merged either way; a Release that did not deploy, other than one a person rejected, needs a person.
524
+ if (released.status !== 'deployed' && released.status !== 'rejected')
525
+ yield* ask('release-failed', {
526
+ prompt: `Merged at ${merged.revision}, but its Release ended ${released.status}: ${released.reason}`,
527
+ options: [{ value: 'Taken over', description: 'A person looks at the release and production from here.' }],
528
+ recommended: 'Taken over',
529
+ recommendationReason: 'The merge is in, but production may not run it, or may be broken.',
530
+ // No default: full auto runs unattended, and would take it at once and finish as merged.
531
+ default: null
532
+ })
533
+
534
+ return {
535
+ ...done('merged', `Merged at ${merged.revision}; Release: ${released.reason}`),
536
+ release: {
537
+ runStatus: released.status,
538
+ reason: released.reason,
539
+ ...(released.tag === undefined ? undefined : { tag: released.tag })
540
+ }
541
+ }
504
542
  }
505
543
  }).pipe(
506
544
  // Kitman fails a Step that runs past its time budget with this reason.