mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-11-03 10:37:24 +09:00
fix(ruflo-console): transcript search, model keys and peer sends on Windows; smoke under Git Bash
The last Windows gaps of the sweep, each tested. - Stopping or messaging a workflow run reads the run's task id from the session transcript; over 2 MB that was `grep -o`, which Windows lacks. Windows now runs PowerShell's Select-String (windowsSearchArgv in data/wf-control.ts). The path and the pattern ride inside the script as base64, so a path with a space, a quote, $, & or ; is only data and the script holds no double quote. Tested against a real PowerShell on a 5 MB-class file through such a path. - taskIdOf returned the FIRST launch when a search printed two launches of one run (a resume), because the pattern's `[^}]` ran across both matches (nothing prints a closing brace between them). It takes the last task id in the match. This also affected `grep -o` on Linux and macOS. - A model key for an HTTP endpoint was read with `printenv`, which Windows lacks. Host gains an optional `env` (register.ts: $.env.get, already used for other names) and SendDeps an optional `readEnv`, set only for a Windows home; elsewhere `printenv` is unchanged. - A send to a federation peer runs `bash <helper>`. On a Windows PATH `bash` is the WSL launcher (System32\bash.exe), which cannot open a C:\ script path, so the send failed obscurely. On a Windows home it now refuses, running nothing, and says why. - ruflo-cost-tracker scripts/smoke.sh builds ROOT with `pwd -W` under Git Bash: a Windows node could not open /c/... paths, which failed 5 steps (now 66/0). Checked and left alone: the npx launcher. The installed Claude Code (a native binary with the host code inside it) runs $.process.run through a bundled execa on cross-spawn, which finds `npx` as npx.cmd through PATHEXT and runs it via cmd.exe /d /s /c with its own argument escaping, so ['npx', ...] works on Windows. The same lookup is why `bash` there is the first bash.exe on the PATH (the WSL launcher on this machine), which the peer refusal above relies on. CHANGELOG: both plugins' unreleased entries (0.47.2, 0.28.3) list these. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01C9t8G7o2vTPAxSFNb8rP4n
This commit is contained in:
1 parent
7c8f221fb7
commit
f547724241
11 files changed
+231
-6
No files matched your search
@@ -10,6 +10,10 @@ Newest first. One `## <version> — <date>` heading per version, then `feat:`, `
|
||||
- fix: mission spend works on Windows (needs ruflo-cost-tracker 0.28.3, which fixes the ledger script itself).
|
||||
- fix: the autopilot's text checks know Windows (groundwork: a start is still refused there). A task naming `C:\…`, `\\share\…`, `..\…`, `%USERPROFILE%\…` or `$env:USERPROFILE\…` is checked against the envelope folders; `rmdir /s`, `del C:\`, `format C:`, `Remove-Item -Recurse` and `rm -rf C:\` are hard denies; a verify command may not be `cmd`, `powershell`, `pwsh`, `wsl`, `bash.exe` and the like, by basename under either separator and without `.exe`. These only park or refuse more.
|
||||
- fix: the Overview heading names the last two folders of a Windows project (`ruflo state in x/proj`), where it printed the whole `C:\…` path because it split on `/` only.
|
||||
- fix: stopping or messaging a workflow run on Windows finds its task id in a session transcript over 2 MB. The search used `grep`; Windows now uses PowerShell's `Select-String` (path and pattern passed as base64, never parsed as PowerShell).
|
||||
- fix: a model key for an HTTP endpoint is read through the engine's environment read on Windows, which has no `printenv`.
|
||||
- fix: a send to a federation peer on Windows says why nothing was sent. The helper is a bash script, and `bash` on a Windows PATH is the WSL launcher, which cannot open a `C:…` path.
|
||||
- fix: for a resumed run, the task id from a search is the latest launch, not the first. The search prints matches with no closing brace between them, so the pattern ran across both (any platform).
|
||||
- chore: the real-disk POSIX write specs (modes, FIFOs) are skipped on Windows, which writes through the host file API; one `ruflo tui --plain` spec no longer needs a runnable fake codex there.
|
||||
|
||||
## 0.47.1 — 2026-10-24
|
||||
|
||||
@@ -86,7 +86,25 @@ export function taskIdOf(text: string, runId: string): string | null {
|
||||
const hits = [...text.matchAll(new RegExp(taskPattern(runId), 'g'))]
|
||||
const last = hits.at(-1)
|
||||
|
||||
return last === undefined ? null : (/"taskId":"([A-Za-z0-9]{6,40})"/.exec(last[0])?.[1] ?? null)
|
||||
// A search prints only the matches, with no closing brace between two launches of one run, so one match can hold both: the last task id in it wins.
|
||||
return last === undefined ? null : ([...last[0].matchAll(/"taskId":"([A-Za-z0-9]{6,40})"/g)].at(-1)?.[1] ?? null)
|
||||
}
|
||||
|
||||
const utf8Base64 = (text: string): string => btoa(Array.from(new TextEncoder().encode(text), byte => String.fromCharCode(byte)).join(''))
|
||||
|
||||
/**
|
||||
* The transcript search for Windows, which has no `grep`: PowerShell's Select-String reads the file line by line and prints only what
|
||||
* matched (`grep -o`). The path and the pattern ride inside the script as base64 and are decoded there, so no character of either is ever
|
||||
* parsed as PowerShell (a path with a space, a quote or a `$` is only data); the script itself has no double quote. Null for a run id that is
|
||||
* not one, a path with a NUL, or a host without TextEncoder/btoa.
|
||||
*/
|
||||
export function windowsSearchArgv(path: string, runId: string): readonly string[] | null {
|
||||
if (!RUN_ID.test(runId) || path === '' || path.includes('\0') || typeof TextEncoder !== 'function' || typeof btoa !== 'function') return null
|
||||
|
||||
const text = (value: string): string => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${utf8Base64(value)}'))`
|
||||
const script = `$p=${text(path)};$q=${text(taskPattern(runId))};Select-String -LiteralPath $p -Pattern $q -AllMatches | ForEach-Object { $_.Matches } | ForEach-Object { $_.Value }`
|
||||
|
||||
return ['powershell', '-NoProfile', '-NonInteractive', '-Command', script]
|
||||
}
|
||||
|
||||
/** The session transcript that launched a run: the run's directory (`<session>/subagents/workflows/<run>`) without its tail, plus `.jsonl`. Null when the path is not a run path. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { callTool, permissionOf } from './wf-control'
|
||||
import { cleanText } from './wf-clean'
|
||||
import { guardText } from './wf-guide'
|
||||
import { idOf, plain, type HiveInfo } from './parse'
|
||||
import { isWindowsPath } from './paths'
|
||||
import { endpointsOf, type Config, type Endpoint, type Target } from './wf-targets'
|
||||
|
||||
export type SendDeps = Pick<Host, 'toolCall' | 'toolCheck' | 'run' | 'httpSend' | 'submitPrompt'> & {
|
||||
@@ -24,6 +25,8 @@ export type SendDeps = Pick<Host, 'toolCall' | 'toolCheck' | 'run' | 'httpSend'
|
||||
cwd: string
|
||||
hive: HiveInfo | null
|
||||
config: Config
|
||||
/** Reads one environment variable. Set on Windows, which has no `printenv`; where absent a key is read with `printenv`. */
|
||||
readEnv?: (name: string) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
export type SendState = 'reply' | 'queued' | 'sent' | 'error' | 'refused'
|
||||
@@ -178,6 +181,9 @@ export async function sendTo(deps: SendDeps, target: Target, raw: string): Promi
|
||||
case 'x-channel':
|
||||
return viaCli(deps, 'x_federation_channel_publish', { channel: target.ref, msgType: 'Status', payload: { text: body, from: 'ruflo-console' } })
|
||||
case 'peer': {
|
||||
// The helper is a shell script. Windows has no bash of its own on the PATH (`bash` there is the WSL launcher, which cannot open a C:... script path), so nothing is run.
|
||||
if (isWindowsPath(deps.helper)) return err('refused', 'a peer is reached through a bash helper script (federation.sh), which Windows cannot run from here: nothing was sent. Use a BBS room or a channel instead.')
|
||||
|
||||
const result = await deps.run(['bash', deps.helper, 'dispatch', target.ref, body, ...(deps.trustedPeers.has(target.ref) ? [] : ['--confirm'])], WAIT.peer).catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : 'refused' }))
|
||||
|
||||
return result.exitCode === 0 && result.stdout.trim() !== '' ? { ok: true, state: 'reply', text: tidy(result.stdout).trim() } : err('error', result.stderr.trim() !== '' ? result.stderr : `the peer answered nothing (exit ${result.exitCode})`)
|
||||
@@ -197,9 +203,12 @@ export async function sendTo(deps: SendDeps, target: Target, raw: string): Promi
|
||||
let key: string | undefined
|
||||
|
||||
if (endpoint.keyEnv !== 'NONE') {
|
||||
const read = await deps.run(['printenv', endpoint.keyEnv], 5_000).catch(() => undefined)
|
||||
if (deps.readEnv !== undefined) key = (await deps.readEnv(endpoint.keyEnv).catch(() => undefined))?.trim()
|
||||
else {
|
||||
const read = await deps.run(['printenv', endpoint.keyEnv], 5_000).catch(() => undefined)
|
||||
|
||||
key = read !== undefined && read.exitCode === 0 ? read.stdout.trim() : undefined
|
||||
key = read !== undefined && read.exitCode === 0 ? read.stdout.trim() : undefined
|
||||
}
|
||||
|
||||
if (key === undefined || key === '') return err('refused', `$${endpoint.keyEnv} is not set in the environment Claude Code runs in: nothing was sent`)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export type Host = {
|
||||
settings: () => Promise<unknown>
|
||||
home: () => Promise<string | undefined>
|
||||
configDir: () => Promise<string | undefined>
|
||||
/** One environment variable of the engine's process, or undefined when unset. Windows has no `printenv`, so a key is read through this there. */
|
||||
env?: (name: string) => Promise<string | undefined>
|
||||
/** This plugin's folder: where its own files (the command catalog) are. */
|
||||
pluginRoot: string
|
||||
rufloSnapshot: () => Promise<RufloSnapshot>
|
||||
|
||||
@@ -126,6 +126,7 @@ function hostOf($: EngineInterface, cwd: string, toasts: { prefs: () => ToastPre
|
||||
return homeOf(k => envVars[k])
|
||||
},
|
||||
configDir: async () => $.env.get('CLAUDE_CONFIG_DIR'),
|
||||
env: async name => $.env.get(name),
|
||||
pluginRoot: $.plugin.root,
|
||||
// `$.ruflo` exists only where ruflo-mods is seated; validate refuses feature-detecting a noun, so these are
|
||||
// async: a missing noun throws inside the promise and every caller's catch sees a rejection.
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
import type { RenderElement } from 'claude-code'
|
||||
|
||||
import { cleanText } from '../data/wf-clean'
|
||||
import { controlActions, taskIdOf, taskPattern, transcriptOf, type ControlAction, type ControlInput, type TaskRef } from '../data/wf-control'
|
||||
import { controlActions, taskIdOf, taskPattern, transcriptOf, windowsSearchArgv, type ControlAction, type ControlInput, type TaskRef } from '../data/wf-control'
|
||||
import { tidy } from '../data/wf-send'
|
||||
import { writeFlavorReady } from '../data/write-flavor'
|
||||
import type { Host } from '../host'
|
||||
import type { State } from '../state'
|
||||
import type { WfRun } from '../data/workflows'
|
||||
@@ -70,7 +71,12 @@ async function findTask(state: State, host: Host, run: Pick<WfRun, 'id' | 'dir'>
|
||||
|
||||
/** A transcript too large to read whole: grep prints only the launch result's task and run ids (argv, no shell; the path is a checked run path). */
|
||||
async function searchTask(host: Host, path: string, runId: string): Promise<string | null> {
|
||||
const result = await host.run(['grep', '-o', '-E', taskPattern(runId), path], 10_000)
|
||||
// Windows has no grep: the same search through PowerShell (data/wf-control.ts); null where it cannot be built.
|
||||
const argv = (await writeFlavorReady()) === 'host-fs' ? windowsSearchArgv(path, runId) : ['grep', '-o', '-E', taskPattern(runId), path]
|
||||
|
||||
if (argv === null) return null
|
||||
|
||||
const result = await host.run(argv, 10_000)
|
||||
|
||||
return result.exitCode === 0 ? taskIdOf(result.stdout, runId) : null
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ export function depsOf(state: State): SendDeps | null {
|
||||
cwd: state.cwd,
|
||||
hive: state.snapshot?.hive ?? null,
|
||||
config: live.config,
|
||||
// Windows has no printenv: the engine's own environment read supplies a model key there.
|
||||
...(host.env !== undefined && live.home !== null && isWindowsPath(live.home) && { readEnv: host.env }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { taskIdOf, windowsSearchArgv } from '../hooks/data/wf-control'
|
||||
|
||||
// Finding a run's task id in a session transcript too large to read whole: `grep -o` on Linux and macOS, PowerShell's Select-String on Windows.
|
||||
const LAUNCH = (task: string, run: string): string => `{"type":"tool","content":"{\\"taskId\\":\\"${task}\\",\\"taskType\\":\\"local_workflow\\",\\"runId\\":\\"${run}\\"}"}`.replace(/\\"/g, '"')
|
||||
|
||||
describe('windowsSearchArgv', () => {
|
||||
it('is a PowerShell command whose script has no double quote, and carries the path and the pattern only as base64', () => {
|
||||
const argv = windowsSearchArgv('C:\\Users\\John Smith\\.claude\\projects\\a$b\'c;d&e\\s.jsonl', 'wf_abc12345') as readonly string[]
|
||||
|
||||
expect(argv.slice(0, 4)).toEqual(['powershell', '-NoProfile', '-NonInteractive', '-Command'])
|
||||
expect(argv).toHaveLength(5)
|
||||
expect(argv[4]).not.toContain('"')
|
||||
expect(argv[4]).not.toContain('John Smith')
|
||||
expect(argv[4]).not.toContain('wf_abc12345')
|
||||
expect(argv[4]).toContain('Select-String -LiteralPath $p -Pattern $q -AllMatches')
|
||||
})
|
||||
|
||||
it('refuses a run id that is not one, an empty path and a NUL', () => {
|
||||
expect(windowsSearchArgv('C:\\s.jsonl', 'wf_x"; Remove-Item *')).toBeNull()
|
||||
expect(windowsSearchArgv('C:\\s.jsonl', 'not-a-run')).toBeNull()
|
||||
expect(windowsSearchArgv('', 'wf_abc12345')).toBeNull()
|
||||
expect(windowsSearchArgv('C:\\s\0.jsonl', 'wf_abc12345')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('taskIdOf on what a search printed (any platform)', () => {
|
||||
it('takes the latest launch when the output holds two of one run (a resume), as the whole-file reader does', () => {
|
||||
const printed = [`"taskId":"first0task","taskType":"local_workflow","runId":"wf_abc12345"`, `"taskId":"resumed9tk","taskType":"local_workflow","runId":"wf_abc12345"`].join('\n')
|
||||
|
||||
expect(taskIdOf(printed, 'wf_abc12345')).toBe('resumed9tk')
|
||||
expect(taskIdOf(printed.split('\n')[0] as string, 'wf_abc12345')).toBe('first0task')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')('the Windows search, through a real PowerShell', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'wf search '))
|
||||
const folder = join(root, "o'brien $x & y; z")
|
||||
|
||||
mkdirSync(folder)
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const file = join(folder, 's.jsonl')
|
||||
const filler = Array.from({ length: 20_000 }, (_unused, i) => JSON.stringify({ type: 'user', i, pad: 'x'.repeat(120) }))
|
||||
|
||||
writeFileSync(file, [...filler.slice(0, 50), LAUNCH('first0task', 'wf_abc12345'), ...filler.slice(50, 900), LAUNCH('resumed9tk', 'wf_abc12345'), LAUNCH('other1task', 'wf_zzz99999'), ...filler.slice(900)].join('\r\n'))
|
||||
|
||||
const search = (path: string, runId: string): Promise<{ code: number; stdout: string }> =>
|
||||
new Promise(resolve => {
|
||||
const argv = windowsSearchArgv(path, runId) as readonly string[]
|
||||
|
||||
execFile(argv[0] as string, argv.slice(1), { timeout: 30_000, windowsHide: true }, (error, stdout) => resolve({ code: error === null ? 0 : 1, stdout }))
|
||||
})
|
||||
|
||||
it('prints only the launch lines of that run, through a path with a space, a quote, $, & and ;', { timeout: 30_000 }, async () => {
|
||||
const found = await search(file, 'wf_abc12345')
|
||||
|
||||
expect(found.code).toBe(0)
|
||||
expect(found.stdout).toContain('first0task')
|
||||
expect(found.stdout).toContain('resumed9tk')
|
||||
expect(found.stdout).not.toContain('other1task')
|
||||
expect(found.stdout).not.toContain('"user"')
|
||||
})
|
||||
|
||||
it('gives the same answer as the in-process reader: the last launch of the run wins (a resume), another run is not found', { timeout: 30_000 }, async () => {
|
||||
expect(taskIdOf((await search(file, 'wf_abc12345')).stdout, 'wf_abc12345')).toBe('resumed9tk')
|
||||
expect(taskIdOf((await search(file, 'wf_zzz99999')).stdout, 'wf_zzz99999')).toBe('other1task')
|
||||
expect(taskIdOf((await search(file, 'wf_missing00')).stdout, 'wf_missing00')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { sendTo, type SendDeps } from '../hooks/data/wf-send'
|
||||
import { parseConfig, targetsOf, type Target } from '../hooks/data/wf-targets'
|
||||
|
||||
// A model key is read from the environment of the process Claude Code runs in. `printenv` does that on Linux and macOS; Windows has none, so
|
||||
// there the engine's own environment read (host.env) supplies it and no program is run.
|
||||
const CONFIG = 'endpoint:a=https://a.example.com/v1|KEY_A|m1'
|
||||
|
||||
function rig(env?: (name: string) => Promise<string | undefined>) {
|
||||
const ran: string[][] = []
|
||||
const headers: Record<string, string>[] = []
|
||||
const deps = {
|
||||
cli: [],
|
||||
helper: '',
|
||||
trustedPeers: new Set<string>(),
|
||||
cwd: 'C:\\proj',
|
||||
hive: null,
|
||||
config: parseConfig(CONFIG),
|
||||
run: async (argv: readonly string[]) => {
|
||||
ran.push([...argv])
|
||||
|
||||
return argv[0] === 'printenv' ? { exitCode: 0, stdout: 'from-printenv\n', stderr: '' } : { exitCode: 127, stdout: '', stderr: 'not found' }
|
||||
},
|
||||
httpSend: async (_url: string, init: { headers: Record<string, string> }) => {
|
||||
headers.push(init.headers)
|
||||
|
||||
return { ok: true, status: 200, text: JSON.stringify({ model: 'm1', choices: [{ message: { content: 'hello' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } }) }
|
||||
},
|
||||
submitPrompt: async () => undefined,
|
||||
...(env !== undefined && { readEnv: env }),
|
||||
} as unknown as SendDeps
|
||||
const target = targetsOf({ agents: [], peers: [], config: deps.config, hasHive: false, hasCodex: false }).find(candidate => candidate.id === 'a') as Target
|
||||
|
||||
return { deps, target, ran, headers }
|
||||
}
|
||||
|
||||
describe('reading a model key for an endpoint send', () => {
|
||||
it('uses the engine environment read where it is given (Windows) and runs no program', async () => {
|
||||
const r = rig(async name => (name === 'KEY_A' ? ' win-secret ' : undefined))
|
||||
const result = await sendTo(r.deps, r.target, 'hello')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(r.headers[0]?.authorization).toBe('Bearer win-secret')
|
||||
expect(r.ran).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses, sending nothing, when that read finds no key or fails', async () => {
|
||||
for (const env of [async () => undefined, async () => '', async (): Promise<string | undefined> => Promise.reject(new Error('refused'))]) {
|
||||
const r = rig(env)
|
||||
const result = await sendTo(r.deps, r.target, 'hello')
|
||||
|
||||
expect(result.state).toBe('refused')
|
||||
expect(result.text).toContain('KEY_A is not set')
|
||||
expect(r.headers).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps `printenv` where no environment read is given (Linux, macOS)', async () => {
|
||||
const r = rig()
|
||||
const result = await sendTo(r.deps, r.target, 'hello')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(r.ran).toEqual([['printenv', 'KEY_A']])
|
||||
expect(r.headers[0]?.authorization).toBe('Bearer from-printenv')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sending to a federation peer', () => {
|
||||
const peerRig = (helper: string) => {
|
||||
const ran: string[][] = []
|
||||
const deps = {
|
||||
cli: [],
|
||||
helper,
|
||||
trustedPeers: new Set(['zenbook']),
|
||||
cwd: '/work',
|
||||
hive: null,
|
||||
config: parseConfig(''),
|
||||
run: async (argv: readonly string[]) => (ran.push([...argv]), { exitCode: 0, stdout: 'peer says hi', stderr: '' }),
|
||||
httpSend: async () => ({ ok: true, status: 200, text: '' }),
|
||||
submitPrompt: async () => undefined,
|
||||
} as unknown as SendDeps
|
||||
const target = targetsOf({ agents: [], peers: [{ host: 'zenbook', trusted: true }], config: deps.config, hasHive: false, hasCodex: false }).find(candidate => candidate.id === 'peer-zenbook') as Target
|
||||
|
||||
return { deps, target, ran }
|
||||
}
|
||||
|
||||
it('is refused on Windows, running nothing: the helper is a bash script and `bash` there is the WSL launcher', async () => {
|
||||
for (const helper of ['C:\\Users\\u/.claude/helpers/federation.sh', 'C:/Users/u/.claude/helpers/federation.sh']) {
|
||||
const r = peerRig(helper)
|
||||
const result = await sendTo(r.deps, r.target, 'hello')
|
||||
|
||||
expect(result).toMatchObject({ ok: false, state: 'refused' })
|
||||
expect(result.text).toContain('cannot run from here')
|
||||
expect(r.ran).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('still runs the helper with bash on a POSIX home', async () => {
|
||||
const r = peerRig('/home/u/.claude/helpers/federation.sh')
|
||||
const result = await sendTo(r.deps, r.target, 'hello')
|
||||
|
||||
expect(result).toMatchObject({ ok: true, state: 'reply', text: 'peer says hi' })
|
||||
expect(r.ran).toEqual([['bash', '/home/u/.claude/helpers/federation.sh', 'dispatch', 'zenbook', 'hello']])
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ Newest first. One `## <version> — <date>` heading per version, then `feat:`, `
|
||||
## 0.28.3 — 2026-10-24
|
||||
- fix: `ledger.mjs`, `advise.mjs` and `local-cost.mjs` do nothing on Windows: they ran only when `import.meta.url === "file://" + argv[1]`, which is never true there (`file:///C:/…` against `C:…`), so every call exited 0 with no output. They now compare against `pathToFileURL(argv[1])`.
|
||||
- fix: `ledger.mjs --project` takes a drive-lettered path, and the project filter matches Windows paths ignoring separator style and drive-letter case, so the ruflo console's mission spend works on Windows.
|
||||
- chore: `scripts/smoke.sh` builds its root with `pwd -W` under Git Bash, so a Windows `node` can open the paths it is given (5 steps failed there before).
|
||||
|
||||
## 0.28.2 — 2026-10-12
|
||||
- fix: a failed Stop-hook telemetry write is logged to ~/.claude-flow/logs/cost-tracker-failures.log (capped at 512 KB, rotated to the newest 200 lines) instead of being discarded; the hook still never blocks a turn (#3227, #4078, thanks @nicholas-ruest)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Structural smoke test for ruflo-cost-tracker v0.28.3.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# `pwd -W` (Git Bash on Windows) gives C:/Users/...; plain pwd gives /c/Users/..., which a Windows node cannot open. Elsewhere -W fails and pwd answers.
|
||||
ROOT="$(cd "$(dirname "$0")/.." && { pwd -W 2>/dev/null || pwd; })"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
step() { printf "→ %s ... " "$1"; }
|
||||
|
||||
Reference in new issue
Block a user