thinkpool-pair 0.7.362 → 0.7.363
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 +112 -7
- package/flow-receipt.mjs +7 -0
- package/flow-scope-evidence.mjs +117 -0
- package/flow-task-graph.mjs +54 -6
- package/package.json +3 -1
- package/past-work-search.mjs +105 -0
- package/session-store.mjs +22 -0
- package/thinkpool-capabilities.json +17 -6
package/bridge.mjs
CHANGED
|
@@ -98,6 +98,8 @@ function stopFlowPreviews (flowId, laneId = null) {
|
|
|
98
98
|
}
|
|
99
99
|
import { FLOW_REVIEWER_PROMPT, FLOW_CODEX_REVIEWER_PROMPT, revertLane, parseReviewVerdict, reviewVerdictToReflection } from './flow-review.mjs'
|
|
100
100
|
import { createFlowReviewReceipt, createFlowReviewSingleFlight, isFlowReviewReceipt } from './flow-receipt.mjs'
|
|
101
|
+
import { collectFlowScopeEvidence, normalizeFlowScopeEvidence } from './flow-scope-evidence.mjs'
|
|
102
|
+
import { formatPastWorkSearch, searchPastWork } from './past-work-search.mjs'
|
|
101
103
|
import { reviewGateDecision } from './flow-review-gate.mjs'
|
|
102
104
|
import { IMMUTABLE_REVIEW_READ_TOOL_EXTRAS, UNSANDBOXED_REVIEW_CHECK_TOOL_EXTRAS, readReviewFile, runReviewCheck } from './review-check.mjs'
|
|
103
105
|
import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
|
|
@@ -125,12 +127,35 @@ const flowRedispatch = new Map()
|
|
|
125
127
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
126
128
|
// broadcasts; without persistent state the cap can never bite.
|
|
127
129
|
const flowBudgets = new Map()
|
|
130
|
+
const changedFilesBetween = (cwd, baseSha, candidateSha) => {
|
|
131
|
+
if (!cwd || !baseSha || !candidateSha) return null
|
|
132
|
+
try {
|
|
133
|
+
return execFileSync('git', ['-C', cwd, 'diff', '--name-only', '--diff-filter=ACMRD', `${baseSha}..${candidateSha}`], {
|
|
134
|
+
encoding: 'utf8',
|
|
135
|
+
timeout: 5000,
|
|
136
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
137
|
+
}).split('\n').map((line) => line.trim()).filter(Boolean)
|
|
138
|
+
} catch {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const worktreeChanges = (cwd) => {
|
|
143
|
+
try {
|
|
144
|
+
return execFileSync('git', ['-C', cwd, 'status', '--porcelain=v1', '--untracked-files=all'], {
|
|
145
|
+
encoding: 'utf8',
|
|
146
|
+
timeout: 5000,
|
|
147
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
148
|
+
}).split('\n').map((line) => line.trim()).filter(Boolean)
|
|
149
|
+
} catch {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
128
153
|
import { formatPeek, PEEK, readTerminalTurnBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, settleLaneControl, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
|
|
129
154
|
import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
|
|
130
155
|
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
131
156
|
import { supersedeDispatchLease } from './dispatch-lease.mjs'
|
|
132
157
|
import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
|
|
133
|
-
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage, acknowledgePendingScheduledOutcome, commitRecordedScheduledOutcome, deletePendingScheduledOutcome, loadPendingScheduledOutcome, loadPendingScheduledOutcomes, savePendingScheduledOutcome } from './session-store.mjs'
|
|
158
|
+
import { saveSession, flushSession, deleteSession, loadAll, loadArchived, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage, acknowledgePendingScheduledOutcome, commitRecordedScheduledOutcome, deletePendingScheduledOutcome, loadPendingScheduledOutcome, loadPendingScheduledOutcomes, savePendingScheduledOutcome } from './session-store.mjs'
|
|
134
159
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, appendCurrentPersonRequest, buildCheckpointFromLog, buildRecapFromLog, resolveCheckpointCarry, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
135
160
|
import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
|
|
136
161
|
import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
|
|
@@ -2682,6 +2707,25 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2682
2707
|
if (!reviewPass && entry.dispatchBaseSha && commitSha === entry.dispatchBaseSha) {
|
|
2683
2708
|
return `Slice "${entry.flowTaskKey}" is not done: HEAD is still the dispatch base (${commitSha.slice(0, 8)}). Commit the verified implementation first.`
|
|
2684
2709
|
}
|
|
2710
|
+
if (!reviewPass) {
|
|
2711
|
+
const dirty = worktreeChanges(entry.cwd || process.cwd())
|
|
2712
|
+
if (dirty == null) return `Slice "${entry.flowTaskKey}" is not done: the bridge could not verify a clean worktree.`
|
|
2713
|
+
if (dirty.length) return `Slice "${entry.flowTaskKey}" is not done: commit or remove every remaining worktree change before completion (${dirty.slice(0, 4).join(', ')}).`
|
|
2714
|
+
}
|
|
2715
|
+
let scopeEvidence = null
|
|
2716
|
+
if (!reviewPass && entry.flowTaskContract?.scopePaths?.length) {
|
|
2717
|
+
const changed = changedFilesBetween(entry.cwd || process.cwd(), entry.dispatchBaseSha, commitSha)
|
|
2718
|
+
if (!changed) return `Slice "${entry.flowTaskKey}" is not done: the bridge could not derive its Git scope footprint.`
|
|
2719
|
+
scopeEvidence = collectFlowScopeEvidence({
|
|
2720
|
+
log: entry.log,
|
|
2721
|
+
repoRoot: entry.cwd || process.cwd(),
|
|
2722
|
+
declared: entry.flowTaskContract.scopePaths,
|
|
2723
|
+
changed,
|
|
2724
|
+
})
|
|
2725
|
+
if (!scopeEvidence.held) {
|
|
2726
|
+
return `Slice "${entry.flowTaskKey}" is not done: scope drift detected in ${scopeEvidence.unexpected.join(', ')}. The approved scope must be amended before these files can ship.`
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2685
2729
|
let previewUrl = null
|
|
2686
2730
|
try { const pv = await startPreview({ dir: entry.cwd || process.cwd(), id: `lane:${entry.flowSessionId}:${id}` }); previewUrl = pv.url } catch { /* preview best-effort */ }
|
|
2687
2731
|
// S1 (context-offload) — digest THIS closed slice into the durable store so the next
|
|
@@ -2712,8 +2756,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2712
2756
|
process.stderr.write(`\n ${A.dim}◆ flow digest skip ${entry.flowTaskKey} — ${e?.message || e}${A.rst}\n`)
|
|
2713
2757
|
}
|
|
2714
2758
|
const completedContract = requiresBaselineReceipt
|
|
2715
|
-
? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt } }
|
|
2716
|
-
: entry.flowTaskContract
|
|
2759
|
+
? { ...entry.flowTaskContract, baseline: { ...entry.flowTaskContract.baseline, evidence: baselineReceipt }, ...(scopeEvidence ? { scopeEvidence } : {}) }
|
|
2760
|
+
: entry.flowTaskContract
|
|
2761
|
+
? { ...entry.flowTaskContract, ...(scopeEvidence ? { scopeEvidence } : {}) }
|
|
2762
|
+
: null
|
|
2717
2763
|
bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha, previewUrl, contract: completedContract, ...(reviewPass ? { reviewAction: 'pass' } : {}) }, flowChannel)
|
|
2718
2764
|
process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${previewUrl ? ` · preview ${previewUrl}` : ''}${A.rst}\n`)
|
|
2719
2765
|
entry.flowDone = true // FL-B3 — retire immediately so it drops from the ≤8 lane cap
|
|
@@ -2802,6 +2848,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2802
2848
|
rounds: round,
|
|
2803
2849
|
candidateSha: targetSnapshot?.sha,
|
|
2804
2850
|
acceptanceDigest,
|
|
2851
|
+
scope: targetSnapshot?.scopeEvidence,
|
|
2805
2852
|
reasons: receiptReasons,
|
|
2806
2853
|
})
|
|
2807
2854
|
} catch (error) {
|
|
@@ -2816,6 +2863,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2816
2863
|
rounds: round,
|
|
2817
2864
|
candidateSha: targetSnapshot?.sha,
|
|
2818
2865
|
acceptanceDigest,
|
|
2866
|
+
scope: targetSnapshot?.scopeEvidence,
|
|
2819
2867
|
reasons: receiptReasons,
|
|
2820
2868
|
})
|
|
2821
2869
|
}
|
|
@@ -3148,6 +3196,44 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3148
3196
|
return { content: [{ type: 'text', text }] }
|
|
3149
3197
|
},
|
|
3150
3198
|
),
|
|
3199
|
+
...(!entry.flowRole ? [tool(
|
|
3200
|
+
'search_past_work',
|
|
3201
|
+
'Search bounded saved terminal work from THIS ThinkPool room. Returns privacy-filtered excerpts for prior decisions, failed attempts, commands, checks, and Flow receipts. Every hit cites the room, terminal, date, and event sequence. It never searches another room, exposes prompts or host paths, or changes state.',
|
|
3202
|
+
{
|
|
3203
|
+
query: z.string().min(2).max(160).describe('specific terms from the prior decision, failure, command, check, or feature'),
|
|
3204
|
+
limit: z.number().int().min(1).max(8).optional().describe('maximum cited hits; default 8'),
|
|
3205
|
+
},
|
|
3206
|
+
async (args) => {
|
|
3207
|
+
if (entry.pastWorkSearchTurnRev !== entry._turnRev) {
|
|
3208
|
+
entry.pastWorkSearchTurnRev = entry._turnRev
|
|
3209
|
+
entry.pastWorkSearchCount = 0
|
|
3210
|
+
}
|
|
3211
|
+
if ((entry.pastWorkSearchCount || 0) >= 3) {
|
|
3212
|
+
return { content: [{ type: 'text', text: 'Past-work search limit reached for this turn (3). Use the cited results already returned or narrow the next room turn.' }] }
|
|
3213
|
+
}
|
|
3214
|
+
entry.pastWorkSearchCount = (entry.pastWorkSearchCount || 0) + 1
|
|
3215
|
+
const records = new Map()
|
|
3216
|
+
for (const record of loadArchived(room, 40)) {
|
|
3217
|
+
if (!record?.id || !Array.isArray(record.log)) continue
|
|
3218
|
+
records.set(record.id, { id: record.id, name: termNames[record.id] || record.name || 'Archived terminal', savedAt: record.savedAt || record._archivedAt, log: record.log })
|
|
3219
|
+
}
|
|
3220
|
+
for (const [terminalId, terminalEntry] of sessions) {
|
|
3221
|
+
records.set(terminalId, {
|
|
3222
|
+
id: terminalId,
|
|
3223
|
+
name: termNames[terminalId] || `Terminal ${String(terminalId).slice(0, 8)}`,
|
|
3224
|
+
savedAt: terminalEntry.openedAt || Date.now(),
|
|
3225
|
+
log: terminalEntry.log || [],
|
|
3226
|
+
})
|
|
3227
|
+
}
|
|
3228
|
+
let results
|
|
3229
|
+
try {
|
|
3230
|
+
results = searchPastWork([...records.values()], args?.query || '', { room, limit: args?.limit })
|
|
3231
|
+
} catch (error) {
|
|
3232
|
+
return { content: [{ type: 'text', text: `Past-work search rejected: ${error?.message || error}` }] }
|
|
3233
|
+
}
|
|
3234
|
+
return { content: [{ type: 'text', text: formatPastWorkSearch(results, args?.query || '') }] }
|
|
3235
|
+
},
|
|
3236
|
+
)] : []),
|
|
3151
3237
|
// Tier 1 cross-ROOM peek — list_sessions / read_session. Read-only reach into the
|
|
3152
3238
|
// account's OTHER rooms (this session's sibling SESSIONS, not just sibling terminals),
|
|
3153
3239
|
// routed through the account supervisor over IPC (pairRequest). Auto-allowed by the
|
|
@@ -3528,7 +3614,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3528
3614
|
// (slices whose deps just got satisfied).
|
|
3529
3615
|
...(entry.flowRole === 'builder' ? [tool(
|
|
3530
3616
|
'mark_flow_done',
|
|
3531
|
-
"ThinkPool Flow ONLY — call this ONCE when your slice is built, RUNS, and meets its acceptance criteria. For a gate-first task, pass `baselineEvidence`: the real, bounded pre-edit command/behavior receipt proving the assigned failure/absence. Never fabricate a RED sentence. Records your latest commit as the slice's atomic-revert target and signals the room that this slice is done (which unblocks slices that depended on you). Do not call before the slice actually runs + meets acceptance.",
|
|
3617
|
+
"ThinkPool Flow ONLY — call this ONCE when your slice is built, RUNS, and meets its acceptance criteria. For a gate-first task, pass `baselineEvidence`: the real, bounded pre-edit command/behavior receipt proving the assigned failure/absence. Never fabricate a RED sentence. The bridge derives the edited-file footprint from Git, compares it with the sealed scopePaths, and refuses unexpected touches. Records your latest commit as the slice's atomic-revert target and signals the room that this slice is done (which unblocks slices that depended on you). Do not call before the slice actually runs + meets acceptance.",
|
|
3532
3618
|
{ baselineEvidence: z.string().max(1400).optional().describe('real single-line pre-edit baseline command/behavior receipt; required for new gate-first tasks') },
|
|
3533
3619
|
async (args) => ({ content: [{ type: 'text', text: await markFlowDone({ baselineEvidence: args?.baselineEvidence || '' }) }] }),
|
|
3534
3620
|
)] : []),
|
|
@@ -3540,14 +3626,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3540
3626
|
// reject malformed plans back to the conductor so it re-emits.
|
|
3541
3627
|
...(entry.flowRole === 'conductor' ? [tool(
|
|
3542
3628
|
'submit_flow_plan',
|
|
3543
|
-
'ThinkPool Flow CONDUCTOR ONLY — submit your decomposition for human approval. Call this ONCE when your task-graph is ready. Pass it as `plan`: a JSON string {"summary":"<one line; assumptions: …>","tasks":[{"key":"<kebab>","title":"…","scope":"
|
|
3629
|
+
'ThinkPool Flow CONDUCTOR ONLY — submit your decomposition for human approval. Call this ONCE when your task-graph is ready. Pass it as `plan`: a JSON string {"summary":"<one line; assumptions: …>","tasks":[{"key":"<kebab>","title":"…","scope":"human-readable ownership","scopePaths":["repo/file.js","repo/directory/**"],"acceptance":"observable proof","nonGoals":["bounded exclusion"],"baseline":{"gate":"what fails/is absent before edits","evidence":"optional observed receipt"},"deps":["<key>"],"sliceType":"feature|scaffold|review|fix"}]}. Each builder/fix/scaffold needs acceptance, machine-readable repo-relative scopePaths, nonGoals, and a safe bounded baseline gate; review tasks inherit one builder contract. When uncertain, choose and record a safe reversible default; ask only when a choice materially expands scope or authority. The room validates and persists the graph, then shows approval. Do NOT use ExitPlanMode or write a plan file.',
|
|
3544
3630
|
{ plan: z.string().describe('the task-graph as a JSON string (the {summary, tasks:[…]} object)') },
|
|
3545
3631
|
async (args) => {
|
|
3546
3632
|
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
3547
3633
|
if (!entry.flowSessionId || entry.flowRole !== 'conductor') return okText('Not a Flow conductor — there is no flow to submit a plan for.')
|
|
3548
3634
|
let norm
|
|
3549
3635
|
try { norm = validatePlanForRuntime(normalizePlanOutput(args?.plan || ''), entry.runtime) }
|
|
3550
|
-
catch (e) { return okText(`Plan REJECTED: ${e?.message || e}. Re-call submit_flow_plan with valid JSON: a non-empty acyclic tasks array; each builder/fix/scaffold has observable acceptance, bounded nonGoals, and a safe baseline gate that says what fails/is absent; each review targets exactly one builder contract.`) }
|
|
3636
|
+
catch (e) { return okText(`Plan REJECTED: ${e?.message || e}. Re-call submit_flow_plan with valid JSON: a non-empty acyclic tasks array; each builder/fix/scaffold has observable acceptance, repo-relative scopePaths, bounded nonGoals, and a safe baseline gate that says what fails/is absent; each review targets exactly one builder contract.`) }
|
|
3551
3637
|
bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: JSON.stringify(norm) }, flowChannel)
|
|
3552
3638
|
process.stderr.write(`\n ${A.mag}◆ flow plan submitted — flow ${String(entry.flowSessionId).slice(0, 8)} (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}).${A.rst}\n`)
|
|
3553
3639
|
return okText(`Plan submitted (${norm.tasks.length} slice${norm.tasks.length === 1 ? '' : 's'}) with gate-first contracts. Safe reversible assumptions belong in the summary; user steering remains authoritative. The room is showing approval. You are DONE; stop here and wait for approval (lane dispatch is the room's job).`)
|
|
@@ -5593,7 +5679,26 @@ flowChannel
|
|
|
5593
5679
|
const cwd = worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir
|
|
5594
5680
|
let sha = null
|
|
5595
5681
|
try { sha = execFileSync('git', ['-C', cwd, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { /* target is not safely reviewable */ }
|
|
5596
|
-
|
|
5682
|
+
let scopeEvidence = null
|
|
5683
|
+
if (sha && t.contract?.scopePaths?.length) {
|
|
5684
|
+
let baseSha = null
|
|
5685
|
+
try {
|
|
5686
|
+
baseSha = execFileSync('git', ['-C', cwd, 'merge-base', sha, 'origin/main'], {
|
|
5687
|
+
encoding: 'utf8',
|
|
5688
|
+
timeout: 3000,
|
|
5689
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
5690
|
+
}).trim()
|
|
5691
|
+
} catch { /* missing immutable base holds the review below */ }
|
|
5692
|
+
const changed = changedFilesBetween(cwd, baseSha, sha)
|
|
5693
|
+
if (changed) {
|
|
5694
|
+
const mechanical = collectFlowScopeEvidence({ log: [], repoRoot: cwd, declared: t.contract.scopePaths, changed })
|
|
5695
|
+
const observed = normalizeFlowScopeEvidence(t.contract.scopeEvidence)
|
|
5696
|
+
scopeEvidence = observed
|
|
5697
|
+
? { ...mechanical, read: observed.read, verified: observed.verified }
|
|
5698
|
+
: mechanical
|
|
5699
|
+
}
|
|
5700
|
+
}
|
|
5701
|
+
return { taskKey: dep, cwd, sha, ...(scopeEvidence ? { scopeEvidence } : {}) }
|
|
5597
5702
|
}).filter((item) => item.sha) : []
|
|
5598
5703
|
// Lanes build autonomously in their own worktree — bypassPermissions so they
|
|
5599
5704
|
// don't stall on a card for every write/bash (matches the user's expectation that
|
package/flow-receipt.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// admits the evidence a pair can act on. Prompts, environment, paths, logs, and
|
|
4
4
|
// hidden reasoning never have a field in the public shape.
|
|
5
5
|
|
|
6
|
+
import { normalizeFlowScopeEvidence } from './flow-scope-evidence.mjs'
|
|
7
|
+
|
|
6
8
|
export const FLOW_REVIEW_RECEIPT_KIND = 'flow-review-receipt'
|
|
7
9
|
|
|
8
10
|
const MAX_TASK_KEY = 120
|
|
@@ -75,6 +77,7 @@ export function createFlowReviewReceipt(input = {}) {
|
|
|
75
77
|
}
|
|
76
78
|
const candidateSha = cleanSha(input.candidateSha)
|
|
77
79
|
const acceptanceDigest = cleanDigest(input.acceptanceDigest)
|
|
80
|
+
const scope = normalizeFlowScopeEvidence(input.scope)
|
|
78
81
|
// Presence, not truthiness, is intentional: deployments are never inferred
|
|
79
82
|
// from a pass/reject outcome or from a candidate SHA.
|
|
80
83
|
const deployment = Object.hasOwn(input, 'deployment') ? cleanText(input.deployment, MAX_LINE) : null
|
|
@@ -84,6 +87,10 @@ export function createFlowReviewReceipt(input = {}) {
|
|
|
84
87
|
if (outcome === 'pass' && !receipt.verified) {
|
|
85
88
|
throw new TypeError('Passing flow review receipt requires an exact candidate and acceptance digest')
|
|
86
89
|
}
|
|
90
|
+
if (scope) receipt.scope = scope
|
|
91
|
+
if (outcome === 'pass' && !scope?.held) {
|
|
92
|
+
throw new TypeError('Passing flow review receipt requires a held scope footprint')
|
|
93
|
+
}
|
|
87
94
|
if (deployment) receipt.deployment = deployment
|
|
88
95
|
return receipt
|
|
89
96
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
const MAX_SCOPE_PATHS = 32
|
|
4
|
+
const MAX_PATH = 180
|
|
5
|
+
const MAX_OBSERVED = 32
|
|
6
|
+
const MAX_CHECKS = 8
|
|
7
|
+
const MAX_CHECK = 220
|
|
8
|
+
const PRIVATE_PATH = /(?:^|\/)(?:\.env(?:\.|$)|\.git(?:\/|$)|\.thinkpool(?:\/|$)|node_modules(?:\/|$))/
|
|
9
|
+
const SECRET = /(?:sk[_-](?:proj[_-])?[a-z0-9_-]{8,}|github_pat_[a-z0-9_]{20,}|gh[pousr]_[a-z0-9_-]{8,}|(?:AKIA|ASIA)[0-9A-Z]{16}|npm_[a-z0-9]{24,}|bearer\s+[a-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|auth(?:orization)?|token|secret)\s*[=:]\s*\S+)/i
|
|
10
|
+
const VERIFY_COMMAND = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|check|typecheck|verify)\b|(?:^|\s)node\s+--test\b|(?:vitest|jest|playwright|eslint|tsc)\b/i
|
|
11
|
+
|
|
12
|
+
function uniqueSorted(values, max = MAX_OBSERVED) {
|
|
13
|
+
return [...new Set(values)].sort().slice(0, max)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function repoPath(value, { allowPattern = false } = {}) {
|
|
17
|
+
if (typeof value !== 'string') return null
|
|
18
|
+
let text = value.trim().replaceAll('\\', '/')
|
|
19
|
+
if (!text || text.length > MAX_PATH || text.startsWith('/') || /^[A-Za-z]:\//.test(text)) return null
|
|
20
|
+
const directoryPattern = allowPattern && text.endsWith('/**')
|
|
21
|
+
if (text.includes('*') && !directoryPattern) return null
|
|
22
|
+
const bare = directoryPattern ? text.slice(0, -3) : text
|
|
23
|
+
if (!bare || bare.endsWith('/') || bare.split('/').some((part) => !part || part === '.' || part === '..')) return null
|
|
24
|
+
if (PRIVATE_PATH.test(bare)) return null
|
|
25
|
+
const last = bare.split('/').at(-1)
|
|
26
|
+
if (allowPattern && !directoryPattern && !last.includes('.') && !/^(?:Dockerfile|Makefile|LICENSE|README)$/i.test(last)) return null
|
|
27
|
+
text = directoryPattern ? `${bare}/**` : bare
|
|
28
|
+
return text
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeFlowScopePaths(value) {
|
|
32
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_SCOPE_PATHS) {
|
|
33
|
+
throw new TypeError(`scope paths must contain 1-${MAX_SCOPE_PATHS} repo-relative entries`)
|
|
34
|
+
}
|
|
35
|
+
const paths = []
|
|
36
|
+
for (const item of value) {
|
|
37
|
+
const safe = repoPath(item, { allowPattern: true })
|
|
38
|
+
if (!safe) throw new TypeError(`invalid scope path: ${JSON.stringify(item)}`)
|
|
39
|
+
if (!paths.includes(safe)) paths.push(safe)
|
|
40
|
+
}
|
|
41
|
+
return paths.sort()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function classifyFlowScope({ declared, changed } = {}) {
|
|
45
|
+
const safeDeclared = normalizeFlowScopePaths(declared)
|
|
46
|
+
const safeChanged = uniqueSorted((Array.isArray(changed) ? changed : []).map((item) => repoPath(item)).filter(Boolean))
|
|
47
|
+
const unexpected = safeChanged.filter((file) => !safeDeclared.some((scope) =>
|
|
48
|
+
scope.endsWith('/**') ? file.startsWith(scope.slice(0, -2)) : file === scope
|
|
49
|
+
))
|
|
50
|
+
return { declared: safeDeclared, changed: safeChanged, unexpected, held: unexpected.length === 0 }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function observedPath(value, repoRoot) {
|
|
54
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
55
|
+
const absoluteRoot = path.resolve(repoRoot)
|
|
56
|
+
const absolute = path.isAbsolute(value) ? path.resolve(value) : path.resolve(absoluteRoot, value)
|
|
57
|
+
const relative = path.relative(absoluteRoot, absolute).replaceAll('\\', '/')
|
|
58
|
+
if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return null
|
|
59
|
+
return repoPath(relative)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeCheck(value, repoRoot) {
|
|
63
|
+
if (typeof value !== 'string') return null
|
|
64
|
+
let command = value.replaceAll(path.resolve(repoRoot), '.').replace(/\s+/g, ' ').trim()
|
|
65
|
+
if (!command || command.length > MAX_CHECK || SECRET.test(command) || !VERIFY_COMMAND.test(command)) return null
|
|
66
|
+
return command
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function collectFlowScopeEvidence({ log = [], repoRoot, declared, changed } = {}) {
|
|
70
|
+
const classified = classifyFlowScope({ declared, changed })
|
|
71
|
+
const reads = []
|
|
72
|
+
const checks = new Map()
|
|
73
|
+
const successful = new Set()
|
|
74
|
+
for (const event of Array.isArray(log) ? log : []) {
|
|
75
|
+
if (event?.kind === 'assistant') {
|
|
76
|
+
for (const block of event.blocks || []) {
|
|
77
|
+
if (block?.type !== 'tool_use') continue
|
|
78
|
+
const name = String(block.name || '').toLowerCase()
|
|
79
|
+
if (['read', 'read_file', 'edit', 'write'].includes(name)) {
|
|
80
|
+
const candidate = block.input?.file_path ?? block.input?.path
|
|
81
|
+
const safe = observedPath(candidate, repoRoot)
|
|
82
|
+
if (safe) reads.push(safe)
|
|
83
|
+
}
|
|
84
|
+
if (name === 'bash' || name === 'exec_command' || name === 'command_execution') {
|
|
85
|
+
const command = safeCheck(block.input?.command ?? block.input?.cmd, repoRoot)
|
|
86
|
+
if (block.id && command) checks.set(block.id, command)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} else if (event?.kind === 'tool_result' && event.toolUseId && event.isError !== true) {
|
|
90
|
+
successful.add(event.toolUseId)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
declared: classified.declared,
|
|
95
|
+
read: uniqueSorted(reads),
|
|
96
|
+
changed: classified.changed,
|
|
97
|
+
verified: uniqueSorted([...checks].filter(([id]) => successful.has(id)).map(([, command]) => command), MAX_CHECKS),
|
|
98
|
+
unexpected: classified.unexpected,
|
|
99
|
+
held: classified.held,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function normalizeFlowScopeEvidence(value) {
|
|
104
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
105
|
+
try {
|
|
106
|
+
const classified = classifyFlowScope({ declared: value.declared, changed: value.changed })
|
|
107
|
+
const read = uniqueSorted((Array.isArray(value.read) ? value.read : []).map((item) => repoPath(item)).filter(Boolean))
|
|
108
|
+
const verified = uniqueSorted((Array.isArray(value.verified) ? value.verified : [])
|
|
109
|
+
.map((item) => typeof item === 'string' && item.length <= MAX_CHECK && !SECRET.test(item) && VERIFY_COMMAND.test(item) ? item.trim() : null)
|
|
110
|
+
.filter(Boolean), MAX_CHECKS)
|
|
111
|
+
if (Boolean(value.held) !== classified.held) return null
|
|
112
|
+
if (JSON.stringify(uniqueSorted(Array.isArray(value.unexpected) ? value.unexpected : [])) !== JSON.stringify(classified.unexpected)) return null
|
|
113
|
+
return { declared: classified.declared, read, changed: classified.changed, verified, unexpected: classified.unexpected, held: classified.held }
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
package/flow-task-graph.mjs
CHANGED
|
@@ -51,6 +51,8 @@ export const FLOW_CONTRACT_LIMITS = Object.freeze({
|
|
|
51
51
|
maxNonGoalChars: 240,
|
|
52
52
|
maxBaselineGateChars: 600,
|
|
53
53
|
maxBaselineEvidenceChars: 1400,
|
|
54
|
+
maxScopePaths: 32,
|
|
55
|
+
maxScopePathChars: 180,
|
|
54
56
|
})
|
|
55
57
|
|
|
56
58
|
const BASELINE_NEGATIVE_SIGNAL = /\b(fail(?:s|ed|ing)?|absent|missing|not\s+(?:present|implemented|available|found)|does\s+not|404|empty)\b/i
|
|
@@ -90,6 +92,39 @@ function boundedLine (value, label, max, { required = false } = {}) {
|
|
|
90
92
|
return text
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
function normalizeScopePath (value) {
|
|
96
|
+
if (typeof value !== 'string') return null
|
|
97
|
+
const text = value.trim().replaceAll('\\', '/')
|
|
98
|
+
if (!text || text.length > FLOW_CONTRACT_LIMITS.maxScopePathChars || text.startsWith('/') || /^[A-Za-z]:\//.test(text)) return null
|
|
99
|
+
const directoryPattern = text.endsWith('/**')
|
|
100
|
+
if (text.includes('*') && !directoryPattern) return null
|
|
101
|
+
const bare = directoryPattern ? text.slice(0, -3) : text
|
|
102
|
+
if (!bare || bare.endsWith('/') || bare.split('/').some((part) => !part || part === '.' || part === '..')) return null
|
|
103
|
+
if (/(?:^|\/)(?:\.env(?:\.|$)|\.git(?:\/|$)|\.thinkpool(?:\/|$)|node_modules(?:\/|$))/.test(bare)) return null
|
|
104
|
+
const last = bare.split('/').at(-1)
|
|
105
|
+
if (!directoryPattern && !last.includes('.') && !/^(?:Dockerfile|Makefile|LICENSE|README)$/i.test(last)) return null
|
|
106
|
+
return directoryPattern ? `${bare}/**` : bare
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizeTaskScopePaths (value, { fallbackScope = '', required = true } = {}) {
|
|
110
|
+
let values = value
|
|
111
|
+
if (values == null && fallbackScope) {
|
|
112
|
+
const inferred = normalizeScopePath(fallbackScope)
|
|
113
|
+
if (inferred) values = [inferred]
|
|
114
|
+
}
|
|
115
|
+
if (values == null) values = []
|
|
116
|
+
if (!Array.isArray(values)) throw new Error('scopePaths must be an array')
|
|
117
|
+
if (values.length > FLOW_CONTRACT_LIMITS.maxScopePaths) throw new Error(`scopePaths exceeds ${FLOW_CONTRACT_LIMITS.maxScopePaths} entries`)
|
|
118
|
+
const out = []
|
|
119
|
+
for (const item of values) {
|
|
120
|
+
const safe = normalizeScopePath(item)
|
|
121
|
+
if (!safe) throw new Error(`invalid scope path: ${JSON.stringify(item)}`)
|
|
122
|
+
if (!out.includes(safe)) out.push(safe)
|
|
123
|
+
}
|
|
124
|
+
if (required && out.length === 0) throw new Error('machine-readable scopePaths are required for builder/fix/scaffold tasks')
|
|
125
|
+
return out.sort()
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
export function normalizeBaselineEvidence (value) {
|
|
94
129
|
return boundedLine(value, 'baseline evidence receipt', FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars, { required: true })
|
|
95
130
|
}
|
|
@@ -154,14 +189,16 @@ function sha256Hex (message) {
|
|
|
154
189
|
// The acceptance pack belongs to the builder target. A review task copies that
|
|
155
190
|
// target's digest, so its inherited target is bound without re-hashing reviewer
|
|
156
191
|
// prose or later baseline evidence receipts.
|
|
157
|
-
function acceptancePack ({ key, title, scope, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key }) {
|
|
158
|
-
|
|
159
|
-
version
|
|
192
|
+
function acceptancePack ({ key, title, scope, scopePaths, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key, version = 2 }) {
|
|
193
|
+
const pack = {
|
|
194
|
+
version,
|
|
160
195
|
task: { key, title, scope, acceptance, sliceType },
|
|
161
196
|
nonGoals: [...nonGoals].sort(),
|
|
162
197
|
baselineGate,
|
|
163
198
|
inheritedReviewerTarget,
|
|
164
199
|
}
|
|
200
|
+
if (version >= 2) pack.task.scopePaths = normalizeTaskScopePaths(scopePaths, { fallbackScope: scope })
|
|
201
|
+
return pack
|
|
165
202
|
}
|
|
166
203
|
|
|
167
204
|
export function acceptancePackDigest (input) {
|
|
@@ -174,6 +211,7 @@ function contractSource (task) {
|
|
|
174
211
|
: {}
|
|
175
212
|
if (source.nonGoals === undefined) source.nonGoals = task?.nonGoals ?? task?.non_goals
|
|
176
213
|
if (source.baseline === undefined) source.baseline = task?.baseline ?? task?.baselineGate ?? task?.baseline_gate
|
|
214
|
+
if (source.scopePaths === undefined) source.scopePaths = task?.scopePaths ?? task?.scope_paths
|
|
177
215
|
return source
|
|
178
216
|
}
|
|
179
217
|
|
|
@@ -216,19 +254,22 @@ export function normalizeTaskContract (task, { sliceType, legacy = false } = {})
|
|
|
216
254
|
const evidence = baselineObject?.evidence == null || baselineObject?.evidence === ''
|
|
217
255
|
? ''
|
|
218
256
|
: normalizeBaselineEvidence(baselineObject.evidence)
|
|
219
|
-
|
|
257
|
+
const scopePaths = normalizeTaskScopePaths(source.scopePaths, { fallbackScope: task?.scope ?? task?.description ?? '', required })
|
|
258
|
+
return { nonGoals, baseline: gate ? { gate, evidence } : null, scopePaths, digest, acceptancePack: sealedPack }
|
|
220
259
|
}
|
|
221
260
|
|
|
222
|
-
function bindContractDigest (task) {
|
|
261
|
+
function bindContractDigest (task, { legacy = false } = {}) {
|
|
223
262
|
const sealedPack = acceptancePack({
|
|
224
263
|
key: task.key,
|
|
225
264
|
title: task.title,
|
|
226
265
|
scope: task.scope,
|
|
266
|
+
scopePaths: task.contract.scopePaths,
|
|
227
267
|
acceptance: task.acceptance,
|
|
228
268
|
sliceType: task.sliceType,
|
|
229
269
|
nonGoals: task.contract.nonGoals,
|
|
230
270
|
baselineGate: task.contract.baseline?.gate || '',
|
|
231
271
|
inheritedReviewerTarget: task.key,
|
|
272
|
+
version: legacy ? 1 : 2,
|
|
232
273
|
})
|
|
233
274
|
const digest = sha256Hex(JSON.stringify(sealedPack))
|
|
234
275
|
if (task.contract.digest && task.contract.digest !== digest) throw new Error(`task "${task.key}" contract digest does not match its acceptance pack`)
|
|
@@ -257,22 +298,26 @@ export function validateTaskContractSeal (task, { legacy = false } = {}) {
|
|
|
257
298
|
key: packTask.key,
|
|
258
299
|
title: packTask.title,
|
|
259
300
|
scope: packTask.scope,
|
|
301
|
+
scopePaths: packTask.scopePaths,
|
|
260
302
|
acceptance: packTask.acceptance,
|
|
261
303
|
sliceType: packTask.sliceType,
|
|
262
304
|
nonGoals: pack.nonGoals,
|
|
263
305
|
baselineGate: pack.baselineGate,
|
|
264
306
|
inheritedReviewerTarget: pack.inheritedReviewerTarget,
|
|
307
|
+
version: pack.version === 1 ? 1 : 2,
|
|
265
308
|
})
|
|
266
309
|
if (JSON.stringify(pack) !== JSON.stringify(canonicalPack)) throw new Error('task sealed acceptance pack is not canonical')
|
|
267
310
|
if (acceptancePackDigest({
|
|
268
311
|
key: packTask.key,
|
|
269
312
|
title: packTask.title,
|
|
270
313
|
scope: packTask.scope,
|
|
314
|
+
scopePaths: packTask.scopePaths,
|
|
271
315
|
acceptance: packTask.acceptance,
|
|
272
316
|
sliceType: packTask.sliceType,
|
|
273
317
|
nonGoals: pack.nonGoals,
|
|
274
318
|
baselineGate: pack.baselineGate,
|
|
275
319
|
inheritedReviewerTarget: pack.inheritedReviewerTarget,
|
|
320
|
+
version: pack.version === 1 ? 1 : 2,
|
|
276
321
|
}) !== contract.digest) throw new Error('task acceptance digest does not match its sealed pack')
|
|
277
322
|
|
|
278
323
|
const sliceType = task.sliceType ?? task.slice_type
|
|
@@ -291,11 +336,13 @@ export function validateTaskContractSeal (task, { legacy = false } = {}) {
|
|
|
291
336
|
key,
|
|
292
337
|
title: task.title,
|
|
293
338
|
scope: task.scope ?? '',
|
|
339
|
+
scopePaths: contract.scopePaths,
|
|
294
340
|
acceptance: task.acceptance ?? '',
|
|
295
341
|
sliceType,
|
|
296
342
|
nonGoals: contract.nonGoals || [],
|
|
297
343
|
baselineGate: contract.baseline?.gate || '',
|
|
298
344
|
inheritedReviewerTarget: key,
|
|
345
|
+
version: pack.version === 1 ? 1 : 2,
|
|
299
346
|
})
|
|
300
347
|
if (JSON.stringify(livePack) !== JSON.stringify(canonicalPack)) {
|
|
301
348
|
throw new Error(`task "${key}" changed after its acceptance pack was sealed`)
|
|
@@ -433,7 +480,7 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
|
|
|
433
480
|
})
|
|
434
481
|
validateDag(tasks)
|
|
435
482
|
for (const task of tasks) {
|
|
436
|
-
if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task)
|
|
483
|
+
if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task, { legacy })
|
|
437
484
|
}
|
|
438
485
|
for (const task of tasks) {
|
|
439
486
|
if (task.sliceType !== SLICE_TYPE.review) continue
|
|
@@ -449,6 +496,7 @@ export function normalizePlanOutput (raw, { legacy = false } = {}) {
|
|
|
449
496
|
task.contract = {
|
|
450
497
|
nonGoals: [...(target.contract?.nonGoals || [])],
|
|
451
498
|
baseline: target.contract?.baseline ? { ...target.contract.baseline } : null,
|
|
499
|
+
scopePaths: [...(target.contract?.scopePaths || [])],
|
|
452
500
|
inheritedFrom: target.key,
|
|
453
501
|
digest: target.contract.digest,
|
|
454
502
|
acceptancePack: target.contract.acceptancePack,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.363",
|
|
4
4
|
"description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -94,6 +94,8 @@
|
|
|
94
94
|
"design-source-contract.mjs",
|
|
95
95
|
"flow-review.mjs",
|
|
96
96
|
"flow-receipt.mjs",
|
|
97
|
+
"flow-scope-evidence.mjs",
|
|
98
|
+
"past-work-search.mjs",
|
|
97
99
|
"review-check.mjs",
|
|
98
100
|
"flow-review-gate.mjs",
|
|
99
101
|
"flow-review-reflect.mjs",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const MAX_QUERY = 160
|
|
2
|
+
const MAX_RESULTS = 8
|
|
3
|
+
const MAX_RECORDS = 48
|
|
4
|
+
const MAX_EVENTS = 800
|
|
5
|
+
const MAX_EXCERPT = 260
|
|
6
|
+
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}|npm_[a-z0-9]{24,}|bearer\s+[a-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|auth(?:orization)?|token|secret)\s*[=:]\s*\S+)/i
|
|
7
|
+
const PRIVATE_WORDS = /(?:system\s+prompt|hidden\s+reasoning|chain[ -]of[ -]thought|\bpassword\b|private[_ -]?key|\.env\b)/i
|
|
8
|
+
const HOST_PATH = /(?:^|[\s=("'`])(?:~\/|\/(?:Users|home|var|tmp|private|workspace)\/|[A-Za-z]:\\|\\\\)[^\s)"'`]*/i
|
|
9
|
+
|
|
10
|
+
function textContent(content) {
|
|
11
|
+
if (typeof content === 'string') return content
|
|
12
|
+
if (!Array.isArray(content)) return ''
|
|
13
|
+
return content.map((item) => typeof item === 'string' ? item : item?.text || '').join(' ')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function safeExcerpt(value) {
|
|
17
|
+
if (typeof value !== 'string') return null
|
|
18
|
+
const line = value.replace(/\s+/g, ' ').trim()
|
|
19
|
+
if (!line || SECRET_VALUE.test(line) || PRIVATE_WORDS.test(line) || HOST_PATH.test(line)) return null
|
|
20
|
+
return line.slice(0, MAX_EXCERPT)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function eventCandidates(event) {
|
|
24
|
+
if (!event || typeof event !== 'object') return []
|
|
25
|
+
if (event.kind === 'flow-review-receipt') {
|
|
26
|
+
const scope = event.scope
|
|
27
|
+
return [
|
|
28
|
+
`Flow ${event.outcome || 'review'}: ${event.taskKey || ''}`,
|
|
29
|
+
...(event.checks || []).map((line) => `Check: ${line}`),
|
|
30
|
+
...(event.reasons || []).map((line) => `Finding: ${line}`),
|
|
31
|
+
...((scope?.changed || []).map((line) => `Edited: ${line}`)),
|
|
32
|
+
...((scope?.unexpected || []).map((line) => `Unexpected: ${line}`)),
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
if (event.kind === 'assistant') {
|
|
36
|
+
const out = []
|
|
37
|
+
for (const block of event.blocks || []) {
|
|
38
|
+
if (block?.type === 'text') out.push(block.text)
|
|
39
|
+
if (block?.type === 'tool_use') {
|
|
40
|
+
const name = String(block.name || '')
|
|
41
|
+
if (/^(?:bash|exec_command|command_execution)$/i.test(name)) {
|
|
42
|
+
const command = block.input?.command ?? block.input?.cmd
|
|
43
|
+
if (command) out.push(`Command: ${command}`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
if (event.kind === 'you') return [event.text]
|
|
50
|
+
if (event.kind === 'tool_result') return [`Result: ${textContent(event.content)}`]
|
|
51
|
+
if (event.kind === 'result') return [event.resultText]
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function queryParts(query) {
|
|
56
|
+
const text = String(query || '').replace(/\s+/g, ' ').trim()
|
|
57
|
+
if (text.length < 2 || text.length > MAX_QUERY) throw new TypeError(`past-work query must contain 2-${MAX_QUERY} characters`)
|
|
58
|
+
if (SECRET_VALUE.test(text) || PRIVATE_WORDS.test(text) || HOST_PATH.test(text)) return null
|
|
59
|
+
return { text, lower: text.toLowerCase(), tokens: text.toLowerCase().split(/[^a-z0-9._/-]+/).filter((token) => token.length > 1) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function searchPastWork(records, query, { room, limit = MAX_RESULTS } = {}) {
|
|
63
|
+
const parsed = queryParts(query)
|
|
64
|
+
if (!parsed) return []
|
|
65
|
+
const boundedLimit = Math.max(1, Math.min(MAX_RESULTS, Number(limit) || MAX_RESULTS))
|
|
66
|
+
const results = []
|
|
67
|
+
for (const record of (Array.isArray(records) ? records : []).slice(0, MAX_RECORDS)) {
|
|
68
|
+
const terminal = safeExcerpt(record?.name) || 'Unnamed terminal'
|
|
69
|
+
const events = Array.isArray(record?.log) ? record.log.slice(-MAX_EVENTS) : []
|
|
70
|
+
for (const event of events) {
|
|
71
|
+
for (const raw of eventCandidates(event)) {
|
|
72
|
+
const excerpt = safeExcerpt(raw)
|
|
73
|
+
if (!excerpt) continue
|
|
74
|
+
const lower = excerpt.toLowerCase()
|
|
75
|
+
const tokenHits = parsed.tokens.filter((token) => lower.includes(token)).length
|
|
76
|
+
if (!lower.includes(parsed.lower) && (!parsed.tokens.length || tokenHits !== parsed.tokens.length)) continue
|
|
77
|
+
results.push({
|
|
78
|
+
room: String(room || '').trim() || 'current',
|
|
79
|
+
terminal,
|
|
80
|
+
terminalId: String(record?.id || '').slice(0, 80),
|
|
81
|
+
ts: Number(event?.ts) || Number(record?.savedAt) || 0,
|
|
82
|
+
seq: Number.isFinite(Number(event?.seq)) ? Number(event.seq) : null,
|
|
83
|
+
excerpt,
|
|
84
|
+
score: (lower.includes(parsed.lower) ? 10 : 0) + tokenHits + (excerpt.startsWith('Command:') ? 0 : 1),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return results
|
|
90
|
+
.sort((a, b) => b.score - a.score || b.ts - a.ts)
|
|
91
|
+
.slice(0, boundedLimit)
|
|
92
|
+
.map(({ score, ...result }) => result)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatPastWorkSearch(results, query) {
|
|
96
|
+
const safeQuery = safeExcerpt(String(query || '').trim())
|
|
97
|
+
if (!results.length) return safeQuery
|
|
98
|
+
? `No cited past work matched “${safeQuery}” in this room.`
|
|
99
|
+
: 'No cited past work matched that privacy-filtered query in this room.'
|
|
100
|
+
return results.map((result, index) => {
|
|
101
|
+
const date = result.ts ? new Date(result.ts).toISOString().slice(0, 10) : 'date unavailable'
|
|
102
|
+
const event = result.seq == null ? 'saved transcript' : `event ${result.seq}`
|
|
103
|
+
return `${index + 1}. ${result.excerpt}\n [Room ${result.room} · ${result.terminal} · ${date} · ${event}]`
|
|
104
|
+
}).join('\n')
|
|
105
|
+
}
|
package/session-store.mjs
CHANGED
|
@@ -426,6 +426,28 @@ export function loadAll(room) {
|
|
|
426
426
|
return listRecs(room).sort((a, b) => (a.savedAt || 0) - (b.savedAt || 0))
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
// Closed terminal snapshots stay recoverable under .archive/. Past-work search
|
|
430
|
+
// reads only these already-bounded snapshots, never the unbounded JSONL logs.
|
|
431
|
+
export function loadArchived(room, limit = 40) {
|
|
432
|
+
try {
|
|
433
|
+
return fs.readdirSync(archiveDir(room))
|
|
434
|
+
.map((name) => {
|
|
435
|
+
const file = path.join(archiveDir(room), name)
|
|
436
|
+
try {
|
|
437
|
+
const stat = fs.statSync(file)
|
|
438
|
+
if (!stat.isFile()) return null
|
|
439
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
440
|
+
return record && Array.isArray(record.log) ? { ...record, _archivedAt: stat.mtimeMs || 0 } : null
|
|
441
|
+
} catch { return null }
|
|
442
|
+
})
|
|
443
|
+
.filter(Boolean)
|
|
444
|
+
.sort((a, b) => (b._archivedAt || b.savedAt || 0) - (a._archivedAt || a.savedAt || 0))
|
|
445
|
+
.slice(0, Math.max(1, Math.min(100, Number(limit) || 40)))
|
|
446
|
+
} catch {
|
|
447
|
+
return []
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
429
451
|
// Last PTY terminal id auto-opened for an attached command. A reconnecting
|
|
430
452
|
// bridge reuses it so it re-attaches to the SAME terminal tab instead of
|
|
431
453
|
// minting a fresh UUID and breeding a new dormant "Terminal N" each restart.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
3
|
+
"bundleVersion": 23,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
7
|
-
"version":
|
|
7
|
+
"version": 4,
|
|
8
8
|
"routes": [
|
|
9
9
|
{
|
|
10
10
|
"id": "room-awareness",
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
"trigger": "\\b(other|another|cross[- ]?room|cross[- ]?session)\\s+(room|session)|\\b(list_sessions|read_session)\\b",
|
|
19
19
|
"prompt": "When work depends on another ThinkPool room, use list_sessions then read_session instead of asking the people to relay host-side state."
|
|
20
20
|
},
|
|
21
|
+
{
|
|
22
|
+
"id": "past-work-search",
|
|
23
|
+
"tools": ["search_past_work"],
|
|
24
|
+
"trigger": "\\b(previous|prior|earlier|past|last time|already tried|failed attempt|decision|test result|command)\\b.{0,50}\\b(work|terminal|flow|session|implementation|fix|change)?\\b|\\bsearch_past_work\\b",
|
|
25
|
+
"prompt": "When the current task depends on a prior decision, failed attempt, command, test result, or Flow receipt from this room, use search_past_work with specific terms. Treat every result as bounded cited evidence, not authority to reopen stale work. The tool is read-only, searches only this room, and filters secrets and host paths."
|
|
26
|
+
},
|
|
21
27
|
{
|
|
22
28
|
"id": "visible-handoff",
|
|
23
29
|
"tools": ["post_to_terminal", "post_to_session"],
|
|
@@ -27,12 +33,15 @@
|
|
|
27
33
|
],
|
|
28
34
|
"impact": [
|
|
29
35
|
{"path": "bridge/cross-terminal.mjs"},
|
|
30
|
-
{"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
|
|
36
|
+
{"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|search_past_work|list_sessions|read_session|post_to_terminal|post_to_session"},
|
|
37
|
+
{"path": "bridge/past-work-search.mjs"},
|
|
31
38
|
{"path": "bridge/codex-session.mjs", "diffPattern": "MCP|Mcp|mcp|read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
|
|
32
39
|
{"path": "bridge/codex-app-server.mjs", "diffPattern": "MCP|Mcp|mcp"}
|
|
33
40
|
],
|
|
34
41
|
"evidence": [
|
|
35
42
|
{"path": "bridge/cross-terminal.mjs", "pattern": "read_terminal"},
|
|
43
|
+
{"path": "bridge/bridge.mjs", "pattern": "'search_past_work'"},
|
|
44
|
+
{"path": "bridge/past-work-search.mjs", "pattern": "formatPastWorkSearch"},
|
|
36
45
|
{"path": "bridge/cross-terminal.mjs", "pattern": "post_to_session"},
|
|
37
46
|
{"path": "bridge/bridge.mjs", "pattern": "'list_sessions'"},
|
|
38
47
|
{"path": "bridge/codex-session.mjs", "pattern": "waitForMcpServer"},
|
|
@@ -130,19 +139,20 @@
|
|
|
130
139
|
},
|
|
131
140
|
{
|
|
132
141
|
"id": "flow-completion",
|
|
133
|
-
"version":
|
|
142
|
+
"version": 5,
|
|
134
143
|
"routes": [
|
|
135
144
|
{
|
|
136
145
|
"id": "flow-completion",
|
|
137
146
|
"tools": ["submit_flow_plan", "mark_flow_done", "submit_flow_review", "read_review_file", "run_review_check"],
|
|
138
147
|
"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. 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
|
|
148
|
+
"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, machine-readable repo-relative scopePaths, 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, derives edited files from Git, and refuses unexpected touches. The exact dependent reviewer inherits the contract and the bridge recomputes the scope footprint from the immutable candidate. A conclusive pass binds one bounded terminal receipt to the exact reviewed candidate SHA, acceptance digest, and held scope; missing or drifted 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
149
|
}
|
|
141
150
|
],
|
|
142
151
|
"impact": [
|
|
143
152
|
{"path": "bridge/bridge.mjs", "diffPattern": "submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check"},
|
|
144
153
|
{"path": "bridge/flow-review.mjs"},
|
|
145
154
|
{"path": "bridge/flow-receipt.mjs"},
|
|
155
|
+
{"path": "bridge/flow-scope-evidence.mjs"},
|
|
146
156
|
{"path": "bridge/flow-review-gate.mjs"},
|
|
147
157
|
{"path": "bridge/flow-task-graph.mjs"}
|
|
148
158
|
],
|
|
@@ -151,7 +161,8 @@
|
|
|
151
161
|
{"path": "bridge/bridge.mjs", "pattern": "'mark_flow_done'"},
|
|
152
162
|
{"path": "bridge/bridge.mjs", "pattern": "'submit_flow_review'"},
|
|
153
163
|
{"path": "bridge/flow-task-graph.mjs", "pattern": "acceptancePackDigest"},
|
|
154
|
-
{"path": "bridge/flow-receipt.mjs", "pattern": "Passing flow review receipt requires
|
|
164
|
+
{"path": "bridge/flow-receipt.mjs", "pattern": "Passing flow review receipt requires a held scope footprint"},
|
|
165
|
+
{"path": "bridge/flow-scope-evidence.mjs", "pattern": "classifyFlowScope"}
|
|
155
166
|
]
|
|
156
167
|
},
|
|
157
168
|
{
|