typeclaw 0.34.1 → 0.35.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 +3 -1
- package/src/agent/plugin-tools.ts +53 -5
- package/src/agent/provider-error.ts +10 -0
- package/src/agent/session-origin.ts +26 -0
- package/src/agent/tools/channel-disengage.ts +13 -9
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
- package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
- package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
- package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
- package/src/channels/adapters/github/inbound.ts +41 -3
- package/src/channels/adapters/slack-bot.ts +17 -9
- package/src/channels/continuation-willingness.ts +331 -0
- package/src/channels/github-review-claim.ts +105 -0
- package/src/channels/github-token-bridge.ts +7 -0
- package/src/channels/router.ts +103 -24
- package/src/cli/channel.ts +102 -11
- package/src/cli/qr.ts +130 -0
- package/src/config/config.ts +98 -2
- package/src/container/start.ts +12 -0
- package/src/init/dockerfile.ts +64 -0
- package/src/init/line-auth.ts +8 -3
- package/src/plugin/context.ts +5 -1
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/types.ts +1 -0
- package/src/run/index.ts +1 -0
- package/src/sandbox/build.ts +27 -0
- package/src/sandbox/index.ts +6 -0
- package/src/sandbox/package-install.ts +23 -0
- package/src/sandbox/policy.ts +31 -0
- package/src/sandbox/symlinks.ts +34 -0
- package/src/sandbox/writable-zones.ts +164 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
- package/typeclaw.schema.json +32 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
|
|
2
2
|
import path, { isAbsolute, join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
export type WritableZones = {
|
|
@@ -35,6 +35,25 @@ export type ProtectedZones = {
|
|
|
35
35
|
// exactly that escalation.
|
|
36
36
|
const WRITABLE_DIRS = ['workspace', 'public', 'mounts', '.git'] as const
|
|
37
37
|
|
|
38
|
+
// SECURITY: configured writable paths (`sandbox.writablePaths`) may NOT resolve
|
|
39
|
+
// onto these. `.git` carries the hook/config escalation surface; `.env` and
|
|
40
|
+
// `secrets.json` are the credential files; `sessions`/`memory` are the agent's
|
|
41
|
+
// private surface (masked from low-trust roles by hidden-paths); `.typeclaw`
|
|
42
|
+
// holds system-managed home persistence; `node_modules` is executable
|
|
43
|
+
// dependency code. Granting blanket RW to any of these via config would defeat
|
|
44
|
+
// the very guards the narrow built-in set exists to preserve. The agent root
|
|
45
|
+
// itself is also rejected (a writablePaths of '' or '.') — an RW bind of the
|
|
46
|
+
// whole tree erases the read-only confinement wholesale.
|
|
47
|
+
const FORBIDDEN_WRITABLE_ROOTS = [
|
|
48
|
+
'.git',
|
|
49
|
+
'.env',
|
|
50
|
+
'secrets.json',
|
|
51
|
+
'sessions',
|
|
52
|
+
'memory',
|
|
53
|
+
'.typeclaw',
|
|
54
|
+
'node_modules',
|
|
55
|
+
] as const
|
|
56
|
+
|
|
38
57
|
const PROTECTED_GIT_DIRS = ['.git/hooks'] as const
|
|
39
58
|
const PROTECTED_GIT_FILES = ['.git/config'] as const
|
|
40
59
|
|
|
@@ -54,16 +73,157 @@ const WRITABLE_ROOT_FILES = [
|
|
|
54
73
|
// so a `workspace -> /etc` symlink at a zone root would grant write access to an
|
|
55
74
|
// outside path. (Symlinks INSIDE a real zone are already safe — the kernel
|
|
56
75
|
// resolves them to the read-only parent mount.)
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
//
|
|
77
|
+
// `configuredWritablePaths` are operator-chosen agent-relative dirs from
|
|
78
|
+
// `sandbox.writablePaths`. They join the built-in dirs through the SAME
|
|
79
|
+
// existence + symlink filter, plus the extra guardrails in
|
|
80
|
+
// `resolveConfiguredWritableDirs`: each must resolve inside agentDir and must
|
|
81
|
+
// not land on a forbidden root. A path that fails any check is dropped, never
|
|
82
|
+
// throws — a stale config should degrade the one bad entry, not abort sandboxing.
|
|
83
|
+
export async function resolveWritableZones(
|
|
84
|
+
agentDir: string,
|
|
85
|
+
configuredWritablePaths: readonly string[] = [],
|
|
86
|
+
): Promise<WritableZones> {
|
|
87
|
+
const builtinDirs = await collectExisting(
|
|
59
88
|
WRITABLE_DIRS.map((d) => join(agentDir, d)),
|
|
60
89
|
'dir',
|
|
61
90
|
)
|
|
91
|
+
const configuredDirs = await resolveConfiguredWritableDirs(agentDir, configuredWritablePaths)
|
|
62
92
|
const files = await collectExisting(
|
|
63
93
|
WRITABLE_ROOT_FILES.map((f) => join(agentDir, f)),
|
|
64
94
|
'file',
|
|
65
95
|
)
|
|
66
|
-
return { dirs, files }
|
|
96
|
+
return { dirs: dedupe([...builtinDirs, ...configuredDirs]), files }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// SECURITY: validation is on the REAL path, not the lexical one. A lexical-only
|
|
100
|
+
// check (resolve + isInside) is bypassable by a symlinked INTERMEDIATE component:
|
|
101
|
+
// with `/agent/alias -> /tmp/outside` (or `-> /agent/sessions`) and a config of
|
|
102
|
+
// `alias/sub`, the lexical path `/agent/alias/sub` passes isInside and the
|
|
103
|
+
// forbidden-root check, while the bwrap `--bind` follows the ancestor symlink to
|
|
104
|
+
// write outside /agent (or onto a forbidden root). The zone-root lstat alone
|
|
105
|
+
// can't see it — lstat of the final component follows ancestor symlinks. So we
|
|
106
|
+
// realpath BOTH the candidate and agentDir (+ the forbidden roots) and validate
|
|
107
|
+
// the resolved targets. A path whose real form escapes agentDir or lands on a
|
|
108
|
+
// real forbidden root is dropped. realpath also rejects the final component
|
|
109
|
+
// being a symlink (its real target is re-checked), subsuming the prior lstat.
|
|
110
|
+
async function resolveConfiguredWritableDirs(agentDir: string, configured: readonly string[]): Promise<string[]> {
|
|
111
|
+
const realAgentDir = await realpathOrUndefined(agentDir)
|
|
112
|
+
if (realAgentDir === undefined) return []
|
|
113
|
+
const realForbidden = await resolveRealForbiddenRoots(agentDir)
|
|
114
|
+
|
|
115
|
+
const accepted: string[] = []
|
|
116
|
+
for (const rel of configured) {
|
|
117
|
+
const absolute = resolve(agentDir, rel)
|
|
118
|
+
// Cheap lexical pre-filter: reject obvious escapes before touching the disk.
|
|
119
|
+
if (absolute === agentDir || !isInside(agentDir, absolute)) continue
|
|
120
|
+
const real = await realpathOrUndefined(absolute)
|
|
121
|
+
if (real === undefined) continue
|
|
122
|
+
if (!(await isRealEntry(real, 'dir'))) continue
|
|
123
|
+
if (real === realAgentDir || !isInside(realAgentDir, real)) continue
|
|
124
|
+
if (realForbidden.some((root) => real === root || isInside(root, real))) continue
|
|
125
|
+
// Bind the lexical (caller-facing) path; bwrap resolves it to `real` itself.
|
|
126
|
+
accepted.push(absolute)
|
|
127
|
+
}
|
|
128
|
+
return accepted
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function resolveRealForbiddenRoots(agentDir: string): Promise<string[]> {
|
|
132
|
+
const resolved: string[] = []
|
|
133
|
+
for (const root of FORBIDDEN_WRITABLE_ROOTS) {
|
|
134
|
+
const real = await realpathOrUndefined(join(agentDir, root))
|
|
135
|
+
if (real !== undefined) resolved.push(real)
|
|
136
|
+
}
|
|
137
|
+
return resolved
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function realpathOrUndefined(target: string): Promise<string | undefined> {
|
|
141
|
+
try {
|
|
142
|
+
return await realpath(target)
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function dedupe(values: string[]): string[] {
|
|
149
|
+
return [...new Set(values)]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type PackageInstallZones = {
|
|
153
|
+
root: string
|
|
154
|
+
protected: ProtectedZones
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// SECURITY: the package-install RW root is governed by an ALLOWLIST, not a
|
|
158
|
+
// denylist. `bun add` writes exactly these and nothing else: `node_modules/`
|
|
159
|
+
// (deps), `package.json` + `bun.lock` (manifest + lockfile, plus the temp
|
|
160
|
+
// lockfile created in the root DIR). The scratch zones (`workspace`, `public`,
|
|
161
|
+
// `mounts`) stay writable to match the normal jail. EVERY other existing root
|
|
162
|
+
// entry is RO-bound, so a denylist of "executable/runtime-sensitive" paths is
|
|
163
|
+
// not needed — it would be unbounded (any file the unsandboxed runtime reads or
|
|
164
|
+
// execs, including `src/`/`scripts/` in dev-mode agents where typeclaw is a
|
|
165
|
+
// file:/link: dep, the agent's own lifecycle scripts, and prompt-source files)
|
|
166
|
+
// and fails OPEN for any root entry not yet listed. An allowlist fails CLOSED.
|
|
167
|
+
const PACKAGE_INSTALL_WRITABLE_DIRS = ['node_modules', 'workspace', 'public', 'mounts'] as const
|
|
168
|
+
const PACKAGE_INSTALL_WRITABLE_FILES = ['package.json', 'bun.lock'] as const
|
|
169
|
+
|
|
170
|
+
// Resolves the jail layout for a recognized standalone dependency install
|
|
171
|
+
// (`bun add` / `bun install`). The RW root lets bun create node_modules/ and its
|
|
172
|
+
// temp lockfile (`bun.lock.NNN.tmp`, renamed) — a file-level bind of `bun.lock`
|
|
173
|
+
// alone cannot, since the temp file needs DIRECTORY write. Pre-creates an empty
|
|
174
|
+
// node_modules/ so the dir exists before the RW root bind. Then RO-binds every
|
|
175
|
+
// EXISTING root entry not in the writable allowlist (readdir enumeration, so a
|
|
176
|
+
// new file like `src/` or a planted `cron.json` is covered without a hardcoded
|
|
177
|
+
// list), plus `node_modules/typeclaw` (the live/symlinked runtime, nested under
|
|
178
|
+
// the writable node_modules) and the whole `.git` (a `bun add` never needs git,
|
|
179
|
+
// so RO-binding it wholesale is simpler and safer than the hooks/config carve-out
|
|
180
|
+
// — it closes the hook / core.hooksPath escalation by construction).
|
|
181
|
+
//
|
|
182
|
+
// SECURITY: rejects a symlink at agentDir, at any install-touched path
|
|
183
|
+
// (node_modules, package.json, bun.lock), and at every RO-bind source — an RW
|
|
184
|
+
// root or an RO bind that follows a symlink would write/read outside the jail.
|
|
185
|
+
// The secret/private masks render AFTER this protected set (subtractMasked in
|
|
186
|
+
// applyBashSandbox drops any protected entry a mask already hides), so .env /
|
|
187
|
+
// secrets.json / memory / sessions stay hidden, not merely RO.
|
|
188
|
+
export async function resolvePackageInstallZones(agentDir: string): Promise<PackageInstallZones> {
|
|
189
|
+
await assertNotSymlink(agentDir)
|
|
190
|
+
await mkdir(join(agentDir, 'node_modules'), { recursive: true })
|
|
191
|
+
for (const rel of ['node_modules', ...PACKAGE_INSTALL_WRITABLE_FILES] as const) {
|
|
192
|
+
const target = join(agentDir, rel)
|
|
193
|
+
if (await exists(target)) await assertNotSymlink(target)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const writable = new Set<string>([...PACKAGE_INSTALL_WRITABLE_DIRS, ...PACKAGE_INSTALL_WRITABLE_FILES])
|
|
197
|
+
const dirs: string[] = []
|
|
198
|
+
const files: string[] = []
|
|
199
|
+
for (const entry of await readdir(agentDir, { withFileTypes: true })) {
|
|
200
|
+
if (writable.has(entry.name)) continue
|
|
201
|
+
// A symlinked root entry is skipped, not RO-bound: an RO bind follows it to
|
|
202
|
+
// an outside target. Skipping leaves it under the RW root — but it is the
|
|
203
|
+
// agent's OWN symlink under its OWN root, contained by the agent-folder bind
|
|
204
|
+
// and the always-on kernel invariants, the same residual the default jail
|
|
205
|
+
// accepts for symlinks pointing outside /agent.
|
|
206
|
+
if (entry.isSymbolicLink()) continue
|
|
207
|
+
const target = join(agentDir, entry.name)
|
|
208
|
+
if (entry.isDirectory()) dirs.push(target)
|
|
209
|
+
else if (entry.isFile()) files.push(target)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// node_modules itself is writable (deps land there), but the runtime under it
|
|
213
|
+
// must not be — RO-bind it nested, last-op-wins over the writable node_modules.
|
|
214
|
+
const runtime = join(agentDir, 'node_modules', 'typeclaw')
|
|
215
|
+
if (await isRealEntry(runtime, 'dir')) dirs.push(runtime)
|
|
216
|
+
|
|
217
|
+
return { root: agentDir, protected: { dirs: dedupe(dirs), files: dedupe(files) } }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function exists(target: string): Promise<boolean> {
|
|
221
|
+
try {
|
|
222
|
+
await lstat(target)
|
|
223
|
+
return true
|
|
224
|
+
} catch {
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
67
227
|
}
|
|
68
228
|
|
|
69
229
|
// Read-only re-protections rendered on top of the writable .git bind. Unlike
|
|
@@ -145,7 +145,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
145
145
|
|
|
146
146
|
Then submit the review. **Write the JSON payload to a file with the `write` tool, then run a single bare `gh api --input <file>`** — two steps:
|
|
147
147
|
|
|
148
|
-
First write `/tmp/review-<N>.json` (via the `write` tool, not bash) — `/tmp` is per-session scratch, and the `<N>` keeps concurrent reviews in one session from colliding:
|
|
148
|
+
First write `/tmp/review-<N>.json` (via the `write` tool, not bash) — `/tmp` is per-session scratch, and the `<N>` keeps concurrent reviews in one session from colliding. **Every `gh api --input` payload — review JSON, dismissal JSON, top-level issue-comment JSON — is throwaway scratch and MUST be written under `/tmp/`, never the workspace/agent folder.** A path with no leading `/tmp/` (e.g. a bare `review.json`, `review_comment.json`, or `review-<N>-approve.json`) lands in the agent root and gets force-committed by the backup loop, littering the repo with review payloads. Always prefix the path with `/tmp/`; never write a `*.json` scratch file to the workspace:
|
|
149
149
|
|
|
150
150
|
```json
|
|
151
151
|
{
|
|
@@ -190,7 +190,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
190
190
|
A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
|
|
191
191
|
|
|
192
192
|
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **Post the `<summary>` verbatim — do not pad it back into a play-by-play.** The reviewer's contract already makes the summary a terse, author-facing verdict justification (no process narration, no "I loaded the X skill", no recap of what the PR does); your job is to forward it, not re-expand it. **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
|
|
193
|
-
- `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments`
|
|
193
|
+
- `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments`, feeding the body from a `/tmp/` scratch file: write `/tmp/review-comment-<N>.json` (e.g. `{ "body": "<summary>" }`) with the `write` tool, then `gh api -X POST /repos/owner/repo/issues/<N>/comments --input /tmp/review-comment-<N>.json`. As with the review payload, this scratch file goes under `/tmp/`, **never** a bare `review_comment.json` in the workspace — the file-then-`--input` shape also keeps backticks/newlines in the summary from being mangled by shell parsing. Submit an empty review instead of this comment only when a formal review is required. **Exception — re-reviews:** if this is a re-review (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
|
|
194
194
|
- `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
|
|
195
195
|
|
|
196
196
|
The bundled `agent-browser` is **not** for PR reviews — `gh api` is faster and more reliable. Only use the browser when the API genuinely can't reach what you need.
|
|
@@ -272,6 +272,8 @@ gh pr create --repo owner/repo --title "Fix: ..." --head my-branch --base main -
|
|
|
272
272
|
|
|
273
273
|
For App auth, `GH_TOKEN` is an installation access token that refreshes automatically — it stays current as long as the adapter is running.
|
|
274
274
|
|
|
275
|
+
Before you compose the issue/PR body, read `typeclaw-github-contributing` — it covers the target repo's contribution etiquette (fill the issue/PR template if one exists, honor `CONTRIBUTING.md`, match the repo's title conventions, search for duplicates first). Opening an issue or PR that ignores the repo's template reads as careless; following it reads as someone who belongs. That skill applies whenever you open a new issue/PR, whether or not the work arrived through this channel.
|
|
276
|
+
|
|
275
277
|
## Self-loop safety
|
|
276
278
|
|
|
277
279
|
The adapter will **not** wake you when you assign yourself as a reviewer (e.g., via `gh pr edit --add-reviewer`). It will only wake you when someone else requests your review.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-github-contributing
|
|
3
|
+
description: Use this skill BEFORE you open a new issue or pull request on GitHub with `gh issue create` / `gh pr create` (or before composing the `--body`/`--title` for one, or editing an issue/PR body after the fact). Triggers include any `gh issue create`, `gh pr create`, `gh api … /issues`, `gh api … /pulls`, any time you are about to file a bug report, feature request, or PR against a repo — yours or someone else's — and any phrasing like "open an issue", "file a bug", "raise a PR", "submit a pull request". Read it first, because every repo has its own contribution rules — an issue/PR template, a CONTRIBUTING.md, a title convention — and a maintainer's first impression of your contribution is whether you bothered to follow them. Ignoring a template that the repo author wrote on purpose reads as careless; following it reads as someone who belongs. This is platform etiquette, independent of how the work reached you (it applies whether the request came through the github channel or you decided to file on your own).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-github-contributing
|
|
7
|
+
|
|
8
|
+
When you open an issue or a PR on GitHub, you are writing into **someone else's repository** — even when it's nominally "yours," it usually has collaborators, a history, and conventions that predate this turn. The repo's maintainers encoded what they want from a contribution in a few well-known files. Honoring them is not bureaucracy; it's the difference between a contribution that gets triaged and merged and one that gets a "please use the template" and sits.
|
|
9
|
+
|
|
10
|
+
This skill is the **etiquette of contributing to a GitHub repo**. It is not about replying to inbound GitHub events or running PR reviews — that's `typeclaw-channel-github`. It is not about committing to your own agent folder — that's `typeclaw-git`. It is specifically about _what you put on GitHub the moment you open a new issue or PR._
|
|
11
|
+
|
|
12
|
+
## The rule
|
|
13
|
+
|
|
14
|
+
**Before you open an issue or PR, find the repo's contribution conventions and follow them. If a template exists, fill it — don't bypass it. If a CONTRIBUTING file exists, read it and honor it. Match the title style the repo already uses.**
|
|
15
|
+
|
|
16
|
+
The principle underneath all five rules below: **the repo already told you how it wants contributions; your job is to look before you write, not to impose your defaults.**
|
|
17
|
+
|
|
18
|
+
## Do this first — read the repo before you write
|
|
19
|
+
|
|
20
|
+
A contribution composed from your defaults and one composed from the repo's conventions look completely different to a maintainer. The five-second check that separates them:
|
|
21
|
+
|
|
22
|
+
**Templates do not live in one fixed spot — GitHub resolves them from several.** Probing only `.github/PULL_REQUEST_TEMPLATE.md` is the trap: a repo that keeps its template at the root, under `docs/`, or in a `PULL_REQUEST_TEMPLATE/` directory will look template-less to that one check, and you'll bypass a convention the maintainer actually set. GitHub looks in **three base locations** — the repo root, `docs/`, and `.github/` — and the filename is **case-insensitive** (`PULL_REQUEST_TEMPLATE.md`, `pull_request_template.md`). It also supports a **`PULL_REQUEST_TEMPLATE/` _directory_** of multiple named templates (same three base locations). Issue templates follow the same pattern: a single `.github/ISSUE_TEMPLATE.md`, or — far more common now — an `ISSUE_TEMPLATE/` directory of forms (`*.md` / `*.yml`).
|
|
23
|
+
|
|
24
|
+
The cheapest reliable check is to list each base directory once and scan the names, rather than guessing exact filenames:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
# PR template — scan all three base dirs for any case of the file OR a PULL_REQUEST_TEMPLATE/ dir.
|
|
28
|
+
# (Listing the dir surfaces both the single-file and the multi-template-directory forms at once.)
|
|
29
|
+
for base in "" "docs/" ".github/"; do
|
|
30
|
+
gh api "repos/OWNER/REPO/contents/${base}" --jq '.[].name' 2>/dev/null \
|
|
31
|
+
| grep -iE '^pull_request_template(\.md)?$'
|
|
32
|
+
done
|
|
33
|
+
# If a PULL_REQUEST_TEMPLATE/ directory exists in ANY base, list the choices inside it
|
|
34
|
+
# (the directory form is supported at root, docs/, and .github/ alike):
|
|
35
|
+
for base in "" "docs/" ".github/"; do
|
|
36
|
+
gh api "repos/OWNER/REPO/contents/${base}PULL_REQUEST_TEMPLATE" --jq '.[].name' 2>/dev/null
|
|
37
|
+
done
|
|
38
|
+
|
|
39
|
+
# Issue templates — the modern form is an ISSUE_TEMPLATE/ directory of forms;
|
|
40
|
+
# the legacy form is a single ISSUE_TEMPLATE.md. Check both, in all three base dirs.
|
|
41
|
+
for base in "" "docs/" ".github/"; do
|
|
42
|
+
gh api "repos/OWNER/REPO/contents/${base}ISSUE_TEMPLATE" --jq '.[].name' 2>/dev/null # directory of forms
|
|
43
|
+
gh api "repos/OWNER/REPO/contents/${base}" --jq '.[].name' 2>/dev/null \
|
|
44
|
+
| grep -iE '^issue_template(\.md)?$' # single legacy file
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
# Contribution guide (root or .github/)
|
|
48
|
+
gh api repos/OWNER/REPO/contents/CONTRIBUTING.md 2>/dev/null
|
|
49
|
+
gh api repos/OWNER/REPO/contents/.github/CONTRIBUTING.md 2>/dev/null
|
|
50
|
+
|
|
51
|
+
# What do existing titles look like?
|
|
52
|
+
gh pr list --repo OWNER/REPO --state all --limit 20 --json title --jq '.[].title'
|
|
53
|
+
gh issue list --repo OWNER/REPO --state all --limit 20 --json title --jq '.[].title'
|
|
54
|
+
|
|
55
|
+
# Is this a duplicate?
|
|
56
|
+
gh issue list --repo OWNER/REPO --search "<keywords from what you're about to file>" --state all
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A `grep` that matches **nothing across all three base dirs** is your evidence there is genuinely no template — a single 404 on `.github/PULL_REQUEST_TEMPLATE.md` is not. You don't need every command every time, but you do need to _look in all the supported places_ before concluding "no template" and composing from your own defaults. The cost is a few API calls; the cost of skipping it is bypassing a convention the repo set on purpose — the exact failure this skill exists to prevent.
|
|
60
|
+
|
|
61
|
+
## The five rules
|
|
62
|
+
|
|
63
|
+
### 1. Fill the issue/PR template if one exists
|
|
64
|
+
|
|
65
|
+
If the discovery scan above surfaced a template in **any** of the supported locations (root, `docs/`, or `.github/` — single file or directory), the maintainers want every issue/PR to follow that shape. Fetch its content, read its sections, and produce a `--body` that fills each one with real content.
|
|
66
|
+
|
|
67
|
+
- For PRs, the template often has a checklist ("- [ ] tests added", "- [ ] docs updated"). Fill the prose sections; for checkboxes, check only what is genuinely true and leave the rest unchecked — don't tick a box you can't back up.
|
|
68
|
+
- A **`PULL_REQUEST_TEMPLATE/` directory** holds more than one PR template; pick the one that matches your change and fill it. (GitHub can also select one via a `?template=` URL param, but when you're filing through `gh` you choose by reading the directory and using the right file as your body.)
|
|
69
|
+
- An **`ISSUE_TEMPLATE/` directory** likewise holds multiple issue forms (bug report, feature request, etc.) — pick the one that matches what you're filing. A bug filed against the feature-request template is noise. Forms may be YAML (`.yml` issue forms) rather than markdown; translate their fields into a sensible body, or use the matching markdown template if one is offered.
|
|
70
|
+
- A repo with genuinely no template (the scan matched nothing in all three base dirs) gives you latitude — but a clear, scannable body (what / why / how, repro steps for bugs) is still the courteous default.
|
|
71
|
+
|
|
72
|
+
### 2. Don't bypass the template to file faster
|
|
73
|
+
|
|
74
|
+
This is rule 1's hard edge and the failure this skill most exists to prevent. When a template has required sections or a checklist, **fill or honor them — do not delete them to save effort.** Stripping the template, leaving placeholder text (`<!-- describe your change -->`) un-replaced, or dumping a one-liner where the template asked for repro steps all read as "I didn't bother." If a section genuinely doesn't apply, say so explicitly ("N/A — no user-facing change") rather than silently removing it; the maintainer can tell the difference between _considered and skipped_ and _ignored_.
|
|
75
|
+
|
|
76
|
+
### 3. Follow CONTRIBUTING.md
|
|
77
|
+
|
|
78
|
+
If the repo has a `CONTRIBUTING.md` (root or `.github/`), read it before you open anything. It encodes rules the templates can't — branch naming, whether PRs go against `main` or `develop`, whether a linked issue is required first, commit sign-off (DCO), the expected PR description format, "open an issue before a large PR," and so on. These are the maintainers' actual process; violating them is the most common reason a well-intentioned PR gets bounced. Honor what it says even when it differs from your habits.
|
|
79
|
+
|
|
80
|
+
### 4. Match the repo's title conventions
|
|
81
|
+
|
|
82
|
+
A repo's existing issue/PR titles tell you the house style. Pull the last ~20 (commands above) and infer the pattern, then match it:
|
|
83
|
+
|
|
84
|
+
- **Conventional-commit prefixes** (`feat:`, `fix:`, `docs(scope):`) — if the PR list is full of them, your PR title uses one too.
|
|
85
|
+
- **Ticket/issue references** (`[PROJ-123]`, `(#456)`) — if titles routinely carry them and you have a ref, include it.
|
|
86
|
+
- **Sentence case vs. lowercase, imperative mood** — small things, but matching them signals you read the log.
|
|
87
|
+
|
|
88
|
+
If there's no discernible pattern, a clear imperative summary (`Fix race in port allocation`) is the safe default. The point is never to invent your own scheme on top of an established one.
|
|
89
|
+
|
|
90
|
+
### 5. Search for duplicates before opening
|
|
91
|
+
|
|
92
|
+
Before filing an issue, search existing issues (open _and_ closed — a closed one may carry the resolution or the maintainer's "won't fix" rationale). If a matching thread exists:
|
|
93
|
+
|
|
94
|
+
- **Open and relevant** → add your context as a comment on that thread instead of opening a duplicate. Duplicates fragment the discussion and annoy triagers.
|
|
95
|
+
- **Closed as resolved** → check whether the fix is in the version you're on before re-filing; if it's a regression, reference the old issue in your new one.
|
|
96
|
+
|
|
97
|
+
The same applies to PRs: a quick `gh pr list --search` avoids opening a PR for something already in flight.
|
|
98
|
+
|
|
99
|
+
## Workflow
|
|
100
|
+
|
|
101
|
+
1. **Identify the target repo** (`OWNER/REPO`) and whether you're filing an issue or a PR.
|
|
102
|
+
2. **Read the room** — run the checks in "Do this first": templates, CONTRIBUTING, existing titles, duplicate search.
|
|
103
|
+
3. **Compose to the conventions** — fill the template, honor CONTRIBUTING, match the title style. Write the body to a file when it's long or contains backticks/markdown that shell-quoting would mangle, then pass it with `--body-file`.
|
|
104
|
+
4. **Open it** with `gh`:
|
|
105
|
+
```sh
|
|
106
|
+
gh issue create --repo OWNER/REPO --title "<conventional title>" --body-file /tmp/issue-body.md
|
|
107
|
+
gh pr create --repo OWNER/REPO --title "<conventional title>" --body-file /tmp/pr-body.md --base <branch>
|
|
108
|
+
```
|
|
109
|
+
5. **Verify it landed** as intended (`gh issue view` / `gh pr view`) — confirm the template rendered and nothing got truncated.
|
|
110
|
+
|
|
111
|
+
## Things you must not do
|
|
112
|
+
|
|
113
|
+
- **Do not open an issue/PR without checking for a template.** A repo that ships a template wants it used; skipping the check is how you end up filing against conventions you never looked at.
|
|
114
|
+
- **Do not strip, gut, or placeholder-leave a template** to file faster. Fill it, or explicitly mark sections N/A. An empty template body is worse than a thoughtful free-form one.
|
|
115
|
+
- **Do not ignore CONTRIBUTING.md** because its rules differ from your defaults. Its rules win in its repo.
|
|
116
|
+
- **Do not invent a title scheme** when the repo already has one. Match what's there.
|
|
117
|
+
- **Do not file a duplicate** without searching first. Comment on the existing thread instead.
|
|
118
|
+
- **Do not tick checklist boxes you can't back up.** A checked "tests added" with no tests is a false claim the reviewer will catch.
|
|
119
|
+
|
|
120
|
+
## What this skill does not cover
|
|
121
|
+
|
|
122
|
+
- **Replying to inbound GitHub events, and running PR reviews** — `typeclaw-channel-github`. That skill owns triage of github-channel inbounds, formal reviews via the reviews API, and resolving review threads. This skill owns only the act of _opening_ a new issue/PR.
|
|
123
|
+
- **Committing to your own agent folder** — `typeclaw-git`. Local commit hygiene and decision-context messages live there; this skill is about GitHub artifacts, not your local history.
|
|
124
|
+
- **The `gh` CLI's full surface** — auth, sub-commands, flags. Defer to `gh <command> --help`. Under the github channel adapter, `GH_TOKEN` is pre-injected; see `typeclaw-channel-github` for the single-bare-invocation constraint that applies to repo-targeting `gh` calls (no pipes, `;`, `&&`, heredocs, or command substitution).
|
package/typeclaw.schema.json
CHANGED
|
@@ -1310,13 +1310,44 @@
|
|
|
1310
1310
|
},
|
|
1311
1311
|
"sandbox": {
|
|
1312
1312
|
"default": {
|
|
1313
|
-
"realProc": false
|
|
1313
|
+
"realProc": false,
|
|
1314
|
+
"writablePaths": [],
|
|
1315
|
+
"symlinks": []
|
|
1314
1316
|
},
|
|
1315
1317
|
"type": "object",
|
|
1316
1318
|
"properties": {
|
|
1317
1319
|
"realProc": {
|
|
1318
1320
|
"default": false,
|
|
1319
1321
|
"type": "boolean"
|
|
1322
|
+
},
|
|
1323
|
+
"writablePaths": {
|
|
1324
|
+
"default": [],
|
|
1325
|
+
"type": "array",
|
|
1326
|
+
"items": {
|
|
1327
|
+
"type": "string",
|
|
1328
|
+
"minLength": 1
|
|
1329
|
+
}
|
|
1330
|
+
},
|
|
1331
|
+
"symlinks": {
|
|
1332
|
+
"default": [],
|
|
1333
|
+
"type": "array",
|
|
1334
|
+
"items": {
|
|
1335
|
+
"type": "object",
|
|
1336
|
+
"properties": {
|
|
1337
|
+
"from": {
|
|
1338
|
+
"type": "string",
|
|
1339
|
+
"minLength": 1
|
|
1340
|
+
},
|
|
1341
|
+
"to": {
|
|
1342
|
+
"type": "string",
|
|
1343
|
+
"minLength": 1
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
"required": [
|
|
1347
|
+
"from",
|
|
1348
|
+
"to"
|
|
1349
|
+
]
|
|
1350
|
+
}
|
|
1320
1351
|
}
|
|
1321
1352
|
}
|
|
1322
1353
|
},
|