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.
@@ -38,7 +38,7 @@ export { normalizePlanOutput }
38
38
  // moat, partition by file ownership, plan-in-store-not-context, right-size the slice
39
39
  // count (3 focused beats 7 scattered), explicit acyclic deps.
40
40
  export const FLOW_CONDUCTOR_PROMPT = [
41
- 'THINKPOOL FLOW CONDUCTOR — HARD RULES (read first, no exceptions): You are a DECOMPOSITION-ONLY conductor. Your ONLY output action is using the Write tool ONCE to write your task-graph JSON to the file named exactly FLOW_PLAN.json. That single Write IS how you submit your plan — the room ingests it directly. Do NOT use ExitPlanMode and do NOT call submit_flow_plan (both hang here). Do NOT write any other file and do NOT write a plan-document markdown. You MUST NOT write or edit files, MUST NOT run bash/git, MUST NOT call AskUserQuestion, and MUST NOT build the project yourself — those are blocked and will just stall you. You do NOT build; you DECOMPOSE so the lanes build. A fully-specified build is STILL decomposed, never built here. If a detail is ambiguous, make a reasonable assumption and note it in the plan summary — do NOT ask. Think, then call submit_flow_plan. That is the whole job.',
41
+ 'THINKPOOL FLOW CONDUCTOR — HARD RULES (read first, no exceptions): You are a DECOMPOSITION-ONLY conductor. Your ONLY output action is using the Write tool ONCE to write your task-graph JSON to the file named exactly FLOW_PLAN.json. That single Write IS how you submit your plan — the room ingests it directly. Do NOT use ExitPlanMode and do NOT call submit_flow_plan (both hang here). Do NOT write any other file and do NOT write a plan-document markdown. You MUST NOT write or edit files, MUST NOT run bash/git, MUST NOT call AskUserQuestion, and MUST NOT build the project yourself — those are blocked and will just stall you. You do NOT build; you DECOMPOSE so the lanes build. A fully-specified build is STILL decomposed, never built here. When a detail is ambiguous or the user is unsure, choose the least-invasive reversible default, record that assumption in the summary, and continue. Never ask merely because the user is unsure; ask only when a choice would materially expand scope or authority. User steering always wins. Think, then call submit_flow_plan. That is the whole job.',
42
42
 
43
43
  'DO NOT SPAWN SUBAGENTS. You MUST NOT use the Task/Agent tool or launch Explore / sub-agents — every subagent spawn pops a permission card and stalls the whole flow (this is the #1 way the conductor hangs asking for permission). You almost never need to look at code to decompose: decompose from the build request itself. If you genuinely must inspect something first, read it YOURSELF with Read/Grep/Glob (those are allowed without a prompt) — never delegate it to a spawned agent. Decompose, then Write FLOW_PLAN.json.',
44
44
 
@@ -52,10 +52,12 @@ export const FLOW_CONDUCTOR_PROMPT = [
52
52
 
53
53
  'ACCEPTANCE CRITERIA per slice: how does the lane know it is DONE AND RUNNING? A lane is done when its slice runs in its WebContainer and meets the criteria — not when it has merely written code. State the observable, runnable proof (e.g. "GET /api/todos returns 200 + []", "submitting the form adds a row to the list that persists across reload").',
54
54
 
55
+ 'GATE-FIRST CONTRACT per builder/fix/scaffold slice: include an explicit bounded `nonGoals` list (what this slice will not change) and a `baseline` gate describing the real pre-edit condition that must FAIL or be ABSENT. The builder must observe that gate before editing and later supply a bounded evidence receipt; do not invent a RED sentence. A review task must depend on exactly one builder/fix task and inherits that target contract — it verifies the same acceptance, non-goals, and baseline evidence instead of inventing a second baseline.',
56
+
55
57
  'RIGHT-SIZE THE SLICE COUNT. Bias to FEW slices that each carry real weight, not many tiny ones. For a small app: 2-3 slices. Concurrent lanes are bounded (a low dispatch ceiling), so a flat fan-out of well-chosen slices beats a sprawling one. Three focused slices outperform seven scattered ones.',
56
58
 
57
59
  'SUBMIT BY WRITING FLOW_PLAN.json — when the decomposition is ready, use the Write tool with file_path "FLOW_PLAN.json" and content set to your plan as ONE JSON object. The room validates the plan is a real DAG (acyclic, all deps resolve), persists it to the task-graph store, and shows a plan-approval card. A malformed plan is rejected with the reason as the tool result — fix it and Write FLOW_PLAN.json again. Do not also narrate the plan in prose. The exact shape:',
58
- '{ "summary": "<one line: what we are building>", "tasks": [ { "key": "<short-unique-kebab-key>", "title": "<imperative title>", "scope": "<files/components this lane owns>", "acceptance": "<runnable proof it is done>", "deps": ["<other-task-key>", ...], "sliceType": "feature|scaffold|review|fix" } ] }',
60
+ '{ "summary": "<one line: what we are building; assumptions: …>", "tasks": [ { "key": "<short-unique-kebab-key>", "title": "<imperative title>", "scope": "<files/components this lane owns>", "acceptance": "<runnable proof it is done>", "nonGoals": ["<bounded exclusion>"], "baseline": { "gate": "<what currently fails/is absent before edits>", "evidence": "<optional observed command/output receipt>" }, "deps": ["<other-task-key>", ...], "sliceType": "feature|scaffold|review|fix" } ] }',
59
61
  '`key` values are stable short kebab-case identifiers. `deps` references other tasks\' keys. The content of FLOW_PLAN.json must be ONLY the JSON object (no surrounding prose, no markdown fences).',
60
62
 
61
63
  'PLAN LIVES IN THE STORE, NOT THE CHAT. Your JSON plan is the artifact — it gets persisted to the task-graph (flow_sessions / flow_tasks) and rendered as an approval card. Do not ALSO narrate a long plan in prose. You may add 1-2 sentences framing the decomposition choice (why these slices, what was coupled), nothing more.',
@@ -65,7 +67,8 @@ export const FLOW_CONDUCTOR_PROMPT = [
65
67
 
66
68
  export const FLOW_CODEX_CONDUCTOR_PROMPT = [
67
69
  'THINKPOOL FLOW CONDUCTOR — you are decomposition-only. Do not build, edit files, run commands, or spawn agents. Read only when needed to make the decomposition concrete.',
68
- 'Turn the request into a small acyclic graph of independently runnable slices with disjoint file ownership. Each task needs key, title, scope, acceptance, deps, and sliceType (scaffold, feature, fix, or review). Review is intelligence-sensitive and must never be assigned a cheap/mechanical tier. Every review task must depend on exactly ONE builder task; express builder -> review -> downstream explicitly in the DAG.',
70
+ 'Turn the request into a small acyclic graph of independently runnable slices with disjoint file ownership. Each builder/fix/scaffold task needs key, title, scope, observable acceptance, bounded nonGoals, and a baseline {gate,evidence?}; the gate states what currently fails or is absent before edits. Every review task depends on exactly ONE builder task and inherits/verifies that builder contract. Review is intelligence-sensitive and must never be assigned a cheap/mechanical tier.',
71
+ 'When details are ambiguous or the user says they are unsure, choose a safe reversible default, record it in summary.assumptions text, and continue. Do not ask merely because of uncertainty; ask only when a choice materially expands scope or authority. Honor later user steering.',
69
72
  'Submit the finished graph by calling the ThinkPool submit_flow_plan MCP tool with one JSON object encoded in its plan argument. Do not write FLOW_PLAN.json and do not use ExitPlanMode. The MCP submission is your only completion path; after it succeeds, stop and wait for the room.',
70
73
  ].join(' ')
71
74
 
@@ -95,9 +98,11 @@ export const FLOW_LANE_PROMPT = [
95
98
 
96
99
  'OWN YOUR FILES ONLY. The `scope` names the files/components this lane OWNS — they are DISJOINT from every other lane. Edit ONLY those. Touching another lane\'s files is a collision that breaks the merge — never do it. If you genuinely need a file outside your scope, STOP and say so instead of editing it.',
97
100
 
101
+ 'GATE BEFORE EDITS. First reproduce the assigned baseline gate in the real pre-edit state. Record only what you actually observed (a command/behavior + bounded receipt); never fabricate a RED sentence. Preserve the assigned non-goals. If a bounded detail is ambiguous, take the least-invasive reversible default and state it in your receipt; ask only if the choice would materially expand scope or authority.',
102
+
98
103
  'DONE MEANS RUNS. You are not done when you\'ve written code — you\'re done when your slice RUNS and meets the `acceptance` criteria (the observable, runnable proof). Verify it yourself before declaring done. State the proof (the command, the output) when you finish.',
99
104
 
100
- 'COMMIT WHEN DONE, THEN SIGNAL. You are in your own git worktree on your own branch. When your slice meets acceptance — it RUNS and you have verified the proof — commit it atomically (one focused commit) with `git` via the Bash tool; that commit is the revert target if review later rejects it. THEN, as your VERY LAST action, signal completion by using the Write tool to write a file named exactly FLOW_DONE in your worktree root (any content, e.g. "done"). That single Write IS the done signal — it records your commit + tells the room the slice is done (unblocking dependents). Do NOT use mark_flow_done (it hangs here). Do not push. Do NOT write FLOW_DONE before your slice actually runs, meets acceptance, AND is committed.',
105
+ 'COMMIT WHEN DONE, THEN SIGNAL. You are in your own git worktree on your own branch. When your slice meets acceptance — it RUNS and you have verified the proof — commit it atomically (one focused commit) with `git` via the Bash tool; that commit is the revert target if review later rejects it. THEN, as your VERY LAST action, signal completion by using the Write tool to write a file named exactly FLOW_DONE in your worktree root with content `{"baselineEvidence":"<real bounded pre-edit command/behavior receipt>"}`. That single Write IS the done signal — it records your commit + tells the room the slice is done (unblocking dependents). Do NOT use mark_flow_done (it hangs here). Do not push. Do NOT write FLOW_DONE before your slice actually runs, meets acceptance, records real baseline evidence, AND is committed.',
101
106
 
102
107
  'STAY IN YOUR LANE. Do NOT spawn further lanes (one hop only — the fork-bomb breaker). Do NOT edit other worktrees. If you\'re blocked on a dependency that isn\'t ready, say so + stop — another lane is building it.',
103
108
 
@@ -106,8 +111,9 @@ export const FLOW_LANE_PROMPT = [
106
111
 
107
112
  export const FLOW_CODEX_LANE_PROMPT = [
108
113
  'THINKPOOL FLOW — you are one builder lane in an ensemble. The conductor gives you one runnable slice with disjoint scope. Edit only that scope and never touch another lane or worktree.',
114
+ 'Before editing, reproduce the assigned baseline gate in the real pre-edit state. Preserve the explicit non-goals. Record only an actual bounded command/behavior receipt; never manufacture a RED claim. For a bounded ambiguity, take the least-invasive reversible default and state it in that receipt; ask only if a choice would materially expand scope or authority.',
109
115
  'Done means the slice runs and its acceptance proof has been observed. Diagnose before changing code, verify with the real command or behavior, and make one focused git commit in your assigned worktree. Do not push.',
110
- 'After the commit and verification succeed, call the ThinkPool mark_flow_done MCP tool exactly once. Do not write FLOW_DONE. The MCP call is the completion signal that records your commit and unblocks dependent slices.',
116
+ 'After the commit and verification succeed, call the ThinkPool mark_flow_done MCP tool exactly once with the real `baselineEvidence` receipt. Do not write FLOW_DONE. The MCP call is the completion signal that records your commit and unblocks dependent slices.',
111
117
  'Do not spawn more lanes. If the scope is unsafe or a dependency is missing, report the blocker instead of editing outside your ownership.',
112
118
  ].join(' ')
113
119
 
@@ -0,0 +1,115 @@
1
+ // Flow review receipts are deliberately smaller than a review verdict. They cross
2
+ // from a private reviewer lane into a shared transcript, so this module only
3
+ // admits the evidence a pair can act on. Prompts, environment, paths, logs, and
4
+ // hidden reasoning never have a field in the public shape.
5
+
6
+ export const FLOW_REVIEW_RECEIPT_KIND = 'flow-review-receipt'
7
+
8
+ const MAX_TASK_KEY = 120
9
+ const MAX_LINE = 180
10
+ const MAX_LINES = 4
11
+ const OUTCOMES = new Set(['pass', 'reject', 'held'])
12
+ const DIGEST = /^[a-f0-9]{64}$/
13
+ 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
14
+ const SECRET_OR_PRIVATE = /(?:api[_ -]?key|access[_ -]?token|\btoken\s*[=:]|auth(?:orization)?|bearer\s+|secret|password|private[_ -]?key|system\s+prompt|raw\s+prompt|hidden\s+reasoning|chain[ -]of[ -]thought|\benv(?:ironment)?\b|\.env\b)/i
15
+ const HOST_PATH = /(?:^~[\\/]|^\.?[\\/]|(?:^|\s)\/[\w.-]+|[A-Za-z]:\\|\\\\|(?:^|[\\/])(?:Users|home|var|tmp|private|workspace)(?:[\\/]|$)|\.thinkpool[\\/])/i
16
+ const PATH_TRAVERSAL = /(?:^|[^a-z0-9.])\.\.(?:[\\/]|$)/i
17
+
18
+ function cleanText(value, max) {
19
+ if (typeof value !== 'string' || value.length === 0 || value.length > max) return null
20
+ if (/\r|\n/.test(value) || SECRET_VALUE.test(value) || SECRET_OR_PRIVATE.test(value) || HOST_PATH.test(value) || PATH_TRAVERSAL.test(value)) return null
21
+ const text = value.replace(/\s+/g, ' ').trim()
22
+ return text && text.length <= max ? text : null
23
+ }
24
+
25
+ function cleanLines(value) {
26
+ if (!Array.isArray(value)) return []
27
+ const out = []
28
+ for (const line of value) {
29
+ const safe = cleanText(line, MAX_LINE)
30
+ if (safe && !out.includes(safe)) out.push(safe)
31
+ if (out.length === MAX_LINES) break
32
+ }
33
+ return out
34
+ }
35
+
36
+ function cleanTaskKey(value) {
37
+ const taskKey = cleanText(value, MAX_TASK_KEY)
38
+ if (!taskKey || !/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(taskKey) || taskKey.includes('..')) return null
39
+ return taskKey
40
+ }
41
+
42
+ function cleanSha(value) {
43
+ if (typeof value !== 'string') return null
44
+ const sha = value.trim().toLowerCase()
45
+ return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(sha) ? sha : null
46
+ }
47
+
48
+ function cleanRounds(value) {
49
+ return Number.isInteger(value) && value >= 1 && value <= 99 ? value : null
50
+ }
51
+
52
+ function cleanDigest(value) {
53
+ if (typeof value !== 'string') return null
54
+ const digest = value.trim().toLowerCase()
55
+ return DIGEST.test(digest) ? digest : null
56
+ }
57
+
58
+ // Build the only wire shape the renderer accepts. Optional fields are omitted
59
+ // rather than represented as null, making absence honest (notably deployment).
60
+ export function createFlowReviewReceipt(input = {}) {
61
+ const taskKey = cleanTaskKey(input.taskKey)
62
+ const outcome = typeof input.outcome === 'string' ? input.outcome.toLowerCase() : ''
63
+ const rounds = cleanRounds(input.rounds)
64
+ if (!taskKey || !OUTCOMES.has(outcome) || rounds == null) {
65
+ throw new TypeError('Invalid flow review receipt identity')
66
+ }
67
+
68
+ const receipt = {
69
+ kind: FLOW_REVIEW_RECEIPT_KIND,
70
+ taskKey,
71
+ outcome,
72
+ rounds,
73
+ checks: cleanLines(input.checks),
74
+ reasons: cleanLines(input.reasons),
75
+ }
76
+ const candidateSha = cleanSha(input.candidateSha)
77
+ const acceptanceDigest = cleanDigest(input.acceptanceDigest)
78
+ // Presence, not truthiness, is intentional: deployments are never inferred
79
+ // from a pass/reject outcome or from a candidate SHA.
80
+ const deployment = Object.hasOwn(input, 'deployment') ? cleanText(input.deployment, MAX_LINE) : null
81
+ if (candidateSha) receipt.candidateSha = candidateSha
82
+ if (acceptanceDigest) receipt.acceptanceDigest = acceptanceDigest
83
+ receipt.verified = Boolean(candidateSha && acceptanceDigest)
84
+ if (outcome === 'pass' && !receipt.verified) {
85
+ throw new TypeError('Passing flow review receipt requires an exact candidate and acceptance digest')
86
+ }
87
+ if (deployment) receipt.deployment = deployment
88
+ return receipt
89
+ }
90
+
91
+ export function isFlowReviewReceipt(value) {
92
+ try {
93
+ const receipt = createFlowReviewReceipt(value)
94
+ return receipt.kind === value?.kind
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+
100
+ // A review lane may receive the same terminal submission twice (tool retry,
101
+ // double click, or concurrent provider delivery). Serialize the whole verdict
102
+ // transaction—not just receipt emission—so an awaited revert cannot run twice.
103
+ export function createFlowReviewSingleFlight() {
104
+ let pending = null
105
+ return async function runFlowReviewVerdict(operation) {
106
+ if (pending) return pending
107
+ const current = (async () => operation())()
108
+ pending = current
109
+ try {
110
+ return await current
111
+ } finally {
112
+ if (pending === current) pending = null
113
+ }
114
+ }
115
+ }
package/flow-review.mjs CHANGED
@@ -17,11 +17,11 @@ import { reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect
17
17
  // The reviewer lane's rolePrompt (via startClaudeSession). Mirrors FLOW_LANE_PROMPT's
18
18
  // join(' ') style. This lane is ADVERSARIAL — its job is to disprove "done", not to build.
19
19
  export const FLOW_REVIEWER_PROMPT = [
20
- 'THINKPOOL FLOW — you are an ADVERSARIAL REVIEWER in an ensemble build. You did NOT build this slice. Another lane did, and claims it is done. Your job is to TRY TO BREAK IT — independently verify whether its acceptance criteria are ACTUALLY met. The slice you are reviewing (its scope, its acceptance criteria, and its worktree/branch) arrives as your first message.',
20
+ 'THINKPOOL FLOW — you are an ADVERSARIAL REVIEWER in an ensemble build. You did NOT build this slice. Another lane did, and claims it is done. Your job is to TRY TO BREAK IT — independently verify whether its acceptance criteria are ACTUALLY met. The slice you are reviewing (its scope, acceptance criteria, inherited non-goals, baseline gate/evidence, and worktree/branch) arrives as your first message.',
21
21
 
22
22
  'RUN IT, DON\'T TRUST IT. Check out the lane\'s worktree/branch and RUN the slice yourself. "It compiles" is not "it works". Reproduce the acceptance proof with your own hands — run the command, hit the endpoint, submit the form, reload the page. If you cannot independently reproduce the acceptance criteria, that is a REJECT.',
23
23
 
24
- 'HUNT FOR FAILURE. Look where the builder didn\'t: edge cases, empty/malformed input, the second click, the reload, concurrent use, the error path. Check the slice didn\'t regress anything outside its acceptance line. A green happy-path is the floor, not the bar.',
24
+ 'VERIFY THE INHERITED CONTRACT. Check the builder\'s baseline receipt against the real pre-edit claim where feasible; it must be an observed command/behavior, not a fabricated sentence. Check the implementation did not cross its inherited non-goals. Do not invent another baseline. HUNT FOR FAILURE: edge cases, empty/malformed input, the second click, the reload, concurrent use, and the error path. A green happy-path is the floor, not the bar.',
25
25
 
26
26
  'DEFAULT TO REJECT. If you cannot independently verify EVERY acceptance criterion — reject. Ambiguity, "probably fine", "looks right", an untestable claim, a flaky run — all REJECT. The cost of a false pass (a broken slice merged into the assembly) is far higher than a re-run.',
27
27
 
@@ -35,7 +35,7 @@ export const FLOW_REVIEWER_PROMPT = [
35
35
  export const FLOW_CODEX_REVIEWER_PROMPT = [
36
36
  'THINKPOOL FLOW REVIEW — you are a non-mutating adversarial reviewer. Your native sandbox lets you write only inside your OWN disposable reviewer worktree. The reviewed builder worktree is outside that writable root: never edit, commit, revert, install into, or repair it.',
37
37
  'For any install/build/test that produces files, copy the reviewed source into a scratch directory under your own current worktree first, then run the write-producing command against that scratch copy. Read-only inspection may target the reviewed worktree directly.',
38
- 'Independently reproduce every acceptance criterion and hunt edge cases, reloads, repeated actions, concurrency, malformed input, and error paths. A green happy path is only the floor. If a claim cannot be reproduced, reject it with the exact command/output.',
38
+ 'Independently reproduce every acceptance criterion, verify the inherited baseline evidence and non-goals without inventing a second baseline, and hunt edge cases, reloads, repeated actions, concurrency, malformed input, and error paths. A green happy path is only the floor. If a claim cannot be reproduced, reject it with the exact command/output.',
39
39
  'Submit each review round by calling the ThinkPool submit_flow_review MCP tool with a JSON verdict: {"pass":boolean,"reasons":["specific evidence"],"taskKey":"allowed reviewed task","exhausted":boolean}. Do not write FLOW_REVIEW.json and do not call mark_flow_done. A pass without exhausted=true may trigger another bounded hunt round; a failure triggers the room revert path.',
40
40
  ].join(' ')
41
41
 
@@ -43,10 +43,270 @@ export const SLICE_TYPE = {
43
43
  fix: 'fix',
44
44
  }
45
45
 
46
+ // Gate-first Flow contracts are stored in flow_tasks.contract (JSONB). Keep every
47
+ // free-text field bounded and single-line: this payload crosses the terminal MCP
48
+ // boundary and is later injected into builder/reviewer prompts.
49
+ export const FLOW_CONTRACT_LIMITS = Object.freeze({
50
+ maxNonGoals: 6,
51
+ maxNonGoalChars: 240,
52
+ maxBaselineGateChars: 600,
53
+ maxBaselineEvidenceChars: 1400,
54
+ })
55
+
56
+ const BASELINE_NEGATIVE_SIGNAL = /\b(fail(?:s|ed|ing)?|absent|missing|not\s+(?:present|implemented|available|found)|does\s+not|404|empty)\b/i
57
+ const CONTRACT_DIGEST = /^[a-f0-9]{64}$/
58
+ const SHA256_K = [
59
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
60
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
61
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
62
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
63
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
64
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
65
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
66
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
67
+ ]
68
+ const rotateRight = (value, bits) => (value >>> bits) | (value << (32 - bits))
69
+ const hasDisallowedControl = (value) => {
70
+ for (const char of value) {
71
+ const code = char.charCodeAt(0)
72
+ if (code === 0x7f || (code < 0x20 && code !== 0x09)) return true
73
+ }
74
+ return false
75
+ }
76
+
77
+ function boundedLine (value, label, max, { required = false } = {}) {
78
+ if (value == null || value === '') {
79
+ if (required) throw new Error(`${label} is required`)
80
+ return ''
81
+ }
82
+ if (typeof value !== 'string') throw new Error(`${label} must be a string`)
83
+ const text = value.trim()
84
+ if (!text) {
85
+ if (required) throw new Error(`${label} is required`)
86
+ return ''
87
+ }
88
+ if (text.length > max) throw new Error(`${label} exceeds ${max} characters`)
89
+ if (hasDisallowedControl(text)) throw new Error(`${label} must be safe single-line text`)
90
+ return text
91
+ }
92
+
93
+ export function normalizeBaselineEvidence (value) {
94
+ return boundedLine(value, 'baseline evidence receipt', FLOW_CONTRACT_LIMITS.maxBaselineEvidenceChars, { required: true })
95
+ }
96
+
97
+ // This is deliberately a small synchronous SHA-256 implementation instead of a
98
+ // Node crypto import or WebCrypto: the task graph must stay byte-identical and run
99
+ // in both the bridge's Node runtime and the browser bundle.
100
+ function sha256Hex (message) {
101
+ const source = new TextEncoder().encode(message)
102
+ const totalLength = Math.ceil((source.length + 9) / 64) * 64
103
+ const bytes = new Uint8Array(totalLength)
104
+ bytes.set(source)
105
+ bytes[source.length] = 0x80
106
+ let bitLength = BigInt(source.length) * 8n
107
+ for (let index = totalLength - 1; index >= totalLength - 8; index--) {
108
+ bytes[index] = Number(bitLength & 0xffn)
109
+ bitLength >>= 8n
110
+ }
111
+
112
+ const hash = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
113
+ const words = new Uint32Array(64)
114
+ for (let offset = 0; offset < bytes.length; offset += 64) {
115
+ for (let index = 0; index < 16; index++) {
116
+ const start = offset + (index * 4)
117
+ words[index] = ((bytes[start] << 24) | (bytes[start + 1] << 16) | (bytes[start + 2] << 8) | bytes[start + 3]) >>> 0
118
+ }
119
+ for (let index = 16; index < 64; index++) {
120
+ const low = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3)
121
+ const high = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10)
122
+ words[index] = (words[index - 16] + low + words[index - 7] + high) >>> 0
123
+ }
124
+
125
+ let [a, b, c, d, e, f, g, h] = hash
126
+ for (let index = 0; index < 64; index++) {
127
+ const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
128
+ const choose = (e & f) ^ (~e & g)
129
+ const temp1 = (h + sigma1 + choose + SHA256_K[index] + words[index]) >>> 0
130
+ const sigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
131
+ const majority = (a & b) ^ (a & c) ^ (b & c)
132
+ const temp2 = (sigma0 + majority) >>> 0
133
+ h = g
134
+ g = f
135
+ f = e
136
+ e = (d + temp1) >>> 0
137
+ d = c
138
+ c = b
139
+ b = a
140
+ a = (temp1 + temp2) >>> 0
141
+ }
142
+ hash[0] = (hash[0] + a) >>> 0
143
+ hash[1] = (hash[1] + b) >>> 0
144
+ hash[2] = (hash[2] + c) >>> 0
145
+ hash[3] = (hash[3] + d) >>> 0
146
+ hash[4] = (hash[4] + e) >>> 0
147
+ hash[5] = (hash[5] + f) >>> 0
148
+ hash[6] = (hash[6] + g) >>> 0
149
+ hash[7] = (hash[7] + h) >>> 0
150
+ }
151
+ return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
152
+ }
153
+
154
+ // The acceptance pack belongs to the builder target. A review task copies that
155
+ // target's digest, so its inherited target is bound without re-hashing reviewer
156
+ // prose or later baseline evidence receipts.
157
+ function acceptancePack ({ key, title, scope, acceptance, sliceType, nonGoals, baselineGate, inheritedReviewerTarget = key }) {
158
+ return {
159
+ version: 1,
160
+ task: { key, title, scope, acceptance, sliceType },
161
+ nonGoals: [...nonGoals].sort(),
162
+ baselineGate,
163
+ inheritedReviewerTarget,
164
+ }
165
+ }
166
+
167
+ export function acceptancePackDigest (input) {
168
+ return sha256Hex(JSON.stringify(acceptancePack(input)))
169
+ }
170
+
171
+ function contractSource (task) {
172
+ const source = task?.contract && typeof task.contract === 'object' && !Array.isArray(task.contract)
173
+ ? { ...task.contract }
174
+ : {}
175
+ if (source.nonGoals === undefined) source.nonGoals = task?.nonGoals ?? task?.non_goals
176
+ if (source.baseline === undefined) source.baseline = task?.baseline ?? task?.baselineGate ?? task?.baseline_gate
177
+ return source
178
+ }
179
+
180
+ function normalizeProvidedContractDigest (source) {
181
+ if (source.digest === undefined) return null
182
+ if (typeof source.digest !== 'string' || !CONTRACT_DIGEST.test(source.digest)) {
183
+ throw new Error('contract digest must be a lowercase SHA-256 hex string')
184
+ }
185
+ return source.digest
186
+ }
187
+
188
+ function normalizeProvidedAcceptancePack (source) {
189
+ if (source.acceptancePack === undefined) return null
190
+ if (!source.acceptancePack || typeof source.acceptancePack !== 'object' || Array.isArray(source.acceptancePack)) {
191
+ throw new Error('contract acceptance pack must be an object')
192
+ }
193
+ return source.acceptancePack
194
+ }
195
+
196
+ // Strict for new terminal submissions. `legacy:true` is restore-only: old persisted
197
+ // plans predate this contract and must remain runnable, but they can never be used to
198
+ // submit a new incomplete plan.
199
+ export function normalizeTaskContract (task, { sliceType, legacy = false } = {}) {
200
+ const source = contractSource(task)
201
+ const digest = normalizeProvidedContractDigest(source)
202
+ const sealedPack = normalizeProvidedAcceptancePack(source)
203
+ if (sliceType === SLICE_TYPE.review) return legacy ? null : { digest, acceptancePack: sealedPack }
204
+ const required = !legacy
205
+ const rawNonGoals = source.nonGoals
206
+ const values = rawNonGoals == null ? [] : (Array.isArray(rawNonGoals) ? rawNonGoals : [rawNonGoals])
207
+ if (values.length > FLOW_CONTRACT_LIMITS.maxNonGoals) throw new Error(`non-goals exceeds ${FLOW_CONTRACT_LIMITS.maxNonGoals} items`)
208
+ const nonGoals = values.map((value) => boundedLine(value, 'non-goal', FLOW_CONTRACT_LIMITS.maxNonGoalChars, { required: true }))
209
+ if (required && !nonGoals.length) throw new Error('explicit non-goals are required for builder/fix/scaffold tasks')
210
+
211
+ const rawBaseline = source.baseline
212
+ const baselineObject = typeof rawBaseline === 'string' ? { gate: rawBaseline } : rawBaseline
213
+ if (baselineObject != null && (typeof baselineObject !== 'object' || Array.isArray(baselineObject))) throw new Error('baseline gate must be a string or object')
214
+ const gate = boundedLine(baselineObject?.gate ?? baselineObject?.expectation, 'baseline gate', FLOW_CONTRACT_LIMITS.maxBaselineGateChars, { required })
215
+ if (gate && !BASELINE_NEGATIVE_SIGNAL.test(gate)) throw new Error('baseline gate must state what fails or is absent before implementation')
216
+ const evidence = baselineObject?.evidence == null || baselineObject?.evidence === ''
217
+ ? ''
218
+ : normalizeBaselineEvidence(baselineObject.evidence)
219
+ return { nonGoals, baseline: gate ? { gate, evidence } : null, digest, acceptancePack: sealedPack }
220
+ }
221
+
222
+ function bindContractDigest (task) {
223
+ const sealedPack = acceptancePack({
224
+ key: task.key,
225
+ title: task.title,
226
+ scope: task.scope,
227
+ acceptance: task.acceptance,
228
+ sliceType: task.sliceType,
229
+ nonGoals: task.contract.nonGoals,
230
+ baselineGate: task.contract.baseline?.gate || '',
231
+ inheritedReviewerTarget: task.key,
232
+ })
233
+ const digest = sha256Hex(JSON.stringify(sealedPack))
234
+ if (task.contract.digest && task.contract.digest !== digest) throw new Error(`task "${task.key}" contract digest does not match its acceptance pack`)
235
+ if (task.contract.acceptancePack && JSON.stringify(task.contract.acceptancePack) !== JSON.stringify(sealedPack)) {
236
+ throw new Error(`task "${task.key}" sealed acceptance pack does not match its task fields`)
237
+ }
238
+ task.contract.digest = digest
239
+ task.contract.acceptancePack = sealedPack
240
+ }
241
+
242
+ // The browser persists normalized task rows before approval, then later sends a
243
+ // ready row back to the bridge for dispatch. Recompute the seal at that final
244
+ // boundary so changed acceptance/scope/non-goals can never ride beside an old hash.
245
+ export function validateTaskContractSeal (task, { legacy = false } = {}) {
246
+ const contract = task?.contract
247
+ if (!contract?.digest && legacy) return true
248
+ if (!contract || typeof contract !== 'object' || !contract.digest || !contract.acceptancePack) {
249
+ throw new Error('task is missing its sealed acceptance contract')
250
+ }
251
+ const pack = contract.acceptancePack
252
+ const packTask = pack?.task
253
+ if (!packTask || typeof packTask !== 'object' || !Array.isArray(pack.nonGoals)) {
254
+ throw new Error('task sealed acceptance pack is malformed')
255
+ }
256
+ const canonicalPack = acceptancePack({
257
+ key: packTask.key,
258
+ title: packTask.title,
259
+ scope: packTask.scope,
260
+ acceptance: packTask.acceptance,
261
+ sliceType: packTask.sliceType,
262
+ nonGoals: pack.nonGoals,
263
+ baselineGate: pack.baselineGate,
264
+ inheritedReviewerTarget: pack.inheritedReviewerTarget,
265
+ })
266
+ if (JSON.stringify(pack) !== JSON.stringify(canonicalPack)) throw new Error('task sealed acceptance pack is not canonical')
267
+ if (acceptancePackDigest({
268
+ key: packTask.key,
269
+ title: packTask.title,
270
+ scope: packTask.scope,
271
+ acceptance: packTask.acceptance,
272
+ sliceType: packTask.sliceType,
273
+ nonGoals: pack.nonGoals,
274
+ baselineGate: pack.baselineGate,
275
+ inheritedReviewerTarget: pack.inheritedReviewerTarget,
276
+ }) !== contract.digest) throw new Error('task acceptance digest does not match its sealed pack')
277
+
278
+ const sliceType = task.sliceType ?? task.slice_type
279
+ const key = task.key ?? task.task_key
280
+ const deps = Array.isArray(task.deps) ? task.deps : []
281
+ if (sliceType === SLICE_TYPE.review) {
282
+ if (contract.inheritedFrom !== packTask.key || deps.length !== 1 || deps[0] !== packTask.key) {
283
+ throw new Error('review task no longer targets its sealed builder')
284
+ }
285
+ if (pack.inheritedReviewerTarget !== packTask.key || task.acceptance !== packTask.acceptance) {
286
+ throw new Error(`review task "${key}" changed after its builder acceptance pack was sealed`)
287
+ }
288
+ return true
289
+ }
290
+ const livePack = acceptancePack({
291
+ key,
292
+ title: task.title,
293
+ scope: task.scope ?? '',
294
+ acceptance: task.acceptance ?? '',
295
+ sliceType,
296
+ nonGoals: contract.nonGoals || [],
297
+ baselineGate: contract.baseline?.gate || '',
298
+ inheritedReviewerTarget: key,
299
+ })
300
+ if (JSON.stringify(livePack) !== JSON.stringify(canonicalPack)) {
301
+ throw new Error(`task "${key}" changed after its acceptance pack was sealed`)
302
+ }
303
+ return true
304
+ }
305
+
46
306
  // A FlowTask slice. Each is RUNNABLE — a lane can build + run + self-correct it in
47
307
  // its own WebContainer (research: the runtime is the moat). Partition by file
48
308
  // ownership (scope) so two lanes never edit the same file (a branch is NOT isolation).
49
- export function makeTask ({ key, title, scope = '', acceptance = '', deps = [], sliceType = SLICE_TYPE.feature }) {
309
+ export function makeTask ({ key, title, scope = '', acceptance = '', deps = [], sliceType = SLICE_TYPE.feature, contract = null }) {
50
310
  if (!key || typeof key !== 'string') throw new Error('task key required (string)')
51
311
  if (!title) throw new Error('task title required')
52
312
  if (!Array.isArray(deps)) throw new Error('deps must be an array of task keys')
@@ -56,6 +316,7 @@ export function makeTask ({ key, title, scope = '', acceptance = '', deps = [],
56
316
  title,
57
317
  scope, // files/components this lane OWNS (disjoint from other slices)
58
318
  acceptance, // runnable proof the lane + reviewer use to call it done
319
+ contract, // bounded gate-first contract; null only for legacy/restored rows
59
320
  deps, // task keys this depends on (must form a DAG)
60
321
  sliceType,
61
322
  status: TASK_STATUS.pending,
@@ -142,7 +403,7 @@ export function makeFlowSession ({ prompt, mode = FLOW_MODE.guide, sessionRef =
142
403
  // Accepts a parsed object OR a JSON string (optionally ```json-fenced). Throws if the
143
404
  // tasks don't form a real DAG. Lives in this shared module so neither the bridge nor
144
405
  // the client has to import across the bridge↔src boundary.
145
- export function normalizePlanOutput (raw) {
406
+ export function normalizePlanOutput (raw, { legacy = false } = {}) {
146
407
  let obj = raw
147
408
  if (typeof raw === 'string') {
148
409
  let s = raw.trim()
@@ -167,9 +428,36 @@ export function normalizePlanOutput (raw) {
167
428
  acceptance: String(t.acceptance ?? ''),
168
429
  deps: Array.isArray(t.deps) ? t.deps.map(String) : [],
169
430
  sliceType: Object.values(SLICE_TYPE).includes(sliceType) ? sliceType : SLICE_TYPE.feature,
431
+ contract: normalizeTaskContract(t, { sliceType: Object.values(SLICE_TYPE).includes(sliceType) ? sliceType : SLICE_TYPE.feature, legacy }),
170
432
  })
171
433
  })
172
434
  validateDag(tasks)
435
+ for (const task of tasks) {
436
+ if (task.sliceType !== SLICE_TYPE.review) bindContractDigest(task)
437
+ }
438
+ for (const task of tasks) {
439
+ if (task.sliceType !== SLICE_TYPE.review) continue
440
+ // New review tasks verify exactly one builder contract. Legacy persisted reviews
441
+ // keep their prior multi-target shape for restore compatibility only.
442
+ if (legacy) continue
443
+ if (task.deps.length !== 1) throw new Error(`review task "${task.key}" must depend on exactly one builder task to inherit its contract`)
444
+ const target = tasks.find((candidate) => candidate.key === task.deps[0])
445
+ if (!target || target.sliceType === SLICE_TYPE.review) throw new Error(`review task "${task.key}" must target a builder/fix/scaffold task`)
446
+ if (task.contract.digest && task.contract.digest !== target.contract.digest) {
447
+ throw new Error(`review task "${task.key}" contract digest does not match target "${target.key}"`)
448
+ }
449
+ task.contract = {
450
+ nonGoals: [...(target.contract?.nonGoals || [])],
451
+ baseline: target.contract?.baseline ? { ...target.contract.baseline } : null,
452
+ inheritedFrom: target.key,
453
+ digest: target.contract.digest,
454
+ acceptancePack: target.contract.acceptancePack,
455
+ }
456
+ // Reviewer-authored prose is not an authority boundary. Normalize the live
457
+ // review row to the builder's sealed acceptance so dispatch and the prompt
458
+ // cannot silently substitute a weaker criterion.
459
+ task.acceptance = target.contract.acceptancePack.task.acceptance
460
+ }
173
461
  return { summary, tasks }
174
462
  }
175
463
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.360",
3
+ "version": "0.7.362",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,6 +93,7 @@
93
93
  "design-edit.mjs",
94
94
  "design-source-contract.mjs",
95
95
  "flow-review.mjs",
96
+ "flow-receipt.mjs",
96
97
  "review-check.mjs",
97
98
  "flow-review-gate.mjs",
98
99
  "flow-review-reflect.mjs",