@iceinvein/agent-skills 0.23.9 → 0.23.13

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 (75) hide show
  1. package/package.json +1 -1
  2. package/skills/index.json +2 -2
  3. package/skills/magpie/SKILL.md +21 -20
  4. package/skills/magpie/bin/magpie.ts +63 -2
  5. package/skills/magpie/evals/quality/README.md +74 -1
  6. package/skills/magpie/evals/quality/__tests__/probe-specialist.test.ts +498 -0
  7. package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +9 -0
  8. package/skills/magpie/evals/quality/probe-specialist.ts +672 -0
  9. package/skills/magpie/evals/quality/replay-full.ts +6 -2
  10. package/skills/magpie/fixtures/fake-gh-with-lockfile.sh +37 -2
  11. package/skills/magpie/fixtures/fake-gh.sh +49 -2
  12. package/skills/magpie/references/critic.md +20 -4
  13. package/skills/magpie/references/proof.md +102 -0
  14. package/skills/magpie/references/scout.md +6 -1
  15. package/skills/magpie/references/specialists.md +15 -9
  16. package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +103 -7
  17. package/skills/magpie/scripts/__tests__/cli.test.ts +69 -0
  18. package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +46 -0
  19. package/skills/magpie/scripts/__tests__/critic.test.ts +11 -1
  20. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +20 -1
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +23 -4
  22. package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +68 -0
  23. package/skills/magpie/scripts/__tests__/history.test.ts +232 -0
  24. package/skills/magpie/scripts/__tests__/impact-cmd.test.ts +477 -0
  25. package/skills/magpie/scripts/__tests__/impact-render.test.ts +365 -0
  26. package/skills/magpie/scripts/__tests__/impact.test.ts +915 -0
  27. package/skills/magpie/scripts/__tests__/incremental.test.ts +59 -8
  28. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -1
  29. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +58 -0
  30. package/skills/magpie/scripts/__tests__/pr-activity.test.ts +281 -0
  31. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +4 -4
  32. package/skills/magpie/scripts/__tests__/previous-findings.test.ts +267 -0
  33. package/skills/magpie/scripts/__tests__/proof-cmd.test.ts +260 -0
  34. package/skills/magpie/scripts/__tests__/proof.test.ts +226 -0
  35. package/skills/magpie/scripts/__tests__/refresh.test.ts +71 -0
  36. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +34 -0
  37. package/skills/magpie/scripts/__tests__/render-findings.test.ts +75 -0
  38. package/skills/magpie/scripts/__tests__/render-progress.test.ts +9 -0
  39. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +376 -1
  40. package/skills/magpie/scripts/__tests__/shard-coverage.test.ts +79 -0
  41. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +48 -2
  42. package/skills/magpie/scripts/__tests__/status-cmd.test.ts +20 -8
  43. package/skills/magpie/scripts/__tests__/types.test.ts +46 -0
  44. package/skills/magpie/scripts/cleanup-cmd.ts +88 -0
  45. package/skills/magpie/scripts/critic-cmd.ts +2 -0
  46. package/skills/magpie/scripts/critic.ts +4 -0
  47. package/skills/magpie/scripts/dedupe-cmd.ts +4 -0
  48. package/skills/magpie/scripts/evidence-filter.ts +10 -0
  49. package/skills/magpie/scripts/git-diff.ts +50 -19
  50. package/skills/magpie/scripts/handoff-cmd.ts +42 -3
  51. package/skills/magpie/scripts/history.ts +143 -0
  52. package/skills/magpie/scripts/impact-cmd.ts +202 -0
  53. package/skills/magpie/scripts/impact-render.ts +158 -0
  54. package/skills/magpie/scripts/impact.ts +622 -0
  55. package/skills/magpie/scripts/incremental.ts +44 -5
  56. package/skills/magpie/scripts/labels-cmd.ts +7 -3
  57. package/skills/magpie/scripts/post-cmd.ts +46 -0
  58. package/skills/magpie/scripts/pr-activity.ts +243 -0
  59. package/skills/magpie/scripts/preview-cmd.ts +3 -0
  60. package/skills/magpie/scripts/previous-findings.ts +181 -0
  61. package/skills/magpie/scripts/proof-cmd.ts +226 -0
  62. package/skills/magpie/scripts/proof.ts +150 -0
  63. package/skills/magpie/scripts/refresh.ts +11 -0
  64. package/skills/magpie/scripts/render-annotation.ts +17 -0
  65. package/skills/magpie/scripts/render-cmd.ts +12 -0
  66. package/skills/magpie/scripts/render-findings.ts +37 -1
  67. package/skills/magpie/scripts/render-progress.ts +3 -0
  68. package/skills/magpie/scripts/setup-cmd.ts +142 -11
  69. package/skills/magpie/scripts/shard-coverage.ts +53 -0
  70. package/skills/magpie/scripts/status-cmd.ts +2 -0
  71. package/skills/magpie/scripts/types.ts +39 -0
  72. package/skills/magpie/skill.json +1 -1
  73. package/skills/magpie/templates/styles.css +30 -0
  74. package/skills/sluice/scripts/status.sh +32 -13
  75. package/skills/sluice/skill.json +1 -1
@@ -1,10 +1,13 @@
1
1
  import { appendFile, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
3
  import { fetchPr } from './gh.ts'
4
- import { buildIncrementalContext, findPreviousRun } from './incremental.ts'
4
+ import { resolvePrHead } from './git-diff.ts'
5
+ import { buildIncrementalContext, changedFilesSince, findPreviousRun } from './incremental.ts'
5
6
  import { diffSha256, localBranchDiff, localBranchMeta } from './local-diff.ts'
6
7
  import { filterDiff, loadPathFilterConfig } from './path-filter.ts'
8
+ import { fetchPrActivity } from './pr-activity.ts'
7
9
  import { type Deps, defaultDeps, preflight, renderInstallHint } from './preflight.ts'
10
+ import { readPreviousFindings, writePreviousFindings } from './previous-findings.ts'
8
11
  import { shardDiff } from './shard.ts'
9
12
  import { writeTarget } from './target.ts'
10
13
  import { detectMissingTests } from './tests-check.ts'
@@ -93,6 +96,13 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
93
96
  await cleanup(runDir)
94
97
  return diffExit
95
98
  }
99
+ if (input.mode === 'pr') {
100
+ const activityExit = await writePrActivity(runDir, deps, repoPath, input.prNumber)
101
+ if (activityExit !== 0) {
102
+ await cleanup(runDir)
103
+ return activityExit
104
+ }
105
+ }
96
106
 
97
107
  await applyPathFilter(runDir, repoPath)
98
108
 
@@ -113,15 +123,35 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
113
123
  return 0
114
124
  }
115
125
 
116
- await detectIncrementalReview(runDir, input.prNumber)
126
+ const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as { headRefOid: string }
127
+ const head = await resolvePrHead({
128
+ gitBin: deps.git,
129
+ repoPath,
130
+ prNumber: input.prNumber,
131
+ headRefOid: prJson.headRefOid,
132
+ })
133
+ if (!head.ok) {
134
+ await logLine(runDir, { stage: 'worktree', status: 'error', error: head.error })
135
+ process.stderr.write(`magpie: ${head.error}\n`)
136
+ await cleanup(runDir)
137
+ return 5
138
+ }
117
139
 
118
- const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as { headRefName: string }
119
- const branch = prJson.headRefName
140
+ let previous: { runId: string; runDir: string } | null
141
+ try {
142
+ previous = await detectIncrementalReview(runDir, deps.git, repoPath, input.prNumber)
143
+ } catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err)
145
+ await logLine(runDir, { stage: 'incremental', status: 'error', error: message })
146
+ process.stderr.write(`magpie: ${message}\n`)
147
+ await cleanup(runDir)
148
+ return 4
149
+ }
120
150
 
121
151
  const wt = await createWorktree({
122
152
  gitBin: deps.git,
123
153
  repoPath,
124
- branch,
154
+ branch: head.sha,
125
155
  runDir: runDir,
126
156
  })
127
157
  if (!wt.ok) {
@@ -146,10 +176,90 @@ export async function runSetup(input: RunSetupInput): Promise<number> {
146
176
  await cleanup(runDir)
147
177
  return 5
148
178
  }
179
+ if (previous) {
180
+ try {
181
+ await carryPreviousFindings(runDir, previous, wt.worktreePath)
182
+ } catch (err) {
183
+ const message = err instanceof Error ? err.message : String(err)
184
+ await logLine(runDir, { stage: 'previous-findings', status: 'error', error: message })
185
+ process.stderr.write(`magpie: ${message}\n`)
186
+ const removed = await removeWorktree({
187
+ gitBin: deps.git,
188
+ repoPath,
189
+ worktreePath: wt.worktreePath,
190
+ })
191
+ if (!removed.ok) process.stderr.write(`magpie: ${removed.error}\n`)
192
+ await cleanup(runDir)
193
+ return 5
194
+ }
195
+ }
149
196
  await logLine(runDir, { stage: 'setup', status: 'done', worktree: wt.worktreePath })
150
197
  return 0
151
198
  }
152
199
 
200
+ async function writePrActivity(
201
+ runDir: string,
202
+ deps: Deps,
203
+ repoPath: string,
204
+ prNumber: number,
205
+ ): Promise<number> {
206
+ const fail = async (error: string): Promise<number> => {
207
+ await logLine(runDir, { stage: 'pr-activity', status: 'error', error })
208
+ process.stderr.write(`magpie: ${error}\n`)
209
+ return 4
210
+ }
211
+ const prJson = (await Bun.file(join(runDir, 'pr.json')).json()) as { url?: unknown }
212
+ if (typeof prJson.url !== 'string') return fail('pr.json has no url')
213
+ let result: Awaited<ReturnType<typeof fetchPrActivity>>
214
+ try {
215
+ result = await fetchPrActivity({
216
+ ghBin: deps.gh,
217
+ prNumber,
218
+ prUrl: prJson.url,
219
+ runDir,
220
+ cwd: repoPath,
221
+ })
222
+ } catch (err) {
223
+ return fail(err instanceof Error ? err.message : String(err))
224
+ }
225
+ if (!result.ok) return fail(result.error)
226
+ await logLine(runDir, {
227
+ stage: 'pr-activity',
228
+ status: 'done',
229
+ checks: result.activity.checks.length,
230
+ threads: result.activity.threads.length,
231
+ comments: result.activity.comments.length,
232
+ })
233
+ return 0
234
+ }
235
+
236
+ async function carryPreviousFindings(
237
+ runDir: string,
238
+ previous: { runId: string; runDir: string },
239
+ worktree: string,
240
+ ): Promise<void> {
241
+ const carried = await readPreviousFindings({
242
+ runDir: previous.runDir,
243
+ runId: previous.runId,
244
+ worktree,
245
+ })
246
+ if (!carried) {
247
+ await logLine(runDir, {
248
+ stage: 'previous-findings',
249
+ status: 'none',
250
+ previousRunId: previous.runId,
251
+ })
252
+ return
253
+ }
254
+ await writePreviousFindings(runDir, carried)
255
+ await logLine(runDir, {
256
+ stage: 'previous-findings',
257
+ status: 'done',
258
+ previousRunId: previous.runId,
259
+ findings: carried.findings.length,
260
+ })
261
+ }
262
+
153
263
  /** realpath of the nearest existing ancestor, with the not-yet-created tail re-appended. */
154
264
  async function realpathAllowingMissing(path: string): Promise<string> {
155
265
  const absolute = resolve(path)
@@ -309,6 +419,8 @@ async function cleanup(runDir: string): Promise<void> {
309
419
  'excluded-files.json',
310
420
  'shards',
311
421
  'incremental.json',
422
+ 'pr-activity.json',
423
+ 'previous-findings.json',
312
424
  'findings',
313
425
  'screen',
314
426
  'state',
@@ -340,25 +452,44 @@ async function runTestsCheck(runDir: string): Promise<void> {
340
452
  await logLine(runDir, { stage: 'tests-check', status: 'done', findings: findings.length })
341
453
  }
342
454
 
343
- async function detectIncrementalReview(runDir: string, prNumber: number): Promise<void> {
344
- const previous = await findPreviousRun(prNumber, runDir)
455
+ /** The previous run, for setup to carry its findings forward once the worktree exists. */
456
+ async function detectIncrementalReview(
457
+ runDir: string,
458
+ gitBin: string,
459
+ repoPath: string,
460
+ prNumber: number,
461
+ ): Promise<{ runId: string; runDir: string } | null> {
462
+ const currentPr = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
463
+ if (typeof currentPr.url !== 'string') throw new Error('pr.json has no url')
464
+ const previous = await findPreviousRun(prNumber, currentPr.url, runDir)
345
465
  if (!previous) {
346
466
  await logLine(runDir, { stage: 'incremental', status: 'first-run' })
347
- return
467
+ return null
348
468
  }
349
- const currentPr = (await Bun.file(join(runDir, 'pr.json')).json()) as Record<string, unknown>
350
469
  const context = buildIncrementalContext(currentPr, previous)
351
470
  if (!context) {
352
471
  await logLine(runDir, { stage: 'incremental', status: 'missing-sha' })
353
- return
472
+ return previous
354
473
  }
355
- await writeFile(join(runDir, 'incremental.json'), `${JSON.stringify(context, null, 2)}\n`)
474
+ const changedSincePrevious = context.sameSha
475
+ ? []
476
+ : await changedFilesSince({
477
+ gitBin,
478
+ repoPath,
479
+ fromSha: context.previousSha,
480
+ toSha: context.currentSha,
481
+ })
482
+ await writeFile(
483
+ join(runDir, 'incremental.json'),
484
+ `${JSON.stringify({ ...context, changedSincePrevious }, null, 2)}\n`,
485
+ )
356
486
  await logLine(runDir, {
357
487
  stage: 'incremental',
358
488
  status: 'done',
359
489
  previousRunId: context.previousRunId,
360
490
  sameSha: context.sameSha,
361
491
  })
492
+ return previous
362
493
  }
363
494
 
364
495
  async function applyPathFilter(runDir: string, repoPath: string): Promise<void> {
@@ -0,0 +1,53 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { ShardManifest } from './shard.ts'
4
+
5
+ /** The shards stage 4 skipped at the gate, from the run's last `shard-coverage` entry. */
6
+ export type ShardCoverage = { skipped: Array<{ id: number; files: string[] }> }
7
+
8
+ function isMissingFile(err: unknown): boolean {
9
+ return (err as NodeJS.ErrnoException)?.code === 'ENOENT'
10
+ }
11
+
12
+ /** Null when no shard was skipped, which is every run that did not answer the gate that way. */
13
+ export async function readShardCoverage(runDir: string): Promise<ShardCoverage | null> {
14
+ let log: string
15
+ try {
16
+ log = await readFile(join(runDir, 'log.jsonl'), 'utf8')
17
+ } catch (err) {
18
+ if (isMissingFile(err)) return null
19
+ throw err
20
+ }
21
+ let skippedIds: number[] | null = null
22
+ for (const line of log.split('\n').filter(Boolean)) {
23
+ let entry: Record<string, unknown>
24
+ try {
25
+ entry = JSON.parse(line)
26
+ } catch {
27
+ continue
28
+ }
29
+ if (entry.stage !== 'shard-coverage' || entry.status !== 'partial') continue
30
+ if (!Array.isArray(entry.skipped) || !entry.skipped.every((id) => Number.isInteger(id))) {
31
+ throw new Error(`${join(runDir, 'log.jsonl')}: shard-coverage skipped must be shard ids`)
32
+ }
33
+ skippedIds = entry.skipped as number[]
34
+ }
35
+ if (skippedIds === null || skippedIds.length === 0) return null
36
+ const manifestPath = join(runDir, 'shards', 'manifest.json')
37
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ShardManifest
38
+ return {
39
+ skipped: skippedIds.map((id) => {
40
+ const shard = manifest.shards.find((s) => s.id === id)
41
+ if (!shard) {
42
+ throw new Error(`${manifestPath}: shard ${id} was skipped but is not in the manifest`)
43
+ }
44
+ return { id, files: shard.files }
45
+ }),
46
+ }
47
+ }
48
+
49
+ export function describeUnreviewed(coverage: ShardCoverage): string {
50
+ const ids = coverage.skipped.map((s) => s.id)
51
+ const files = coverage.skipped.reduce((n, s) => n + s.files.length, 0)
52
+ return `Not reviewed: ${ids.length === 1 ? 'shard' : 'shards'} ${ids.join(', ')} (${files} ${files === 1 ? 'file' : 'files'})`
53
+ }
@@ -12,6 +12,7 @@ export const ORDER = [
12
12
  'dedupe',
13
13
  'critic',
14
14
  'peer-review',
15
+ 'proof',
15
16
  'report',
16
17
  'post',
17
18
  ] as const
@@ -24,6 +25,7 @@ export const LOCAL_ORDER = [
24
25
  'dedupe',
25
26
  'critic',
26
27
  'peer-review',
28
+ 'proof',
27
29
  'handoff',
28
30
  ] as const
29
31
 
@@ -42,6 +42,19 @@ export type MergedFromEntry = {
42
42
  title: string
43
43
  }
44
44
 
45
+ export type Repro = {
46
+ /** Where the prover wrote the repro, relative to the worktree. It is deleted after the run. */
47
+ path: string
48
+ command: string
49
+ source: string
50
+ output: string
51
+ }
52
+
53
+ /** A disproved finding is dropped, so a kept finding carries only these two. */
54
+ export type Proof =
55
+ | { status: 'reproduced'; reason: string; repro: Repro }
56
+ | { status: 'not-proven'; reason: string }
57
+
45
58
  export type ReviewFinding = {
46
59
  id: string
47
60
  file: string
@@ -62,6 +75,8 @@ export type ReviewFinding = {
62
75
  onChangedLine?: boolean | null
63
76
  /** Verbatim code the reviewer quoted as grounds for the finding. */
64
77
  evidence?: string
78
+ /** Set by the proof stage on the findings it tried to reproduce. */
79
+ proof?: Proof
65
80
  }
66
81
 
67
82
  const IMPACT_SYNONYMS: Record<string, Impact> = {
@@ -262,6 +277,29 @@ export function looksLikeProse(body: string): boolean {
262
277
  return false
263
278
  }
264
279
 
280
+ export function isRepro(raw: unknown): raw is Repro {
281
+ if (!raw || typeof raw !== 'object') return false
282
+ const r = raw as Record<string, unknown>
283
+ return [r.path, r.command, r.source, r.output].every((v) => typeof v === 'string' && v !== '')
284
+ }
285
+
286
+ function parseProof(raw: unknown): Proof {
287
+ if (!raw || typeof raw !== 'object') throw new Error('Finding.proof must be an object')
288
+ const p = raw as Record<string, unknown>
289
+ if (typeof p.reason !== 'string' || p.reason === '') {
290
+ throw new Error('Finding.proof.reason must be a non-empty string')
291
+ }
292
+ if (p.status === 'not-proven') return { status: 'not-proven', reason: p.reason }
293
+ if (p.status !== 'reproduced') {
294
+ throw new Error('Finding.proof.status must be reproduced or not-proven')
295
+ }
296
+ if (!isRepro(p.repro)) {
297
+ throw new Error('Finding.proof.repro needs path, command, source and output')
298
+ }
299
+ const { path, command, source, output } = p.repro
300
+ return { status: 'reproduced', reason: p.reason, repro: { path, command, source, output } }
301
+ }
302
+
265
303
  export function parseFinding(raw: unknown): ReviewFinding {
266
304
  if (!raw || typeof raw !== 'object') {
267
305
  throw new Error(`Finding must be an object, got ${typeof raw}`)
@@ -306,6 +344,7 @@ export function parseFinding(raw: unknown): ReviewFinding {
306
344
  ? { onChangedLine: r.onChangedLine as boolean | null }
307
345
  : {}),
308
346
  ...(typeof r.evidence === 'string' && r.evidence.length > 0 ? { evidence: r.evidence } : {}),
347
+ ...(r.proof !== undefined ? { proof: parseProof(r.proof) } : {}),
309
348
  }
310
349
  }
311
350
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture) plus a once-per-PR checks pass that finds tests and controls able to pass without proving their purpose, drops findings whose quoted evidence is not in the code, dedupes, runs critic subagents that re-check each finding against the worktree and set its risk label, peer-reviews via codex exec (falling back to a Claude second opinion when codex is unavailable), and serves an interactive HTML report for selecting or dismissing findings to post via gh, recording the outcome per finding as labels. Bundles a Bun CLI put on PATH by the skill's postinstall step under a bunx install, or by the plugin's bin/ under a Claude Code plugin install. Use when the user asks to review a GitHub pull request. Local mode (setup --local) runs the same pipeline on the current branch's diff from its merge base, uncommitted and untracked work included, against the caller's live tree, with no server or posting, and hands the ranked findings back in the terminal for an implementing or orchestrating agent to address. Splits oversized diffs into budgeted shards and rebuilds the diff from the local clone when gh pr diff refuses it.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",
@@ -1785,3 +1785,33 @@ code.inline-code {
1785
1785
  color: var(--accent);
1786
1786
  text-decoration: none;
1787
1787
  }
1788
+
1789
+ .proof-chip {
1790
+ font-family: var(--font-mono);
1791
+ font-size: 10px;
1792
+ font-weight: 700;
1793
+ padding: 2px 6px;
1794
+ border-radius: 999px;
1795
+ flex-shrink: 0;
1796
+ }
1797
+
1798
+ .proof-chip.reproduced {
1799
+ background: var(--badge-posted-bg);
1800
+ color: var(--badge-posted-ink);
1801
+ }
1802
+
1803
+ .proof-chip.not-proven {
1804
+ background: var(--card-inset);
1805
+ color: var(--ink-muted);
1806
+ }
1807
+
1808
+ pre.repro {
1809
+ font-family: var(--font-mono);
1810
+ font-size: 12px;
1811
+ background: var(--card-inset);
1812
+ border-radius: var(--radius-sm);
1813
+ padding: 6px 8px;
1814
+ margin: 4px 0 0;
1815
+ white-space: pre-wrap;
1816
+ overflow-x: auto;
1817
+ }
@@ -862,11 +862,13 @@ write_state() {
862
862
  # their own task at the same moment. A flip reads the whole file, edits it with
863
863
  # jq and writes it back, so unserialised the later write is built on a snapshot
864
864
  # taken before the earlier one landed and drops that row without saying so.
865
- # mkdir is the atomic primitive every platform this runs on has; flock is Linux
866
- # only. Reads do not take it: write_state installs through a rename, so a reader
867
- # sees either the whole old file or the whole new one.
865
+ # The lock is a file created with bash's noclobber, which bash opens itself with
866
+ # O_CREAT|O_EXCL, so exactly one racer gets it on every platform. Not mkdir:
867
+ # Ubuntu 26.04's uutils mkdir reports success to several callers racing on one
868
+ # path. flock is Linux only. Reads do not take the lock: write_state installs
869
+ # through a rename, so a reader sees either the whole old file or the whole new one.
868
870
  #
869
- # The holder's pid goes inside the directory so a killed run cannot wedge every
871
+ # The holder's pid is the file's content so a killed run cannot wedge every
870
872
  # later one. A lock whose holder is gone is broken rather than waited out, and
871
873
  # one whose holder is alive is waited on for a bounded time and then reported,
872
874
  # because a command that hangs in a status bar is worse than one that fails.
@@ -874,18 +876,36 @@ write_state() {
874
876
  LOCKS_TAKEN=()
875
877
  release_lock() {
876
878
  local l
877
- for l in "${LOCKS_TAKEN[@]+"${LOCKS_TAKEN[@]}"}"; do rm -rf "$l"; done
879
+ for l in "${LOCKS_TAKEN[@]+"${LOCKS_TAKEN[@]}"}"; do rm -f "$l"; done
878
880
  LOCKS_TAKEN=()
879
881
  }
880
882
 
883
+ # Every waiter reads the holder's pid while it holds, so when it exits they all
884
+ # see it dead at once. Each removing the lock on that stale read would remove
885
+ # the lock the next command had just taken, leaving two writers. Breaking
886
+ # is serialised instead, and the pid is read again once its process is known
887
+ # dead: the holder may have released and a new command taken the lock between
888
+ # the first read and the check. A dead holder cannot remove its own lock,
889
+ # so the same pid on the second read means the lock is still the stale one.
890
+ # A file read empty is a holder between creating it and writing its pid, alive.
891
+ break_stale_lock() { # <lock path>
892
+ local lock="$1" holder broke=1
893
+ (set -o noclobber; : >"$lock.break") 2>/dev/null || return 1
894
+ holder="$(cat "$lock" 2>/dev/null)"
895
+ if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null \
896
+ && [ "$(cat "$lock" 2>/dev/null)" = "$holder" ]; then
897
+ rm -f "$lock"
898
+ broke=0
899
+ fi
900
+ rm -f "$lock.break"
901
+ return "$broke"
902
+ }
903
+
881
904
  take_lock() { # [<lock path>, default the run's own]
882
- local lock="${1:-$LOCK}" waited=0 holder
883
- while ! mkdir "$lock" 2>/dev/null; do
884
- holder="$(cat "$lock/pid" 2>/dev/null)"
885
- if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then
886
- rm -rf "$lock"
887
- continue
888
- fi
905
+ local lock="${1:-$LOCK}" waited=0
906
+ # $$ in the subshell is still this command's pid.
907
+ while ! (set -o noclobber; printf '%s\n' "$$" >"$lock") 2>/dev/null; do
908
+ break_stale_lock "$lock" && continue
889
909
  if [ "$waited" -ge 100 ]; then
890
910
  err "another sluice command has held $lock for 10s; remove it if nothing is running"
891
911
  exit 1
@@ -893,7 +913,6 @@ take_lock() { # [<lock path>, default the run's own]
893
913
  sleep 0.1
894
914
  waited=$((waited + 1))
895
915
  done
896
- printf '%s\n' "$$" >"$lock/pid" 2>/dev/null || true
897
916
  LOCKS_TAKEN+=("$lock")
898
917
  trap release_lock EXIT INT TERM
899
918
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sluice",
3
- "version": "0.28.0",
3
+ "version": "0.28.2",
4
4
  "description": "Routes work by change shape into four channels (bypass, fast, main, deep) and applies only the rules each channel needs, so a one-line fix does not pay the cost of a multi-subsystem build. Carries nine rules as one-liners in the router and the full treatment in references read only on friction. Deep runs read the repo before designing, map the spec's intent to its decisions, and check spec against intent and plan against spec with a fresh reader, with plan.sh validate enforcing that every decision is covered. Checks the finished plan with plan.sh validate rather than trusting it to memory, seeds the run state from it, keeps a deep run's task breakdown in .sluice/run.json so a statusline segment, one status command and a SessionStart hook can answer where the run is (the hook prints a live run at every session start, compaction included), lists every live deep run and routed fast or main session on the machine in one board.sh view (drawn once, watched, or as JSON), and closes each run with a ledger read out of the session transcript: elapsed, tools, tokens, and what each dispatched agent cost where the transcript recorded it. Two Stop hooks hold a handback: one refuses a tree that moved since its last recorded check, the other refuses a changed existing test not proven to fail on the session's starting code (or recorded with a reason). Claude Code only; stands down where the superpowers pipeline governs the repo.",
5
5
  "author": "iceinvein",
6
6
  "type": "prompt",