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.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/flow-review.mjs
CHANGED
|
@@ -1,152 +1 @@
|
|
|
1
|
-
|
|
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"})}
|
package/flow-scope-evidence.mjs
CHANGED
|
@@ -1,117 +1 @@
|
|
|
1
|
-
import
|
|
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}}
|
package/flow-skill-registry.mjs
CHANGED
|
@@ -1,140 +1 @@
|
|
|
1
|
-
|
|
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}
|