@codyswann/lisa 4.87.2 → 4.87.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.
Files changed (164) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +36 -21
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +20 -14
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/dist/opencode/plugin-templates/parity-safety-net.sh +36 -21
  10. package/package.json +4 -4
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa/.codex-plugin/skills/lisa-parity-code-review/SKILL.md +3 -2
  14. package/plugins/lisa/.codex-plugin/skills/lisa-parity-code-simplifier/SKILL.md +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-parity-coderabbit/SKILL.md +1 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-session-manager/SKILL.md +25 -6
  17. package/plugins/lisa/.codex-plugin/skills/lisa-session-manager/scripts/cli-human.mjs +37 -10
  18. package/plugins/lisa/.codex-plugin/skills/lisa-session-manager/scripts/cli-setup.mjs +22 -6
  19. package/plugins/lisa/.codex-plugin/skills/lisa-session-manager/scripts/cli-usage.mjs +22 -12
  20. package/plugins/lisa/.codex-plugin/skills/lisa-session-manager/scripts/cli-workers.mjs +21 -0
  21. package/plugins/lisa/.codex-plugin/skills/lisa-session-manager/scripts/human-answer.mjs +76 -21
  22. package/plugins/lisa/.codex-plugin/skills/lisa-session-manager/scripts/ledger.mjs +6 -2
  23. package/plugins/lisa/.codex-plugin/skills/lisa-session-manager/scripts/token-migrate.mjs +15 -3
  24. package/plugins/lisa/hooks/parity-safety-net.sh +36 -21
  25. package/plugins/lisa/skills/lisa-parity-code-review/SKILL.md +3 -2
  26. package/plugins/lisa/skills/lisa-parity-code-simplifier/SKILL.md +1 -1
  27. package/plugins/lisa/skills/lisa-parity-coderabbit/SKILL.md +1 -1
  28. package/plugins/lisa/skills/lisa-session-manager/SKILL.md +25 -6
  29. package/plugins/lisa/skills/lisa-session-manager/scripts/cli-human.mjs +37 -10
  30. package/plugins/lisa/skills/lisa-session-manager/scripts/cli-setup.mjs +22 -6
  31. package/plugins/lisa/skills/lisa-session-manager/scripts/cli-usage.mjs +22 -12
  32. package/plugins/lisa/skills/lisa-session-manager/scripts/cli-workers.mjs +21 -0
  33. package/plugins/lisa/skills/lisa-session-manager/scripts/human-answer.mjs +76 -21
  34. package/plugins/lisa/skills/lisa-session-manager/scripts/ledger.mjs +6 -2
  35. package/plugins/lisa/skills/lisa-session-manager/scripts/token-migrate.mjs +15 -3
  36. package/plugins/lisa-agy/hooks/parity-safety-net.sh +36 -21
  37. package/plugins/lisa-agy/plugin.json +1 -1
  38. package/plugins/lisa-agy/skills/lisa-parity-code-review/SKILL.md +3 -2
  39. package/plugins/lisa-agy/skills/lisa-parity-code-simplifier/SKILL.md +1 -1
  40. package/plugins/lisa-agy/skills/lisa-parity-coderabbit/SKILL.md +1 -1
  41. package/plugins/lisa-agy/skills/lisa-session-manager/SKILL.md +25 -6
  42. package/plugins/lisa-agy/skills/lisa-session-manager/scripts/cli-human.mjs +37 -10
  43. package/plugins/lisa-agy/skills/lisa-session-manager/scripts/cli-setup.mjs +22 -6
  44. package/plugins/lisa-agy/skills/lisa-session-manager/scripts/cli-usage.mjs +22 -12
  45. package/plugins/lisa-agy/skills/lisa-session-manager/scripts/cli-workers.mjs +21 -0
  46. package/plugins/lisa-agy/skills/lisa-session-manager/scripts/human-answer.mjs +76 -21
  47. package/plugins/lisa-agy/skills/lisa-session-manager/scripts/ledger.mjs +6 -2
  48. package/plugins/lisa-agy/skills/lisa-session-manager/scripts/token-migrate.mjs +15 -3
  49. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  52. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +1 -1
  56. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +36 -21
  58. package/plugins/lisa-copilot/skills/lisa-parity-code-review/SKILL.md +3 -2
  59. package/plugins/lisa-copilot/skills/lisa-parity-code-simplifier/SKILL.md +1 -1
  60. package/plugins/lisa-copilot/skills/lisa-parity-coderabbit/SKILL.md +1 -1
  61. package/plugins/lisa-copilot/skills/lisa-session-manager/SKILL.md +25 -6
  62. package/plugins/lisa-copilot/skills/lisa-session-manager/scripts/cli-human.mjs +37 -10
  63. package/plugins/lisa-copilot/skills/lisa-session-manager/scripts/cli-setup.mjs +22 -6
  64. package/plugins/lisa-copilot/skills/lisa-session-manager/scripts/cli-usage.mjs +22 -12
  65. package/plugins/lisa-copilot/skills/lisa-session-manager/scripts/cli-workers.mjs +21 -0
  66. package/plugins/lisa-copilot/skills/lisa-session-manager/scripts/human-answer.mjs +76 -21
  67. package/plugins/lisa-copilot/skills/lisa-session-manager/scripts/ledger.mjs +6 -2
  68. package/plugins/lisa-copilot/skills/lisa-session-manager/scripts/token-migrate.mjs +15 -3
  69. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +36 -21
  71. package/plugins/lisa-cursor/skills/lisa-parity-code-review/SKILL.md +3 -2
  72. package/plugins/lisa-cursor/skills/lisa-parity-code-simplifier/SKILL.md +1 -1
  73. package/plugins/lisa-cursor/skills/lisa-parity-coderabbit/SKILL.md +1 -1
  74. package/plugins/lisa-cursor/skills/lisa-session-manager/SKILL.md +25 -6
  75. package/plugins/lisa-cursor/skills/lisa-session-manager/scripts/cli-human.mjs +37 -10
  76. package/plugins/lisa-cursor/skills/lisa-session-manager/scripts/cli-setup.mjs +22 -6
  77. package/plugins/lisa-cursor/skills/lisa-session-manager/scripts/cli-usage.mjs +22 -12
  78. package/plugins/lisa-cursor/skills/lisa-session-manager/scripts/cli-workers.mjs +21 -0
  79. package/plugins/lisa-cursor/skills/lisa-session-manager/scripts/human-answer.mjs +76 -21
  80. package/plugins/lisa-cursor/skills/lisa-session-manager/scripts/ledger.mjs +6 -2
  81. package/plugins/lisa-cursor/skills/lisa-session-manager/scripts/token-migrate.mjs +15 -3
  82. package/plugins/lisa-devin/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-devin/.devin-plugin/plugin.json +1 -1
  84. package/plugins/lisa-devin/hooks/parity-safety-net.sh +36 -21
  85. package/plugins/lisa-devin/skills/lisa-parity-code-review/SKILL.md +3 -2
  86. package/plugins/lisa-devin/skills/lisa-parity-code-simplifier/SKILL.md +1 -1
  87. package/plugins/lisa-devin/skills/lisa-parity-coderabbit/SKILL.md +1 -1
  88. package/plugins/lisa-devin/skills/lisa-session-manager/SKILL.md +25 -6
  89. package/plugins/lisa-devin/skills/lisa-session-manager/scripts/cli-human.mjs +37 -10
  90. package/plugins/lisa-devin/skills/lisa-session-manager/scripts/cli-setup.mjs +22 -6
  91. package/plugins/lisa-devin/skills/lisa-session-manager/scripts/cli-usage.mjs +22 -12
  92. package/plugins/lisa-devin/skills/lisa-session-manager/scripts/cli-workers.mjs +21 -0
  93. package/plugins/lisa-devin/skills/lisa-session-manager/scripts/human-answer.mjs +76 -21
  94. package/plugins/lisa-devin/skills/lisa-session-manager/scripts/ledger.mjs +6 -2
  95. package/plugins/lisa-devin/skills/lisa-session-manager/scripts/token-migrate.mjs +15 -3
  96. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  98. package/plugins/lisa-expo-agy/plugin.json +1 -1
  99. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +1 -1
  103. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  105. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  106. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +1 -1
  110. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  112. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  113. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  115. package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +1 -1
  117. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  119. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  120. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +1 -1
  123. package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +1 -1
  124. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  125. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  126. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  127. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  128. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  129. package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +1 -1
  130. package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +1 -1
  131. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  132. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  133. package/plugins/lisa-rails-agy/plugin.json +1 -1
  134. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  135. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  136. package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +1 -1
  137. package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +1 -1
  138. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  139. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  140. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  141. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  142. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  143. package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +1 -1
  144. package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +1 -1
  145. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  146. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  147. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  148. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  149. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  150. package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +1 -1
  151. package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +1 -1
  152. package/plugins/src/base/hooks/parity-safety-net.sh +36 -21
  153. package/plugins/src/base/skills/lisa-parity-code-review/SKILL.md +3 -2
  154. package/plugins/src/base/skills/lisa-parity-code-simplifier/SKILL.md +1 -1
  155. package/plugins/src/base/skills/lisa-parity-coderabbit/SKILL.md +1 -1
  156. package/plugins/src/base/skills/lisa-session-manager/SKILL.md +25 -6
  157. package/plugins/src/base/skills/lisa-session-manager/scripts/cli-human.mjs +37 -10
  158. package/plugins/src/base/skills/lisa-session-manager/scripts/cli-setup.mjs +22 -6
  159. package/plugins/src/base/skills/lisa-session-manager/scripts/cli-usage.mjs +22 -12
  160. package/plugins/src/base/skills/lisa-session-manager/scripts/cli-workers.mjs +21 -0
  161. package/plugins/src/base/skills/lisa-session-manager/scripts/human-answer.mjs +76 -21
  162. package/plugins/src/base/skills/lisa-session-manager/scripts/ledger.mjs +6 -2
  163. package/plugins/src/base/skills/lisa-session-manager/scripts/token-migrate.mjs +15 -3
  164. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +62 -4
@@ -16,7 +16,10 @@
16
16
  * answer, and an ordinary reply such as `[x] option 1` is not a machine tag
17
17
  * (#4561). Only then is the author matched, on stable ids only: a GitHub
18
18
  * login or numeric id, a Jira accountId, a Linear user id, never a display
19
- * name or email, which anyone can set. The text is carried as data.
19
+ * name or email, which anyone can set. An all-digit allowed entry is a
20
+ * numeric id and matches only the `id` or `accountId` field, never a login,
21
+ * which its owner chooses and may make all digits (#4571). The text is
22
+ * carried as data.
20
23
  *
21
24
  * The question the answer must follow is the comment whose id the manager
22
25
  * recorded last; with no recorded id, the latest comment that STARTS with
@@ -46,6 +49,17 @@ const COMMENT_ID_KEYS = ["id", "node_id"];
46
49
  /** The author fields that are stable ids, never settable display text. */
47
50
  const STABLE_ID_KEYS = ["login", "id", "accountId"];
48
51
 
52
+ /**
53
+ * The author fields an all-digit allowed entry may match. A GitHub login can
54
+ * be all digits and is chosen by its owner, so a numeric operator id must
55
+ * never be matched against a login: user `583231` would otherwise pass as the
56
+ * operator whose numeric id is 583231 (CodySwannGT/lisa#4571).
57
+ */
58
+ const NUMERIC_ID_KEYS = ["id", "accountId"];
59
+
60
+ /** An allowed entry that is a numeric account id, never a login. */
61
+ const ALL_DIGITS = /^\d+$/;
62
+
49
63
  /**
50
64
  * The string or number values of `keys` on `value`, as strings.
51
65
  * @param {unknown} value - Object to read
@@ -126,37 +140,78 @@ function questionIndex(rows, managerCommentIds, authorOf) {
126
140
  * the manager's latest question comment ({@link questionIndex}) that is not
127
141
  * a machine's ({@link isMachineAuthoredComment}), has text of its own
128
142
  * outside quoted lines (a comment made only of `>` lines quotes someone
129
- * else and answers nothing), and whose author's stable id is allowed. Comments are taken in the order given (chronological).
143
+ * else and answers nothing), whose author's stable id is allowed, and that
144
+ * `refuses` does not refuse. Comments are taken in the order given
145
+ * (chronological).
146
+ *
147
+ * A refused reply (one that looks like it holds a credential) is skipped and
148
+ * reported in `refused`, never returned: returning it made every later cycle
149
+ * pick the same reply and fail on it again, so a corrected reply posted after
150
+ * it was never read (CodySwannGT/lisa#4571).
130
151
  * @param {{comments: unknown, allowed: readonly string[],
131
- * managerCommentIds?: readonly (string|number)[]}} input - The item's
132
- * comments, the answerer ids, and the ids the manager posted
152
+ * managerCommentIds?: readonly (string|number)[],
153
+ * refuses?: (text: string) => string | null | undefined}} input - The
154
+ * item's comments, the answerer ids, the ids the manager posted, and why a
155
+ * reply's text may not be queued (null when it may)
133
156
  * @returns {{answer: {text: string, author: string, at: string|null} | null,
134
- * reason: string}} The answer, or null with why there is none
157
+ * reason: string, refused?: {id: string|null, author: string, why: string}[]}}
158
+ * The answer, or null with why there is none, and (only when any were)
159
+ * the replies refused
135
160
  */
136
- export function pickItemAnswer({ comments, allowed, managerCommentIds = [] }) {
161
+ export function pickItemAnswer({
162
+ comments,
163
+ allowed,
164
+ managerCommentIds = [],
165
+ refuses = () => null,
166
+ }) {
137
167
  if (!Array.isArray(comments)) {
138
168
  throw new Error('stdin must be JSON {"comments":[...]}');
139
169
  }
140
170
  const rows = comments.filter(row => row && typeof row === "object");
141
- const allow = new Set(allowed.map(String));
142
- const authorOf = row =>
143
- idsOf(row.author ?? row.user, STABLE_ID_KEYS).find(id => allow.has(id));
171
+ const allow = allowed.map(String);
172
+ const numeric = new Set(allow.filter(id => ALL_DIGITS.test(id)));
173
+ const named = new Set(allow.filter(id => !ALL_DIGITS.test(id)));
174
+ const authorOf = row => {
175
+ const author = row.author ?? row.user;
176
+ return (
177
+ idsOf(author, NUMERIC_ID_KEYS).find(id => numeric.has(id)) ??
178
+ idsOf(author, STABLE_ID_KEYS).find(id => named.has(id))
179
+ );
180
+ };
144
181
  const asked = questionIndex(rows, managerCommentIds, authorOf);
182
+ const refused = [];
145
183
  if (asked === -1) return { answer: null, reason: "no-question-comment" };
146
- const reply = rows
184
+ const withRefused = result =>
185
+ refused.length > 0 ? { ...result, refused } : result;
186
+ const replies = rows
147
187
  .slice(asked + 1)
148
188
  .filter(row => !isMachineAuthoredComment(row, { managerCommentIds }))
149
- .find(
189
+ .filter(
150
190
  row => authorOf(row) !== undefined && unquoted(bodyOf(row)).trim() !== ""
151
191
  );
152
- if (!reply) return { answer: null, reason: "no-answer-yet" };
153
- const at = reply.createdAt ?? reply.created ?? reply.created_at ?? null;
154
- return {
155
- answer: {
156
- text: bodyOf(reply).trim(),
157
- author: authorOf(reply),
158
- at: typeof at === "string" ? at : null,
159
- },
160
- reason: "answered",
161
- };
192
+ for (const reply of replies) {
193
+ const text = bodyOf(reply).trim();
194
+ const why = refuses(text);
195
+ if (why) {
196
+ refused.push({
197
+ id: idsOf(reply, COMMENT_ID_KEYS)[0] ?? null,
198
+ author: authorOf(reply),
199
+ why,
200
+ });
201
+ continue;
202
+ }
203
+ const at = reply.createdAt ?? reply.created ?? reply.created_at ?? null;
204
+ return withRefused({
205
+ answer: {
206
+ text,
207
+ author: authorOf(reply),
208
+ at: typeof at === "string" ? at : null,
209
+ },
210
+ reason: "answered",
211
+ });
212
+ }
213
+ return withRefused({
214
+ answer: null,
215
+ reason: refused.length > 0 ? "credential-refused" : "no-answer-yet",
216
+ });
162
217
  }
@@ -297,7 +297,9 @@ export function recordSpawn(
297
297
  startedAt: at,
298
298
  lastProgressAt: at,
299
299
  logsHash: null,
300
- pendingQuestion: null,
300
+ // Kept, never reset: only clearPendingQuestion clears a question, once
301
+ // its answer is delivered and the marker removed (lisa#4571).
302
+ pendingQuestion: previous.pendingQuestion ?? null,
301
303
  reason: null,
302
304
  finishedAt: null,
303
305
  attempts,
@@ -354,7 +356,9 @@ export function recordFinish(projectRoot, { ref, status, reason }, seams) {
354
356
  status,
355
357
  reason,
356
358
  finishedAt: at,
357
- pendingQuestion: null,
359
+ // Kept: a question recorded before the finish still holds its answer
360
+ // and the tracker marker the manager must remove (lisa#4571).
361
+ pendingQuestion: previous.pendingQuestion ?? null,
358
362
  history: [
359
363
  ...(previous.history ?? []),
360
364
  { event: "finish", at, status, reason },
@@ -62,8 +62,9 @@ export function digest(value) {
62
62
  * of anything else in it (values are never kept or reported).
63
63
  * @param {string} stateDir - Resolved state dir
64
64
  * @param {Pick<import("node:fs"), "existsSync" | "readFileSync">} fs - Seam
65
- * @returns {{ path: string, token: string | undefined, others: string[] } |
66
- * null} The file, or null when absent
65
+ * @returns {{ path: string, token: string | undefined, tokenLines: number,
66
+ * others: string[] } | null} The file, or null when absent; `token` is the
67
+ * first `TELEGRAM_BOT_TOKEN` line's value and `tokenLines` counts them all
67
68
  */
68
69
  export function readEnvFile(stateDir, fs) {
69
70
  const file = path.join(stateDir, TOKEN_FILE);
@@ -74,10 +75,12 @@ export function readEnvFile(stateDir, fs) {
74
75
  .map((line, at) => ({ line: line.trim(), at }))
75
76
  .filter(({ line }) => line !== "" && !line.startsWith("#"))
76
77
  .map(({ line, at }) => ({ match: ENV_LINE.exec(line), at }));
77
- const token = entries.find(({ match }) => match?.[1] === TOKEN_VAR)?.match;
78
+ const tokenLines = entries.filter(({ match }) => match?.[1] === TOKEN_VAR);
79
+ const token = tokenLines[0]?.match;
78
80
  return {
79
81
  path: file,
80
82
  token: token ? token[2].replace(/^(['"])(.*)\1$/, "$2") : undefined,
83
+ tokenLines: tokenLines.length,
81
84
  others: entries
82
85
  .filter(({ match }) => match?.[1] !== TOKEN_VAR)
83
86
  .map(({ match, at }) => match?.[1] ?? `an unrecognised line ${at + 1}`),
@@ -109,6 +112,15 @@ export function removable(file) {
109
112
  */
110
113
  export function assertMigratable(file) {
111
114
  if (file === null) return;
115
+ // Only the first line's token is verified, and the whole file is deleted
116
+ // after, so a later line would be lost unverified (CodySwannGT/lisa#4571).
117
+ if ((file.tokenLines ?? 0) > 1) {
118
+ throw setupError(
119
+ `${file.path} has ${file.tokenLines} ${TOKEN_VAR} lines, so which ` +
120
+ `token is meant is ambiguous. Keep exactly one, then run store-token ` +
121
+ `again. Nothing was changed.`
122
+ );
123
+ }
112
124
  if (file.others.length > 0) {
113
125
  throw setupError(
114
126
  `${file.path} holds ${file.others.join(", ")} besides ${TOKEN_VAR}, so ` +
@@ -2862,6 +2862,50 @@ export function readReviewSubstitutes(
2862
2862
  return substitutesAtHead(comments, names, headSha, canWrite);
2863
2863
  }
2864
2864
 
2865
+ /**
2866
+ * Review states that are a VERDICT — the ones that set or clear an objection.
2867
+ * `COMMENTED` and `PENDING` are absent on purpose: neither withdraws one.
2868
+ */
2869
+ const DECISIVE_REVIEW_STATES = new Set([
2870
+ REVIEW_OBJECTION_STATE,
2871
+ "APPROVED",
2872
+ "DISMISSED",
2873
+ ]);
2874
+
2875
+ /**
2876
+ * Reduces reviews to each author's latest decisive review.
2877
+ *
2878
+ * Later wins by `submittedAt` when both reviews carry a parseable one; when
2879
+ * either does not, the later array position wins, which is the reviews
2880
+ * endpoint's chronological order.
2881
+ * @param {ReadonlyArray<{state?: string, author?: string, submittedAt?: string}>} reviews - Reviews, oldest first
2882
+ * @returns {Map<string|symbol, {state?: string, author?: string, submittedAt?: string}>} Latest decisive review per author; each author-less review keys alone
2883
+ */
2884
+ function latestDecisiveByAuthor(reviews) {
2885
+ const latest = new Map();
2886
+ for (const review of reviews) {
2887
+ const state = String(review?.state ?? "").toUpperCase();
2888
+ if (!DECISIVE_REVIEW_STATES.has(state)) continue;
2889
+ // An author-less review (a deleted account) is never reduced against
2890
+ // another: two of them are not the same reviewer, so a missing login can
2891
+ // never let one's approval withdraw the other's objection.
2892
+ const author =
2893
+ typeof review?.author === "string" && review.author !== ""
2894
+ ? review.author
2895
+ : Symbol("unknown-author");
2896
+ const prior = latest.get(author);
2897
+ const priorAt = Date.parse(prior?.submittedAt ?? "");
2898
+ const thisAt = Date.parse(review?.submittedAt ?? "");
2899
+ const earlier =
2900
+ prior !== undefined &&
2901
+ Number.isFinite(priorAt) &&
2902
+ Number.isFinite(thisAt) &&
2903
+ thisAt < priorAt;
2904
+ if (!earlier) latest.set(author, review);
2905
+ }
2906
+ return latest;
2907
+ }
2908
+
2865
2909
  /**
2866
2910
  * Whether an objection STANDS at this head, from the review objects.
2867
2911
  *
@@ -2880,12 +2924,26 @@ export function readReviewSubstitutes(
2880
2924
  * those were at the merged head. An unfiltered read would have reddened the
2881
2925
  * other two for objections their authors had already addressed.
2882
2926
  *
2927
+ * ## Each reviewer's LATEST verdict at head, not any verdict at head
2928
+ *
2929
+ * A reviewer can object and then approve the SAME commit — a bot running a
2930
+ * request-changes workflow submits `CHANGES_REQUESTED`, the threads get
2931
+ * resolved, and it submits `APPROVED` against the unchanged head a minute
2932
+ * later. GitHub's own `reviewDecision` clears on that approval; counting every
2933
+ * `CHANGES_REQUESTED` at head kept the gate red on an objection its author had
2934
+ * withdrawn (CodySwannGT/lisa#4571). So the head-filtered reviews are reduced
2935
+ * to each author's latest DECISIVE review — `APPROVED`, `CHANGES_REQUESTED` or
2936
+ * `DISMISSED` — ordered by `submittedAt`, with the endpoint's chronological
2937
+ * order breaking ties and standing in when a timestamp is missing. A
2938
+ * `COMMENTED` review does not withdraw an objection, for the same reason it
2939
+ * does not clear GitHub's `reviewDecision`: commenting is not a verdict.
2940
+ *
2883
2941
  * ## Reported, never inferred
2884
2942
  *
2885
2943
  * This says only what the review objects say. It does NOT consult the status
2886
2944
  * description, and nothing about a waiver, a rate limit or an entitlement
2887
2945
  * reaches it — that is the point of having a second input at all.
2888
- * @param {ReadonlyArray<{state?: string, commitSha?: string, author?: string}>} reviews - Review objects
2946
+ * @param {ReadonlyArray<{state?: string, commitSha?: string, author?: string, submittedAt?: string}>} reviews - Review objects, in the endpoint's chronological order
2889
2947
  * @param {string|undefined} headSha - The head commit under inspection
2890
2948
  * @returns {{objected: boolean, reviewers: string[], staleDropped: number}} What stands at head
2891
2949
  */
@@ -2895,7 +2953,7 @@ export function readObjectionAtHead(reviews, headSha) {
2895
2953
  headSha === undefined || headSha === ""
2896
2954
  ? []
2897
2955
  : all.filter(review => review?.commitSha === headSha);
2898
- const objections = atHead.filter(
2956
+ const objections = [...latestDecisiveByAuthor(atHead).values()].filter(
2899
2957
  review =>
2900
2958
  String(review?.state ?? "").toUpperCase() === REVIEW_OBJECTION_STATE
2901
2959
  );
@@ -2917,7 +2975,7 @@ export function readObjectionAtHead(reviews, headSha) {
2917
2975
  * carries `commit_id`, which is what makes the staleness filter possible.
2918
2976
  * @param {string|number} pr - The pull request
2919
2977
  * @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
2920
- * @returns {Array<{state: string, commitSha: string, author: string}>} Review objects
2978
+ * @returns {Array<{state: string, commitSha: string, author: string, submittedAt: string}>} Review objects
2921
2979
  * @throws {Error} When the slug cannot be resolved or `gh` cannot answer
2922
2980
  */
2923
2981
  export function fetchPullRequestReviews(pr, repo) {
@@ -2929,7 +2987,7 @@ export function fetchPullRequestReviews(pr, repo) {
2929
2987
  }
2930
2988
  return ghApiPaginatedArray(
2931
2989
  `repos/${slug}/pulls/${pr}/reviews?per_page=100`,
2932
- "[.[] | {state: .state, commitSha: .commit_id, author: .user.login}]"
2990
+ "[.[] | {state: .state, commitSha: .commit_id, author: .user.login, submittedAt: .submitted_at}]"
2933
2991
  );
2934
2992
  }
2935
2993