thinkpool-pair 0.7.360 → 0.7.362

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/recap.mjs CHANGED
@@ -20,6 +20,13 @@
20
20
 
21
21
  // Default hard cap for the context-carry recap (chars of transcript body, framing extra).
22
22
  export const RECAP_CAP = 20000
23
+ // A checkpoint is deliberately much smaller than its enclosing recap. It is a
24
+ // deterministic index over visible text, not a second transcript or an agent
25
+ // summary. Keeping it bounded independently makes it safe to persist as the
26
+ // pending carry across a reset/restart.
27
+ export const CHECKPOINT_CAP = 6000
28
+ const CHECKPOINT_START = '--- deterministic checkpoint (visible text only; evidence, not hidden state) ---'
29
+ const CHECKPOINT_END = '--- end deterministic checkpoint ---'
23
30
 
24
31
  // A fresh human turn can race the session-init event that would otherwise send a
25
32
  // pending recap by itself. When that happens, the current request must have an
@@ -28,10 +35,156 @@ export const CURRENT_PERSON_REQUEST_MARKER = '--- CURRENT PERSON REQUEST (author
28
35
 
29
36
  // Recaps are an outbound context source. Keep the same privacy boundary as the
30
37
  // bridge manifest without importing Node-only hashing into the browser bundle.
31
- const UNSAFE_CONTEXT = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|sk[_-][a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning)/i
38
+ // A visible turn containing an unsafe span is omitted whole. Partial redaction
39
+ // could preserve enough neighboring material to reconstruct a credential or
40
+ // host location, so this boundary deliberately fails closed.
41
+ const HOST_PATH = /(?:file:\/\/|[a-z]:\\|\\\\|(?:^|[^a-z0-9/])(?:~[\\/]|\/(?:users|home|private|tmp|var\/folders|volumes|root|workspace|workspaces|mnt|srv|opt|etc)\/))/i
42
+ // Traversal is a path segment, not a path prefix. Keep it separate from
43
+ // HOST_PATH so nested forms such as `foo/../secret` and `foo\..\secret`
44
+ // cannot bypass a boundary rule intended for absolute path roots.
45
+ const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
46
+ const SECRET_VALUE = /(?:sk[_-](?:proj[_-])?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{8,}|x(?:ox[baprsce]|app)-[a-z0-9_-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{16}|sbp_[a-z0-9_-]{20,}|sb_secret_[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|hooks\.slack\.com\/services\/[a-z0-9/_-]{8,})/i
47
+ const SECRET_ASSIGNMENT = /(?:[a-z0-9]+[_ -])*(?:token|secret(?:[_ -]access[_ -]key)?|password|authorization|api[_ -]?key|private[_ -]?key|access[_ -]?key(?:[_ -]?id)?)\s*[:=]\s*\S{4,}/i
48
+ const PROHIBITED_CONTEXT = /(?:BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning|system prompt|environment dump|provider key)/i
32
49
  const safeRecapText = (value) => {
33
50
  const text = String(value || '').trim()
34
- return text && !UNSAFE_CONTEXT.test(text) ? text : ''
51
+ return text && !HOST_PATH.test(text) && !PATH_TRAVERSAL.test(text) && !SECRET_VALUE.test(text) && !SECRET_ASSIGNMENT.test(text) && !PROHIBITED_CONTEXT.test(text) ? text : ''
52
+ }
53
+
54
+ const clip = (value, cap) => {
55
+ const text = String(value || '').trim().replace(/\s+/g, ' ')
56
+ if (!text) return ''
57
+ return text.length <= cap ? text : `${text.slice(0, Math.max(0, cap - 1)).trimEnd()}…`
58
+ }
59
+
60
+ const visibleTurns = (log) => {
61
+ if (!Array.isArray(log)) return []
62
+ const turns = []
63
+ for (const event of log) {
64
+ if (!event || typeof event !== 'object') continue
65
+ if (event.kind === 'you') {
66
+ const text = safeRecapText(event.text)
67
+ if (text) turns.push({ who: 'person', text })
68
+ continue
69
+ }
70
+ if (event.kind !== 'assistant') continue
71
+ const text = (Array.isArray(event.blocks) ? event.blocks : [])
72
+ .filter((block) => block?.type === 'text')
73
+ .map((block) => safeRecapText(block.text))
74
+ .filter(Boolean)
75
+ .join('\n')
76
+ if (text) turns.push({ who: 'assistant', text })
77
+ }
78
+ return turns
79
+ }
80
+
81
+ const unique = (values, cap = 6) => [...new Set(values.filter(Boolean))].slice(0, cap)
82
+ const sentenceCandidates = (turns, matcher, cap = 4) => unique(
83
+ turns.flatMap((turn) => turn.text.split(/(?<=[.!?])\s+|\n+/)
84
+ .map((line) => clip(line, 500))
85
+ .filter((line) => matcher.test(line))),
86
+ cap,
87
+ )
88
+
89
+ const publicReceipts = (turns) => {
90
+ const receipts = []
91
+ for (const { text } of turns) {
92
+ // Never infer a receipt from an unsafe source turn; safeRecapText already
93
+ // rejected host paths, credential-shaped values, and hidden/tool text.
94
+ for (const sha of text.matchAll(/\b[0-9a-f]{7,40}\b/gi)) receipts.push(`commit ${sha[0]}`)
95
+ for (const file of text.matchAll(/(?<![\w/])(?:[\w.-]+\/)+[\w.-]+\.(?:[cm]?[jt]sx?|json|md|css|html|py|sh|yml|yaml)\b/g)) receipts.push(`file ${file[0]}`)
96
+ for (const url of text.matchAll(/https?:\/\/[^\s)\]}>]+/gi)) {
97
+ const candidate = safeRecapText(url[0])
98
+ if (candidate) receipts.push(`URL ${candidate}`)
99
+ }
100
+ }
101
+ return unique(receipts, 8)
102
+ }
103
+
104
+ /*
105
+ * Build a small, explicit resume checkpoint from *only* the visible person and
106
+ * assistant text that buildRecapFromLog is allowed to carry. This intentionally
107
+ * does not read tool calls/results, thinking, runtime state, files, or external
108
+ * systems. It is heuristic evidence, so labels stay conservative: absence is
109
+ * stated as "not established" rather than invented work/completion.
110
+ */
111
+ export function buildCheckpointFromLog(log, cap = CHECKPOINT_CAP) {
112
+ const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
113
+ if (!budget) return ''
114
+ const turns = visibleTurns(log)
115
+ if (!turns.length) return ''
116
+ const people = turns.filter((turn) => turn.who === 'person')
117
+ const assistants = turns.filter((turn) => turn.who === 'assistant')
118
+ const objective = clip(people.at(-1)?.text || 'not established from visible text', 1400)
119
+ const recent = clip(assistants.at(-1)?.text || turns.at(-1)?.text || 'not established from visible text', 1400)
120
+ const blockers = sentenceCandidates(turns, /\b(block(?:ed|er|ing)?|cannot|can['’]?t|unable|waiting|stuck|failed|failure|error|unverified)\b/i)
121
+ const attempted = sentenceCandidates(assistants, /\b(implemented|added|changed|updated|fixed|ran|tested|checked|inspected|tried|attempted|committed|built)\b/i)
122
+ const completed = sentenceCandidates(assistants, /\b(done|complete(?:d)?|implemented|fixed|passing|passed|succeeded|shipped)\b/i)
123
+ const next = clip(
124
+ people.length > 1 ? people.at(-1)?.text :
125
+ sentenceCandidates(assistants, /\b(next|remaining|follow[- ]?up|todo|will)\b/i, 1)[0] || 'not established from visible text',
126
+ 1000,
127
+ )
128
+ const lines = [
129
+ CHECKPOINT_START,
130
+ `Objective / live request: ${objective}`,
131
+ `Current step / recent work: ${recent}`,
132
+ `Blockers: ${blockers.length ? blockers.join(' | ') : 'none stated in visible text'}`,
133
+ `Attempted / completed actions: ${attempted.length || completed.length ? unique([...attempted, ...completed], 6).join(' | ') : 'not established from visible text'}`,
134
+ `Receipts: ${publicReceipts(turns).join(' | ') || 'none safely detected'}`,
135
+ `Next action: ${next}`,
136
+ CHECKPOINT_END,
137
+ ]
138
+ // Preserve explicit labels under a small caller cap; trim values before
139
+ // trimming structure so a checkpoint never becomes a misleading fragment.
140
+ let checkpoint = lines.join('\n')
141
+ if (checkpoint.length <= budget) return checkpoint
142
+ const minimal = [
143
+ lines[0],
144
+ 'Objective / live request:',
145
+ 'Current step / recent work:',
146
+ 'Blockers:',
147
+ 'Attempted / completed actions:',
148
+ 'Receipts:',
149
+ 'Next action:',
150
+ lines.at(-1),
151
+ ].join('\n')
152
+ // A partial envelope could be mistaken for a complete checkpoint. Fail closed
153
+ // rather than omit a label or expose an over-budget payload.
154
+ if (minimal.length > budget) return ''
155
+ const allowance = budget - minimal.length - 6 // one separating space per field
156
+ const values = [objective, recent, blockers.join(' | '), unique([...attempted, ...completed], 6).join(' | '), publicReceipts(turns).join(' | '), next]
157
+ const shares = values.map((value) => clip(value, Math.max(0, Math.floor(allowance / values.length))))
158
+ checkpoint = [
159
+ lines[0],
160
+ `Objective / live request: ${shares[0] || 'not established'}`,
161
+ `Current step / recent work: ${shares[1] || 'not established'}`,
162
+ `Blockers: ${shares[2] || 'none stated'}`,
163
+ `Attempted / completed actions: ${shares[3] || 'not established'}`,
164
+ `Receipts: ${shares[4] || 'none safely detected'}`,
165
+ `Next action: ${shares[5] || 'not established'}`,
166
+ lines.at(-1),
167
+ ].join('\n')
168
+ return checkpoint
169
+ }
170
+
171
+ export function recapHasCheckpoint(value) {
172
+ const text = String(value || '')
173
+ return text.includes(CHECKPOINT_START) && text.includes(CHECKPOINT_END)
174
+ }
175
+
176
+ // A recap that already contains the checkpoint is the sole delivery owner.
177
+ // Resolve this in one place for both restored sessions and the live fallback
178
+ // compaction path; otherwise an older separately-persisted checkpoint can sit
179
+ // beside the recap and be prepended a second time on the next person turn.
180
+ export function resolveCheckpointCarry(carryRecap, carryCheckpoint) {
181
+ const pendingRecap = typeof carryRecap === 'string' && carryRecap.trim()
182
+ ? carryRecap
183
+ : null
184
+ const pendingCheckpoint = !recapHasCheckpoint(pendingRecap) && typeof carryCheckpoint === 'string' && carryCheckpoint.trim()
185
+ ? carryCheckpoint
186
+ : null
187
+ return { pendingRecap, pendingCheckpoint }
35
188
  }
36
189
 
37
190
  export function appendCurrentPersonRequest(carried, text) {
@@ -88,7 +241,7 @@ const FRAMING = {
88
241
 
89
242
  `reason` selects the framing only; the windowing is identical and the default
90
243
  ('switch') is byte-for-byte what this function returned before the S4 extraction. */
91
- export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } = {}) {
244
+ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch', includeCheckpoint = true } = {}) {
92
245
  if (!Array.isArray(log) || !log.length) return ''
93
246
  const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
94
247
  if (!budget) return ''
@@ -127,6 +280,11 @@ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } =
127
280
  kept.reverse()
128
281
  const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
129
282
  const frame = FRAMING[reason] || FRAMING.switch
283
+ // Side-lane snapshots already have their own explicit handoff contract. A
284
+ // deterministic resume checkpoint belongs only to a lane that is actually
285
+ // losing SDK context (switch, wake, or compaction); adding it to side
286
+ // snapshots duplicates visible text and can exceed their bounded payload.
287
+ const checkpoint = includeCheckpoint ? buildCheckpointFromLog(log) : ''
130
288
  return [
131
289
  frame.header,
132
290
  frame.body,
@@ -134,5 +292,6 @@ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } =
134
292
  '--- conversation so far (oldest → newest) ---',
135
293
  body,
136
294
  '--- end recap ---',
295
+ checkpoint,
137
296
  ].join('\n')
138
297
  }
package/side-lane.mjs CHANGED
@@ -12,7 +12,7 @@ export const SIDE_MAIN_TURN_PROMPT = `A room member chose Bring to main.
12
12
  Read the side-lane handoff below, incorporate the relevant findings into the main lane's current work, and respond now. If the handoff recommends a next step that is already authorized and in scope, take it; otherwise explain the concrete impact on the current work.`
13
13
 
14
14
  export function sideSnapshot(log) {
15
- return buildRecapFromLog(Array.isArray(log) ? log : [], SIDE_RECAP_CAP)
15
+ return buildRecapFromLog(Array.isArray(log) ? log : [], SIDE_RECAP_CAP, { includeCheckpoint: false })
16
16
  }
17
17
 
18
18
  export function assistantTextSince(log, afterSeq = 0) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 20,
3
+ "bundleVersion": 22,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -130,25 +130,28 @@
130
130
  },
131
131
  {
132
132
  "id": "flow-completion",
133
- "version": 2,
133
+ "version": 4,
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 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."
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. New builder/fix/scaffold tasks must carry a bounded gate-first contract: observable acceptance, explicit non-goals, and a real pre-edit failure/absence gate. Approval seals that acceptance pack with a stable digest; completion supplies observed baseline evidence without changing the digest, and the exact dependent reviewer inherits it. A conclusive pass binds one bounded terminal receipt to the exact reviewed candidate SHA and acceptance digest; missing proof holds the Flow instead of completing it. When a bounded detail is ambiguous or the user is unsure, choose the least-invasive reversible default, state the assumption, and continue; ask only when the choice would materially expand scope or authority. 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": [
143
143
  {"path": "bridge/bridge.mjs", "diffPattern": "submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check"},
144
144
  {"path": "bridge/flow-review.mjs"},
145
+ {"path": "bridge/flow-receipt.mjs"},
145
146
  {"path": "bridge/flow-review-gate.mjs"},
146
147
  {"path": "bridge/flow-task-graph.mjs"}
147
148
  ],
148
149
  "evidence": [
149
150
  {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_plan'"},
150
151
  {"path": "bridge/bridge.mjs", "pattern": "'mark_flow_done'"},
151
- {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"}
152
+ {"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"},
153
+ {"path": "bridge/flow-task-graph.mjs", "pattern": "acceptancePackDigest"},
154
+ {"path": "bridge/flow-receipt.mjs", "pattern": "Passing flow review receipt requires an exact candidate and acceptance digest"}
152
155
  ]
153
156
  },
154
157
  {