typeclaw 0.28.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/index.ts +37 -5
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +102 -41
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagents.ts +7 -0
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-reply.ts +1 -0
- package/src/agent/tools/channel-send.ts +2 -1
- package/src/agent/tools/spawn-subagent.ts +21 -0
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
- package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
- package/src/bundled-plugins/memory/memory-logger.ts +3 -3
- package/src/bundled-plugins/operator/operator.ts +2 -0
- package/src/bundled-plugins/planner/index.ts +11 -0
- package/src/bundled-plugins/planner/planner.ts +282 -0
- package/src/bundled-plugins/planner/skills/general.ts +65 -0
- package/src/bundled-plugins/planner/skills/project.ts +69 -0
- package/src/bundled-plugins/researcher/index.ts +11 -0
- package/src/bundled-plugins/researcher/researcher.ts +226 -0
- package/src/bundled-plugins/researcher/skills/general.ts +105 -0
- package/src/bundled-plugins/researcher/write-report.ts +107 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +29 -11
- package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
- package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
- package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
- package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
- package/src/bundled-plugins/scout/scout.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
- package/src/channels/adapters/discord-bot.ts +38 -11
- package/src/channels/adapters/github/inbound.ts +74 -9
- package/src/channels/adapters/github/index.ts +36 -11
- package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
- package/src/channels/adapters/github/review-state.ts +71 -2
- package/src/channels/adapters/kakaotalk-classify.ts +2 -2
- package/src/channels/adapters/kakaotalk.ts +2 -2
- package/src/channels/adapters/slack-bot-classify.ts +1 -1
- package/src/channels/adapters/slack-bot.ts +3 -0
- package/src/channels/adapters/telegram-bot.ts +3 -0
- package/src/channels/engagement.ts +12 -7
- package/src/channels/github-rereview-guard.ts +32 -8
- package/src/channels/github-review-claim.ts +53 -6
- package/src/channels/router.ts +44 -9
- package/src/channels/schema.ts +4 -3
- package/src/channels/types.ts +17 -6
- package/src/cli/init.ts +13 -2
- package/src/cli/ui.ts +64 -0
- package/src/config/config.ts +21 -15
- package/src/container/start.ts +5 -1
- package/src/init/dockerfile.ts +19 -56
- package/src/init/hatching.ts +1 -1
- package/src/init/index.ts +5 -1
- package/src/run/bundled-plugins.ts +4 -0
- package/src/server/index.ts +24 -5
- package/src/shared/host-locale.ts +27 -0
- package/src/shared/protocol.ts +1 -1
- package/src/shared/wordmark.ts +19 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
- package/src/skills/typeclaw-config/SKILL.md +32 -32
- package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
- package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
- package/src/tui/banner.ts +19 -0
- package/src/tui/format.ts +34 -0
- package/src/tui/index.ts +121 -22
- package/src/tui/theme.ts +26 -1
- package/src/tunnels/providers/cloudflare-named.ts +15 -4
- package/src/tunnels/providers/cloudflare-quick.ts +15 -4
- package/src/tunnels/providers/cloudflared-binary.ts +11 -0
- package/typeclaw.schema.json +15 -7
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { LoadableSkill } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
export const DOC_REVIEW_SKILL_NAME = 'doc-review'
|
|
4
|
+
|
|
5
|
+
export const DOC_REVIEW_SKILL_DESCRIPTION =
|
|
6
|
+
'Review a document written to inform or instruct a reader. Covers purpose and audience fit, completeness for the stated job, accuracy of examples and claims, navigability, staleness, terminology consistency, and accessibility — for any kind of document, with a scoped lens for technical docs when the target is one.'
|
|
7
|
+
|
|
8
|
+
export const DOC_REVIEW_SKILL_CONTENT = `# doc-review
|
|
9
|
+
|
|
10
|
+
You have been asked to review a document — anything written to inform or instruct a reader. Do not assume a kind. The craft below is universal and applies whatever the document turns out to be; the technical-docs section near the end is one specialization you apply only when the target is in fact developer documentation. Read the target, let it tell you what it is, and review it on its own terms. Apply all of this on top of the reviewer's neutral output contract (severity-tagged findings, evidence quotes, suggestions, verdict).
|
|
11
|
+
|
|
12
|
+
## How to acquire the target
|
|
13
|
+
|
|
14
|
+
- **A file path** — \`read\` it. \`ls\` the surrounding directory to see how this page fits the larger set; a document is reviewed in the context of the set it belongs to.
|
|
15
|
+
- **A URL** — \`web_fetch\` it. If it is a private site the fetch cannot reach, say so in \`<summary>\` and review what the payload provided.
|
|
16
|
+
- **A PR or diff that touches docs** — \`gh pr diff <n>\` for the changed pages; \`read\` the surrounding sections the diff did not touch, because a doc edit is judged against the whole page's flow, not the hunk alone.
|
|
17
|
+
- **A doc set / directory** — \`ls\` and \`grep\` for the navigation or index file; a finding about findability needs the table of contents, not just one page.
|
|
18
|
+
- **Verify external claims.** If the document cites a law, a standard, a price, an SLA, a statistic, or a linked source, check it with \`web_search\`/\`web_fetch\` before letting it stand.
|
|
19
|
+
|
|
20
|
+
## State the document's job before reading for defects
|
|
21
|
+
|
|
22
|
+
Every document exists to let a specific reader do or understand a specific thing. Before looking for problems, answer two questions and hold them while you read: **Who is this for?** and **What should they be able to do or know after reading it?** Most documentation defects are a mismatch between the page and the answer to one of those. A finding is strongest when it names which reader the document fails and why. This is your private grounding — keep the restatement out of \`<summary>\`.
|
|
23
|
+
|
|
24
|
+
## What to look for
|
|
25
|
+
|
|
26
|
+
These apply to any document:
|
|
27
|
+
|
|
28
|
+
1. **Purpose / audience fit.** The document is pitched at the wrong reader: jargon and unexplained acronyms for a lay audience, or hand-holding for an expert one; a policy written for lawyers handed to new hires. Name the mismatch.
|
|
29
|
+
2. **Completeness for the stated job.** A missing step, an undocumented edge case, a process that stops before the reader's actual goal, a policy that does not say what happens on violation. The gap is a finding when it leaves the reader unable to finish what the document promised.
|
|
30
|
+
3. **Accuracy of examples and claims.** Anything the document asserts as fact must be correct: a quoted figure, a referenced rule or standard, a worked example, a screenshot, a sample. A wrong example or an unsupported load-bearing claim is a real defect regardless of document type — for code docs this means samples that do not run; for a policy it means a cited regulation that says something else.
|
|
31
|
+
4. **Missing prerequisites or assumptions.** The document assumes access, a prior step, a role, a tool, or background the reader does not have and is never told to get. State the assumption the reader cannot meet.
|
|
32
|
+
5. **Navigability / findability.** A page unreachable from the index, no clear next step where the reader needs one, no anchor for the thing a reader will search for, a long document with no structure to scan. Hard-to-navigate is a defect when it blocks the reader from reaching the part they need.
|
|
33
|
+
6. **Broken or stale cross-references.** Links that 404, "see the section below" pointing at a section that no longer exists, references to a renamed page or a superseded policy version.
|
|
34
|
+
7. **Staleness.** Content that describes an older state than the one in force: an old release's flags, a deprecated process, a price or date that has moved, a screenshot of a UI that changed. Cite the current value against the stale one.
|
|
35
|
+
8. **Terminology / consistency.** The same concept called several names with no statement they are the same ("workspace" / "project" / "folder"; "member" / "user" / "seat"). Pick the canonical term and flag the drift.
|
|
36
|
+
9. **Accessibility.** Images with no alt text, heading levels that skip (h1 → h3), meaning carried by color alone, bare "click here" link text. Real findings, not nits, when they block a reader using assistive tech.
|
|
37
|
+
|
|
38
|
+
## When the target is technical documentation
|
|
39
|
+
|
|
40
|
+
Developer docs have a failure mode worth naming explicitly: the page is the wrong *type* for what the reader needs. When reviewing technical docs, classify the page against the four Diátaxis modes, because the right content for one is wrong for another:
|
|
41
|
+
|
|
42
|
+
- **Tutorial** — learning-oriented. A guaranteed-to-succeed lesson for a newcomer. Concrete, linear, no detours.
|
|
43
|
+
- **How-to guide** — task-oriented. Steps to achieve one stated goal for someone who already knows the basics.
|
|
44
|
+
- **Reference** — information-oriented. Dry, complete, accurate description of the API/CLI/config. No teaching.
|
|
45
|
+
- **Explanation** — understanding-oriented. The "why" and the trade-offs. No step-by-step.
|
|
46
|
+
|
|
47
|
+
A page that mixes modes — a tutorial padded with architecture explanation, a reference that drifts into opinion — fails the reader who came for one of them. For technical docs, also hold examples to a higher bar: a code sample is a *claim that it runs*, so trace it against the real CLI/API surface (\`read\` the source, \`gh pr diff\`, the changelog) and cite any sample that uses a removed flag, a wrong import, or a renamed subcommand. This Diátaxis lens and runnable-sample check do NOT apply to non-technical documents — do not force a policy or an onboarding page into a "tutorial vs reference" frame.
|
|
48
|
+
|
|
49
|
+
## What NOT to find
|
|
50
|
+
|
|
51
|
+
- **Formatter / linter territory.** Trailing whitespace, line length, fenced-block language tags, table alignment. Assume a docs linter ran.
|
|
52
|
+
- **House-style the page follows.** Second person, sentence-case headings, "e.g." vs "for example" — if the document is consistent with its house style, that is not a finding. Only the deviation is.
|
|
53
|
+
- **Restating the document as a finding.** "This page documents the start command" / "this policy covers expenses" is not a review.
|
|
54
|
+
- **Rewriting for taste.** A sentence you would have phrased differently but that reads clearly for its reader is not a finding. Clarity is the bar, not your preferred cadence.
|
|
55
|
+
- **Generic "add more examples" / "make it clearer".** Without naming the specific step, field, or passage that is under-documented or unclear, it is noise.
|
|
56
|
+
|
|
57
|
+
## Severity hints specific to docs
|
|
58
|
+
|
|
59
|
+
- **blocker** — An example or claim that is factually wrong and will lead the reader astray (a sample that fails for everyone, a cited rule that says the opposite). A prerequisite gap that strands the reader at step one. An audience mismatch so severe the intended reader cannot use the document at all.
|
|
60
|
+
- **concern** — A stale reference that still mostly works but will mislead, a missing prerequisite for a later step, a completeness gap that blocks an edge case, terminology drift that will confuse a newcomer, an accessibility defect that degrades but does not block.
|
|
61
|
+
- **nit** — A single awkward sentence, a missing "next step" link, a minor terminology wobble in an aside. Optional.
|
|
62
|
+
- **praise** — A document that genuinely lands its reader: a tutorial that reaches a working state, a policy that is unambiguous on the hard case, an explanation that makes a difficult concept click. Rare.
|
|
63
|
+
|
|
64
|
+
## Verdict mapping
|
|
65
|
+
|
|
66
|
+
- **approve** — Publishable. The document serves its reader; any gaps are nits.
|
|
67
|
+
- **request-changes** — At least one blocker: a wrong example or claim, an audience mismatch that defeats the purpose, a prerequisite gap that strands the reader.
|
|
68
|
+
- **comment** — Useful observations without a clean accept/reject. Common for an early draft or a partial review of a large doc set.
|
|
69
|
+
|
|
70
|
+
## Final output
|
|
71
|
+
|
|
72
|
+
Return findings inside the reviewer's neutral \`<review>\` block. Do NOT invent your own output format.
|
|
73
|
+
`
|
|
74
|
+
|
|
75
|
+
export const DOC_REVIEW_SKILL: LoadableSkill = {
|
|
76
|
+
name: DOC_REVIEW_SKILL_NAME,
|
|
77
|
+
description: DOC_REVIEW_SKILL_DESCRIPTION,
|
|
78
|
+
content: DOC_REVIEW_SKILL_CONTENT,
|
|
79
|
+
}
|
|
@@ -20,7 +20,7 @@ You have been asked to review something that does not clearly fit a specific dom
|
|
|
20
20
|
|
|
21
21
|
A general review is the hardest because there are no domain shortcuts. Replace shortcuts with discipline:
|
|
22
22
|
|
|
23
|
-
1. **State the target's purpose in your own words.** What is the artifact trying to achieve? Who is it for?
|
|
23
|
+
1. **State the target's purpose in your own words — to yourself, as a comprehension check.** What is the artifact trying to achieve? Who is it for? If you cannot state it after reading, that itself is a finding — the artifact does not communicate its purpose. This is your private grounding, not summary copy: keep the restatement out of \`<summary>\`, which stays a terse verdict justification per the output contract.
|
|
24
24
|
2. **Identify the load-bearing claims.** What does the artifact assert that, if wrong, would invalidate the whole thing? List them mentally before looking for issues.
|
|
25
25
|
3. **Stress-test the load-bearing claims.** For each one: is the evidence sufficient? Are the assumptions stated? Are the counter-arguments addressed?
|
|
26
26
|
4. **Stress-test the boundaries.** Where does the artifact's argument or design stop applying? Does it acknowledge that boundary, or does it overgeneralize?
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { LoadableSkill } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
export const PLAN_REVIEW_SKILL_NAME = 'plan-review'
|
|
4
|
+
|
|
5
|
+
export const PLAN_REVIEW_SKILL_DESCRIPTION =
|
|
6
|
+
'Review a plan, RFC, design doc, PRFAQ, or task breakdown. Covers problem framing, measurable success criteria, alternatives considered, reversibility (one-way vs two-way doors), risk and dependency analysis, and RFC-2119 requirement-keyword discipline.'
|
|
7
|
+
|
|
8
|
+
export const PLAN_REVIEW_SKILL_CONTENT = `# plan-review
|
|
9
|
+
|
|
10
|
+
You have been asked to review a plan — an RFC, a design doc, a PRFAQ, a roadmap, a todo breakdown, or any document that proposes a course of action. Apply this guidance on top of the reviewer's neutral output contract (severity-tagged findings, evidence quotes, suggestions, verdict).
|
|
11
|
+
|
|
12
|
+
## How to acquire the target
|
|
13
|
+
|
|
14
|
+
- **A file path** — \`read\` it. \`ls\` the directory for a template or sibling RFCs that establish the expected shape; deviation from an established plan template is itself worth noting.
|
|
15
|
+
- **A URL or doc** — \`web_fetch\` it. If it is a private doc the fetch cannot reach, say so in \`<summary>\` and review what the payload provided.
|
|
16
|
+
- **A PR that adds a design doc** — \`gh pr diff <n>\`, then \`read\` the linked issue or prior discussion if one is referenced; a plan is judged partly on whether it answers the question that prompted it.
|
|
17
|
+
- **An inline plan in the payload** — read it carefully and quote from it when forming evidence.
|
|
18
|
+
|
|
19
|
+
## What to look for
|
|
20
|
+
|
|
21
|
+
1. **Problem framing.** Does the plan state the problem before the solution? Who feels the pain, and what does "solved" look like for them? A plan that opens with the solution and never names the problem cannot be evaluated — that gap is the first finding.
|
|
22
|
+
2. **Measurable success criteria.** Goals must be checkable. "Improve performance" is unverifiable; "P95 latency under 200ms on the checkout path" is. Flag every load-bearing goal that has no metric, threshold, or acceptance condition.
|
|
23
|
+
3. **Alternatives considered.** A serious proposal names the approaches it rejected and why. A plan that presents one path as if it were the only path has hidden its reasoning — ask for the alternatives, because the rejected ones are where the real trade-off lives.
|
|
24
|
+
4. **Reversibility — one-way vs two-way doors.** Identify the decisions that are hard or impossible to undo: public API contracts, on-disk schema changes, data migrations, anything external parties will depend on. A plan that makes a one-way-door decision without acknowledging it as irreversible has under-weighted its own risk. This is frequently the single most important finding.
|
|
25
|
+
5. **Risk and dependency analysis.** External dependencies, blocking teams, legal or compliance constraints, the order in which steps must land. A plan whose step 3 silently depends on a team that has not agreed is carrying unpriced risk.
|
|
26
|
+
6. **Scope boundaries.** What is explicitly in scope and out of scope? A plan that conflates several unrelated changes, or whose title promises A but whose body spends half its bytes on B, has a scope problem — either the scope is wrong or the framing is.
|
|
27
|
+
7. **Requirement-keyword discipline (RFC-2119).** If the plan uses MUST / SHOULD / MAY, are they used in their precise senses, or interchangeably? A "SHOULD" that is actually a "MUST" will be implemented as optional and bite later. Flag normative keywords whose strength does not match their intent.
|
|
28
|
+
8. **Rollback / recovery.** For a plan that changes a running system, how is it undone if it fails, and how long does that take? Absence of a rollback story is a finding when the change is risky enough to need one.
|
|
29
|
+
|
|
30
|
+
## Review every plan as a first review — do not guess its maturity
|
|
31
|
+
|
|
32
|
+
You are almost never told whether a plan is a first draft, a final RFC, or something between. Do NOT guess, and do NOT let the absence of that signal bias you — neither toward over-blocking (treating a sketch as a contract) nor toward over-softening (treating a serious proposal as throwaway). Review what is actually on the page, every time, as if you are seeing it fresh with no prior history. This neutrality is the point: a plan's verdict should come from the plan, not from an assumption about its stage you had to invent.
|
|
33
|
+
|
|
34
|
+
In practice:
|
|
35
|
+
|
|
36
|
+
- **Judge the idea, not the polish.** A plan can be early and still sound, or finished and still wrong. Your findings target whether the *approach* holds up — internal consistency, reversibility, measurable success, acknowledged alternatives — not how complete the document looks.
|
|
37
|
+
- **Missing context is missing context, not a defect.** A plan reviewed cold will omit things a real org would supply: who owns it, the deadline, the budget, the constraint that rules out option B. Do NOT raise each absence as its own blocker — that is exactly the generic-review noise the contract forbids. Fold what you would genuinely need into a single \`comment\`-level finding: "To judge this as ready-to-execute I'd need the owning team, a success metric, and the rollback constraint." One finding, not ten.
|
|
38
|
+
- **An unfilled section is only a finding if its absence breaks the idea.** A plan with no rollback section is not automatically blocked — unless the plan's viability *depends* on a rollback that may be impossible, in which case the gap is the finding and you say why. Empty-by-stage is not the same as flawed. Test each gap: does this missing piece change whether the approach is sound, or is it just not written yet?
|
|
39
|
+
- **Real flaws are still blockers, regardless of stage.** Reviewing cold does not mean reviewing soft. An internal contradiction, a one-way-door decision the plan does not acknowledge as irreversible, a success criterion that is unmeasurable *as written*, or a recommendation with no alternatives considered — these are flaws in the idea itself. Raise them at full severity whether the plan is draft or final.
|
|
40
|
+
- **State your footing in \`<summary>\`, once.** Open with one clause naming what you could and could not assess: "Reviewed on its own terms; no constraints or finality were stated, so the verdict reflects the idea as written, not its fit to an unstated bar." This keeps the review honest about the context it lacked instead of pretending to a certainty it does not have — without guessing at a maturity label. Keep this to one clause; it is grounding, not a process narration.
|
|
41
|
+
|
|
42
|
+
## Severity hints specific to plans
|
|
43
|
+
|
|
44
|
+
- **blocker** — A load-bearing flaw in the approach: an internal contradiction, a one-way-door decision treated as reversible, a goal that cannot be verified as written, a plan whose central mechanism cannot work. The kind of problem that makes executing the plan a mistake.
|
|
45
|
+
- **concern** — A weakness that should be answered before commitment: a missing alternative that undercuts the recommendation, an unpriced dependency, a scope ambiguity that will mislead implementers, a normative keyword whose strength is wrong.
|
|
46
|
+
- **nit** — A small clarity or structure issue, a section that could be tightened, a stage-normal gap worth a one-line mention.
|
|
47
|
+
- **praise** — A non-obvious risk surfaced and handled, a reversibility analysis done honestly, a success metric that is genuinely measurable. Rare.
|
|
48
|
+
|
|
49
|
+
## Verdict mapping
|
|
50
|
+
|
|
51
|
+
- **approve** — The idea holds and the gaps are stage-normal. No load-bearing flaw in the approach.
|
|
52
|
+
- **request-changes** — At least one blocker: a flaw in the approach that needs an answer before this should be committed to.
|
|
53
|
+
- **comment** — Useful observations that do not resolve to a clean accept/reject. Common when reviewing a plan cold, where your job is to surface what is unverified rather than to gate it.
|
|
54
|
+
|
|
55
|
+
## Final output
|
|
56
|
+
|
|
57
|
+
Return findings inside the reviewer's neutral \`<review>\` block. Do NOT invent your own output format.
|
|
58
|
+
`
|
|
59
|
+
|
|
60
|
+
export const PLAN_REVIEW_SKILL: LoadableSkill = {
|
|
61
|
+
name: PLAN_REVIEW_SKILL_NAME,
|
|
62
|
+
description: PLAN_REVIEW_SKILL_DESCRIPTION,
|
|
63
|
+
content: PLAN_REVIEW_SKILL_CONTENT,
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { LoadableSkill } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
export const SECURITY_AUDIT_SKILL_NAME = 'security-audit'
|
|
4
|
+
|
|
5
|
+
export const SECURITY_AUDIT_SKILL_DESCRIPTION =
|
|
6
|
+
'Audit code or configuration through a threat-model lens: injection, broken access control, SSRF, insecure deserialization, secrets exposure, path traversal, TOCTOU, and cryptographic failures. Maps findings to OWASP/CWE and reasons about exploitability, not style.'
|
|
7
|
+
|
|
8
|
+
export const SECURITY_AUDIT_SKILL_CONTENT = `# security-audit
|
|
9
|
+
|
|
10
|
+
You have been asked to audit a target for security defects. This is not a general code review with a security flavor — it is an adversarial read. Assume an attacker controls every input the target does not prove it controls, and ask what they can make happen. Apply this on top of the reviewer's neutral output contract (severity-tagged findings, evidence quotes, suggestions, verdict).
|
|
11
|
+
|
|
12
|
+
## How to acquire the target
|
|
13
|
+
|
|
14
|
+
- **A PR or diff** — \`gh pr diff <n>\` for the change; then \`read\` the surrounding code, because a vulnerability often lives in the interaction between the changed line and an untouched caller.
|
|
15
|
+
- **A file or module** — \`read\` it, then \`grep\` for the entry points: where does external input enter, and where does it reach a sink (a shell, a query, a file path, a deserializer, an outbound request)?
|
|
16
|
+
- **Config / infra** — \`read\` the manifest, Dockerfile, CI workflow, or IaC. Misconfiguration is a vulnerability class of its own (default credentials, over-broad permissions, secrets in plaintext).
|
|
17
|
+
- **Verify with primary sources.** When you cite a class (OWASP A03, CWE-89, an RFC), confirm the current definition with \`web_search\`/\`web_fetch\` before asserting it. Cite by identifier.
|
|
18
|
+
|
|
19
|
+
## Trace input to sink
|
|
20
|
+
|
|
21
|
+
A security finding is a *path*: untrusted input → (insufficient validation) → dangerous sink. Name both ends and the missing control between them. A finding that only says "this looks unsafe" without tracing the path is not actionable. For each entry point, follow the data: where does it go, what touches it on the way, and what does it reach?
|
|
22
|
+
|
|
23
|
+
## What to look for
|
|
24
|
+
|
|
25
|
+
Prioritize by exploitability, roughly in this order:
|
|
26
|
+
|
|
27
|
+
1. **Injection (CWE-78/89/79/90).** Untrusted input concatenated into a shell command, SQL/NoSQL query, LDAP filter, or HTML sink without parameterization or escaping. OS-command injection via string-interpolated \`bash\` is the highest-value catch.
|
|
28
|
+
2. **Broken access control (OWASP A01).** Missing authorization checks, IDOR (a user can read/write another user's object by changing an ID), endpoints that trust a client-supplied role, path-based bypass.
|
|
29
|
+
3. **SSRF (OWASP A10 / CWE-918).** The server fetches a user-supplied URL with no allowlist, letting an attacker reach internal services or cloud metadata endpoints (\`169.254.169.254\`). Flag any outbound request whose destination is attacker-influenced.
|
|
30
|
+
4. **Insecure deserialization / data-integrity (OWASP A08).** Untrusted bytes fed to a deserializer that can instantiate arbitrary types; unsigned updates; a pipeline that trusts input it did not verify.
|
|
31
|
+
5. **Cryptographic failures (OWASP A02).** Secrets at rest in plaintext, weak or broken hashes (MD5/SHA1 for passwords), missing TLS on sensitive transit, hardcoded keys, predictable tokens.
|
|
32
|
+
6. **Secrets exposure.** API keys, tokens, or passwords in logs, error messages, committed config, or echoed in responses. A stack trace returned to the client is an information-disclosure finding.
|
|
33
|
+
7. **Path traversal (CWE-22).** User input builds a filesystem path without canonicalization, allowing \`../\` escape out of the intended directory.
|
|
34
|
+
8. **TOCTOU (CWE-367).** A check (file exists, permission ok) separated from the use by a window an attacker can exploit to swap the target.
|
|
35
|
+
9. **Authentication weaknesses (OWASP A07).** No brute-force protection, session fixation, missing re-auth on sensitive actions, tokens that never expire.
|
|
36
|
+
|
|
37
|
+
## Severity via exploitability (CVSS-style reasoning)
|
|
38
|
+
|
|
39
|
+
Anchor severity to *what an attacker gains and how easily*, not to how the code reads:
|
|
40
|
+
|
|
41
|
+
- **blocker** — Exploitable now with serious impact: remote code execution, auth bypass, injection reachable from an unauthenticated path, secret disclosure. CVSS roughly High/Critical (7.0+). Do not ship.
|
|
42
|
+
- **concern** — A real weakness that requires a precondition (authenticated attacker, user interaction, an unlikely-but-possible input) or whose impact is bounded. CVSS roughly Medium (4.0–6.9).
|
|
43
|
+
- **nit** — Defense-in-depth hardening with no demonstrated exploit path: a missing security header, a slightly-too-broad scope that is not currently reachable. Optional.
|
|
44
|
+
- **praise** — A non-obvious control done right: input correctly parameterized at a tricky sink, an allowlist that closes an SSRF that an obvious implementation would have left open. Rare.
|
|
45
|
+
|
|
46
|
+
For blocker and concern findings, state the attack in one sentence: who, with what access, can make what happen. That sentence is what separates a security finding from a style opinion.
|
|
47
|
+
|
|
48
|
+
## What NOT to find
|
|
49
|
+
|
|
50
|
+
- **Style and formatting.** Linter territory. A security audit is not the place for naming or spacing.
|
|
51
|
+
- **Performance without a security angle.** A slow loop is not a security finding unless it is a denial-of-service vector you can demonstrate.
|
|
52
|
+
- **Theoretical issues with no reachable path.** "This *could* be unsafe if someone later calls it with attacker input" — only raise it if such a caller exists or is plausible. Name the path or drop the finding; un-anchored "could be exploited" is the security flavor of generic review noise.
|
|
53
|
+
- **Re-flagging controls that are present.** If validation, escaping, or an allowlist already guards the sink, that is not a finding — and if it is done well, it may be a \`praise\`.
|
|
54
|
+
|
|
55
|
+
## Verdict mapping
|
|
56
|
+
|
|
57
|
+
- **approve** — No exploitable finding. Any issues are defense-in-depth nits.
|
|
58
|
+
- **request-changes** — At least one blocker, or a concern serious enough to answer before this lands.
|
|
59
|
+
- **comment** — Observations without a clear gate: a partial audit of a large surface, or hardening advice on code that has no demonstrated vulnerability.
|
|
60
|
+
|
|
61
|
+
## Final output
|
|
62
|
+
|
|
63
|
+
Return findings inside the reviewer's neutral \`<review>\` block. Do NOT invent your own output format.
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
export const SECURITY_AUDIT_SKILL: LoadableSkill = {
|
|
67
|
+
name: SECURITY_AUDIT_SKILL_NAME,
|
|
68
|
+
description: SECURITY_AUDIT_SKILL_DESCRIPTION,
|
|
69
|
+
content: SECURITY_AUDIT_SKILL_CONTENT,
|
|
70
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { LoadableSkill } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
export const WRITING_REVIEW_SKILL_NAME = 'writing-review'
|
|
4
|
+
|
|
5
|
+
export const WRITING_REVIEW_SKILL_DESCRIPTION =
|
|
6
|
+
'Review prose for an audience: a blog post, an announcement, marketing copy, an email, an essay. Covers clarity, audience fit, lede placement, claim-evidence support, tone consistency, and jargon — the editorial craft beyond grammar.'
|
|
7
|
+
|
|
8
|
+
export const WRITING_REVIEW_SKILL_CONTENT = `# writing-review
|
|
9
|
+
|
|
10
|
+
You have been asked to review a piece of writing meant for a reader — a blog post, a launch announcement, marketing copy, an email, an essay. You are an editor, not a proofreader: grammar and spelling are the floor, not the job. Apply this on top of the reviewer's neutral output contract (severity-tagged findings, evidence quotes, suggestions, verdict).
|
|
11
|
+
|
|
12
|
+
## How to acquire the target
|
|
13
|
+
|
|
14
|
+
- **A file or inline text** — \`read\` it (or read the payload) in full before forming any finding. Prose is judged as a whole; a paragraph that works alone can still break the piece's flow.
|
|
15
|
+
- **A URL** — \`web_fetch\` it. If a published page, also note whether the lede survives the reader's first screen.
|
|
16
|
+
- **Verify factual claims.** If the piece asserts a number, a comparison, a "first/fastest/only", or a cited source, check it with \`web_search\`/\`web_fetch\` before letting it stand. An unsupported superlative is the most common defect in persuasive writing.
|
|
17
|
+
|
|
18
|
+
## Read for the reader, not for yourself
|
|
19
|
+
|
|
20
|
+
Before looking for defects, answer two questions and hold them while you read: **Who is this for?** and **What should they do or believe after reading?** Most writing failures are a mismatch between the prose and the answer to one of those. A finding is strong when it names which reader the passage fails and why.
|
|
21
|
+
|
|
22
|
+
## What to look for
|
|
23
|
+
|
|
24
|
+
1. **Buried lede.** The most important thing — the news, the point, the ask — should arrive early (inverted pyramid). If the reader must wade through throat-clearing to find why the piece exists, the lede is buried. Name where the real lede currently sits.
|
|
25
|
+
2. **Audience mismatch.** Jargon and unexplained acronyms for a general audience; over-explanation for an expert one. The register should match who is reading.
|
|
26
|
+
3. **Unsupported claims.** Every load-bearing assertion needs backing. "The fastest runtime", "customers love it", "the industry standard" — without a benchmark, a quote, or a source, these are assertions the reader has no reason to believe. Flag the claim and say what evidence it needs.
|
|
27
|
+
4. **Tone inconsistency.** A piece that starts formal and drifts casual, or whose brand voice wobbles, loses the reader's trust. Point at the shift.
|
|
28
|
+
5. **Clarity / muddy thinking.** Sentences the reader must re-read: ambiguous pronouns, a clause whose subject is lost, a paragraph that says three things and lands none. Unclear prose is usually unclear thinking — point at the sentence.
|
|
29
|
+
6. **Undefined jargon.** A term or acronym used before it is defined, with no gloss and no link. First use should orient the reader.
|
|
30
|
+
7. **Terminology drift.** The same thing named three ways ("dashboard" / "console" / "control panel") confuses; pick one and flag the rest.
|
|
31
|
+
8. **Structure and flow.** Ideas that do not build, missing transitions, a piece that ends without telling the reader what to do next when it clearly wants them to act.
|
|
32
|
+
|
|
33
|
+
## What NOT to find
|
|
34
|
+
|
|
35
|
+
- **Taste dressed as error.** A sentence you would have phrased differently but that reads clearly and serves the audience is not a finding. "I prefer shorter paragraphs" is not a defect.
|
|
36
|
+
- **Valid style/dialect choices.** British vs American spelling, the Oxford comma, em-dash vs parentheses — when the piece is internally consistent and the house style permits it, leave it.
|
|
37
|
+
- **Grammar a proofreader owns.** A genuine grammar error is fair, but do not pad the review with comma surgery; your value is editorial, not mechanical.
|
|
38
|
+
- **Restating the piece.** "This post announces the new feature" is not a review.
|
|
39
|
+
- **Generic "make it clearer".** Without pointing at the specific passage that is unclear, "could be clearer" is noise.
|
|
40
|
+
|
|
41
|
+
## Severity hints specific to writing
|
|
42
|
+
|
|
43
|
+
- **blocker** — A factual claim that is verifiably wrong, an audience mismatch so severe the intended reader cannot use the piece, a lede so buried the piece fails its purpose. The kind of problem that means this should not publish as-is.
|
|
44
|
+
- **concern** — An unsupported load-bearing claim, a tone break that undercuts trust, a structural gap that loses the reader partway. Should fix before publishing.
|
|
45
|
+
- **nit** — A single muddy sentence, a minor terminology wobble, a missing transition. Optional; the author can decline.
|
|
46
|
+
- **praise** — A passage that makes a complex thing plain, a lede that lands, a claim backed cleanly with evidence. Rare; call out writing that earns the reader's trust.
|
|
47
|
+
|
|
48
|
+
## Verdict mapping
|
|
49
|
+
|
|
50
|
+
- **approve** — Ready for its reader. Any issues are nits the author can take or leave.
|
|
51
|
+
- **request-changes** — At least one blocker: a wrong claim, a buried lede that defeats the purpose, an audience mismatch.
|
|
52
|
+
- **comment** — Useful observations without a clean accept/reject. Common for an early draft where the author wants direction more than a gate.
|
|
53
|
+
|
|
54
|
+
## Final output
|
|
55
|
+
|
|
56
|
+
Return findings inside the reviewer's neutral \`<review>\` block. Do NOT invent your own output format.
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
export const WRITING_REVIEW_SKILL: LoadableSkill = {
|
|
60
|
+
name: WRITING_REVIEW_SKILL_NAME,
|
|
61
|
+
description: WRITING_REVIEW_SKILL_DESCRIPTION,
|
|
62
|
+
content: WRITING_REVIEW_SKILL_CONTENT,
|
|
63
|
+
}
|
|
@@ -85,6 +85,8 @@ export function createScoutSubagent(): Subagent<ScoutPayload> {
|
|
|
85
85
|
tools: [webSearchTool, webFetchTool],
|
|
86
86
|
payloadSchema: scoutPayloadSchema,
|
|
87
87
|
visibility: 'public',
|
|
88
|
+
rosterDescription:
|
|
89
|
+
'fast single-pass web lookup in a fresh context — searches and fetches, returns citation-backed findings without the raw pages touching your context',
|
|
88
90
|
inFlightKey: (payload) => payload?.requestId ?? `anon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
89
91
|
toolResultBudget: {
|
|
90
92
|
maxTotalBytes: 512_000,
|
|
@@ -429,10 +429,14 @@ const GIT_EXFIL_VERBS = [
|
|
|
429
429
|
|
|
430
430
|
const GIT_EXFIL_PATTERNS: ReadonlyArray<RegExp> = [
|
|
431
431
|
new RegExp(`(?:${GIT_EXFIL_VERBS})`, 'i'),
|
|
432
|
-
//
|
|
433
|
-
// which is the breach idiom ("...git push origin main
|
|
434
|
-
|
|
435
|
-
//
|
|
432
|
+
// Urgency shorthand ("do it" / "go ahead" / "now") right after a git command,
|
|
433
|
+
// which is the breach idiom ("...git push origin main <go-go>"). Multilingual:
|
|
434
|
+
// the urgency token is what flips a benign-looking push into a "just run it"
|
|
435
|
+
// exfil nudge, so it must catch the idiom across the major channel languages,
|
|
436
|
+
// not Korean alone. Tokens are tight imperative go-aheads to avoid matching
|
|
437
|
+
// ordinary trailing words (e.g. "git push then go to lunch" must NOT match).
|
|
438
|
+
/git\s+push[\s\S]{0,40}(?:\u{3131}\u{3131}|ㄱㄱ|\u{ACE0}\u{ACE0}|\u{C5B4}\u{C11C}|\u{ACA9}\u{ACA9}|go\s?go|asap|do\s+it\s+now|just\s+do\s+it|right\s+now|行け行け|早く|赶紧|快点|赶快|hazlo\s+ya|dale\s+ya)/iu,
|
|
439
|
+
// "back up to github" / Korean "백업 해줘" (back it up) framings often dressed as a benign
|
|
436
440
|
// request - if the same message also names a credential or `.env`, the
|
|
437
441
|
// SECRET_DEMAND_PATTERNS already fires; this catches the standalone
|
|
438
442
|
// "push to my backup repo" framing that doesn't mention secrets.
|
|
@@ -50,8 +50,9 @@ const DANGEROUS_COMMAND_PATTERNS: ReadonlyArray<{ pattern: RegExp; label: string
|
|
|
50
50
|
{ pattern: /set\s+-o\s+posix[\s\S]{0,40}(?:^|[\s;|&(`])set(?:[\s;|&)`]|$)/m, label: 'set -o posix; set (env dump)' },
|
|
51
51
|
{
|
|
52
52
|
// jq/yq read+emit arbitrary files just like cat (e.g. `jq . .env`,
|
|
53
|
-
// `yq '.x' .env`)
|
|
54
|
-
//
|
|
53
|
+
// `yq '.x' .env`). `jq` ships in the container baseline; `yq` no longer
|
|
54
|
+
// does, but a user can re-add it via `docker.file.append`, so both stay
|
|
55
|
+
// gated here as first-class .env exfil vectors — not just the
|
|
55
56
|
// pager/dumper family.
|
|
56
57
|
pattern: /(cat|less|more|head|tail|bat|xxd|od|hexdump|strings|jq|yq)\s+[^\n;|&`]*\.env(\s|$|[;|&`])/,
|
|
57
58
|
label: 'reading .env file',
|
|
@@ -493,16 +493,27 @@ function discordFailureForStatus(status: number): MembershipResolverFailure {
|
|
|
493
493
|
return { kind: 'transient' }
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
+
// Discord message type for THREAD_STARTER_MESSAGE — the first message in a
|
|
497
|
+
// thread created from an existing message. `referenced_message` is also
|
|
498
|
+
// populated for type 19 (REPLY) and 23 (CONTEXT_MENU_COMMAND), so the opener
|
|
499
|
+
// fallback below must gate on this type alone, not on the field's presence.
|
|
500
|
+
const DISCORD_MESSAGE_TYPE_THREAD_STARTER = 21
|
|
501
|
+
|
|
496
502
|
type DiscordRawHistoryMessage = {
|
|
497
503
|
id: string
|
|
498
504
|
channel_id: string
|
|
505
|
+
type?: number
|
|
499
506
|
author: { id: string; username?: string; global_name?: string | null; bot?: boolean }
|
|
500
507
|
content: string
|
|
501
508
|
timestamp: string
|
|
502
|
-
message_reference?: { message_id?: string }
|
|
509
|
+
message_reference?: { message_id?: string; channel_id?: string }
|
|
503
510
|
attachments?: DiscordFile[]
|
|
504
511
|
embeds?: DiscordGatewayEmbed[]
|
|
505
512
|
sticker_items?: DiscordGatewayStickerItem[]
|
|
513
|
+
// A thread started from an existing message has a type-21 starter whose
|
|
514
|
+
// top-level content/author are empty; the real opener lives only here.
|
|
515
|
+
// `null` = referenced message deleted; absent = API did not resolve it.
|
|
516
|
+
referenced_message?: DiscordRawHistoryMessage | null
|
|
506
517
|
}
|
|
507
518
|
|
|
508
519
|
// Discord treats threads as separate channels with their own snowflake ids,
|
|
@@ -565,7 +576,18 @@ export function createDiscordHistoryCallback(deps: {
|
|
|
565
576
|
}
|
|
566
577
|
|
|
567
578
|
function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
|
|
568
|
-
|
|
579
|
+
// A thread started from an existing message exposes that opener only as the
|
|
580
|
+
// type-21 starter's `referenced_message` — the starter itself has empty
|
|
581
|
+
// content and a bot/system author. Without this, the agent never sees the
|
|
582
|
+
// message the thread was created from. Take the opener's author and body
|
|
583
|
+
// (the live inbound path does the equivalent via enrichDiscordMessageReferences),
|
|
584
|
+
// while keeping the starter's own id/timestamp so dedup against the triggering
|
|
585
|
+
// message and chronological ordering stay correct.
|
|
586
|
+
const opener = msg.referenced_message ?? undefined
|
|
587
|
+
const isThreadStarter = msg.type === DISCORD_MESSAGE_TYPE_THREAD_STARTER
|
|
588
|
+
const source = isThreadStarter && opener !== undefined && bodyOf(msg) === '' ? opener : msg
|
|
589
|
+
|
|
590
|
+
const isBot = source.author.bot === true || (botUserId !== null && source.author.id === botUserId)
|
|
569
591
|
const ts = Date.parse(msg.timestamp)
|
|
570
592
|
// The REST history fetch bypasses the inbound classifier, so attachments,
|
|
571
593
|
// embeds, and stickers on already-posted messages (e.g. an image on a thread
|
|
@@ -573,17 +595,12 @@ function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | nu
|
|
|
573
595
|
// otherwise they are silently dropped and look_at_channel_attachment can
|
|
574
596
|
// never resolve them. Mirror the classifier's splitInbound: bake placeholders
|
|
575
597
|
// into text and carry the structured attachments so the router can resolve ids.
|
|
576
|
-
const attachments = describeDiscordMedia(
|
|
577
|
-
const text =
|
|
578
|
-
attachments.length === 0
|
|
579
|
-
? msg.content
|
|
580
|
-
: msg.content === ''
|
|
581
|
-
? attachments.map(renderPlaceholder).join('\n')
|
|
582
|
-
: `${msg.content}\n${attachments.map(renderPlaceholder).join('\n')}`
|
|
598
|
+
const attachments = describeDiscordMedia(source)
|
|
599
|
+
const text = bodyOf(source)
|
|
583
600
|
return {
|
|
584
601
|
externalMessageId: msg.id,
|
|
585
|
-
authorId:
|
|
586
|
-
authorName:
|
|
602
|
+
authorId: source.author.id,
|
|
603
|
+
authorName: source.author.global_name ?? source.author.username ?? source.author.id,
|
|
587
604
|
text,
|
|
588
605
|
ts: Number.isFinite(ts) ? ts : 0,
|
|
589
606
|
isBot,
|
|
@@ -592,6 +609,13 @@ function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | nu
|
|
|
592
609
|
}
|
|
593
610
|
}
|
|
594
611
|
|
|
612
|
+
function bodyOf(msg: DiscordRawHistoryMessage): string {
|
|
613
|
+
const attachments = describeDiscordMedia(msg)
|
|
614
|
+
if (attachments.length === 0) return msg.content
|
|
615
|
+
const placeholders = attachments.map(renderPlaceholder).join('\n')
|
|
616
|
+
return msg.content === '' ? placeholders : `${msg.content}\n${placeholders}`
|
|
617
|
+
}
|
|
618
|
+
|
|
595
619
|
function clampLimit(requested: number, max: number): number {
|
|
596
620
|
if (!Number.isFinite(requested) || requested <= 0) return max
|
|
597
621
|
return Math.min(Math.floor(requested), max)
|
|
@@ -1006,6 +1030,7 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
1006
1030
|
options.router.registerReaction('discord-bot', reactionCallback)
|
|
1007
1031
|
options.router.registerRemoveReaction('discord-bot', removeReactionCallback)
|
|
1008
1032
|
options.router.registerTyping('discord-bot', typingCallback)
|
|
1033
|
+
options.router.setTypingCapability('discord-bot', true)
|
|
1009
1034
|
options.router.registerChannelNameResolver('discord-bot', channelResolver)
|
|
1010
1035
|
options.router.registerSelfIdentity('discord-bot', selfIdentityResolver)
|
|
1011
1036
|
options.router.registerHistory('discord-bot', historyCallback)
|
|
@@ -1023,6 +1048,7 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
1023
1048
|
options.router.unregisterReaction('discord-bot', reactionCallback)
|
|
1024
1049
|
options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
|
|
1025
1050
|
options.router.unregisterTyping('discord-bot', typingCallback)
|
|
1051
|
+
options.router.setTypingCapability('discord-bot', false)
|
|
1026
1052
|
options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
|
|
1027
1053
|
options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
|
|
1028
1054
|
options.router.unregisterHistory('discord-bot', historyCallback)
|
|
@@ -1043,6 +1069,7 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
1043
1069
|
options.router.unregisterReaction('discord-bot', reactionCallback)
|
|
1044
1070
|
options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
|
|
1045
1071
|
options.router.unregisterTyping('discord-bot', typingCallback)
|
|
1072
|
+
options.router.setTypingCapability('discord-bot', false)
|
|
1046
1073
|
options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
|
|
1047
1074
|
options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
|
|
1048
1075
|
options.router.unregisterHistory('discord-bot', historyCallback)
|
|
@@ -4,6 +4,7 @@ import type { GithubReviewOn } from '@/channels/schema'
|
|
|
4
4
|
import type { InboundMessage } from '@/channels/types'
|
|
5
5
|
|
|
6
6
|
import type { GithubAuthContext } from './auth'
|
|
7
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
7
8
|
import { removeRequestedReviewer } from './decoy-reviewer'
|
|
8
9
|
import type { DeliveryDedup } from './dedup'
|
|
9
10
|
import { isGithubEventAllowed } from './event-allowlist'
|
|
@@ -94,10 +95,12 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
const teamIsBotMember = await resolveTeamMembership(event, payload, options)
|
|
98
|
+
const reviewCommentParent = await resolveReviewCommentParent(event, payload, selfId, selfLogin, options)
|
|
97
99
|
const classified = classifyGithubInbound(event, payload, selfLogin, {
|
|
98
100
|
teamIsBotMember,
|
|
99
101
|
authType: options.authType?.() ?? 'pat',
|
|
100
102
|
reviewOn: options.reviewOn?.() ?? 'review_requested',
|
|
103
|
+
...(reviewCommentParent !== null ? { reviewCommentParent } : {}),
|
|
101
104
|
})
|
|
102
105
|
if (classified === null) return ok()
|
|
103
106
|
|
|
@@ -286,7 +289,12 @@ export function classifyGithubInbound(
|
|
|
286
289
|
event: string,
|
|
287
290
|
payload: Record<string, unknown>,
|
|
288
291
|
selfLogin: string | null,
|
|
289
|
-
options?: {
|
|
292
|
+
options?: {
|
|
293
|
+
teamIsBotMember?: boolean
|
|
294
|
+
authType?: 'pat' | 'app'
|
|
295
|
+
reviewOn?: GithubReviewOn
|
|
296
|
+
reviewCommentParent?: ReviewCommentParent
|
|
297
|
+
},
|
|
290
298
|
): InboundMessage | null {
|
|
291
299
|
const repository = readRepository(payload)
|
|
292
300
|
if (repository === null) return null
|
|
@@ -326,7 +334,10 @@ export function classifyGithubInbound(
|
|
|
326
334
|
const number = readNumber(pr, 'number')
|
|
327
335
|
const id = readNumber(comment, 'id')
|
|
328
336
|
if (number === null || id === null) return null
|
|
329
|
-
const
|
|
337
|
+
const parentId = readNumber(comment, 'in_reply_to_id')
|
|
338
|
+
const root = parentId ?? id
|
|
339
|
+
const parent =
|
|
340
|
+
parentId !== null && options?.reviewCommentParent?.parentId === parentId ? options.reviewCommentParent : null
|
|
330
341
|
return buildInbound(
|
|
331
342
|
{ ...base, chat: `pr:${number}`, thread: String(root) },
|
|
332
343
|
comment.body,
|
|
@@ -335,6 +346,12 @@ export function classifyGithubInbound(
|
|
|
335
346
|
mention,
|
|
336
347
|
comment.created_at,
|
|
337
348
|
{ kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
349
|
+
false,
|
|
350
|
+
{
|
|
351
|
+
suppressSticky: true,
|
|
352
|
+
replyToBotMessageId: parent?.isSelf === true ? String(parent.parentId) : null,
|
|
353
|
+
replyToOtherMessageId: parent?.isSelf === false ? String(parent.parentId) : null,
|
|
354
|
+
},
|
|
338
355
|
)
|
|
339
356
|
}
|
|
340
357
|
|
|
@@ -411,6 +428,13 @@ export function classifyGithubInbound(
|
|
|
411
428
|
// the PR is non-draft once ready — preserving "review when no longer draft".
|
|
412
429
|
const isOpenLike = action === 'opened' || action === 'ready_for_review'
|
|
413
430
|
if (isOpenLike && reviewOn === 'opened') {
|
|
431
|
+
// Draft opened under `review.on: "opened"`: skip cleanly (null wakes no
|
|
432
|
+
// session) and wait for the `ready_for_review` trigger. Must NOT fall
|
|
433
|
+
// through to the awareness path below, where a multi-collaborator repo
|
|
434
|
+
// silently `observed`s it — a draft whose `ready_for_review` delivery is
|
|
435
|
+
// later lost would then never get reviewed. `review_requested`
|
|
436
|
+
// on a draft is unaffected: it returns above via classifyReviewRequest.
|
|
437
|
+
if (readBoolean(pr, 'draft') === true) return null
|
|
414
438
|
const trigger = classifyOpenedReviewTrigger({
|
|
415
439
|
payload,
|
|
416
440
|
pr,
|
|
@@ -460,6 +484,7 @@ export function classifyGithubInbound(
|
|
|
460
484
|
review.submitted_at,
|
|
461
485
|
null,
|
|
462
486
|
!hasBody,
|
|
487
|
+
{ suppressSticky: true },
|
|
463
488
|
)
|
|
464
489
|
}
|
|
465
490
|
|
|
@@ -502,6 +527,14 @@ type ReviewRequestInput = {
|
|
|
502
527
|
teamIsBotMember: boolean | undefined
|
|
503
528
|
}
|
|
504
529
|
|
|
530
|
+
type ReviewCommentParent = { isSelf: boolean; parentId: number }
|
|
531
|
+
|
|
532
|
+
type BuildInboundOptions = {
|
|
533
|
+
suppressSticky?: boolean
|
|
534
|
+
replyToBotMessageId?: string | null
|
|
535
|
+
replyToOtherMessageId?: string | null
|
|
536
|
+
}
|
|
537
|
+
|
|
505
538
|
// A GitHub App can never be a `requested_reviewer` — that field only holds
|
|
506
539
|
// real user accounts, and the App actor (`slug[bot]`) is not one. The
|
|
507
540
|
// supported workaround is a decoy user account named after the App that an
|
|
@@ -644,12 +677,6 @@ function classifyOpenedReviewTrigger(input: OpenedReviewTriggerInput): InboundMe
|
|
|
644
677
|
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
645
678
|
if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
|
|
646
679
|
|
|
647
|
-
// A draft PR is work-in-progress, so the automatic `opened` path skips it: null
|
|
648
|
-
// here drops to awareness-only context (like a non-`opened` reviewOn) instead of
|
|
649
|
-
// waking a review. An explicit `review_requested` still triggers on a draft via
|
|
650
|
-
// classifyReviewRequest, preserving "skip until explicitly requested".
|
|
651
|
-
if (readBoolean(pr, 'draft') === true) return null
|
|
652
|
-
|
|
653
680
|
const title = readString(pr, 'title') ?? `#${number}`
|
|
654
681
|
const head = readString(readRecord(pr.head), 'ref')
|
|
655
682
|
const baseRef = readString(readRecord(pr.base), 'ref')
|
|
@@ -700,6 +727,7 @@ function buildInbound(
|
|
|
700
727
|
rawTs: unknown,
|
|
701
728
|
reactionTarget: GithubReactionTarget | null,
|
|
702
729
|
synthesizedAwareness = false,
|
|
730
|
+
options?: BuildInboundOptions,
|
|
703
731
|
): InboundMessage | null {
|
|
704
732
|
if (user === null) return null
|
|
705
733
|
const text = typeof rawText === 'string' ? rawText : ''
|
|
@@ -714,6 +742,8 @@ function buildInbound(
|
|
|
714
742
|
// that handle is the author, never a third-party mention of the bot, so the
|
|
715
743
|
// body-text mention heuristic must not fire on it.
|
|
716
744
|
const isBotMention = !synthesizedAwareness && textMentionsBot(text, mention)
|
|
745
|
+
const replyToBotMessageId = options?.replyToBotMessageId ?? null
|
|
746
|
+
const replyToOtherMessageId = options?.replyToOtherMessageId ?? key.replyToOtherMessageId
|
|
717
747
|
return {
|
|
718
748
|
...key,
|
|
719
749
|
text,
|
|
@@ -723,7 +753,9 @@ function buildInbound(
|
|
|
723
753
|
authorName: user.login,
|
|
724
754
|
authorIsBot: user.type === 'Bot',
|
|
725
755
|
isBotMention,
|
|
726
|
-
|
|
756
|
+
...(options?.suppressSticky === true ? { suppressSticky: true } : {}),
|
|
757
|
+
replyToBotMessageId,
|
|
758
|
+
replyToOtherMessageId,
|
|
727
759
|
ts: typeof rawTs === 'string' ? Date.parse(rawTs) || 0 : 0,
|
|
728
760
|
}
|
|
729
761
|
}
|
|
@@ -790,6 +822,39 @@ async function resolveTeamMembership(
|
|
|
790
822
|
}
|
|
791
823
|
}
|
|
792
824
|
|
|
825
|
+
async function resolveReviewCommentParent(
|
|
826
|
+
event: string,
|
|
827
|
+
payload: Record<string, unknown>,
|
|
828
|
+
selfId: string | null,
|
|
829
|
+
selfLogin: string | null,
|
|
830
|
+
options: GithubWebhookHandlerOptions,
|
|
831
|
+
): Promise<ReviewCommentParent | null> {
|
|
832
|
+
if (event !== 'pull_request_review_comment') return null
|
|
833
|
+
const comment = readRecord(payload.comment)
|
|
834
|
+
const parentId = readNumber(comment, 'in_reply_to_id')
|
|
835
|
+
if (parentId === null) return null
|
|
836
|
+
const repository = readRepository(payload)
|
|
837
|
+
if (repository === null) return null
|
|
838
|
+
const authToken = options.authToken
|
|
839
|
+
if (authToken === undefined) return null
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
const token = await authToken({ repoSlug: `${repository.owner}/${repository.name}` })
|
|
843
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
844
|
+
const response = await fetchImpl(
|
|
845
|
+
`${GITHUB_API_BASE}/repos/${repository.owner}/${repository.name}/pulls/comments/${parentId}`,
|
|
846
|
+
{ headers: githubJsonHeaders(token) },
|
|
847
|
+
)
|
|
848
|
+
if (!response.ok) return null
|
|
849
|
+
const raw = (await response.json().catch(() => null)) as unknown
|
|
850
|
+
const user = readUser(readRecord(raw)?.user)
|
|
851
|
+
if (user === null) return null
|
|
852
|
+
return { parentId, isSelf: isSelfAuthor(user, selfId, selfLogin) }
|
|
853
|
+
} catch {
|
|
854
|
+
return null
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
793
858
|
function readRepository(payload: Record<string, unknown>): { owner: string; name: string } | null {
|
|
794
859
|
const repository = readRecord(payload.repository)
|
|
795
860
|
const owner = readRecord(repository?.owner)
|