@alecsibilia/luca 14.0.0-alpha.34 → 14.0.0-alpha.36

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +5 -1
  3. package/board/package.json +1 -1
  4. package/board/server/describe-record.ts +19 -3
  5. package/board/server/luca-version.ts +1 -1
  6. package/board/server/reduce-board-agents.ts +6 -1
  7. package/board/server/reduce-board-update.ts +6 -0
  8. package/board/server/reduce-board.ts +1 -1
  9. package/engine/agents/claude-options.ts +2 -0
  10. package/engine/agents/role-effort.ts +2 -0
  11. package/engine/agents/role-instructions.ts +17 -2
  12. package/engine/agents/role-prompts.ts +2 -0
  13. package/engine/agents/role-results.ts +22 -3
  14. package/engine/agents/role-turns.ts +2 -0
  15. package/engine/agents/story-writer-instructions.ts +69 -0
  16. package/engine/config/config-fields.ts +10 -0
  17. package/engine/config/config-reload.ts +17 -1
  18. package/engine/config/engine-config-schema.ts +6 -0
  19. package/engine/config/engine-config.ts +1 -0
  20. package/engine/core/decide-limits-helpers.ts +37 -0
  21. package/engine/core/decide-many-tickets-helpers.ts +84 -0
  22. package/engine/core/decide-story.ts +98 -0
  23. package/engine/core/decide-usage-line.ts +1 -0
  24. package/engine/core/decide.ts +9 -1
  25. package/engine/core/execute-action.ts +13 -0
  26. package/engine/core/execute-build.ts +3 -0
  27. package/engine/core/execute-story.ts +398 -0
  28. package/engine/core/execute.ts +17 -3
  29. package/engine/core/final-review-text.ts +1 -1
  30. package/engine/core/live-file.ts +7 -0
  31. package/engine/core/pull-request-text.ts +82 -19
  32. package/engine/core/schedule-rules.ts +8 -2
  33. package/engine/core/session-close.ts +5 -1
  34. package/engine/core/story-asks.ts +100 -0
  35. package/engine/core/story-diff.ts +137 -0
  36. package/engine/core/story-prompt.ts +107 -0
  37. package/engine/core/story-serves.ts +82 -0
  38. package/engine/git/git-adapter.ts +58 -0
  39. package/engine/git/git-diff.ts +230 -0
  40. package/engine/guards/guard-roles.ts +2 -0
  41. package/engine/index.ts +54 -0
  42. package/engine/jev/jev-jobs.ts +8 -2
  43. package/engine/journal/agent-entries.ts +12 -0
  44. package/engine/journal/journal-entry-schema.ts +10 -0
  45. package/engine/journal/journal-kinds.ts +4 -0
  46. package/engine/journal/journal-record-schema.ts +10 -0
  47. package/engine/journal/replay.ts +21 -0
  48. package/engine/journal/run-state.ts +4 -0
  49. package/engine/journal/story-entries.ts +77 -0
  50. package/engine/journal/story-replay.ts +181 -0
  51. package/engine/journal/ticket-replay.ts +4 -0
  52. package/engine/shell/run-command.ts +4 -1
  53. package/engine/story/pr-story-main.ts +71 -0
  54. package/engine/story/story-cli.ts +274 -0
  55. package/engine/story/story-comments.ts +243 -0
  56. package/engine/story/story-github.ts +260 -0
  57. package/engine/story/story-home.ts +10 -0
  58. package/engine/story/story-parts.ts +220 -0
  59. package/engine/story/story-pick.ts +139 -0
  60. package/engine/story/story-render.ts +100 -0
  61. package/engine/story/story-save.ts +94 -0
  62. package/engine/story/story-schema.ts +257 -0
  63. package/engine/story/story-shift-cli.ts +136 -0
  64. package/engine/story/story-shift.ts +300 -0
  65. package/engine/story/story-store.ts +141 -0
  66. package/engine/story/testing/story-fixtures.ts +234 -0
  67. package/engine/testing/agent-turn-fixtures.ts +1 -0
  68. package/engine/testing/auto-retry-fixtures.ts +2 -0
  69. package/engine/testing/build-fixtures.ts +1 -0
  70. package/engine/testing/practice-repo.ts +13 -0
  71. package/engine/testing/practice-run.ts +3 -1
  72. package/engine/testing/run-fixtures.ts +12 -1
  73. package/engine/testing/story-run-fixtures.ts +306 -0
  74. package/engine/testing/unstick-fixtures.ts +77 -0
  75. package/engine/tracker/gh-retry.ts +12 -5
  76. package/engine/tracker/github-tracker-story.ts +85 -0
  77. package/engine/tracker/github-tracker.ts +8 -1
  78. package/engine/tracker/in-memory-tracker.ts +55 -0
  79. package/engine/tracker/tracker.ts +46 -0
  80. package/package.json +3 -3
  81. package/skills/luca-pr-address/SKILL.md +258 -0
  82. package/skills/luca-pr-address/references/judgment-calls.md +49 -0
  83. package/skills/luca-pr-address/references/plan-file.md +26 -0
  84. package/skills/luca-pr-address/references/story-refresh.md +34 -0
  85. package/skills/luca-pr-address/references/summary-comment.md +49 -0
  86. package/skills/luca-pr-address/references/workflow-script.md +437 -0
  87. package/skills/luca-pr-address/scripts/fetch-findings.ts +436 -0
  88. package/skills/luca-pr-address/scripts/pr-findings.ts +388 -0
  89. package/skills/luca-pr-address/scripts/summary-cover.ts +177 -0
  90. package/skills/luca-pr-address/scripts/testing-fixtures.ts +148 -0
  91. package/skills/luca-pr-prep/SKILL.md +150 -0
  92. package/skills/luca-pr-prep/references/story-example.json +201 -0
  93. package/skills/luca-pr-prep/references/story-format.md +98 -0
  94. package/skills/luca-pr-prep/scripts/pr-story.js +87 -0
  95. package/skills/luca-unstick/scripts/stuck-records.ts +82 -1
  96. package/skills/luca-unstick/scripts/stuck-summary.ts +11 -1
  97. package/skills/luca-watch/SKILL.md +1 -1
  98. package/skills/luca-watch/scripts/wait-for-change.ts +64 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.36
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.35
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.34
4
12
 
5
13
  No changes in this release.
package/README.md CHANGED
@@ -42,6 +42,10 @@ If a ticket gets stuck, Luca comments on the spec and waits for your one-word re
42
42
 
43
43
  After a run, type `/luca-retro` the same way to look back on it: it reads the run's journal, ranks what went wrong, and suggests a check, a rule, or a deletion for each, not a code change. It opens an issue for each one you pick, once you say yes.
44
44
 
45
+ To get any pull request ready for review, in any repo, type `/luca-pr-prep [PR number or URL]`: it writes the PR's story, a guide that walks a reviewer through the change in call order, with the why, the proof, and the blast radius of each changed region. In your own repos it posts the story on the PR and adds the `review-ready` label; in anyone else's it saves the story on your computer and posts nothing.
46
+
47
+ Once a PR has been reviewed, type `/luca-pr-address [PR number or URL]`: it reads every review comment and check alert, fixes what was asked or asks you a question, replies on each thread, runs the repo's checks, pushes, and brings the story up to date. It never declines one of your own comments, and it never merges.
48
+
45
49
  ## Commands
46
50
 
47
51
  | Command | What it does |
@@ -92,7 +96,7 @@ This is the one package that goes to npm. It's TypeScript source with no build a
92
96
  | ------ | ---------- |
93
97
  | `engine/` | The engine's source, from [`packages/engine/src`](../engine/src). Both bins point into it. |
94
98
  | `board/` | The board plugin's folder, from [`packages/board`](../board): `paseo-plugin.json`, its entry points, its client, server, and shared code, and its own `package.json`, so Paseo can install it as a folder source. |
95
- | `skills/` | Luca's own Claude Code skills, `luca-unstick`, `luca-retro` and `luca-spec-check`, from [`packages/engine/skills`](../engine/skills). `luca init` and `luca upgrade` copy every folder in it with a `SKILL.md` to `~/.claude/skills`. |
99
+ | `skills/` | Luca's own Claude Code skills, such as `luca-unstick`, `luca-retro`, `luca-spec-check`, `luca-pr-prep` and `luca-pr-address`, from [`packages/engine/skills`](../engine/skills). `luca init` and `luca upgrade` copy every folder in it with a `SKILL.md` to `~/.claude/skills`. |
96
100
  | `LICENSE` | The repo's license. |
97
101
 
98
102
  Test files and test helpers stay out. The engine and the board stay private workspace packages, and this package lists every package they import at runtime as its own dependency.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.34",
3
+ "version": "14.0.0-alpha.36",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -26,6 +26,7 @@ import {
26
26
  runReasonText,
27
27
  roleWords,
28
28
  shortSha,
29
+ STORY_WRITER,
29
30
  testsSentBackText,
30
31
  } from './reduce-board'
31
32
 
@@ -149,6 +150,13 @@ export const describeRecord = ({
149
150
  tone: 'info',
150
151
  })
151
152
  }
153
+ // The story-writer (#743) is no final review agent.
154
+ if (record.content.role === STORY_WRITER) {
155
+ return event({
156
+ text: "The story-writer writes the pull request's review story.",
157
+ tone: 'info',
158
+ })
159
+ }
152
160
  return record.ticket === null
153
161
  ? finalStartedText({ record })
154
162
  : startedText({ before, after, record })
@@ -156,6 +164,12 @@ export const describeRecord = ({
156
164
  if (record.content.role === LEARNER) {
157
165
  return learnerFinishedText({ record })
158
166
  }
167
+ if (record.content.role === STORY_WRITER) {
168
+ return event({
169
+ text: 'The story-writer answered; the engine checks and saves its story.',
170
+ tone: 'info',
171
+ })
172
+ }
159
173
  return record.ticket === null
160
174
  ? finalFinishedText({ record })
161
175
  : finishedText({ before, record })
@@ -164,9 +178,11 @@ export const describeRecord = ({
164
178
  const who =
165
179
  role === LEARNER
166
180
  ? 'The learner'
167
- : record.ticket === null
168
- ? `Final review: the ${roleWords({ role })}`
169
- : `${at}the ${role}`
181
+ : role === STORY_WRITER
182
+ ? 'The story-writer'
183
+ : record.ticket === null
184
+ ? `Final review: the ${roleWords({ role })}`
185
+ : `${at}the ${role}`
170
186
  return event({
171
187
  text: `${who} ${failureText({ failure })}: ${error.split('\n')[0] ?? ''}`,
172
188
  tone: failure === 'engine' ? 'warning' : 'danger',
@@ -1,2 +1,2 @@
1
1
  /** The Luca version of this board, stamped when the package was packed. */
2
- export const LUCA_VERSION: string = "14.0.0-alpha.34"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.36"
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * An agent's turn on a ticket or the final review: its start, how it
3
- * finished, and how it failed.
3
+ * finished, and how it failed. The learner's and the story-writer's turns
4
+ * are neither.
4
5
  */
5
6
  import type { BoardRecord } from './board-vocabulary'
6
7
  import { agentStarted } from './reduce-board-agent-start'
7
8
  import { countFindings, failureText, roleWords } from './reduce-board-text'
8
9
  import {
9
10
  LEARNER,
11
+ STORY_WRITER,
10
12
  finalTried,
11
13
  updateTicket,
12
14
  withLearner,
@@ -118,6 +120,9 @@ export const applyAgentKind = ({
118
120
  record: AgentRecord
119
121
  }): BoardState => {
120
122
  const { ticket } = record
123
+ // The story-writer (#743) has no ticket and is no final review agent;
124
+ // the board doesn't show the PR's story, so its turns change nothing.
125
+ if (record.content.role === STORY_WRITER) return state
121
126
  switch (record.kind) {
122
127
  case 'agent_started':
123
128
  // The learner (#370) has no ticket and is no final review agent.
@@ -21,6 +21,12 @@ export const DETAIL_MAX = 2000
21
21
  /** The learner's role (#370): its records have no ticket. */
22
22
  export const LEARNER = 'learner'
23
23
 
24
+ /**
25
+ * The story-writer's role (#743): it writes the opened PR's story. Its
26
+ * records have no ticket, and are never the final review's.
27
+ */
28
+ export const STORY_WRITER = 'story-writer'
29
+
24
30
  export const withLearner = ({
25
31
  state,
26
32
  learner,
@@ -71,7 +71,7 @@ export {
71
71
  shortSha,
72
72
  testsSentBackText,
73
73
  } from './reduce-board-text'
74
- export { LEARNER } from './reduce-board-update'
74
+ export { LEARNER, STORY_WRITER } from './reduce-board-update'
75
75
 
76
76
  /** Record kinds that don't mean the engine moved on after a stop. */
77
77
  const QUIET_KINDS = new Set([
@@ -12,6 +12,7 @@ import {
12
12
  IntegrationLensResultSchema,
13
13
  LearnerResultSchema,
14
14
  LensReviewResultSchema,
15
+ StoryWriterResultSchema,
15
16
  TestWriterResultSchema,
16
17
  TicketReviewResultSchema,
17
18
  type AgentRole,
@@ -133,6 +134,7 @@ const RESULT_SCHEMAS: Record<AgentRole, z.ZodType> = {
133
134
  'integration-lens': IntegrationLensResultSchema,
134
135
  'rules-lens': LensReviewResultSchema,
135
136
  learner: LearnerResultSchema,
137
+ 'story-writer': StoryWriterResultSchema,
136
138
  }
137
139
 
138
140
  /** A role's result schema as JSON Schema, for the SDK's structured output. */
@@ -30,6 +30,8 @@ export const ROLE_EFFORT: Record<AgentRole, EffortLevel> = {
30
30
  'security-lens': REVIEW_EFFORT,
31
31
  'integration-lens': REVIEW_EFFORT,
32
32
  'rules-lens': REVIEW_EFFORT,
33
+ // No config key (#743): it always runs at high.
34
+ 'story-writer': 'high',
33
35
  }
34
36
 
35
37
  /**
@@ -13,6 +13,11 @@ import {
13
13
  import { LEARNER, LEARNER_RULES } from './learner-instructions'
14
14
  import { LENS, REVIEWER } from './reviewer-instructions'
15
15
  import { lensOf, type AgentRole } from './role-results'
16
+ import {
17
+ STORY_WRITER,
18
+ STORY_WRITER_RULES,
19
+ storyFormat,
20
+ } from './story-writer-instructions'
16
21
  import { IMPLEMENTER, TEST_WRITER } from './writer-instructions'
17
22
 
18
23
  import type { EngineConfig } from '../config/engine-config'
@@ -27,8 +32,8 @@ import { canMessage } from '../messages/agent-messages'
27
32
  * and implementers with messaging are told how agent messages work;
28
33
  * reviewers aren't. Each final review lens gets its own focus; the
29
34
  * integration lens also gives the PR's Summary picture and Merge Danger
30
- * (#513). The learner (#370) gets its own task and rules: no shell,
31
- * nothing written. Every role is told about its read-only helpers (#627),
35
+ * (#513). The learner (#370) and the story-writer (#743) get their own
36
+ * task and rules: no shell, nothing written. Every role is told about its read-only helpers (#627),
32
37
  * and what its sandbox blocks (#540). The writers, as final review fixers
33
38
  * too, are told where the repo's rule files are (#646).
34
39
  *
@@ -53,6 +58,16 @@ export const roleInstructions = ({
53
58
  // The learner has no shell and no agent messages, and its rules differ.
54
59
  if (role === 'learner')
55
60
  return [LEARNER, HELPERS, SANDBOX, LEARNER_RULES].join('\n\n')
61
+ // The story-writer (#743) has no shell either, carries the skill's
62
+ // field guide word for word, and has rules of its own.
63
+ if (role === 'story-writer')
64
+ return [
65
+ STORY_WRITER,
66
+ storyFormat().trim(),
67
+ HELPERS,
68
+ SANDBOX,
69
+ STORY_WRITER_RULES,
70
+ ].join('\n\n')
56
71
  const lens = lensOf({ role })
57
72
  const task =
58
73
  lens !== null
@@ -75,6 +75,8 @@ const ROLE_TASKS: Record<AgentRole, string> = {
75
75
  learner:
76
76
  "Read this run's journal digest below and propose only durable lessons that will help future runs. " +
77
77
  "Pick each one's type, and list the ids of the shown memories that actually helped. Write nothing.",
78
+ 'story-writer':
79
+ "Write the review story of this run's pull request: its summary, its map, and its walk, from the code at its head. Write nothing.",
78
80
  }
79
81
 
80
82
  /**
@@ -30,9 +30,9 @@ export type LensRole = (typeof LENS_ROLES)[number]
30
30
 
31
31
  /**
32
32
  * The roles an agent can play: the ticket roles while a ticket is built, one
33
- * reviewer role per lens in the final review, and the learner at the end of
34
- * a run (#370). The final review's fixers are the test-writer and the
35
- * implementer.
33
+ * reviewer role per lens in the final review, the learner at the end of
34
+ * a run (#370), and the story-writer once the run's PR is open (#743). The
35
+ * final review's fixers are the test-writer and the implementer.
36
36
  */
37
37
  export const AgentRoleSchema = z.enum([
38
38
  'test-writer',
@@ -40,6 +40,7 @@ export const AgentRoleSchema = z.enum([
40
40
  'ticket-reviewer',
41
41
  ...LENS_ROLES,
42
42
  'learner',
43
+ 'story-writer',
43
44
  ])
44
45
 
45
46
  export type AgentRole = z.infer<typeof AgentRoleSchema>
@@ -370,6 +371,20 @@ export const LearnerResultSchema = z.object({
370
371
 
371
372
  export type LearnerResult = z.infer<typeof LearnerResultSchema>
372
373
 
374
+ /**
375
+ * The story-writer's result (#743): the parts of a PR's story only a reader
376
+ * of the code writes. Loose on purpose, so a wrong field inside is named by
377
+ * the story schema's own reader (`readStory`), not lost in a failed turn.
378
+ * Any other top-level key is dropped.
379
+ */
380
+ export const StoryWriterResultSchema = z.object({
381
+ summary: z.record(z.string(), z.unknown()),
382
+ map: z.record(z.string(), z.unknown()),
383
+ stops: z.array(z.unknown()),
384
+ })
385
+
386
+ export type StoryWriterResult = z.infer<typeof StoryWriterResultSchema>
387
+
373
388
  const lensResult = <Role extends LensRole>(role: Role) =>
374
389
  z.object({ role: z.literal(role), result: LensReviewResultSchema })
375
390
 
@@ -396,6 +411,10 @@ export const RoleResultSchema = z.discriminatedUnion('role', [
396
411
  }),
397
412
  lensResult('rules-lens'),
398
413
  z.object({ role: z.literal('learner'), result: LearnerResultSchema }),
414
+ z.object({
415
+ role: z.literal('story-writer'),
416
+ result: StoryWriterResultSchema,
417
+ }),
399
418
  ])
400
419
 
401
420
  export type RoleResult = z.infer<typeof RoleResultSchema>
@@ -20,4 +20,6 @@ export const MAX_TURNS: Record<AgentRole, number> = {
20
20
  'integration-lens': 100,
21
21
  'rules-lens': 100,
22
22
  learner: 40,
23
+ // It reads the whole PR's code to walk it (#743).
24
+ 'story-writer': 80,
23
25
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The story-writer's instructions (#743): a short opening for a writer with
3
+ * no shell, then the `/luca-pr-prep` skill's field guide word for word,
4
+ * the one source of what each story field holds.
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ /**
10
+ * The skill's field guide, next to the engine's source as it ships (see
11
+ * `skillsDir`): `skills/luca-pr-prep/references/story-format.md`.
12
+ */
13
+ export const STORY_FORMAT_PATH = join(
14
+ import.meta.dir,
15
+ '..',
16
+ '..',
17
+ 'skills',
18
+ 'luca-pr-prep',
19
+ 'references',
20
+ 'story-format.md'
21
+ )
22
+
23
+ /** The fields the engine fills in itself; the story-writer leaves them out. */
24
+ export const ENGINE_STORY_FIELDS = [
25
+ 'schema_version',
26
+ 'repo',
27
+ 'pr_number',
28
+ 'pr_url',
29
+ 'title',
30
+ 'base_ref',
31
+ 'head_ref',
32
+ 'head_sha',
33
+ 'base_sha',
34
+ 'written_at',
35
+ 'written_by',
36
+ 'summary.counts',
37
+ ]
38
+
39
+ export const STORY_WRITER = `# Your role: story-writer
40
+
41
+ You run once, after the run's pull request opened. You write its review story: the guide that walks a reviewer through the change in the order the code runs. You read the code in your folder, the run branch at the pull request's head, with Read, Grep and Glob. You have no shell. You write nothing.
42
+
43
+ Your prompt holds the pull request, its changed files, its diff (a file left out for size says so: read the file instead), the run's asks (its spec, and each ticket it built with its acceptance criteria), what the run was unsure about, and the repo's rule files, for flags.
44
+
45
+ The field guide below was written for a writer with a shell. Where this part and the guide disagree, this part wins:
46
+ - You have no shell, so you can't run \`gh\` or \`git\`. Everything you need from git is in your prompt.
47
+ - The engine fills in ${ENGINE_STORY_FIELDS.map((field) => `\`${field}\``).join(', ')}. Leave them out.
48
+ - You can't open the example file or run the helper's \`check\` command the guide points at. Skip those lines. The engine checks your story, and sends every problem back to you.
49
+ - \`summary.feedback_wanted.source\` is \`author\` when its text comes from your prompt's \`What the run was unsure about\` section, and \`guessed\` otherwise. This rule replaces the guide's.
50
+ - A serves ref points only at your prompt's \`The run's asks\`. Its \`label\` is exactly one of two forms, lower case, with nothing before or after: \`spec #12\` for the run's spec, or \`ticket #14, AC2\` for one acceptance criterion of a ticket the run built (the numbers are the issue's and the criterion's). Its \`url\` is that issue's URL, exactly as your prompt gives it. For a ticket ref, \`quote\` is that criterion's exact text; for a spec ref, it is a sentence copied from the spec's body, or null. A change that serves nothing the spec or tickets asked for has \`found: false\`, no refs, and a note that says so.
51
+
52
+ Your result (structured output): \`summary\` (without \`counts\`), \`map\` and \`stops\`, as the guide describes them.`
53
+
54
+ /**
55
+ * The story-writer's own rules, in place of the writers' and reviewers'
56
+ * (whose results it doesn't share), as the learner has its own.
57
+ */
58
+ export const STORY_WRITER_RULES = `## Rules for the story-writer
59
+ - Plain code (the engine) drives this run. It fills in the fields it knows, checks your story, and saves it.
60
+ - Never create, edit, or delete a file. The network is off.
61
+ - Finish by giving your structured result. A turn without one counts as failed.`
62
+
63
+ let format: string | null = null
64
+
65
+ /** The skill's field guide, read once. */
66
+ export const storyFormat = (): string => {
67
+ format ??= readFileSync(STORY_FORMAT_PATH, 'utf8')
68
+ return format
69
+ }
@@ -104,6 +104,16 @@ export const modelsOf = ({ config }: { config: EngineConfig }): ModelsConfig =>
104
104
  export const autoRetryOf = ({ config }: { config: EngineConfig }): boolean =>
105
105
  config.auto_retry ?? true
106
106
 
107
+ /**
108
+ * Whether a run that opens its pull request writes the PR's story (#743):
109
+ * the config's `pr_story`, else `true`.
110
+ *
111
+ * @example
112
+ * prStoryOf({ config: { ...config, pr_story: false } }) // false
113
+ */
114
+ export const prStoryOf = ({ config }: { config: EngineConfig }): boolean =>
115
+ config.pr_story ?? true
116
+
107
117
  /**
108
118
  * The repo's build-cache paths and globs (#721): the config's
109
119
  * `build_cache_paths`, else none.
@@ -3,7 +3,7 @@ import omit from 'lodash/omit'
3
3
  import reduce from 'lodash/reduce'
4
4
  import { z } from 'zod'
5
5
 
6
- import { autoRetryOf, buildCachePathsOf } from './config-fields'
6
+ import { autoRetryOf, buildCachePathsOf, prStoryOf } from './config-fields'
7
7
  import type { EngineConfig } from './engine-config'
8
8
 
9
9
  /**
@@ -35,6 +35,8 @@ import type { EngineConfig } from './engine-config'
35
35
  * - `build_cache_paths`: the build caches cloned into each new ticket
36
36
  * worktree (#721). Each new worktree reads it then, so a new value counts
37
37
  * from the next ticket started.
38
+ * - `pr_story`: whether the run writes its PR's story (#743). Turned off
39
+ * on a resume after the story step began, the step just stops.
38
40
  */
39
41
  export const RELOADED_FIELDS = [
40
42
  'prepare',
@@ -46,6 +48,7 @@ export const RELOADED_FIELDS = [
46
48
  'leftover_allow',
47
49
  'auto_retry',
48
50
  'build_cache_paths',
51
+ 'pr_story',
49
52
  ] as const
50
53
 
51
54
  /**
@@ -151,6 +154,12 @@ export const ConfigChangeSchema = z.discriminatedUnion('field', [
151
154
  from: z.array(z.string().min(1)),
152
155
  to: z.array(z.string().min(1)),
153
156
  }),
157
+ // Never left out: a config without it reads as `true`.
158
+ z.object({
159
+ field: z.literal('pr_story'),
160
+ from: z.boolean(),
161
+ to: z.boolean(),
162
+ }),
154
163
  ])
155
164
 
156
165
  export type ConfigChange = z.infer<typeof ConfigChangeSchema>
@@ -217,6 +226,11 @@ export const configChanges = ({
217
226
  from: buildCachePathsOf({ config: current }),
218
227
  to: buildCachePathsOf({ config: next }),
219
228
  },
229
+ {
230
+ field: 'pr_story',
231
+ from: prStoryOf({ config: current }),
232
+ to: prStoryOf({ config: next }),
233
+ },
220
234
  ]
221
235
  return all.filter(({ from, to }) => !isEqual(from, to))
222
236
  }
@@ -261,6 +275,8 @@ const applyChange = ({
261
275
  return { ...config, auto_retry: change.to }
262
276
  case 'build_cache_paths':
263
277
  return { ...config, build_cache_paths: change.to }
278
+ case 'pr_story':
279
+ return { ...config, pr_story: change.to }
264
280
  }
265
281
  }
266
282
 
@@ -417,6 +417,12 @@ export const EngineConfigSchema = z.object({
417
417
  * written before it may lack it (`buildCachePathsOf`).
418
418
  */
419
419
  build_cache_paths: z.array(BuildCachePathSchema).default([]).optional(),
420
+ /**
421
+ * Whether a run that opens its pull request writes the PR's story
422
+ * (#743). Left out, `true`; a config written before it may lack it
423
+ * (`prStoryOf`).
424
+ */
425
+ pr_story: z.boolean().default(true).optional(),
420
426
  })
421
427
 
422
428
  export type EngineConfig = z.infer<typeof EngineConfigSchema>
@@ -27,6 +27,7 @@ export {
27
27
  modelsOf,
28
28
  prepareConcurrencyOf,
29
29
  prepareTimeoutOf,
30
+ prStoryOf,
30
31
  testTimeoutOf,
31
32
  } from './config-fields'
32
33
  export {
@@ -0,0 +1,37 @@
1
+ import { decide } from './decide'
2
+
3
+ import type { JournalEntry } from '../journal/journal-record'
4
+ import {
5
+ agentStarted,
6
+ intakePassed,
7
+ practiceTicket,
8
+ runBranchCreated,
9
+ ticketBuilt,
10
+ withInstalls,
11
+ } from '../testing/build-fixtures'
12
+ import { recordsFrom } from '../testing/intake-fixtures'
13
+
14
+ /**
15
+ * Helpers shared by the tests of the decision step around plan limits and
16
+ * usage (`decide-limits.test.ts` and `decide-limits-usage.test.ts`).
17
+ */
18
+
19
+ export const TICKET = practiceTicket({ number: 11 })
20
+
21
+ /** Decide on a run with one ticket (#11) and these entries after intake. */
22
+ export const decideAfter = (entries: JournalEntry[]) =>
23
+ decide({
24
+ records: recordsFrom({
25
+ entries: [
26
+ ...intakePassed({ tickets: [TICKET] }),
27
+ ...withInstalls({ entries }),
28
+ ],
29
+ }),
30
+ })
31
+
32
+ /** Ticket #11 up to its test-writer's launch, which has not finished. */
33
+ export const testWriterRunning = (): JournalEntry[] => [
34
+ runBranchCreated(),
35
+ ...ticketBuilt({ ticket: 11 }).slice(0, 2),
36
+ agentStarted({ ticket: 11, role: 'test-writer' }),
37
+ ]
@@ -0,0 +1,84 @@
1
+ import { decideSteps } from './decide'
2
+
3
+ import type { TicketSnapshot } from '../intake/intake-schemas'
4
+ import type { JournalEntry } from '../journal/journal-record'
5
+ import {
6
+ agentStarted,
7
+ commitMade,
8
+ gatesRun,
9
+ implemented,
10
+ intakePassed,
11
+ joinClashed,
12
+ leftoverScan,
13
+ practiceTicket,
14
+ reviewed,
15
+ runBranchCreated,
16
+ SESSIONS,
17
+ ticketApproved,
18
+ ticketRebased,
19
+ withInstalls,
20
+ } from '../testing/build-fixtures'
21
+ import { recordsFrom } from '../testing/intake-fixtures'
22
+
23
+ /**
24
+ * Helpers shared by the tests of the decision step for many tickets in one
25
+ * run (`decide-many-tickets.test.ts` and `decide-many-tickets-clash.test.ts`).
26
+ */
27
+
28
+ export const SUM = practiceTicket({ number: 11, title: 'Add sum' })
29
+ export const PRODUCT = practiceTicket({ number: 12, title: 'Add product' })
30
+ export const AVERAGE = practiceTicket({
31
+ number: 13,
32
+ title: 'Add average',
33
+ blockers: [11, 12],
34
+ })
35
+
36
+ /** Every action that can run now, on a run with these tickets and entries. */
37
+ export const stepsAfter = ({
38
+ tickets,
39
+ entries,
40
+ }: {
41
+ tickets: TicketSnapshot[]
42
+ entries: JournalEntry[]
43
+ }) =>
44
+ decideSteps({
45
+ records: recordsFrom({
46
+ entries: [
47
+ ...intakePassed({ tickets }),
48
+ ...withInstalls({ entries }),
49
+ ],
50
+ }),
51
+ })
52
+
53
+ /** The same, on a run with #11 and #12 only. */
54
+ export const twoSteps = (entries: JournalEntry[]) =>
55
+ stepsAfter({ tickets: [SUM, PRODUCT], entries })
56
+
57
+ /** The same, on a run with #11 only. */
58
+ export const oneSteps = (entries: JournalEntry[]) =>
59
+ stepsAfter({ tickets: [SUM], entries })
60
+
61
+ /** #11 approved, then its join clashed. */
62
+ export const clashed = (): JournalEntry[] => [
63
+ runBranchCreated(),
64
+ ...ticketApproved({ ticket: 11 }),
65
+ joinClashed({ ticket: 11 }),
66
+ ]
67
+
68
+ /** #11 rebased after `src/index.ts` clashed. */
69
+ export const codeClash = (): JournalEntry =>
70
+ ticketRebased({ ticket: 11, cause: 'clash', code: ['src/index.ts'] })
71
+
72
+ /** After a rebase, the ticket fixed and approved again, up to its green commit. */
73
+ export const fixedOnRunBranch = (): JournalEntry[] => [
74
+ agentStarted({
75
+ ticket: 11,
76
+ role: 'implementer',
77
+ follow_up_of: SESSIONS.implementer,
78
+ }),
79
+ implemented({ ticket: 11 }),
80
+ gatesRun({ ticket: 11, target: 'ticket', ok: true }),
81
+ leftoverScan({ ticket: 11, stage: 'green' }),
82
+ commitMade({ ticket: 11, stage: 'green' }),
83
+ reviewed({ ticket: 11 }),
84
+ ]