@tduyng/pi-harness-loop 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tduyng/pi-harness-loop",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Reads what the session repeated, proposes the harness part that would have prevented it, and records the lesson that transfers.",
5
5
  "keywords": [
6
6
  "pi",
package/src/check.ts CHANGED
@@ -1,13 +1,10 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
- import { createLocalBashOperations } from '@earendil-works/pi-coding-agent'
4
+ import { createLocalBashOperations, truncateTail } from '@earendil-works/pi-coding-agent'
5
5
  import { Validator } from 'ata-validator'
6
6
  import { t } from 'ata-validator/t'
7
7
 
8
-
9
- import { runCheck as runSharedCheck, type ExecFn } from '../../pi-goal/src/check.js'
10
-
11
8
  export interface CheckResult {
12
9
  command: string
13
10
  ok: boolean
@@ -22,6 +19,13 @@ const PackageJson = t.object({
22
19
  })
23
20
  const isPackageJson = new Validator(PackageJson).isValidObject
24
21
 
22
+ const TEST_BY_LOCKFILE = [
23
+ ['bun.lock', 'bun run test'],
24
+ ['bun.lockb', 'bun run test'],
25
+ ['pnpm-lock.yaml', 'pnpm test'],
26
+ ['yarn.lock', 'yarn test']
27
+ ] as const satisfies readonly (readonly [string, string])[]
28
+
25
29
  function packageTest(cwd: string): string {
26
30
  const path = join(cwd, 'package.json')
27
31
  if (!existsSync(path)) return ''
@@ -33,7 +37,8 @@ function packageTest(cwd: string): string {
33
37
  }
34
38
  if (!isPackageJson(parsed)) return ''
35
39
  if (parsed.scripts?.['test'] === undefined) return ''
36
- return existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm test' : 'npm test'
40
+ const lockfile = TEST_BY_LOCKFILE.find(([file]) => existsSync(join(cwd, file)))
41
+ return lockfile?.[1] ?? 'npm test'
37
42
  }
38
43
 
39
44
  function justTest(cwd: string): string {
@@ -54,14 +59,59 @@ export function failedCount(output: string): number | null {
54
59
  return generic?.[1] === undefined ? null : Number(generic[1])
55
60
  }
56
61
 
57
- const localExec: ExecFn = (command, cwd, options) => createLocalBashOperations().exec(command, cwd, options)
62
+ export function failureNames(output: string): string[] | null {
63
+ const names = [...output.matchAll(/^\s*(?:\(fail\)|✕|×|✗)\s+(.+)$/gm)]
64
+ .map((match) => (match[1] ?? '').trim())
65
+ .filter((name) => name !== '')
66
+ if (names.length > 0) return names.toSorted()
67
+ return failedCount(output) === 0 ? [] : null
68
+ }
69
+
70
+ const MAX_CHECK_OUTPUT_BYTES = 1500
71
+ const DEFAULT_CHECK_TIMEOUT_MS = 300_000
72
+
73
+ async function runSharedCheck(
74
+ cwd: string,
75
+ command: string,
76
+ options: { timeoutMs?: number } = {}
77
+ ): Promise<{ ok: boolean; exitCode: number | null; durationMs: number; output: string }> {
78
+ const timeoutMs = options.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_CHECK_TIMEOUT_MS
79
+ let captured = Buffer.alloc(0)
80
+ const started = Date.now()
81
+ const finish = (exitCode: number | null, error?: string) => {
82
+ const durationMs = Date.now() - started
83
+ const output = captured.toString('utf8')
84
+ const body = error ? `${output}${output ? '\n' : ''}${error}` : output
85
+ const tail = truncateTail(body, { maxBytes: MAX_CHECK_OUTPUT_BYTES })
86
+ return {
87
+ ok: exitCode === 0,
88
+ exitCode,
89
+ durationMs,
90
+ output: tail.content.trim()
91
+ }
92
+ }
93
+ let exitCode: number | null
94
+ try {
95
+ ;({ exitCode } = await createLocalBashOperations().exec(command, cwd, {
96
+ onData: (data) => {
97
+ const combined = Buffer.concat([captured, data])
98
+ captured = combined.subarray(Math.max(0, combined.length - MAX_CHECK_OUTPUT_BYTES))
99
+ },
100
+ timeout: timeoutMs / 1000
101
+ }))
102
+ } catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error)
104
+ return finish(null, message)
105
+ }
106
+ return finish(exitCode)
107
+ }
58
108
 
59
109
  export async function runCheck(
60
110
  cwd: string,
61
111
  command: string,
62
112
  options: { timeoutMs?: number } = {}
63
113
  ): Promise<CheckResult> {
64
- const result = await runSharedCheck(localExec, cwd, command, options)
114
+ const result = await runSharedCheck(cwd, command, options)
65
115
  return {
66
116
  command,
67
117
  ok: result.ok,
package/src/diversity.ts CHANGED
@@ -9,7 +9,7 @@ export interface ProposalText {
9
9
  export function proposalTexts(harnessDir: string): ProposalText[] {
10
10
  if (!existsSync(harnessDir)) return []
11
11
  const out: ProposalText[] = []
12
- for (const name of readdirSync(harnessDir)) {
12
+ for (const name of readdirSync(harnessDir).toSorted()) {
13
13
  const dir = join(harnessDir, name)
14
14
  let entries: string[]
15
15
  try {
package/src/install.ts CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
4
4
 
5
5
  import { applyProposal, readProposal, revertApplied } from './apply.js'
6
6
  import { readScan } from './auto.js'
7
- import { discoverCheck, runCheck } from './check.js'
7
+ import { discoverCheck, failureNames, runCheck } from './check.js'
8
8
  import { runReplay } from './replay.js'
9
9
  import { acquireLock, appendInstall, readInstalls, readSettings } from './store.js'
10
10
  import { loadHarness } from './tree.js'
@@ -270,15 +270,26 @@ async function installLocked(
270
270
  key: options.key,
271
271
  cost: advertisedChars(applied)
272
272
  }
273
- if (check === '') return finalize(cwd, dir, install, context, `${refusal}, installed without a check`, options.command)
273
+ if (check === '')
274
+ return finalize(cwd, dir, install, context, `${refusal}, installed without a check`, options.command)
274
275
 
275
276
  const result = await runCheck(cwd, check)
276
277
  if (result.ok) return finalize(cwd, dir, install, context, refusal, options.command)
277
278
 
278
279
  revertApplied(applied, installRoots(context), true)
279
280
  const baseline = await runCheck(cwd, check)
281
+ const resultNames = failureNames(result.output)
282
+ const baselineNames = failureNames(baseline.output)
283
+ const sameFailure =
284
+ result.output === baseline.output ||
285
+ (resultNames !== null && baselineNames !== null && resultNames.every((name) => baselineNames.includes(name)))
280
286
  const innocent =
281
- !baseline.ok && baseline.failures !== null && result.failures !== null && result.failures <= baseline.failures
287
+ !baseline.ok &&
288
+ result.failures !== null &&
289
+ result.failures > 0 &&
290
+ baseline.failures !== null &&
291
+ result.failures <= baseline.failures &&
292
+ sameFailure
282
293
  if (innocent) {
283
294
  for (const file of applied) {
284
295
  mkdirSync(dirname(file.to), { recursive: true })
package/src/store.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
2
3
  import { dirname, join } from 'node:path'
3
4
 
4
5
  import { Validator } from 'ata-validator'
5
6
  import { t } from 'ata-validator/t'
6
7
 
7
-
8
8
  import { writeFileAtomic } from './atomic.js'
9
9
  import type { HarnessSettings, Install, Lesson } from './types.js'
10
10
 
@@ -243,9 +243,10 @@ const LOCK_STALE_MS = 5 * 60_000
243
243
  export function acquireLock(cwd: string): (() => void) | null {
244
244
  const path = join(harnessDir(cwd), 'lock')
245
245
  mkdirSync(dirname(path), { recursive: true })
246
+ const token = `${process.pid} ${Date.now()} ${randomUUID()}\n`
246
247
  const take = (): boolean => {
247
248
  try {
248
- writeFileSync(path, `${process.pid} ${Date.now()}\n`, { encoding: 'utf8', flag: 'wx' })
249
+ writeFileSync(path, token, { encoding: 'utf8', flag: 'wx' })
249
250
  return true
250
251
  } catch {
251
252
  return false
@@ -253,12 +254,17 @@ export function acquireLock(cwd: string): (() => void) | null {
253
254
  }
254
255
  if (!take()) {
255
256
  try {
256
- const at = Number(readFileSync(path, 'utf8').split(' ')[1])
257
+ const existing = readFileSync(path, 'utf8')
258
+ const at = Number(existing.split(' ')[1])
257
259
  if (Number.isFinite(at) && Date.now() - at > LOCK_STALE_MS) rmSync(path, { force: true })
258
260
  } catch {}
259
261
  if (!take()) return null
260
262
  }
261
- return () => rmSync(path, { force: true })
263
+ return () => {
264
+ try {
265
+ if (readFileSync(path, 'utf8') === token) rmSync(path, { force: true })
266
+ } catch {}
267
+ }
262
268
  }
263
269
 
264
270
  export function recordLessonUse(cwd: string, id: string): void {