thinkpool-pair 0.7.364 → 0.7.366

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.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
package/flow-review.mjs CHANGED
@@ -1,152 +1 @@
1
- // Thinkpool FlowStep 4: adversarial review + per-lane atomic revert.
2
- //
3
- // After a lane commits its slice (commit_sha on flow_tasks = the revert target), an
4
- // ADVERSARIAL reviewer lane tries to BREAK it: run it, find where acceptance isn't
5
- // actually met, find regressions/edge cases. Its verdict drives the gate — accept iff
6
- // every reviewer passed; any fail → revert THIS lane only. The atomic-revert invariant:
7
- // one lane rolls back (its worktree + branch gone) without touching any other lane's
8
- // work (disjoint worktrees, M57). Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 4).
9
- //
10
- // Pure over injected git/removeWorktree so it's unit-testable against a temp repo.
11
-
12
- import { execFileSync } from 'node:child_process'
13
- import { TASK_STATUS } from './flow-task-graph.mjs'
14
- import { removeFlowWorktree } from './flow-worktree.mjs'
15
- import { reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
16
-
17
- // The reviewer lane's rolePrompt (via startClaudeSession). Mirrors FLOW_LANE_PROMPT's
18
- // join(' ') style. This lane is ADVERSARIAL — its job is to disprove "done", not to build.
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, acceptance criteria, inherited non-goals, baseline gate/evidence, and worktree/branch) arrives as your first message.',
21
-
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
-
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
-
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
-
28
- 'BE SPECIFIC. Your reasons must name exactly WHAT failed and HOW you found it — the command you ran, the output you got, the acceptance criterion it violated. "Doesn\'t work" is useless. "GET /api/todos returned 500 with `column todos.user_id does not exist`; acceptance required 200 + []" is a usable verdict.',
29
-
30
- 'YOU GET BOUNDED HUNT ROUNDS, NOT ONE GLANCE. You may be asked to dig again — up to a hard ceiling of rounds. A green happy-path on round 1 is NOT a pass; use the next round to hunt harder (edge cases, the reload, the second click, concurrent use, the error path). You only stop early two ways: you reproduce a concrete FAILURE (emit pass:false — that ends it, the slice reverts), OR you have genuinely EXHAUSTED your checks and found nothing (emit pass:true AND exhausted:true — that ends it, the slice passes). If you still have angles left to try, emit pass:true and leave exhausted false/absent — you will be asked to dig one more round until the ceiling, at which point the inconclusive result is handed to the two humans to decide (never an unbounded self-loop).',
31
-
32
- 'EMIT YOUR VERDICT BY WRITING FLOW_REVIEW.json. Do NOT call mark_flow_done or ExitPlanMode (they hang here). As your LAST action, use the Write tool with file_path "FLOW_REVIEW.json" and content = a single JSON object { "pass": <boolean>, "reasons": ["<specific finding>", ...], "taskKey": "<the slice you reviewed>", "exhausted": <boolean, optional — true only when you have nothing left to check> }. `pass: false` reverts the reviewed slice so it rebuilds. The content of FLOW_REVIEW.json must be ONLY that JSON object (no prose, no markdown fences). Do not edit the slice — you review, you do not fix.',
33
- ].join(' ')
34
-
35
- export const FLOW_CODEX_REVIEWER_PROMPT = [
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
- '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, 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
- '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
- ].join(' ')
41
-
42
- // Parse a reviewer's raw verdict. Accepts an object OR a JSON string (optionally
43
- // ```json-fenced). Tolerant of the fence the model sometimes wraps; throws on garbage.
44
- // Returns { pass, reasons } — pass coerced to boolean, reasons normalized to string[].
45
- export function parseReviewVerdict (raw) {
46
- let obj = raw
47
- if (typeof raw === 'string') {
48
- let s = raw.trim()
49
- const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i)
50
- if (fence) s = fence[1].trim()
51
- obj = JSON.parse(s) // throws on unparseable
52
- }
53
- if (!obj || typeof obj !== 'object') throw new Error('review verdict is not an object')
54
- if (typeof obj.pass !== 'boolean') throw new Error('review verdict missing boolean `pass`')
55
- const reasons = Array.isArray(obj.reasons) ? obj.reasons.map(String) : []
56
- // E1 (bounded self-test): `exhausted` is an OPTIONAL third signal — true only when the
57
- // reviewer reports it has nothing left to check. Absent/non-boolean → false (keep hunting
58
- // until the round ceiling). Never throws on its absence; it's additive to the old contract.
59
- const exhausted = obj.exhausted === true
60
- return { pass: obj.pass, reasons, exhausted }
61
- }
62
-
63
- // E1 — map a parsed review verdict into the two governor inputs. A concrete FAILURE
64
- // (pass:false) is a reproduced break → foundFailure. A pass is only conclusive when the
65
- // reviewer says it EXHAUSTED its checks; a bare pass:true means "happy path held, more to
66
- // hunt" → keep going until the round ceiling. Pure; the seam the loop drives on.
67
- export function reviewVerdictToReflection (verdict) {
68
- const v = verdict || {}
69
- return {
70
- foundFailure: v.pass === false,
71
- exhausted: v.pass === true && v.exhausted === true,
72
- }
73
- }
74
-
75
- // E1+A1/A2 — the BOUNDED reviewer loop. Drives the adversarial reviewer across up to
76
- // `maxRounds` hunt rounds, consulting reviewReflectionDecision between rounds, and stops at
77
- // the FIRST terminal outcome (reject on a concrete failure, pass on exhaustion, or SURFACE
78
- // when a ceiling — rounds or budget — is hit inconclusively). Pure over two injected effects
79
- // so it's unit-testable without a live Flow room (the S5 split):
80
- // • runRound(round) → Promise<parsed verdict {pass,reasons,exhausted}> — spend one round
81
- // • getSpent() → number — the review lane's tokens spent so far (flow-budget ledger)
82
- // N1 fail-closed: the budget is checked BEFORE each round is spent, so a review lane at/over
83
- // its cap SURFACES without ever spending the next round (never runs the bill up, anti-Emergent).
84
- // Returns { action, surfaceToPair, reason, rounds, verdict } — `rounds` = rounds actually run.
85
- export async function runBoundedReview ({ maxRounds, budgetCap = null, getSpent = () => 0, runRound }) {
86
- if (typeof runRound !== 'function') throw new Error('runBoundedReview needs a runRound(round) function')
87
- const cap = Number.isFinite(maxRounds) && maxRounds > 0 ? maxRounds : REVIEW_DEFAULTS.maxRounds
88
- const capped = budgetCap != null && Number.isFinite(budgetCap)
89
- for (let round = 1; ; round++) {
90
- // N1 fail-closed pre-gate: never SPEND another review round once the cap is reached.
91
- const spentBefore = getSpent()
92
- if (capped && spentBefore >= budgetCap) {
93
- const d = reviewReflectionDecision({
94
- round, maxRounds: cap, spentTokens: spentBefore, budgetCap,
95
- foundFailure: false, exhausted: false,
96
- })
97
- return { ...d, rounds: round - 1, verdict: null }
98
- }
99
- const verdict = await runRound(round)
100
- const decision = reviewReflectionDecision({
101
- round, maxRounds: cap, spentTokens: getSpent(), budgetCap,
102
- ...reviewVerdictToReflection(verdict),
103
- })
104
- if (decision.stop) return { ...decision, rounds: round, verdict }
105
- // else action==='continue' — under both ceilings, not yet concluded → dig another round.
106
- }
107
- }
108
-
109
- // Gate decision over a task's verdicts (≥1 reviewer). Accept iff EVERY verdict passes;
110
- // any fail → mustRevert. reasons collects the failing verdicts' reasons (the why).
111
- export function reviewDecision ({ verdicts }) {
112
- const list = Array.isArray(verdicts) ? verdicts : []
113
- if (list.length === 0) {
114
- // No reviewer ran — cannot independently verify, so default to reject (the prompt's stance).
115
- return { accept: false, mustRevert: true, reasons: ['no review verdict produced'] }
116
- }
117
- const accept = list.every(v => v.pass === true)
118
- const reasons = list.filter(v => !v.pass).flatMap(v => Array.isArray(v.reasons) ? v.reasons : [])
119
- return { accept, mustRevert: !accept, reasons }
120
- }
121
-
122
- // Atomic per-lane revert: tear down ONE lane's worktree + branch, nothing else.
123
- // Delegates to the injected removeWorktree (defaults to removeFlowWorktree). Best-effort
124
- // — never throws on an already-gone worktree/branch. Returns { reverted, branch }.
125
- export function revertLane ({ flowId, taskKey, root, git = defaultGit, removeWorktree = removeFlowWorktree }) {
126
- const { branch } = worktreeBranch({ flowId, taskKey, root })
127
- try {
128
- removeWorktree({ flowId, taskKey, root, git })
129
- } catch { /* best-effort: worktree/branch already gone */ }
130
- return { reverted: true, branch }
131
- }
132
-
133
- // Status a task moves to after the review gate resolves.
134
- export function nextTaskStatusAfterReview ({ accept }) {
135
- return accept ? TASK_STATUS.done : TASK_STATUS.reverted
136
- }
137
-
138
- // ── internals ───────────────────────────────────────────────────────────────
139
- // Resolve the lane's branch name without importing worktreeSpec's full surface here;
140
- // keeps revertLane's return honest even when the injected removeWorktree is a spy.
141
- function worktreeBranch ({ flowId, taskKey, root }) {
142
- const short = String(flowId).replace(/-/g, '').slice(0, 8)
143
- const safeKey = String(taskKey).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 40).replace(/^-+|-+$/g, '') || 'task'
144
- void root
145
- return { branch: `flow/${short}/${safeKey}` }
146
- }
147
-
148
- // Default git runner mirrors flow-worktree's — only used if a caller passes neither
149
- // git nor a custom removeWorktree (the real removeFlowWorktree supplies its own default).
150
- function defaultGit (args, cwd) {
151
- return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
152
- }
1
+ import{execFileSync as e}from"node:child_process";import{TASK_STATUS as t}from"./flow-task-graph.mjs";import{removeFlowWorktree as o}from"./flow-worktree.mjs";import{reviewReflectionDecision as r,REVIEW_DEFAULTS as n}from"./flow-review-reflect.mjs";export const FLOW_REVIEWER_PROMPT=["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.",'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.',"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.",'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.','BE SPECIFIC. Your reasons must name exactly WHAT failed and HOW you found it — the command you ran, the output you got, the acceptance criterion it violated. "Doesn\'t work" is useless. "GET /api/todos returned 500 with `column todos.user_id does not exist`; acceptance required 200 + []" is a usable verdict.',"YOU GET BOUNDED HUNT ROUNDS, NOT ONE GLANCE. You may be asked to dig again — up to a hard ceiling of rounds. A green happy-path on round 1 is NOT a pass; use the next round to hunt harder (edge cases, the reload, the second click, concurrent use, the error path). You only stop early two ways: you reproduce a concrete FAILURE (emit pass:false — that ends it, the slice reverts), OR you have genuinely EXHAUSTED your checks and found nothing (emit pass:true AND exhausted:true — that ends it, the slice passes). If you still have angles left to try, emit pass:true and leave exhausted false/absent — you will be asked to dig one more round until the ceiling, at which point the inconclusive result is handed to the two humans to decide (never an unbounded self-loop).",'EMIT YOUR VERDICT BY WRITING FLOW_REVIEW.json. Do NOT call mark_flow_done or ExitPlanMode (they hang here). As your LAST action, use the Write tool with file_path "FLOW_REVIEW.json" and content = a single JSON object { "pass": <boolean>, "reasons": ["<specific finding>", ...], "taskKey": "<the slice you reviewed>", "exhausted": <boolean, optional — true only when you have nothing left to check> }. `pass: false` reverts the reviewed slice so it rebuilds. The content of FLOW_REVIEW.json must be ONLY that JSON object (no prose, no markdown fences). Do not edit the slice — you review, you do not fix.'].join(" ");export const FLOW_CODEX_REVIEWER_PROMPT=["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.","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.","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.",'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.'].join(" ");export function parseReviewVerdict(e){let t=e;if("string"==typeof e){let o=e.trim();const r=o.match(/```(?:json)?\s*([\s\S]*?)```/i);r&&(o=r[1].trim()),t=JSON.parse(o)}if(!t||"object"!=typeof t)throw new Error("review verdict is not an object");if("boolean"!=typeof t.pass)throw new Error("review verdict missing boolean `pass`");const o=Array.isArray(t.reasons)?t.reasons.map(String):[],r=!0===t.exhausted;return{pass:t.pass,reasons:o,exhausted:r}}export function reviewVerdictToReflection(e){const t=e||{};return{foundFailure:!1===t.pass,exhausted:!0===t.pass&&!0===t.exhausted}}export async function runBoundedReview({maxRounds:e,budgetCap:t=null,getSpent:o=()=>0,runRound:a}){if("function"!=typeof a)throw new Error("runBoundedReview needs a runRound(round) function");const i=Number.isFinite(e)&&e>0?e:n.maxRounds,s=null!=t&&Number.isFinite(t);for(let e=1;;e++){const n=o();if(s&&n>=t)return{...r({round:e,maxRounds:i,spentTokens:n,budgetCap:t,foundFailure:!1,exhausted:!1}),rounds:e-1,verdict:null};const c=await a(e),d=r({round:e,maxRounds:i,spentTokens:o(),budgetCap:t,...reviewVerdictToReflection(c)});if(d.stop)return{...d,rounds:e,verdict:c}}}export function reviewDecision({verdicts:e}){const t=Array.isArray(e)?e:[];if(0===t.length)return{accept:!1,mustRevert:!0,reasons:["no review verdict produced"]};const o=t.every(e=>!0===e.pass);return{accept:o,mustRevert:!o,reasons:t.filter(e=>!e.pass).flatMap(e=>Array.isArray(e.reasons)?e.reasons:[])}}export function revertLane({flowId:e,taskKey:t,root:r,git:n=a,removeWorktree:i=o}){const{branch:s}=function({flowId:e,taskKey:t,root:o}){return{branch:`flow/${String(e).replace(/-/g,"").slice(0,8)}/${String(t).toLowerCase().replace(/[^a-z0-9-]/g,"-").slice(0,40).replace(/^-+|-+$/g,"")||"task"}`}}({flowId:e,taskKey:t,root:r});try{i({flowId:e,taskKey:t,root:r,git:n})}catch{}return{reverted:!0,branch:s}}export function nextTaskStatusAfterReview({accept:e}){return e?t.done:t.reverted}function a(t,o){return e("git",t,{cwd:o,stdio:["ignore","pipe","pipe"],encoding:"utf8"})}
@@ -1,117 +1 @@
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
- }
1
+ import e from"node:path";const t=/(?:^|\/)(?:\.env(?:\.|$)|\.git(?:\/|$)|\.thinkpool(?:\/|$)|node_modules(?:\/|$))/,r=/(?: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,n=/(?:^|\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;function l(e,t=32){return[...new Set(e)].sort().slice(0,t)}function i(e,{allowPattern:r=!1}={}){if("string"!=typeof e)return null;let n=e.trim().replaceAll("\\","/");if(!n||n.length>180||n.startsWith("/")||/^[A-Za-z]:\//.test(n))return null;const l=r&&n.endsWith("/**");if(n.includes("*")&&!l)return null;const i=l?n.slice(0,-3):n;if(!i||i.endsWith("/")||i.split("/").some(e=>!e||"."===e||".."===e))return null;if(t.test(i))return null;const o=i.split("/").at(-1);return!r||l||o.includes(".")||/^(?:Dockerfile|Makefile|LICENSE|README)$/i.test(o)?(n=l?`${i}/**`:i,n):null}export function normalizeFlowScopePaths(e){if(!Array.isArray(e)||0===e.length||e.length>32)throw new TypeError("scope paths must contain 1-32 repo-relative entries");const t=[];for(const r of e){const e=i(r,{allowPattern:!0});if(!e)throw new TypeError(`invalid scope path: ${JSON.stringify(r)}`);t.includes(e)||t.push(e)}return t.sort()}export function classifyFlowScope({declared:e,changed:t}={}){const r=normalizeFlowScopePaths(e),n=l((Array.isArray(t)?t:[]).map(e=>i(e)).filter(Boolean)),o=n.filter(e=>!r.some(t=>t.endsWith("/**")?e.startsWith(t.slice(0,-2)):e===t));return{declared:r,changed:n,unexpected:o,held:0===o.length}}function o(t,r){if("string"!=typeof t||!t.trim())return null;const n=e.resolve(r),l=e.isAbsolute(t)?e.resolve(t):e.resolve(n,t),o=e.relative(n,l).replaceAll("\\","/");return!o||o.startsWith("../")||e.isAbsolute(o)?null:i(o)}function s(t,l){if("string"!=typeof t)return null;let i=t.replaceAll(e.resolve(l),".").replace(/\s+/g," ").trim();return!i||i.length>220||r.test(i)||!n.test(i)?null:i}export function collectFlowScopeEvidence({log:e=[],repoRoot:t,declared:r,changed:n}={}){const i=classifyFlowScope({declared:r,changed:n}),a=[],c=new Map,d=new Set;for(const r of Array.isArray(e)?e:[])if("assistant"===r?.kind)for(const e of r.blocks||[]){if("tool_use"!==e?.type)continue;const r=String(e.name||"").toLowerCase();if(["read","read_file","edit","write"].includes(r)){const r=o(e.input?.file_path??e.input?.path,t);r&&a.push(r)}if("bash"===r||"exec_command"===r||"command_execution"===r){const r=s(e.input?.command??e.input?.cmd,t);e.id&&r&&c.set(e.id,r)}}else"tool_result"===r?.kind&&r.toolUseId&&!0!==r.isError&&d.add(r.toolUseId);return{declared:i.declared,read:l(a),changed:i.changed,verified:l([...c].filter(([e])=>d.has(e)).map(([,e])=>e),8),unexpected:i.unexpected,held:i.held}}export function normalizeFlowScopeEvidence(e){if(!e||"object"!=typeof e||Array.isArray(e))return null;try{const t=classifyFlowScope({declared:e.declared,changed:e.changed}),o=l((Array.isArray(e.read)?e.read:[]).map(e=>i(e)).filter(Boolean)),s=l((Array.isArray(e.verified)?e.verified:[]).map(e=>"string"==typeof e&&e.length<=220&&!r.test(e)&&n.test(e)?e.trim():null).filter(Boolean),8);return Boolean(e.held)!==t.held||JSON.stringify(l(Array.isArray(e.unexpected)?e.unexpected:[]))!==JSON.stringify(t.unexpected)?null:{declared:t.declared,read:o,changed:t.changed,verified:s,unexpected:t.unexpected,held:t.held}}catch{return null}}
@@ -1,140 +1 @@
1
- // Thinkpool Flow progressive skill registry.
2
- //
3
- // A Flow lane boots with only skill METADATA (name + description + whenToUse) injected
4
- // into its base prompt — the compact manifest(). The full SKILL.md body loads ON DEMAND
5
- // via loadBody(name) only when the lane actually activates that skill. Token win: the
6
- // manifest is orders of magnitude smaller than the sum of all skill bodies.
7
- //
8
- // Format: each skill lives at <dir>/<skill-name>/SKILL.md with agentskills.io YAML
9
- // frontmatter (name, description, and optional whenToUse). The body is the full SKILL.md
10
- // file content (frontmatter included) — the complete text a lane sees on activation.
11
- //
12
- // Public/custom split: publicDir carries built-in product skills; customDir (optional)
13
- // carries user/room-level overrides. Custom skills override public ones by `name` field.
14
- //
15
- // Pure module — canonical at src/lib/flow/skillRegistry.js, mirrored byte-identically
16
- // at bridge/flow-skill-registry.mjs (same reason as taskGraph / contextStore: the bridge
17
- // ships as thinkpool-pair npm package and can't import across the bridge↔src boundary
18
- // at publish time). Sync: bridge/flow-skill-registry.sync.test.js.
19
- //
20
- // Node built-ins only (node:fs, node:path) — no new deps.
21
- // All fs calls are injectable (fs option) so logic is unit-testable against temp dirs.
22
-
23
- import nodeFs from 'node:fs'
24
- import nodePath from 'node:path'
25
-
26
- // ── parseFrontmatter ──────────────────────────────────────────────────────────
27
- // Parse agentskills.io YAML-like frontmatter from a SKILL.md string.
28
- // Returns { meta: { name, description, whenToUse? } } or null if malformed/missing.
29
- // Required fields: name, description — missing either → null (skill skipped from manifest).
30
- // Tolerant: unknown YAML fields silently ignored; only scalar key:value pairs parsed.
31
- function parseFrontmatter (content) {
32
- if (typeof content !== 'string') return null
33
- // Strip BOM if present
34
- const s = content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content
35
- if (!s.startsWith('---')) return null
36
- const afterOpen = s.slice(3)
37
- // Closing delimiter: a line that is exactly '---' (accept \r\n or \n line endings)
38
- const closeMatch = afterOpen.match(/\r?\n---(?:\r?\n|$)/)
39
- if (!closeMatch) return null
40
- const yamlBlock = afterOpen.slice(0, closeMatch.index)
41
-
42
- // Scalar key: value extraction only — not a full YAML parser; lists/nested maps silently mis-parsed.
43
- const meta = {}
44
- for (const line of yamlBlock.split(/\r?\n/)) {
45
- const colonIdx = line.indexOf(':')
46
- if (colonIdx === -1) continue
47
- const key = line.slice(0, colonIdx).trim()
48
- const val = line.slice(colonIdx + 1).trim()
49
- if (key) meta[key] = val
50
- }
51
-
52
- // Both name and description are required for a valid skill entry
53
- if (!meta.name || !meta.description) return null
54
- return { meta }
55
- }
56
-
57
- // ── scanDir ───────────────────────────────────────────────────────────────────
58
- // Scan `dir` for skill entries. Each valid skill is a subdirectory containing a SKILL.md
59
- // with parseable frontmatter (name + description required). Malformed SKILL.md → skipped
60
- // without throwing. Missing or inaccessible dir → returns [].
61
- // Returns [{ name, description, whenToUse?, _path }] where _path is the absolute SKILL.md
62
- // path (internal — stripped from manifest() output, used by loadBody).
63
- function scanDir (dir, { fs = nodeFs } = {}) {
64
- if (!dir) return []
65
- let entries
66
- try {
67
- entries = fs.readdirSync(dir)
68
- } catch (e) {
69
- if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return []
70
- throw e
71
- }
72
- const skills = []
73
- for (const entry of entries) {
74
- const skillDir = nodePath.join(dir, entry)
75
- let stat
76
- // lstatSync does NOT follow symlinks — a symlinked entry returns isSymbolicLink()=true
77
- // and isDirectory()=false, so it is skipped. Using statSync would follow the link and
78
- // allow scanning a directory outside publicDir/customDir (symlink escape vector).
79
- try { stat = fs.lstatSync(skillDir) } catch { continue }
80
- if (!stat.isDirectory()) continue
81
- const skillMdPath = nodePath.join(skillDir, 'SKILL.md')
82
- // Belt-and-suspenders: lstat the SKILL.md too — skip if it is itself a symlink.
83
- // Covers a file-level symlink inside a real (non-symlinked) skill dir.
84
- let skillMdStat
85
- try { skillMdStat = fs.lstatSync(skillMdPath) } catch { continue }
86
- if (skillMdStat.isSymbolicLink()) continue
87
- let content
88
- try { content = fs.readFileSync(skillMdPath, 'utf8') } catch { continue }
89
- const parsed = parseFrontmatter(content)
90
- if (!parsed) continue // malformed — skip silently, do not throw
91
- const { meta } = parsed
92
- const skill = { name: meta.name, description: meta.description, _path: skillMdPath }
93
- if (meta.whenToUse) skill.whenToUse = meta.whenToUse
94
- skills.push(skill)
95
- }
96
- return skills
97
- }
98
-
99
- // ── manifest ──────────────────────────────────────────────────────────────────
100
- // Build the compact skill manifest: an array of { name, description, whenToUse? } per
101
- // skill — METADATA ONLY, no SKILL.md body. This is what a lane injects into its base
102
- // prompt to know which skills are available without loading any body.
103
- //
104
- // Merge rule: publicDir skills load first; customDir skills then override/extend by
105
- // `name`. A custom skill with the same name as a public skill replaces it in the result.
106
- //
107
- // Returns [] if both dirs are absent or contain no valid skills.
108
- export function manifest ({ publicDir, customDir, fs = nodeFs } = {}) {
109
- const publicSkills = scanDir(publicDir, { fs })
110
- const customSkills = scanDir(customDir, { fs })
111
- // Merge: public first, custom overrides by name
112
- const byName = new Map()
113
- for (const s of publicSkills) byName.set(s.name, s)
114
- for (const s of customSkills) byName.set(s.name, s)
115
- // Return compact entries — strip internal _path
116
- return [...byName.values()].map(({ name, description, whenToUse }) => {
117
- const entry = { name, description }
118
- if (whenToUse !== undefined) entry.whenToUse = whenToUse
119
- return entry
120
- })
121
- }
122
-
123
- // ── loadBody ──────────────────────────────────────────────────────────────────
124
- // Load the full SKILL.md content for the named skill, on demand.
125
- // customDir is checked before publicDir (a custom skill overrides a public of the same name).
126
- // Unknown name / skill not found in either dir → returns null (clean miss, never throws).
127
- export function loadBody (name, { publicDir, customDir, fs = nodeFs } = {}) {
128
- if (!name || typeof name !== 'string') return null
129
- // Custom takes priority over public — check custom first
130
- for (const dir of [customDir, publicDir]) {
131
- const skills = scanDir(dir, { fs })
132
- const found = skills.find(s => s.name === name)
133
- if (found) {
134
- try {
135
- return fs.readFileSync(found._path, 'utf8')
136
- } catch { /* skill file disappeared — try next dir */ }
137
- }
138
- }
139
- return null // clean miss — not found in either dir
140
- }
1
+ import n from"node:fs";import t from"node:path";function e(n){if("string"!=typeof n)return null;const t=65279===n.charCodeAt(0)?n.slice(1):n;if(!t.startsWith("---"))return null;const e=t.slice(3),o=e.match(/\r?\n---(?:\r?\n|$)/);if(!o)return null;const i=e.slice(0,o.index),r={};for(const n of i.split(/\r?\n/)){const t=n.indexOf(":");if(-1===t)continue;const e=n.slice(0,t).trim(),o=n.slice(t+1).trim();e&&(r[e]=o)}return r.name&&r.description?{meta:r}:null}function o(o,{fs:i=n}={}){if(!o)return[];let r;try{r=i.readdirSync(o)}catch(n){if(n&&("ENOENT"===n.code||"ENOTDIR"===n.code))return[];throw n}const c=[];for(const n of r){const r=t.join(o,n);let s;try{s=i.lstatSync(r)}catch{continue}if(!s.isDirectory())continue;const f=t.join(r,"SKILL.md");let u,a;try{u=i.lstatSync(f)}catch{continue}if(u.isSymbolicLink())continue;try{a=i.readFileSync(f,"utf8")}catch{continue}const l=e(a);if(!l)continue;const{meta:m}=l,d={name:m.name,description:m.description,_path:f};m.whenToUse&&(d.whenToUse=m.whenToUse),c.push(d)}return c}export function manifest({publicDir:t,customDir:e,fs:i=n}={}){const r=o(t,{fs:i}),c=o(e,{fs:i}),s=new Map;for(const n of r)s.set(n.name,n);for(const n of c)s.set(n.name,n);return[...s.values()].map(({name:n,description:t,whenToUse:e})=>{const o={name:n,description:t};return void 0!==e&&(o.whenToUse=e),o})}export function loadBody(t,{publicDir:e,customDir:i,fs:r=n}={}){if(!t||"string"!=typeof t)return null;for(const n of[i,e]){const e=o(n,{fs:r}).find(n=>n.name===t);if(e)try{return r.readFileSync(e._path,"utf8")}catch{}}return null}