typeclaw 0.21.0 → 0.22.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 +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot.ts +2 -0
- package/src/channels/adapters/github/inbound.ts +23 -1
- package/src/channels/adapters/github/index.ts +1 -0
- package/src/channels/adapters/slack-bot.ts +104 -5
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +68 -15
- package/src/channels/schema.ts +18 -0
- package/src/cli/dreams.ts +2 -1
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
- package/typeclaw.schema.json +82 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { ACKNOWLEDGE_GUARDS, type GuardBlock, isGuardAcknowledged } from '../guard/policy'
|
|
2
|
+
|
|
3
|
+
export const GUARD_GLOBAL_INSTALL = 'globalInstall'
|
|
4
|
+
export const GUARD_NON_BUN_PACKAGE_MANAGER = 'nonBunPackageManager'
|
|
5
|
+
|
|
6
|
+
const NON_BUN_MANAGERS = new Set(['npm', 'npx', 'pnpm', 'pnpx', 'yarn'])
|
|
7
|
+
const INSTALL_SUBCOMMANDS = new Set(['install', 'i', 'add'])
|
|
8
|
+
|
|
9
|
+
export function checkBunHygieneGuard(options: { tool: string; args: Record<string, unknown> }): GuardBlock | undefined {
|
|
10
|
+
const { tool, args } = options
|
|
11
|
+
if (tool !== 'bash') return undefined
|
|
12
|
+
|
|
13
|
+
const command = args.command
|
|
14
|
+
if (typeof command !== 'string') return undefined
|
|
15
|
+
|
|
16
|
+
const verdict = classify(command)
|
|
17
|
+
if (verdict === undefined) return undefined
|
|
18
|
+
if (verdict.kind === 'global-install') return blockGlobalInstall(verdict.label, args)
|
|
19
|
+
return blockNonBunManager(verdict.manager, args)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Verdict = { kind: 'global-install'; label: string } | { kind: 'non-bun'; manager: string }
|
|
23
|
+
|
|
24
|
+
// Why a segment model instead of one big regex: every gap in a raw-string match
|
|
25
|
+
// is a shell-structure gap. Splitting into segments first means a global flag on
|
|
26
|
+
// a *different* command (`npm install\n-g x` — two commands) can never combine
|
|
27
|
+
// with an install on another, leading assignment words (`FOO=bar npm ...`) are
|
|
28
|
+
// stripped uniformly, and `--global=false` is inspected as a real token. The
|
|
29
|
+
// global-install verdict wins over the plain non-bun verdict (it's the more
|
|
30
|
+
// specific violation, and acknowledging it is meant to let the whole thing run).
|
|
31
|
+
function classify(command: string): Verdict | undefined {
|
|
32
|
+
let fallback: Verdict | undefined
|
|
33
|
+
for (const segment of splitSegments(command)) {
|
|
34
|
+
const words = segment.map(normalizeWord)
|
|
35
|
+
const manager = leadingCommandWord(words)
|
|
36
|
+
if (manager === undefined) continue
|
|
37
|
+
|
|
38
|
+
// `bun` is the allowed manager, but a `bun add -g` still installs to ~/.bun
|
|
39
|
+
// (outside /agent) and is wiped on restart, so it is a global install too —
|
|
40
|
+
// just never a plain non-bun violation.
|
|
41
|
+
const isBun = manager === 'bun'
|
|
42
|
+
if (!isBun && !NON_BUN_MANAGERS.has(manager)) continue
|
|
43
|
+
|
|
44
|
+
const label = globalInstallLabel(manager, words)
|
|
45
|
+
if (label !== undefined) return { kind: 'global-install', label }
|
|
46
|
+
if (!isBun) fallback ??= { kind: 'non-bun', manager }
|
|
47
|
+
}
|
|
48
|
+
return fallback
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Split on real command separators (`;`, `&&`, `||`, `|`, `&`, newline, `\r`)
|
|
52
|
+
// and on subshell / command-substitution openers (`(`, `$(`, backtick), then
|
|
53
|
+
// tokenize each segment into whitespace-separated words. Quote-aware so a
|
|
54
|
+
// separator inside quotes stays literal; backslash escapes the next character
|
|
55
|
+
// (so `\;` and `\ ` are literal, not separators/breaks). Word-level only — it
|
|
56
|
+
// does not interpret redirections or expansions beyond boundary marking.
|
|
57
|
+
function splitSegments(command: string): string[][] {
|
|
58
|
+
const segments: string[][] = []
|
|
59
|
+
let words: string[] = []
|
|
60
|
+
let current = ''
|
|
61
|
+
let hasWord = false
|
|
62
|
+
let quote: '"' | "'" | null = null
|
|
63
|
+
// Tracks a command substitution entered from inside a double quote, so the
|
|
64
|
+
// outer double-quote mode is restored when the substitution closes. Bash runs
|
|
65
|
+
// `$(...)` and backtick substitutions inside double quotes (but not single),
|
|
66
|
+
// so `echo "$(npm install)"` must be scanned for a manager.
|
|
67
|
+
let commandSub: { kind: '`' | '$('; depth: number; resumeQuote: '"' } | null = null
|
|
68
|
+
|
|
69
|
+
const flushWord = (): void => {
|
|
70
|
+
if (hasWord) {
|
|
71
|
+
words.push(current)
|
|
72
|
+
current = ''
|
|
73
|
+
hasWord = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const flushSegment = (): void => {
|
|
77
|
+
flushWord()
|
|
78
|
+
if (words.length > 0) {
|
|
79
|
+
segments.push(words)
|
|
80
|
+
words = []
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < command.length; i++) {
|
|
85
|
+
const ch = command[i]
|
|
86
|
+
if (quote !== null) {
|
|
87
|
+
// A `$(` or backtick inside a double quote opens a command substitution
|
|
88
|
+
// that Bash executes; scan its body as a fresh segment, remembering to
|
|
89
|
+
// resume double-quote mode when it closes.
|
|
90
|
+
if (quote === '"' && ch === '`') {
|
|
91
|
+
flushSegment()
|
|
92
|
+
commandSub = { kind: '`', depth: 0, resumeQuote: '"' }
|
|
93
|
+
quote = null
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (quote === '"' && ch === '$' && command[i + 1] === '(') {
|
|
97
|
+
flushSegment()
|
|
98
|
+
commandSub = { kind: '$(', depth: 1, resumeQuote: '"' }
|
|
99
|
+
quote = null
|
|
100
|
+
i++
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (ch === quote) quote = null
|
|
104
|
+
else {
|
|
105
|
+
current += ch
|
|
106
|
+
hasWord = true
|
|
107
|
+
}
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
// Close of a command substitution opened from inside a double quote:
|
|
111
|
+
// restore the outer double-quote mode so the rest of the string (and any
|
|
112
|
+
// trailing `&&`/`;`) is parsed correctly rather than re-quoted.
|
|
113
|
+
if (commandSub?.kind === '`' && ch === '`') {
|
|
114
|
+
flushSegment()
|
|
115
|
+
quote = commandSub.resumeQuote
|
|
116
|
+
commandSub = null
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (commandSub?.kind === '$(' && ch === '$' && command[i + 1] === '(') {
|
|
120
|
+
flushSegment()
|
|
121
|
+
commandSub.depth++
|
|
122
|
+
i++
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (commandSub?.kind === '$(' && ch === '(') {
|
|
126
|
+
flushSegment()
|
|
127
|
+
commandSub.depth++
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (commandSub?.kind === '$(' && ch === ')') {
|
|
131
|
+
flushSegment()
|
|
132
|
+
commandSub.depth--
|
|
133
|
+
if (commandSub.depth === 0) {
|
|
134
|
+
quote = commandSub.resumeQuote
|
|
135
|
+
commandSub = null
|
|
136
|
+
}
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (ch === '\\') {
|
|
140
|
+
const next = command[i + 1]
|
|
141
|
+
if (next === undefined) break
|
|
142
|
+
// `\<newline>` (and `\<CR><newline>`) is a shell line continuation: the
|
|
143
|
+
// shell removes it entirely, joining the surrounding text. Dropping it
|
|
144
|
+
// here keeps `npm install \<nl>-g x` tokenized as `install`,`-g` (a global
|
|
145
|
+
// install) instead of producing a malformed `install\n-g` token.
|
|
146
|
+
if (next === '\n') {
|
|
147
|
+
i++
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (next === '\r' && command[i + 2] === '\n') {
|
|
151
|
+
i += 2
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
current += next
|
|
155
|
+
hasWord = true
|
|
156
|
+
i++
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
if (ch === '"' || ch === "'") {
|
|
160
|
+
quote = ch
|
|
161
|
+
hasWord = true
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (ch === ' ' || ch === '\t') {
|
|
165
|
+
flushWord()
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (ch === '\n' || ch === '\r' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === '`') {
|
|
169
|
+
flushSegment()
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (ch === '$' && command[i + 1] === '(') {
|
|
173
|
+
flushSegment()
|
|
174
|
+
i++
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
current += ch
|
|
178
|
+
hasWord = true
|
|
179
|
+
}
|
|
180
|
+
flushSegment()
|
|
181
|
+
return segments
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A word is already quote/escape-collapsed by splitSegments (quotes consumed,
|
|
185
|
+
// backslash-escapes literalized), so the only residue to strip is leftover
|
|
186
|
+
// quote characters that appeared mid-token via concatenation. Keeping this
|
|
187
|
+
// explicit makes `"npm"`, `n\px`, `'npm'` all resolve to their bare binary.
|
|
188
|
+
function normalizeWord(word: string): string {
|
|
189
|
+
return word.replaceAll('"', '').replaceAll("'", '')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Preamble wrappers that prefix a real command (`sudo npm …`, `env FOO=bar npm
|
|
193
|
+
// …`, `nice -n 10 npm …`). The value is the set of SHORT option letters that
|
|
194
|
+
// consume the FOLLOWING word as their argument, so `sudo -u nobody npm` skips
|
|
195
|
+
// `nobody` too. Long `--opt=value` forms are self-contained (one token); bare
|
|
196
|
+
// long `--opt` and unknown short flags are skipped as a single token (the safe
|
|
197
|
+
// default — at worst we skip one extra token and still find the manager later).
|
|
198
|
+
const PREAMBLE_WRAPPERS: Record<string, ReadonlySet<string>> = {
|
|
199
|
+
sudo: new Set(['u', 'g', 'h', 'p', 'C', 'r', 't', 'T', 'U']),
|
|
200
|
+
env: new Set(['u', 'C', 'S']),
|
|
201
|
+
nice: new Set(['n']),
|
|
202
|
+
command: new Set([]),
|
|
203
|
+
exec: new Set(['a']),
|
|
204
|
+
nohup: new Set([]),
|
|
205
|
+
stdbuf: new Set(['i', 'o', 'e']),
|
|
206
|
+
setsid: new Set([]),
|
|
207
|
+
time: new Set(['o', 'f']),
|
|
208
|
+
xargs: new Set([]),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The command word is the first token that is not a shell preamble: a known
|
|
212
|
+
// wrapper (with its options consumed), or a `VAR=val` assignment. This is what
|
|
213
|
+
// makes `FOO=bar npm install` and `env -i npm install` / `sudo -u nobody pnpm
|
|
214
|
+
// add` resolve to the real manager instead of evading the guard.
|
|
215
|
+
function leadingCommandWord(words: string[]): string | undefined {
|
|
216
|
+
let i = 0
|
|
217
|
+
while (i < words.length) {
|
|
218
|
+
const word = words[i]
|
|
219
|
+
if (word === undefined) break
|
|
220
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
221
|
+
i++
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
const argTakingShortOpts = PREAMBLE_WRAPPERS[word]
|
|
225
|
+
if (argTakingShortOpts !== undefined) {
|
|
226
|
+
i = skipWrapperOptions(words, i + 1, argTakingShortOpts)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
return word
|
|
230
|
+
}
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// From the token after a wrapper, skip its option tokens. A long `--opt` is one
|
|
235
|
+
// token. A short `-x` (or bundled `-xy`) consumes the next word only when its
|
|
236
|
+
// LAST letter is in `argTaking` (e.g. `-n 10`, `-u nobody`). `VAR=val` between a
|
|
237
|
+
// wrapper and its command (as `env` allows) is also skipped. Stops at the first
|
|
238
|
+
// non-option, non-assignment token — that is the wrapped command word.
|
|
239
|
+
function skipWrapperOptions(words: string[], start: number, argTaking: ReadonlySet<string>): number {
|
|
240
|
+
let i = start
|
|
241
|
+
while (i < words.length) {
|
|
242
|
+
const word = words[i]
|
|
243
|
+
if (word === undefined) break
|
|
244
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
245
|
+
i++
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
if (word.startsWith('--')) {
|
|
249
|
+
i++
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (word.startsWith('-') && word.length > 1) {
|
|
253
|
+
const lastLetter = word[word.length - 1]
|
|
254
|
+
i++
|
|
255
|
+
if (lastLetter !== undefined && argTaking.has(lastLetter) && i < words.length) i++
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
return i
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function globalInstallLabel(manager: string, words: string[]): string | undefined {
|
|
264
|
+
if (manager === 'yarn') {
|
|
265
|
+
// Real syntax is `yarn global add <pkg>` — `global` immediately followed by
|
|
266
|
+
// `add` as a consecutive sequence. Checking for both tokens anywhere would
|
|
267
|
+
// false-positive on `yarn add global foo` (a local install of a package
|
|
268
|
+
// literally named `global`), which is not a global install at all.
|
|
269
|
+
return hasAdjacentSequence(words, 'global', 'add') ? 'yarn global add' : undefined
|
|
270
|
+
}
|
|
271
|
+
const hasInstall = words.some((w) => INSTALL_SUBCOMMANDS.has(w))
|
|
272
|
+
const hasGlobal = words.some(isGlobalFlag)
|
|
273
|
+
if (!hasInstall || !hasGlobal) return undefined
|
|
274
|
+
return manager === 'bun' ? 'bun global install (-g / --global)' : 'npm/pnpm global install (-g / --global)'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function hasAdjacentSequence(words: string[], first: string, second: string): boolean {
|
|
278
|
+
for (let i = 0; i + 1 < words.length; i++) {
|
|
279
|
+
if (words[i] === first && words[i + 1] === second) return true
|
|
280
|
+
}
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// `-g` / `--global`, including bundled short flags like `-gD` / `-Dg`. An
|
|
285
|
+
// explicit falsy value (`--global=false|0|no|off`) is NOT a global install —
|
|
286
|
+
// it disables the flag — so it must not match.
|
|
287
|
+
function isGlobalFlag(word: string): boolean {
|
|
288
|
+
if (/^--global=(?:false|0|no|off)$/i.test(word)) return false
|
|
289
|
+
if (/^--global(?:=|$)/.test(word)) return true
|
|
290
|
+
return /^-[A-Za-z]*g[A-Za-z]*$/.test(word)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function blockGlobalInstall(label: string, args: Record<string, unknown>): GuardBlock | undefined {
|
|
294
|
+
if (isGuardAcknowledged(args, GUARD_GLOBAL_INSTALL)) return undefined
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
block: true,
|
|
298
|
+
reason: [
|
|
299
|
+
`Guard \`${GUARD_GLOBAL_INSTALL}\` blocked a global install: ${label}.`,
|
|
300
|
+
'Global installs live outside the bind-mounted /agent folder and are wiped on every container restart, so they never persist.',
|
|
301
|
+
'Use `bun add <pkg>` to add a dependency that survives restarts (it writes package.json), or `bunx <pkg>` to run a tool once without installing.',
|
|
302
|
+
`Retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_GLOBAL_INSTALL}: true\` only if a throwaway global install is genuinely what you want.`,
|
|
303
|
+
].join(' '),
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function blockNonBunManager(manager: string, args: Record<string, unknown>): GuardBlock | undefined {
|
|
308
|
+
if (isGuardAcknowledged(args, GUARD_NON_BUN_PACKAGE_MANAGER)) return undefined
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
block: true,
|
|
312
|
+
reason: [
|
|
313
|
+
`Guard \`${GUARD_NON_BUN_PACKAGE_MANAGER}\` blocked \`${manager}\`. This container standardizes on bun.`,
|
|
314
|
+
'Use `bun install` / `bun add <pkg>` instead of npm/pnpm/yarn, and `bunx <pkg>` instead of npx/pnpx.',
|
|
315
|
+
`Retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_NON_BUN_PACKAGE_MANAGER}: true\` if this package manager is genuinely required (e.g. a project pinned to a different lockfile).`,
|
|
316
|
+
].join(' '),
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export type GhCommandDecision =
|
|
2
2
|
| { kind: 'pass-through' }
|
|
3
3
|
| { kind: 'block'; reason: string }
|
|
4
|
-
|
|
4
|
+
// `rewrittenCommand`, when present, MUST replace the executed command: `gh api`
|
|
5
|
+
// rejects `-R/--repo` ("unknown shorthand flag"), so for a graphql endpoint the
|
|
6
|
+
// flag is consumed as our repo hint and stripped before exec. Other inject paths
|
|
7
|
+
// (REST, non-`api` subcommands) leave the command unchanged and omit it.
|
|
8
|
+
| { kind: 'inject'; repoSlug: string; rewrittenCommand?: string }
|
|
5
9
|
|
|
6
10
|
const MISSING_REPO_REASON =
|
|
7
11
|
'This GitHub App spans multiple owners, so `gh` has no single correct token. ' +
|
|
@@ -27,7 +31,9 @@ const API_REPO_CONFLICT_REASON =
|
|
|
27
31
|
type GhSegmentDecision =
|
|
28
32
|
| { kind: 'pass-through' }
|
|
29
33
|
| { kind: 'block'; reason: string }
|
|
30
|
-
|
|
34
|
+
// `stripRepoFlag` marks a graphql inject whose `-R/--repo` is a TypeClaw-only
|
|
35
|
+
// hint that `gh api` would reject, so it must be removed from the command.
|
|
36
|
+
| { kind: 'inject'; repoSlugs: readonly string[]; stripRepoFlag?: boolean }
|
|
31
37
|
|
|
32
38
|
const COMPOSITION_REASON =
|
|
33
39
|
'A repo-targeting `gh` command receives a minted GitHub App token in its process ' +
|
|
@@ -117,13 +123,17 @@ export function analyzeGhCommand(command: string): GhCommandDecision {
|
|
|
117
123
|
if (ghStarts.length === 0) return { kind: 'pass-through' }
|
|
118
124
|
|
|
119
125
|
const repoSlugs: string[] = []
|
|
126
|
+
let stripRepoFlag = false
|
|
120
127
|
for (let i = 0; i < ghStarts.length; i++) {
|
|
121
128
|
const start = ghStarts[i] as number
|
|
122
129
|
const end = ghStarts[i + 1] ?? tokens.length
|
|
123
130
|
const args = tokens.slice(start + 1, end)
|
|
124
131
|
const segment = classifyGhSegment(args)
|
|
125
132
|
if (segment.kind === 'block') return segment
|
|
126
|
-
if (segment.kind === 'inject')
|
|
133
|
+
if (segment.kind === 'inject') {
|
|
134
|
+
repoSlugs.push(...segment.repoSlugs)
|
|
135
|
+
if (segment.stripRepoFlag === true) stripRepoFlag = true
|
|
136
|
+
}
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
if (repoSlugs.length === 0) return { kind: 'pass-through' }
|
|
@@ -135,9 +145,71 @@ export function analyzeGhCommand(command: string): GhCommandDecision {
|
|
|
135
145
|
// shell expansion would inherit it.
|
|
136
146
|
if (!isSingleBareGhCommand(command)) return { kind: 'block', reason: COMPOSITION_REASON }
|
|
137
147
|
|
|
148
|
+
if (stripRepoFlag) {
|
|
149
|
+
return { kind: 'inject', repoSlug: repoSlugs[0] as string, rewrittenCommand: stripRepoFlagFromCommand(command) }
|
|
150
|
+
}
|
|
138
151
|
return { kind: 'inject', repoSlug: repoSlugs[0] as string }
|
|
139
152
|
}
|
|
140
153
|
|
|
154
|
+
// Removes an unquoted `-R`/`--repo` flag (and its repo-slug value) from a single
|
|
155
|
+
// bare command, preserving everything else byte-for-byte. Quote-aware so a `-R`
|
|
156
|
+
// inside a quoted `-f query='...'` value is never touched; a repo slug is
|
|
157
|
+
// owner/name (no whitespace), so the value is always a single unquoted token.
|
|
158
|
+
// Used only for graphql, where `gh api` rejects the flag we consumed as a hint.
|
|
159
|
+
function stripRepoFlagFromCommand(command: string): string {
|
|
160
|
+
let out = ''
|
|
161
|
+
let i = 0
|
|
162
|
+
while (i < command.length) {
|
|
163
|
+
const ch = command[i] as string
|
|
164
|
+
if (ch === '"' || ch === "'") {
|
|
165
|
+
const close = command.indexOf(ch, i + 1)
|
|
166
|
+
const endQuote = close === -1 ? command.length : close
|
|
167
|
+
out += command.slice(i, endQuote + 1)
|
|
168
|
+
i = endQuote + 1
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
const removed = matchRepoFlagAt(command, i)
|
|
172
|
+
if (removed !== null) {
|
|
173
|
+
out = out.replace(/[ \t]+$/, '')
|
|
174
|
+
i = removed
|
|
175
|
+
while (command[i] === ' ' || command[i] === '\t') i += 1
|
|
176
|
+
if (out !== '' && i < command.length) out += ' '
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
out += ch
|
|
180
|
+
i += 1
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// If `command` has an unquoted `-R`/`--repo` repo-flag token starting at `start`
|
|
186
|
+
// (at a word boundary), returns the index just past the flag and its value;
|
|
187
|
+
// otherwise null. Handles `-R o/r`, `--repo o/r`, `-R=o/r`, `--repo=o/r`.
|
|
188
|
+
function matchRepoFlagAt(command: string, start: number): number | null {
|
|
189
|
+
const before = start === 0 ? '' : (command[start - 1] as string)
|
|
190
|
+
if (before !== '' && before !== ' ' && before !== '\t') return null
|
|
191
|
+
|
|
192
|
+
for (const flag of ['--repo', '-R']) {
|
|
193
|
+
if (!command.startsWith(flag, start)) continue
|
|
194
|
+
let i = start + flag.length
|
|
195
|
+
const sep = command[i]
|
|
196
|
+
if (sep === '=') {
|
|
197
|
+
i += 1
|
|
198
|
+
while (i < command.length && command[i] !== ' ' && command[i] !== '\t') i += 1
|
|
199
|
+
return i
|
|
200
|
+
}
|
|
201
|
+
if (sep === ' ' || sep === '\t') {
|
|
202
|
+
let j = i
|
|
203
|
+
while (command[j] === ' ' || command[j] === '\t') j += 1
|
|
204
|
+
const valueStart = j
|
|
205
|
+
while (j < command.length && command[j] !== ' ' && command[j] !== '\t') j += 1
|
|
206
|
+
if (!isRepoSlug(command.slice(valueStart, j))) return null
|
|
207
|
+
return j
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
141
213
|
function classifyGhSegment(args: readonly string[]): GhSegmentDecision {
|
|
142
214
|
const subcommand = args.find((t) => !t.startsWith('-'))
|
|
143
215
|
if (subcommand === undefined) return { kind: 'pass-through' }
|
|
@@ -159,8 +231,9 @@ function classifyGhSegment(args: readonly string[]): GhSegmentDecision {
|
|
|
159
231
|
// Repo authority for `gh api`: the literal endpoint path wins. A `-R/--repo`
|
|
160
232
|
// that names a DIFFERENT repo than the path is a mint-for-X-but-hit-Y attempt
|
|
161
233
|
// and blocks. A placeholder endpoint (`repos/{owner}/{repo}`) has no literal
|
|
162
|
-
// target, so -R fills it and is authoritative. A non-repo endpoint
|
|
163
|
-
// `/user`) passes through —
|
|
234
|
+
// target, so -R fills it and is authoritative. A non-repo endpoint without a
|
|
235
|
+
// `-R` (`graphql`, `/user`) passes through — the flag is what makes it
|
|
236
|
+
// repo-scoped, so absent one there is nothing to mint for.
|
|
164
237
|
function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
|
|
165
238
|
const pathRepos = extractReposFromApiPath(args)
|
|
166
239
|
const flagRepo = extractRepoFlag(args)
|
|
@@ -176,9 +249,22 @@ function classifyGhApiSegment(args: readonly string[]): GhSegmentDecision {
|
|
|
176
249
|
return { kind: 'inject', repoSlugs: [flagRepo] }
|
|
177
250
|
}
|
|
178
251
|
|
|
252
|
+
// graphql encodes its repo in the query body / opaque node IDs, never an
|
|
253
|
+
// inspectable path, so `-R` is taken as the mint hint. Safe because there is
|
|
254
|
+
// no literal path to conflict with (cf. the API_REPO_CONFLICT_REASON guard
|
|
255
|
+
// above): the minted token's installation scope, not the flag, bounds reach.
|
|
256
|
+
// `gh api` rejects `-R`, so the flag must be stripped from the command.
|
|
257
|
+
if (flagRepo !== null && isGraphqlEndpoint(args)) {
|
|
258
|
+
return { kind: 'inject', repoSlugs: [flagRepo], stripRepoFlag: true }
|
|
259
|
+
}
|
|
260
|
+
|
|
179
261
|
return { kind: 'pass-through' }
|
|
180
262
|
}
|
|
181
263
|
|
|
264
|
+
function isGraphqlEndpoint(args: readonly string[]): boolean {
|
|
265
|
+
return findApiEndpoint(args) === 'graphql'
|
|
266
|
+
}
|
|
267
|
+
|
|
182
268
|
function findGhInvocations(tokens: readonly string[]): number[] {
|
|
183
269
|
const starts: number[] = []
|
|
184
270
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -249,6 +335,12 @@ const GH_API_VALUE_FLAGS = new Set([
|
|
|
249
335
|
'--hostname',
|
|
250
336
|
])
|
|
251
337
|
|
|
338
|
+
// `-R`/`--repo` are not real `gh api` flags, but TypeClaw accepts them as a repo
|
|
339
|
+
// hint that can appear BEFORE the endpoint (`gh api -R o/r graphql`). They consume
|
|
340
|
+
// the following token, so findApiEndpoint must skip both — otherwise the slug is
|
|
341
|
+
// misread as the endpoint and the graphql path never runs.
|
|
342
|
+
const REPO_HINT_VALUE_FLAGS = new Set(['-R', '--repo'])
|
|
343
|
+
|
|
252
344
|
// The `gh api` endpoint is the first positional arg after `api` (skipping flags
|
|
253
345
|
// and the tokens that bare value-flags consume). Returns null if there is none.
|
|
254
346
|
function findApiEndpoint(args: readonly string[]): string | null {
|
|
@@ -257,7 +349,7 @@ function findApiEndpoint(args: readonly string[]): string | null {
|
|
|
257
349
|
for (let i = apiIndex + 1; i < args.length; i++) {
|
|
258
350
|
const arg = args[i] as string
|
|
259
351
|
if (arg.startsWith('-')) {
|
|
260
|
-
if (!arg.includes('=') && GH_API_VALUE_FLAGS.has(arg)) i += 1
|
|
352
|
+
if (!arg.includes('=') && (GH_API_VALUE_FLAGS.has(arg) || REPO_HINT_VALUE_FLAGS.has(arg))) i += 1
|
|
261
353
|
continue
|
|
262
354
|
}
|
|
263
355
|
return arg
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ContentPart, ToolResult } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
import { classifyGhToken } from './token-class'
|
|
4
|
+
|
|
5
|
+
export const GRAPHQL_AUTH_NUDGE_TAG = 'github-cli-auth:graphqlRepoHint'
|
|
6
|
+
|
|
7
|
+
const NUDGE_TEXT =
|
|
8
|
+
`\n\n[${GRAPHQL_AUTH_NUDGE_TAG}] That looked like a GitHub auth failure on a ` +
|
|
9
|
+
'`gh api graphql` call. Under a multi-owner GitHub App there is no single ' +
|
|
10
|
+
'`GH_TOKEN`, and graphql carries its repo inside the query — not an inspectable ' +
|
|
11
|
+
'path — so TypeClaw cannot tell which installation token to mint. Re-run with an ' +
|
|
12
|
+
'explicit repo, e.g. `gh api graphql -R owner/repo -f query=...`. `gh api` does ' +
|
|
13
|
+
'not accept `-R/--repo`; TypeClaw consumes it as the mint hint and strips it ' +
|
|
14
|
+
'before running the command with the right token injected.'
|
|
15
|
+
|
|
16
|
+
// The shell strips quotes/escapes, so we match the raw `gh ... graphql` substring
|
|
17
|
+
// rather than parse — the nudge is advisory, so a loose match is acceptable and a
|
|
18
|
+
// false positive only appends a hint to an unrelated failure. Captured through to
|
|
19
|
+
// end of line so we can inspect the SAME invocation's flags (see REPO_FLAG).
|
|
20
|
+
const GRAPHQL_INVOCATION = /\bgh\b[^\n]*\bapi\b[^\n]*\bgraphql\b[^\n]*/
|
|
21
|
+
|
|
22
|
+
// `-R foo`, `-R=foo`, `--repo foo`, `--repo=foo`. Word-boundary anchored so a
|
|
23
|
+
// path or token merely containing "repo" does not count as a repo hint.
|
|
24
|
+
const REPO_FLAG = /(?:^|\s)(?:-R|--repo)(?:[=\s]|$)/
|
|
25
|
+
|
|
26
|
+
// Concrete auth-rejection strings only — gh / the API emit these when the
|
|
27
|
+
// request itself was rejected for auth. Deliberately excludes resolution
|
|
28
|
+
// errors like "Could not resolve to ...", which graphql also returns for an
|
|
29
|
+
// ordinary bad node ID or missing repo (not auth) and would misroute the nudge.
|
|
30
|
+
const AUTH_FAILURE_SIGNATURES = [
|
|
31
|
+
'HTTP 401',
|
|
32
|
+
'Bad credentials',
|
|
33
|
+
'Resource not accessible by integration',
|
|
34
|
+
'Resource not accessible by personal access token',
|
|
35
|
+
'must be authenticated',
|
|
36
|
+
'authentication required',
|
|
37
|
+
'gh auth login',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
export function checkGraphqlAuthNudge(options: { tool: string; result: ToolResult }): void {
|
|
41
|
+
if (options.tool !== 'bash') return
|
|
42
|
+
|
|
43
|
+
// Only meaningful when no usable token is seeded — the multi-owner App case.
|
|
44
|
+
// A seeded global token (single-owner App, classic/fine-grained PAT) means
|
|
45
|
+
// graphql already authenticates, so the advice would be wrong.
|
|
46
|
+
const tokenClass = classifyGhToken(process.env.GH_TOKEN)
|
|
47
|
+
if (tokenClass !== 'none') return
|
|
48
|
+
|
|
49
|
+
const text = collectText(options.result.content)
|
|
50
|
+
const invocation = GRAPHQL_INVOCATION.exec(text)
|
|
51
|
+
if (invocation === null) return
|
|
52
|
+
if (!AUTH_FAILURE_SIGNATURES.some((sig) => text.includes(sig))) return
|
|
53
|
+
if (text.includes(GRAPHQL_AUTH_NUDGE_TAG)) return
|
|
54
|
+
|
|
55
|
+
// The nudge's only message is "add the repo hint". If the failing invocation
|
|
56
|
+
// already carries -R/--repo, the hint is present and the failure is an
|
|
57
|
+
// authorization/permission denial (e.g. the App token lacks a scope), not a
|
|
58
|
+
// missing-repo one — so "re-run with -R" would be misleading, repeated advice.
|
|
59
|
+
if (REPO_FLAG.test(invocation[0])) return
|
|
60
|
+
|
|
61
|
+
appendAdviceToContent(options.result.content, NUDGE_TEXT)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function collectText(content: readonly ContentPart[]): string {
|
|
65
|
+
return content
|
|
66
|
+
.filter((p): p is ContentPart & { type: 'text' } => p.type === 'text')
|
|
67
|
+
.map((p) => p.text)
|
|
68
|
+
.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendAdviceToContent(content: ContentPart[], advice: string): void {
|
|
72
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
73
|
+
const part = content[i]
|
|
74
|
+
if (part && part.type === 'text') {
|
|
75
|
+
content[i] = { ...part, text: `${part.text}${advice}` }
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
content.push({ type: 'text', text: advice.trimStart() })
|
|
80
|
+
}
|
|
@@ -2,6 +2,7 @@ import { TYPECLAW_INTERNAL_BASH_ENV } from '@/agent/plugin-tools'
|
|
|
2
2
|
import { definePlugin } from '@/plugin'
|
|
3
3
|
|
|
4
4
|
import { analyzeGhCommand } from './gh-command'
|
|
5
|
+
import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
|
|
5
6
|
import { classifyGhToken } from './token-class'
|
|
6
7
|
|
|
7
8
|
export default definePlugin({
|
|
@@ -34,8 +35,14 @@ export default definePlugin({
|
|
|
34
35
|
// --setenv by the bash wrapper) so the token never enters the command
|
|
35
36
|
// string, where it could leak through logs or later hooks.
|
|
36
37
|
event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: result.token }
|
|
38
|
+
// graphql consumed `-R/--repo` as a mint hint; `gh api` rejects it, so
|
|
39
|
+
// run the command with the flag stripped (token still rides in env).
|
|
40
|
+
if (decision.rewrittenCommand !== undefined) event.args.command = decision.rewrittenCommand
|
|
37
41
|
return
|
|
38
42
|
},
|
|
43
|
+
'tool.after': async (event) => {
|
|
44
|
+
checkGraphqlAuthNudge({ tool: event.tool, result: event.result })
|
|
45
|
+
},
|
|
39
46
|
},
|
|
40
47
|
}
|
|
41
48
|
},
|
|
@@ -81,9 +81,11 @@ Session transcripts are JSONL files where each line is an entry with an \`id\` f
|
|
|
81
81
|
Typical flow with a watermark:
|
|
82
82
|
|
|
83
83
|
1. \`find_entry(path=<transcript>, entryId=<watermark>)\` → returns \`line=N, totalLines=T, offset=N+1\`.
|
|
84
|
-
2. \`read(path=<transcript>, offset=N+1)\` → returns the chunk starting AT the first unread entry. Repeat with the next offset until the read tool
|
|
84
|
+
2. \`read(path=<transcript>, offset=N+1)\` → returns the chunk starting AT the first unread entry. Repeat with the next offset until you reach the end of the file. \`find_entry\` already told you \`totalLines=T\`: once a \`read\` has returned line T (or the read tool reports no continuation), you have reached the end of the transcript. Stop reading.
|
|
85
85
|
3. As you read, track the most recent \`id\` you see. That is your new watermark value — pass it as \`latestEntryId\` on the final \`append\` call, or to the watermark-advance tool when there are zero fragments.
|
|
86
86
|
|
|
87
|
+
**Reading is bounded — a finite transcript takes a finite number of reads.** \`find_entry\` gives you \`totalLines=T\` up front, so you always know the last line. Each \`read\` returns a slice and an offset to continue; advance the offset forward each time. Once you have read line T, or a \`read\` returns no new content (an empty chunk, or the same slice you already saw, or no continuation offset), you are at the end. Do NOT re-read the same offset, and do NOT keep calling \`read\` hoping more will appear — nothing more will. A read that returns nothing new is the end-of-file signal, not a transient error to retry. Re-reading past the end produces no new information and wastes the entire run; treat the first no-new-content read as "done reading" and move to your fragment decision.
|
|
88
|
+
|
|
87
89
|
Never write the same watermark id you were given as input. If the transcript has no new entries past the watermark, evaluate the entries you can see, then advance the watermark to the latest \`id\` in the transcript (which is on line \`totalLines\` from \`find_entry\`'s reply). The whole point of the watermark is to move forward each run.
|
|
88
90
|
|
|
89
91
|
# Capture philosophy: when in doubt, SKIP
|
|
@@ -202,7 +204,9 @@ When you evaluated the transcript but found nothing worth a fragment, call the w
|
|
|
202
204
|
|
|
203
205
|
# Stopping
|
|
204
206
|
|
|
205
|
-
|
|
207
|
+
You are done the moment BOTH are true: (1) you have read to the end of the transcript (reached \`totalLines\` from \`find_entry\`, or a \`read\` returned no new content), and (2) you have either written your fragment(s) with the final \`latestEntryId\`, or advanced the watermark for the zero-fragment case. When both hold, simply stop. There is no completion message to emit.
|
|
208
|
+
|
|
209
|
+
Do not loop. The hard stop is \`totalLines\`: a long transcript may legitimately need many \`read\` chunks to reach it, and that is fine as long as each \`read\` advances the offset toward \`totalLines\`. What is NOT fine is re-reading without progress. If a \`read\` returns no new content, returns the same slice you already saw, or your offset stops advancing, you are at the end — stop reading immediately and proceed to your fragment decision. A transcript has a fixed length; re-reading the same offset cannot surface content that is not there. The single most expensive failure mode for this subagent is re-reading the same file in a cycle instead of recognizing end-of-file and stopping.`
|
|
206
210
|
|
|
207
211
|
function buildInitialPrompt(payload: MemoryLoggerPayload, streamFile: string, watermark: string | null): string {
|
|
208
212
|
const lines: string[] = [
|
|
@@ -64,6 +64,14 @@ Prioritize in this order:
|
|
|
64
64
|
- **request-changes** — At least one blocker, OR a load-bearing concern that needs an answer before this lands.
|
|
65
65
|
- **comment** — Mixed signal: useful observations without a clear approve/reject. Common on large refactors where you reviewed part of the change, or on early-draft PRs where the author asked for direction more than approval.
|
|
66
66
|
|
|
67
|
+
### Re-reviews must re-decide, not observe
|
|
68
|
+
|
|
69
|
+
When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes and asked again — your verdict's whole purpose is to **re-decide the blocking state**, so:
|
|
70
|
+
|
|
71
|
+
- Return **approve** if the blockers that drove the prior \`request-changes\` are resolved (leftover nits do not block — \`approve\` with inline nits is correct).
|
|
72
|
+
- Return **request-changes** if any blocker remains or a new one appeared.
|
|
73
|
+
- **Do NOT return \`comment\` on a re-review.** \`comment\` is for ambiguous partial reviews with no accept/reject signal; a re-review is the opposite — it is precisely an accept/reject decision. A \`comment\` verdict here leaves the PR's \`REQUEST_CHANGES\` state stuck (a plain comment does not clear it on GitHub), which is the exact failure a re-review exists to resolve. The only escape hatch is the same one that always applies: if you genuinely cannot reach the diff or the prior context, return one \`blocker\` finding stating what you need and a \`comment\` verdict — but a reachable, reviewable re-review must end in \`approve\` or \`request-changes\`.
|
|
74
|
+
|
|
67
75
|
## Line-anchor every finding
|
|
68
76
|
|
|
69
77
|
Code review is line-level work, and your findings are meant to land as **inline comments on the exact lines they describe**. The parent agent posts them that way — it reads the \`location\` on each \`<finding>\` and attaches your \`<issue>\`/\`<evidence>\`/\`<suggestion>\` to that line. A finding with no line anchor cannot be posted inline; the parent can only fold it into a top-level summary, which strips the one thing that made it actionable.
|
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
const SLASH_COMMANDS: readonly DiscordCommandDeclaration[] = [
|
|
53
53
|
{ name: 'help', description: 'List available commands' },
|
|
54
54
|
{ name: 'stop', description: 'Abort the current turn in this channel' },
|
|
55
|
+
{ name: 'reload', description: 'Reload typeclaw config and subsystems from disk' },
|
|
56
|
+
{ name: 'restart', description: 'Restart the typeclaw container' },
|
|
55
57
|
]
|
|
56
58
|
const SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(SLASH_COMMANDS.map((c) => c.name))
|
|
57
59
|
|