@atolis-hq/wake 0.1.3 → 0.1.4

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.
@@ -2,6 +2,10 @@ import { Octokit } from '@octokit/rest';
2
2
  export function createGitHubClient(token) {
3
3
  const octokit = new Octokit({ auth: token });
4
4
  return {
5
+ async getAuthenticatedLogin() {
6
+ const { data } = await octokit.rest.users.getAuthenticated();
7
+ return data.login;
8
+ },
5
9
  // `maxResults` is a hard cap on issues returned, not just a page size:
6
10
  // octokit.paginate otherwise walks every page regardless of page size,
7
11
  // which burns GitHub's rate limit (a "fourth budget") on repos with many
@@ -105,8 +105,14 @@ function normalizeTicketCommentEvent(input) {
105
105
  // not be able to unblock a blocked issue; only an actual human reply should.
106
106
  // The marker check catches Wake's own comments even when expectedEcho
107
107
  // missed them (crash-recovery gap) or the agent account type is 'User'.
108
+ // The selfLogin check catches a comment posted by direct API/CLI call
109
+ // (e.g. a `revise` run replying via `gh api`) that never carries the
110
+ // marker at all — without it, Wake's own reply looks human and
111
+ // re-triggers another run against itself (#258 follow-up incident: 99
112
+ // duplicate replies from exactly this gap).
108
113
  botAuthoredComment: input.comment.user?.type === 'Bot' ||
109
- (input.comment.body ?? '').includes(wakeCommentMarker),
114
+ (input.comment.body ?? '').includes(wakeCommentMarker) ||
115
+ (input.selfLogin !== undefined && input.comment.user?.login === input.selfLogin),
110
116
  },
111
117
  });
112
118
  }
@@ -297,6 +303,7 @@ export function createGitHubIssuesWorkSource(deps) {
297
303
  issueNumber: issue.number,
298
304
  comment,
299
305
  ingestedAt,
306
+ ...(deps.selfLogin === undefined ? {} : { selfLogin: deps.selfLogin }),
300
307
  ...(known?.updatedAt === undefined
301
308
  ? {}
302
309
  : { existingUpdatedAt: known.updatedAt }),
@@ -22,8 +22,17 @@ function reviewThreadRootId(comment, byId) {
22
22
  function reviewThreadResourceUri(repo, prNumber, rootId) {
23
23
  return buildResourceUri('github', 'pr-review-thread', `${repo}#${prNumber}/rt_${rootId}`);
24
24
  }
25
- function isBotAuthored(comment) {
26
- return comment.user?.type === 'Bot' || (comment.body ?? '').includes(wakeCommentMarker);
25
+ // selfLogin is the only reliable signal for an agent-authored comment posted
26
+ // by direct API/CLI call (e.g. a `revise` run replying via `gh api
27
+ // .../replies`), which never carries the marker and whose account `type` is
28
+ // `User`, not `Bot` — checking only those two would let the agent's own
29
+ // replies look human and re-trigger another run against themselves (#258
30
+ // follow-up incident: 99 duplicate replies from exactly this gap). Optional
31
+ // because it's undefined when the GitHub client is a fake/test double.
32
+ function isBotAuthored(comment, selfLogin) {
33
+ return (comment.user?.type === 'Bot' ||
34
+ (comment.body ?? '').includes(wakeCommentMarker) ||
35
+ (selfLogin !== undefined && comment.user?.login === selfLogin));
27
36
  }
28
37
  export function createGitHubPullRequestActivitySource(deps) {
29
38
  function repoAndNumberFromPrUri(resourceUri) {
@@ -119,7 +128,7 @@ export function createGitHubPullRequestActivitySource(deps) {
119
128
  resourceUri,
120
129
  },
121
130
  },
122
- derivedHints: { botAuthoredComment: isBotAuthored(comment) },
131
+ derivedHints: { botAuthoredComment: isBotAuthored(comment, deps.selfLogin) },
123
132
  }));
124
133
  }
125
134
  const reviews = await deps.client.listReviews(ref.owner, ref.repo, ref.number, perPage);
@@ -150,7 +159,7 @@ export function createGitHubPullRequestActivitySource(deps) {
150
159
  resourceUri,
151
160
  },
152
161
  },
153
- derivedHints: { botAuthoredComment: isBotAuthored(review) },
162
+ derivedHints: { botAuthoredComment: isBotAuthored(review, deps.selfLogin) },
154
163
  }));
155
164
  }
156
165
  const reviewComments = await deps.client.listReviewComments(ref.owner, ref.repo, ref.number, perPage);
@@ -188,7 +197,7 @@ export function createGitHubPullRequestActivitySource(deps) {
188
197
  reviewThread: { path: comment.path, line: comment.line ?? comment.original_line ?? undefined },
189
198
  },
190
199
  },
191
- derivedHints: { botAuthoredComment: isBotAuthored(comment) },
200
+ derivedHints: { botAuthoredComment: isBotAuthored(comment, deps.selfLogin) },
192
201
  }));
193
202
  }
194
203
  }
@@ -3,18 +3,30 @@ import { chooseAction, workflowForProjection } from '../../domain/workflows.js';
3
3
  import { branchNameForIssue } from '../git/git-workspace-manager.js';
4
4
  import { loadPromptTemplate, renderPromptTemplate } from './prompt-templates.js';
5
5
  const questionCommandPattern = /^\/question\b/i;
6
+ function reviewCommentApiId(comment) {
7
+ // github-pull-request-activity-source.ts composites review-comment ids as
8
+ // `pr-review-comment-<id>`; strip that prefix back off to recover the raw
9
+ // id `gh api .../pulls/comments/<id>/replies` needs.
10
+ if (comment.reviewThread === undefined) {
11
+ return undefined;
12
+ }
13
+ const match = /^pr-review-comment-(.+)$/.exec(comment.id);
14
+ return match?.[1];
15
+ }
6
16
  function formatComment(comment) {
7
17
  const surfaceLine = comment.reviewThread !== undefined
8
18
  ? `Surface: review comment on ${comment.reviewThread.path}${comment.reviewThread.line === undefined ? '' : `:${comment.reviewThread.line}`}`
9
19
  : comment.resourceUri !== undefined
10
20
  ? `Surface: ${comment.resourceUri}`
11
21
  : 'Surface: issue thread';
22
+ const reviewCommentId = reviewCommentApiId(comment);
12
23
  return [
13
24
  '<wake-comment>',
14
25
  `Author: ${comment.author.login}`,
15
26
  `Created: ${comment.createdAt}`,
16
27
  `Bot-authored: ${comment.isBotAuthored ? 'yes' : 'no'}`,
17
28
  surfaceLine,
29
+ ...(reviewCommentId === undefined ? [] : [`Review-comment-id: ${reviewCommentId}`]),
18
30
  'Body:',
19
31
  comment.body,
20
32
  '</wake-comment>',
@@ -10,6 +10,10 @@ function isAwaitingApproval(issue) {
10
10
  const approvedCommandPattern = /^\/approved\b/i;
11
11
  const changesCommandPattern = /^\/changes\b/i;
12
12
  const questionCommandPattern = /^\/question\b/i;
13
+ // The action Wake runs when a correlated PR gets new reviewer feedback while
14
+ // the work item is awaiting approval. Not configurable per workflow: it's a
15
+ // lateral response to a PR surface, not a workflow stage.
16
+ const reviewFeedbackAction = 'revise';
13
17
  function matchesCommand(body, pattern) {
14
18
  return body
15
19
  .split(/\r?\n/)
@@ -157,6 +161,27 @@ export function createPolicyEngine() {
157
161
  }
158
162
  return { approved, pendingAction };
159
163
  },
164
+ // Callers must try resolveApprovalTransition first and only fall back to
165
+ // this when it returns null. resolveApprovalTransition doesn't check
166
+ // resourceUri, so a PR-surface comment that happens to carry an explicit
167
+ // /approved, /changes, or /question command is deliberately still routed
168
+ // there — this function only ever sees comments resolveApprovalTransition
169
+ // already passed on (plain PR feedback with no command).
170
+ resolvePendingReviewFeedback(issue) {
171
+ if (!isAwaitingApproval(issue)) {
172
+ return null;
173
+ }
174
+ const latestHumanComment = latestUnhandledHumanComment(issue);
175
+ // resourceUri is set only on comments folded from a correlated PR/review
176
+ // surface (schema.ts's commentSnapshotSchema: "absent = the originating
177
+ // issue thread"). A comment on that surface is itself the deliberate
178
+ // act — unlike an issue-thread reply, it doesn't need an explicit
179
+ // /approved-style command to count as a decision.
180
+ if (latestHumanComment === undefined || latestHumanComment.resourceUri === undefined) {
181
+ return null;
182
+ }
183
+ return reviewFeedbackAction;
184
+ },
160
185
  qualifiesForMint(unresolved, config) {
161
186
  const resourceUri = unresolved.sourceRefs.resourceUri;
162
187
  if (resourceUri === undefined) {
@@ -33,6 +33,9 @@ function latestHumanCommentId(candidate) {
33
33
  // projection passed in as `candidate`/`projection`, since the completion
34
34
  // event that would update lastHandledCommentId for *this* run hasn't been
35
35
  // folded yet at the point this runs).
36
+ function isReviewThreadResourceUri(resourceUri) {
37
+ return resourceUri.split(':')[1] === 'pr-review-thread';
38
+ }
36
39
  function isFreshTriggeringComment(candidate) {
37
40
  const context = candidate.context;
38
41
  const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
@@ -115,8 +118,20 @@ export function createTickRunner(deps) {
115
118
  // latestComment.resourceUri would misroute the reply to whatever
116
119
  // surface last happened to comment, even long after that comment
117
120
  // was already replied to.
121
+ //
122
+ // Never threads a pr-review-thread surface specifically: this is
123
+ // Wake's own status/approval-request/question card, a milestone
124
+ // message, not a targeted reply to one inline comment — burying it
125
+ // as a reply deep in a single review thread makes it easy to miss.
126
+ // Omitting resourceUri here falls back to sourceOrigin in
127
+ // sink-router.ts, landing it on the correlated issue (or, for a
128
+ // standalone PR-only work item, GitHub's shared issue/PR comments
129
+ // endpoint posts it as a top-level PR comment instead). Replies to
130
+ // individual review threads are the agent's own job now — see
131
+ // prompts/revise.md — via `gh api .../replies`, not this card.
118
132
  ...(input.projection.latestComment?.resourceUri === undefined ||
119
- !isFreshTriggeringComment(input.projection)
133
+ !isFreshTriggeringComment(input.projection) ||
134
+ isReviewThreadResourceUri(input.projection.latestComment.resourceUri)
120
135
  ? {}
121
136
  : { resourceUri: input.projection.latestComment.resourceUri }),
122
137
  },
@@ -196,7 +211,8 @@ export function createTickRunner(deps) {
196
211
  return false;
197
212
  }
198
213
  if (isAwaitingApproval(projection)) {
199
- return policy.resolveApprovalTransition(projection) !== null;
214
+ return (policy.resolveApprovalTransition(projection) !== null ||
215
+ policy.resolvePendingReviewFeedback(projection) !== null);
200
216
  }
201
217
  const nextAction = policy.chooseAction(projection, workflow) ??
202
218
  policy.chooseRetryActionAfterHumanReply(projection);
@@ -951,9 +967,16 @@ export function createTickRunner(deps) {
951
967
  if (isAwaitingApproval(candidate)) {
952
968
  const approvalResolution = policy.resolveApprovalTransition(candidate);
953
969
  if (approvalResolution === null) {
954
- return { status: 'idle' };
970
+ const reviewAction = policy.resolvePendingReviewFeedback(candidate);
971
+ if (reviewAction === null) {
972
+ return { status: 'idle' };
973
+ }
974
+ action = reviewAction;
975
+ const workflowAction = chooseWorkflowAction(candidate, workflow);
976
+ claimedStage = workflowAction?.stage ?? candidate.wake.stage;
977
+ workspaceMode = workflowAction?.workspace ?? 'none';
955
978
  }
956
- if (approvalResolution.approved) {
979
+ else if (approvalResolution.approved) {
957
980
  const approvalId = `approval-${candidate.issue.number}-${deps.clock.now().getTime()}`;
958
981
  const approvedAt = deps.clock.now().toISOString();
959
982
  const nextStage = lifecycle.nextStageFromSentinel(candidate.wake.stage, 'DONE', workflow);
@@ -1000,15 +1023,28 @@ export function createTickRunner(deps) {
1000
1023
  nextStage,
1001
1024
  };
1002
1025
  }
1003
- action = approvalResolution.pendingAction;
1004
- const workflowAction = chooseWorkflowAction(candidate, workflow);
1005
- claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1006
- workspaceMode = workflowAction?.workspace ?? 'none';
1026
+ else {
1027
+ action = approvalResolution.pendingAction;
1028
+ const workflowAction = chooseWorkflowAction(candidate, workflow);
1029
+ claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1030
+ workspaceMode = workflowAction?.workspace ?? 'none';
1031
+ }
1007
1032
  }
1008
1033
  else {
1009
1034
  const workflowAction = chooseWorkflowAction(candidate, workflow);
1010
- const nextAction = workflowAction?.action ??
1011
- policy.chooseRetryActionAfterHumanReply(candidate);
1035
+ // Retry takes priority over the stage's fresh default action.
1036
+ // chooseWorkflowAction almost always returns a non-null action for
1037
+ // any valid stage (e.g. 'implement'), so checking it first would
1038
+ // silently discard chooseRetryActionAfterHumanReply's decision
1039
+ // whenever a FAILED/BLOCKED run left a lateral action (like
1040
+ // `revise`) unfinished with a fresh human reply waiting — instead
1041
+ // of resuming that action, it would restart the stage from
1042
+ // scratch (#258 follow-up incident: a FAILED `revise` run fell
1043
+ // back to a full fresh `implement` run and lost the PR-feedback
1044
+ // context).
1045
+ const nextAction = policy.chooseRetryActionAfterHumanReply(candidate) ??
1046
+ workflowAction?.action ??
1047
+ null;
1012
1048
  if (nextAction === null) {
1013
1049
  return { status: 'idle' };
1014
1050
  }
@@ -378,8 +378,8 @@ export const wakeConfigSchema = z.object({
378
378
  intervalMs: z.number().int().positive().default(60 * 1000),
379
379
  // Cap for the idle-cadence backoff (#81): consecutive idle ticks double the
380
380
  // sleep, up to this ceiling, so a quiet repo doesn't poll every intervalMs.
381
- maxIntervalMs: z.number().int().positive().default(10 * 60 * 1000),
382
- }).default({ intervalMs: 60 * 1000, maxIntervalMs: 10 * 60 * 1000 }),
381
+ maxIntervalMs: z.number().int().positive().default(5 * 60 * 1000),
382
+ }).default({ intervalMs: 60 * 1000, maxIntervalMs: 5 * 60 * 1000 }),
383
383
  transcripts: z.object({
384
384
  enabled: z.boolean().default(false),
385
385
  retainAfterWorkspaceCleanup: z.boolean().default(false),
package/dist/src/main.js CHANGED
@@ -178,6 +178,14 @@ export async function buildRuntime(args) {
178
178
  const githubClient = config.sources.github.enabled
179
179
  ? createGitHubClient(await resolveGitHubToken())
180
180
  : undefined;
181
+ // Resolved once alongside the client: the login Wake itself posts as, so
182
+ // both GitHub work sources can recognize a comment Wake's own agent posted
183
+ // by direct API/CLI call (not through formatWakeComment, so it never
184
+ // carries the wake:agent marker) as bot-authored instead of a fresh human
185
+ // reply that would re-trigger another run against itself.
186
+ const selfLogin = githubClient !== undefined
187
+ ? await githubClient.getAuthenticatedLogin()
188
+ : undefined;
181
189
  const artifactVerifier = prTrackingEnabled && githubClient !== undefined
182
190
  ? createGitHubArtifactVerifier({ client: githubClient })
183
191
  : undefined;
@@ -188,6 +196,7 @@ export async function buildRuntime(args) {
188
196
  config,
189
197
  resourceIndex,
190
198
  now: () => systemClock.now(),
199
+ ...(selfLogin === undefined ? {} : { selfLogin }),
191
200
  })
192
201
  : await createFileBackedFakeTicketingSystem({
193
202
  fixturePath: stateStore.paths.issueFixtureFile,
@@ -202,6 +211,7 @@ export async function buildRuntime(args) {
202
211
  config,
203
212
  resourceIndex,
204
213
  now: () => systemClock.now(),
214
+ ...(selfLogin === undefined ? {} : { selfLogin }),
205
215
  })
206
216
  : null;
207
217
  const workSource = createWorkSourceFanIn([
@@ -1 +1 @@
1
- export const wakeVersion = "0.1.3+g38d84e4";
1
+ export const wakeVersion = "0.1.4+g6aa4c64";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1,61 @@
1
+ ---
2
+ stage: implement
3
+ permissionMode: acceptEdits
4
+ allowedTools: Bash(git *), Bash(gh *), Bash(npm *), Bash(curl *), Bash(jq *), Edit, Write, Read, Glob, Grep, WebSearch, WebFetch
5
+ extraArgs:
6
+ maxTurns: 100
7
+ skipApproval: false
8
+ ---
9
+ {{#if isStart}}
10
+ You are Wake, running the REVISE action for {{workItemKey}}, responding to
11
+ feedback on the pull request already open for this work item.
12
+
13
+ Your current working directory is a git checkout of {{repo}}, already on
14
+ branch {{branch}}, with an open pull request against main.
15
+
16
+ Wake will provide the comment(s) that triggered this run below in a
17
+ delimited untrusted data block. Each one is tagged with the surface it came
18
+ from — a specific file/line on the PR (a review comment) or the PR
19
+ conversation itself.
20
+ {{else}}
21
+ Resuming the REVISE action session for {{workItemKey}}.
22
+
23
+ Your current working directory is still the git checkout of {{repo}} on
24
+ branch {{branch}}. Continue from where you left off rather than starting
25
+ over, unless the new comments below change the approach.
26
+
27
+ New comments since your last turn (excludes Wake/bot comments) are provided
28
+ below in a delimited untrusted data block, tagged with the surface each one
29
+ came from.
30
+ {{/if}}
31
+
32
+ For each new comment, decide independently what it actually needs — do not
33
+ apply one blanket response to the whole batch:
34
+ - A concrete, reasonable change: make it, commit, and push to {{branch}}.
35
+ - A question, or something you'd want clarified before acting on it: answer
36
+ it in your response. Do not change code solely because a question was
37
+ asked.
38
+ - A request that seems mistaken, suboptimal, or in tension with the existing
39
+ approach: don't implement it reflexively. Explain your reasoning, and
40
+ either justify the current approach or propose an alternative. Reserve
41
+ pushing back for requests you have a concrete, substantive reason to
42
+ disagree with — when a reasonable person could go either way, prefer
43
+ making the change over defending your original choice.
44
+
45
+ Reply routing: for every review comment in this batch (each is tagged below
46
+ with its `Review-comment-id`), reply directly on that comment's own thread
47
+ yourself — do not rely on anything else to do this for you. Look up the PR
48
+ number if you need it with `gh pr view --json number -q .number`, then
49
+ reply with:
50
+ `gh api repos/{{repo}}/pulls/<pr-number>/comments/<review-comment-id>/replies -f body="<!-- wake:agent -->
51
+
52
+ <reply>"`
53
+ The leading `<!-- wake:agent -->` line is required on every reply body —
54
+ without it, Wake cannot tell your own reply apart from a new human comment,
55
+ which would make Wake reply to itself again. Do not reply to the same
56
+ comment more than once. Your prose response here (outside of any `gh api`
57
+ calls) is only a short summary for Wake's own status update — it is posted
58
+ separately (to the issue, or as a top-level PR comment), not attached to any
59
+ specific thread — so don't rely on it to answer a specific comment; put the
60
+ actual answer in the threaded reply.
61
+ Do not merge the pull request yourself.