thinkpool-pair 0.7.333 → 0.7.335

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/bridge.mjs CHANGED
@@ -92,7 +92,7 @@ function stopFlowPreviews (flowId, laneId = null) {
92
92
  }
93
93
  import { FLOW_REVIEWER_PROMPT, FLOW_CODEX_REVIEWER_PROMPT, revertLane, parseReviewVerdict, reviewVerdictToReflection } from './flow-review.mjs'
94
94
  import { reviewGateDecision } from './flow-review-gate.mjs'
95
- import { IMMUTABLE_REVIEW_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
95
+ import { IMMUTABLE_REVIEW_READ_TOOL_EXTRAS, UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
96
96
  import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
97
97
  import { mergeWorktrees, inlineSingleHtml, initRepo } from './flow-assembly.mjs'
98
98
  import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from './flow-budget.mjs'
@@ -3032,13 +3032,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3032
3032
  'Read one regular file from the declared immutable review target. This always reads the target’s pinned Git object, never a live worktree; target and path are validated and the response is byte-bounded.',
3033
3033
  { target: z.string(), path: z.string() },
3034
3034
  async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await readReviewFile({ target: args?.target, filePath: args?.path, snapshots: entry.flowReviewSnapshots })) }] }),
3035
- IMMUTABLE_REVIEW_TOOL_EXTRAS,
3035
+ IMMUTABLE_REVIEW_READ_TOOL_EXTRAS,
3036
3036
  ), tool(
3037
3037
  'run_review_check',
3038
- 'Run one fixed verification check against an immutable git-archived review target. This accepts only the declared target and a fixed check id; it never accepts a shell command, cwd, environment, executable, or arguments. The check runs in a scratch archive, never in a builder worktree.',
3038
+ 'Run one fixed verification check against an immutable git-archived review target. This accepts only the declared target and a fixed check id, but executes target-defined code in a scratch archive that is not an OS filesystem/network sandbox. It can require approval and must be refused by approvalPolicy=never reviewers.',
3039
3039
  { target: z.string(), checkId: z.string() },
3040
3040
  async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await runReviewCheck({ target: args?.target, checkId: args?.checkId, snapshots: entry.flowReviewSnapshots })) }] }),
3041
- IMMUTABLE_REVIEW_TOOL_EXTRAS,
3041
+ UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS,
3042
3042
  )] : []),
3043
3043
  ],
3044
3044
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.333",
3
+ "version": "0.7.335",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -113,6 +113,7 @@
113
113
  "@anthropic-ai/claude-agent-sdk": "^0.3.198",
114
114
  "@modelcontextprotocol/sdk": "^1.29.0",
115
115
  "@supabase/supabase-js": "^2.45.0",
116
+ "compromise": "14.16.0",
116
117
  "yaml": "2.9.0",
117
118
  "zod": "^4.4.3"
118
119
  },
package/review-check.mjs CHANGED
@@ -17,13 +17,10 @@ const CHECKS = Object.freeze({
17
17
  })
18
18
  const OUTPUT_CAP = 512 * 1024
19
19
 
20
- // Codex's native read-only/review mode never raises approval cards. MCP tools
21
- // without annotations are conservatively treated as approval-requiring, which
22
- // makes `approvalPolicy: never` auto-decline even our immutable readers before
23
- // their handlers run. Keep the policy next to the implementation and reuse it
24
- // for both source reads and fixed scratch checks: neither mutates the reviewed
25
- // target, neither reaches the open world, and repeating either is safe.
26
- export const IMMUTABLE_REVIEW_TOOL_EXTRAS = Object.freeze({
20
+ // Codex's native read-only/review mode never raises approval cards. Mark only
21
+ // the pinned Git-blob reader as approval-free: it cannot execute target code,
22
+ // mutate the target, or reach the open world.
23
+ export const IMMUTABLE_REVIEW_READ_TOOL_EXTRAS = Object.freeze({
27
24
  annotations: Object.freeze({
28
25
  readOnlyHint: true,
29
26
  destructiveHint: false,
@@ -32,6 +29,21 @@ export const IMMUTABLE_REVIEW_TOOL_EXTRAS = Object.freeze({
32
29
  }),
33
30
  })
34
31
 
32
+ // Fixed check ids prevent command injection, but the selected check still runs
33
+ // target-defined code. The scratch archive protects the reviewed worktree; it
34
+ // is not an OS filesystem/network sandbox. Advertise that boundary honestly so
35
+ // approvalPolicy=never reviewers refuse execution instead of silently granting
36
+ // host/open-world authority. Sandboxed runners can still expose this behind an
37
+ // explicit approval in runtimes that support one.
38
+ export const UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS = Object.freeze({
39
+ annotations: Object.freeze({
40
+ readOnlyHint: false,
41
+ destructiveHint: true,
42
+ idempotentHint: false,
43
+ openWorldHint: true,
44
+ }),
45
+ })
46
+
35
47
  export function reviewCheckCommand(checkId) {
36
48
  if (!Object.hasOwn(CHECKS, checkId)) throw new Error('unapproved review check')
37
49
  const [command, args] = CHECKS[checkId]
package/terminal-name.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { nativeClaudeProviderAccessFailed } from './cross-terminal.mjs'
2
+ import nlp from 'compromise/two'
2
3
 
3
4
  const SKIP = new Set([
4
5
  'a', 'an', 'and', 'are', 'at', 'be', 'can', 'could', 'for', 'from', 'how', 'i',
@@ -18,12 +19,66 @@ const NOISE = /^(?:context|background|for reference|here(?:'s| is)|note|current(
18
19
  const EXPLANATION = /^(?:because|cause|since|so that|this is because)\b/i
19
20
  const CONSTRAINT = /^(?:users? can still|keep|must|never|should|without)\b/i
20
21
  const DEPENDENT_FRAGMENT = /^(?:after|before|by|during|for|from|in|instead(?: of)?|on|through|until|with|without)\b/i
21
- const INFORMATION_REQUEST = /^(?:which|what|who|where|when|why|how)\b/i
22
- const NEGATIVE_PREFERENCE = /^(?:(?:i|we)\s+)?(?:do not|don't|dont|would not|wouldn't|won't|wont)\s+(?:(?:want|wanna|need)(?:\s+to)?|use|include|choose)\b|^(?:(?:i|we)\s+)?(?:want|wanna|need)(?:\s+to)?\s+avoid\b/i
23
22
  const ISSUE = /\b(?:broken|buggy|crash(?:es|ed|ing)?|duplicate|error|fail(?:s|ed|ing|ure)?|flash(?:es|ed|ing)?|missing|no animation|not working|out of (?:scrollable )?view|stuck|wrong)\b/i
24
23
  const REQUEST = /^(?:please\s+)?(?:can|could|would|will)\s+(?:we|you)\b|^(?:please\s+)?(?:how about|let's|let us|we need to|i want you to)\b/i
25
24
  const SECRET = /(?<![\p{L}\p{N}])(?:sk-(?:proj-)?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|xox[baprs]-[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|glpat-[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|AIza[a-z0-9_-]{8,}|AKIA[A-Z0-9]{12,}|bearer\s+[a-z0-9._-]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,})(?![\p{L}\p{N}])/giu
26
25
  const HANDOFF_MARKER = /^---\s*(?:the person's next message|current person request(?:\s*\([^\n]*\))?|side task)\s*---\s*$/gim
26
+ const QUESTION_WORDS = new Set(['how', 'what', 'when', 'where', 'which', 'who', 'whom', 'whose', 'why'])
27
+ const PREFERENCE_WORDS = new Set(['avoid', 'choose', 'include', 'need', 'use', 'want'])
28
+ const PREFERENCE_OBJECT_WORDS = new Set(['avoid', 'choose', 'include', 'use'])
29
+
30
+ const conceptsForTerm = (term) => new Set(
31
+ [term?.text, term?.normal, term?.machine, term?.implicit]
32
+ .map((value) => String(value || '').trim().toLowerCase())
33
+ .filter(Boolean),
34
+ )
35
+
36
+ const analyzeGrammar = (value, suppliedTerms = null) => {
37
+ const text = String(value || '').trim()
38
+ const terms = suppliedTerms || nlp(text).json({ terms: true }).flatMap((sentence) => sentence.terms || [])
39
+ const visible = terms
40
+ .filter((term) => String(term?.text || '').trim())
41
+ .map((term) => ({ text: term.text, concepts: conceptsForTerm(term), tags: new Set(term.tags || []) }))
42
+ const hasQuestionWord = visible.some((term) => [...term.concepts].some((word) => QUESTION_WORDS.has(word)))
43
+ const taggedQuestionWord = visible.some((term) => term.tags.has('QuestionWord'))
44
+ const negative = terms.some((term) => Array.isArray(term?.tags) && term.tags.includes('Negative'))
45
+ const preference = terms.some((term) => [...conceptsForTerm(term)].some((word) => PREFERENCE_WORDS.has(word)))
46
+ return {
47
+ text,
48
+ visible,
49
+ informationRequest: hasQuestionWord && (taggedQuestionWord || /\?\s*$/.test(text)),
50
+ negativePreference: negative && preference,
51
+ }
52
+ }
53
+
54
+ const parsedClauses = (value) => String(value || '')
55
+ .replace(/,\s*(?:but|however)\s+/gi, '\n')
56
+ .split(/\n+|;\s*/)
57
+ .flatMap((part) => {
58
+ const text = part.trim()
59
+ if (!text) return []
60
+ const sentences = nlp(text).json({ terms: true })
61
+ return sentences.length
62
+ ? sentences.map((sentence) => analyzeGrammar(sentence.text, sentence.terms || []))
63
+ : [analyzeGrammar(text)]
64
+ })
65
+ .filter((clause) => clause.text)
66
+
67
+ const questionSubject = (analysis) => {
68
+ const index = analysis.visible.findIndex((term) => [...term.concepts].some((word) => QUESTION_WORDS.has(word)))
69
+ return index >= 0 ? analysis.visible.slice(index + 1).map((term) => term.text).join(' ') : analysis.text
70
+ }
71
+
72
+ const negativePreferenceSubject = (analysis) => {
73
+ let index = -1
74
+ for (let i = 0; i < analysis.visible.length; i++) {
75
+ if ([...analysis.visible[i].concepts].some((word) => PREFERENCE_OBJECT_WORDS.has(word))) index = i
76
+ }
77
+ if (index < 0) {
78
+ index = analysis.visible.findIndex((term) => [...term.concepts].some((word) => word === 'need' || word === 'want'))
79
+ }
80
+ return index >= 0 ? analysis.visible.slice(index + 1).map((term) => term.text).join(' ') : analysis.text
81
+ }
27
82
 
28
83
  const ACTIONS = Object.freeze([
29
84
  { title: 'Fix', score: 100, re: /\b(?:fix|fixes|fixed|fixing|repair|repairs|repaired|repairing)\b/i },
@@ -117,35 +172,31 @@ const explicitIntent = (text) => {
117
172
  }
118
173
 
119
174
  const bestIntentClause = (text) => {
120
- const clauses = text
121
- .replace(/,\s*(?:but|however)\s+/gi, '\n')
122
- .split(/\n+|(?<=[.!?;])\s+/)
123
- .map((part) => part.trim())
124
- .filter(Boolean)
175
+ const clauses = parsedClauses(text)
125
176
  let best = null
126
- for (let i = 0; i < clauses.length; i++) {
127
- const clause = clauses[i].replace(/^[-*#>\d.)\s]+/, '').trim()
177
+ for (const parsed of clauses) {
178
+ const clause = parsed.text.replace(/^[-*#>\d.)\s]+/, '').trim()
128
179
  if (!clause || /^(?:https?:\/\/|\/|[A-Za-z]:\\)/.test(clause)) continue
129
- const words = clause.match(/[\p{L}\p{N}][\p{L}\p{N}+#.'-]*/gu) || []
180
+ const grammar = clause === parsed.text ? parsed : analyzeGrammar(clause)
130
181
  const action = bestAction(clause)
131
182
  let score = action?.score || 0
132
183
  if (REQUEST.test(clause)) score += 18
133
- if (INFORMATION_REQUEST.test(clause)) score += 36
184
+ if (grammar.informationRequest) score += 36
134
185
  if (ISSUE.test(clause)) score += /\b(?:crash|fail|flash|stuck|wrong)\w*\b/i.test(clause) ? 36 : 26
135
- if (words.length >= 3 && words.length <= 24) score += 10
136
- else if (words.length > 40) score -= 14
186
+ if (grammar.visible.length >= 3 && grammar.visible.length <= 24) score += 10
187
+ else if (grammar.visible.length > 40) score -= 14
137
188
  if (NOISE.test(clause)) score -= 30
138
189
  if (EXPLANATION.test(clause)) score -= 34
139
190
  if (CONSTRAINT.test(clause)) score -= 45
140
- if (NEGATIVE_PREFERENCE.test(clause)) score -= 70
191
+ if (grammar.negativePreference) score -= 70
141
192
  // A short dependent tail such as "From ground up" or "On mobile" adds
142
193
  // scope to the preceding request; it is not a standalone task. Recency is
143
194
  // deliberately not a signal here: the title should represent the main
144
195
  // theme, not whichever sentence happened to come last.
145
- if (!action && !REQUEST.test(clause) && !INFORMATION_REQUEST.test(clause) && DEPENDENT_FRAGMENT.test(clause)) score -= 24
196
+ if (!action && !REQUEST.test(clause) && !grammar.informationRequest && DEPENDENT_FRAGMENT.test(clause)) score -= 24
146
197
  if (!best || score > best.score) best = { clause, score }
147
198
  }
148
- return best?.clause || clauses[0] || null
199
+ return best?.clause || clauses[0]?.text || null
149
200
  }
150
201
 
151
202
  export function extractTerminalTask(value) {
@@ -210,9 +261,13 @@ const taskTitle = (value) => {
210
261
  .replace(/^\s*(?:how about|i (?:do not|don't) know|i guess|i want you to|we need to|your task is to|let(?:'s| us))\s+/i, '')
211
262
  .trim()
212
263
  if (!body) return null
213
- if (NEGATIVE_PREFERENCE.test(body)) return null
214
- const informationRequest = INFORMATION_REQUEST.test(body)
215
- if (informationRequest) body = body.replace(/^\s*(?:which|what)\s+/i, '').trim()
264
+ const grammar = analyzeGrammar(body)
265
+ if (grammar.negativePreference) {
266
+ const subject = contentWords(negativePreferenceSubject(grammar)).slice(0, 4)
267
+ return subject.length ? cleanTerminalName(['Avoid', ...subject.map(titleWord)].join(' ')) : null
268
+ }
269
+ const informationRequest = grammar.informationRequest
270
+ if (informationRequest) body = questionSubject(grammar).trim()
216
271
  const signalText = body
217
272
  body = body
218
273
  .replace(/\s*,?\s+(?:so (?:that|you|we|it)\b|because\b|cause\b|if you know what i mean\b)[\s\S]*$/i, '')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 15,
3
+ "bundleVersion": 16,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -130,13 +130,13 @@
130
130
  },
131
131
  {
132
132
  "id": "flow-completion",
133
- "version": 1,
133
+ "version": 2,
134
134
  "routes": [
135
135
  {
136
136
  "id": "flow-completion",
137
137
  "tools": ["submit_flow_plan", "mark_flow_done", "submit_flow_review", "read_review_file", "run_review_check"],
138
138
  "trigger": "\\b(flow|submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check)\\b",
139
- "prompt": "Managed Flow roles use only their exposed completion and immutable-review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check."
139
+ "prompt": "Managed Flow roles use only their exposed completion and review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check. read_review_file is approval-free pinned-source access. run_review_check executes target-defined code without OS filesystem or network isolation and therefore remains subject to the runtime's normal approval boundary; approvalPolicy=never reviewers must not call it and must report the skipped check."
140
140
  }
141
141
  ],
142
142
  "impact": [