@iceinvein/agent-skills 0.1.39 → 0.2.0

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 (148) hide show
  1. package/README.md +18 -2
  2. package/dist/cli/index.js +105 -28
  3. package/package.json +1 -1
  4. package/skills/index.json +14 -2
  5. package/skills/magpie/README.md +2 -1
  6. package/skills/magpie/SKILL.md +138 -45
  7. package/skills/magpie/bin/magpie.ts +43 -0
  8. package/skills/magpie/fixtures/example-pr/brief.json +18 -0
  9. package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
  10. package/skills/magpie/fixtures/fake-gh.sh +14 -0
  11. package/skills/magpie/package.json +1 -1
  12. package/skills/magpie/references/peer-review.md +7 -2
  13. package/skills/magpie/references/scout.md +90 -0
  14. package/skills/magpie/references/specialists.md +117 -6
  15. package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
  16. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
  17. package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
  18. package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
  19. package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
  20. package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
  22. package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
  23. package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
  24. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
  25. package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
  26. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
  27. package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
  28. package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
  29. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
  30. package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
  31. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
  32. package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
  33. package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
  34. package/skills/magpie/scripts/diff-chunks.ts +28 -0
  35. package/skills/magpie/scripts/findings-files.ts +32 -0
  36. package/skills/magpie/scripts/gh.ts +68 -14
  37. package/skills/magpie/scripts/git-diff.ts +111 -0
  38. package/skills/magpie/scripts/path-filter.ts +9 -5
  39. package/skills/magpie/scripts/preview-cmd.ts +11 -1
  40. package/skills/magpie/scripts/refresh.ts +32 -3
  41. package/skills/magpie/scripts/render-cmd.ts +35 -11
  42. package/skills/magpie/scripts/render-findings.ts +78 -2
  43. package/skills/magpie/scripts/render-progress.ts +6 -1
  44. package/skills/magpie/scripts/setup-cmd.ts +38 -1
  45. package/skills/magpie/scripts/shard.ts +171 -0
  46. package/skills/magpie/scripts/status-cmd.ts +7 -4
  47. package/skills/magpie/scripts/types.ts +50 -0
  48. package/skills/magpie/skill.json +2 -2
  49. package/skills/magpie/templates/styles.css +66 -0
  50. package/skills/migrate/README.md +194 -0
  51. package/skills/migrate/SKILL.md +197 -0
  52. package/skills/migrate/bin/migrate +15 -0
  53. package/skills/migrate/bin/migrate.ts +309 -0
  54. package/skills/migrate/biome.json +35 -0
  55. package/skills/migrate/bun.lock +24 -0
  56. package/skills/migrate/docs/architecture.md +294 -0
  57. package/skills/migrate/docs/reference.md +590 -0
  58. package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
  59. package/skills/migrate/fixtures/tiny-express/app.js +29 -0
  60. package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
  61. package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
  62. package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
  63. package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
  64. package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
  65. package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
  66. package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
  67. package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
  68. package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
  69. package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
  70. package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
  71. package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
  72. package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
  73. package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
  74. package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
  75. package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
  76. package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
  77. package/skills/migrate/install.sh +68 -0
  78. package/skills/migrate/package.json +17 -0
  79. package/skills/migrate/references/phases/enumerate.md +291 -0
  80. package/skills/migrate/references/phases/extract.md +652 -0
  81. package/skills/migrate/references/phases/parity.md +275 -0
  82. package/skills/migrate/references/phases/probe.md +135 -0
  83. package/skills/migrate/references/phases/queue.md +242 -0
  84. package/skills/migrate/references/phases/seam.md +416 -0
  85. package/skills/migrate/references/recipes/README.md +116 -0
  86. package/skills/migrate/references/recipes/aspnet.md +287 -0
  87. package/skills/migrate/references/run-ops.md +280 -0
  88. package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
  89. package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
  90. package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
  91. package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
  92. package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
  93. package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
  94. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
  95. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
  96. package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
  97. package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
  98. package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
  99. package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
  100. package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
  101. package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
  102. package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
  103. package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
  104. package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
  105. package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
  106. package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
  107. package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
  108. package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
  109. package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
  110. package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
  111. package/skills/migrate/scripts/census-cmd.ts +109 -0
  112. package/skills/migrate/scripts/census.ts +342 -0
  113. package/skills/migrate/scripts/check-cmd.ts +24 -0
  114. package/skills/migrate/scripts/check.ts +376 -0
  115. package/skills/migrate/scripts/citations.ts +92 -0
  116. package/skills/migrate/scripts/config.ts +237 -0
  117. package/skills/migrate/scripts/ids.ts +31 -0
  118. package/skills/migrate/scripts/import-cmd.ts +141 -0
  119. package/skills/migrate/scripts/init-cmd.ts +118 -0
  120. package/skills/migrate/scripts/leaks.ts +184 -0
  121. package/skills/migrate/scripts/lock.ts +188 -0
  122. package/skills/migrate/scripts/paths.ts +103 -0
  123. package/skills/migrate/scripts/phase-cmd.ts +63 -0
  124. package/skills/migrate/scripts/phases.ts +113 -0
  125. package/skills/migrate/scripts/queue-cmd.ts +98 -0
  126. package/skills/migrate/scripts/queue.ts +258 -0
  127. package/skills/migrate/scripts/report-cmd.ts +47 -0
  128. package/skills/migrate/scripts/report.ts +131 -0
  129. package/skills/migrate/scripts/reset-cmd.ts +120 -0
  130. package/skills/migrate/scripts/status-cmd.ts +52 -0
  131. package/skills/migrate/scripts/store.ts +159 -0
  132. package/skills/migrate/scripts/types.ts +137 -0
  133. package/skills/migrate/scripts/validate.ts +221 -0
  134. package/skills/migrate/skill.json +33 -0
  135. package/skills/migrate/templates/config.toml +27 -0
  136. package/skills/migrate/templates/queue-item.md +17 -0
  137. package/skills/migrate/tsconfig.json +18 -0
  138. package/skills/migrate/uninstall.sh +31 -0
  139. package/skills/sluice/SKILL.md +82 -0
  140. package/skills/sluice/references/deep-channel.md +94 -0
  141. package/skills/sluice/references/finish.md +35 -0
  142. package/skills/sluice/references/intent.md +29 -0
  143. package/skills/sluice/references/review.md +42 -0
  144. package/skills/sluice/references/root-cause.md +38 -0
  145. package/skills/sluice/references/show-or-say.md +36 -0
  146. package/skills/sluice/references/test-first.md +35 -0
  147. package/skills/sluice/references/verify.md +26 -0
  148. package/skills/sluice/skill.json +32 -0
@@ -0,0 +1,184 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { relative } from 'node:path'
4
+ import { storePaths } from './paths.ts'
5
+ import type { Violation } from './types.ts'
6
+
7
+ const MIN_SECRET_LENGTH = 8
8
+
9
+ export function parseEnv(text: string): Record<string, string> {
10
+ const env: Record<string, string> = {}
11
+ for (const rawLine of text.split('\n')) {
12
+ const line = rawLine.trim()
13
+ if (!line || line.startsWith('#')) continue
14
+ const withoutExport = line.startsWith('export ') ? line.slice(7) : line
15
+ const at = withoutExport.indexOf('=')
16
+ if (at === -1) continue
17
+ const key = withoutExport.slice(0, at).trim()
18
+ let value = withoutExport.slice(at + 1).trim()
19
+ if (
20
+ (value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
21
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
22
+ ) {
23
+ value = value.slice(1, -1)
24
+ }
25
+ env[key] = value
26
+ }
27
+ return env
28
+ }
29
+
30
+ // A URL is excluded because artifacts reference the environment by host on
31
+ // purpose; flagging it would make the gate cry wolf on correct prose.
32
+ //
33
+ // Everything below matches secrets by substring, which has two inherent
34
+ // blind spots that are not worth chasing: a secret that got line-wrapped
35
+ // when it was pasted (a newline now sits inside it) and a paste that differs
36
+ // from the source only in case. Widening the match to catch either would
37
+ // trade a rare miss for false positives common enough to make the gate
38
+ // ignorable, so both are accepted as known limits rather than bugs.
39
+ function isSecretValue(value: string): boolean {
40
+ if (value.length < MIN_SECRET_LENGTH) return false
41
+ if (/^https?:\/\//.test(value)) return false
42
+ return true
43
+ }
44
+
45
+ export function secretValues(env: Record<string, string>): string[] {
46
+ return secretEntries(env).map((entry) => entry.value)
47
+ }
48
+
49
+ type SecretEntry = { value: string; names: string[] }
50
+
51
+ // Two env vars can legitimately hold the same value (a password reused
52
+ // between a normal and a read-only runtime account, for instance). Grouping
53
+ // by value up front means a shared value is reported once per file, or once
54
+ // per history, naming every variable that holds it, instead of once per
55
+ // variable with the wrong one picked.
56
+ function secretEntries(env: Record<string, string>): SecretEntry[] {
57
+ const byValue = new Map<string, string[]>()
58
+ for (const [name, value] of Object.entries(env)) {
59
+ if (!isSecretValue(value)) continue
60
+ const names = byValue.get(value)
61
+ if (names) names.push(name)
62
+ else byValue.set(value, [name])
63
+ }
64
+ return Array.from(byValue, ([value, names]) => ({ value, names }))
65
+ }
66
+
67
+ function violationFor(entry: SecretEntry, where: string): Violation {
68
+ return { gate: 'leaks', message: `value of ${entry.names.join(', ')} appears in ${where}` }
69
+ }
70
+
71
+ async function scanArtifacts(
72
+ dir: string,
73
+ root: string,
74
+ entries: SecretEntry[],
75
+ ): Promise<Violation[]> {
76
+ const violations: Violation[] = []
77
+ const glob = new Bun.Glob('**/*')
78
+ for await (const rel of glob.scan({ cwd: dir, onlyFiles: true, dot: true })) {
79
+ if (rel === '.env') continue
80
+ const abs = `${dir}/${rel}`
81
+ // A file the scanner could not read is a file it did not scan. Treating
82
+ // that as a clean result would be a false negative, so an unreadable
83
+ // artifact is reported as a leaks violation rather than skipped in silence.
84
+ let text: string
85
+ try {
86
+ text = await readFile(abs, 'utf8')
87
+ } catch {
88
+ violations.push({
89
+ gate: 'leaks',
90
+ message: `${relative(root, abs)} could not be read, so it was not checked for leaked credentials`,
91
+ })
92
+ continue
93
+ }
94
+ for (const entry of entries) {
95
+ if (text.includes(entry.value)) violations.push(violationFor(entry, relative(root, abs)))
96
+ }
97
+ }
98
+ return violations
99
+ }
100
+
101
+ // Reads a subprocess's stdout in whatever chunks the pipe delivers and tests
102
+ // each chunk for the secrets, without ever buffering the full history into
103
+ // one string. A secret can straddle the boundary between two chunks, so each
104
+ // pass keeps a tail of the combined text at least as long as the longest
105
+ // secret minus one character and prepends it to the next chunk; that is
106
+ // enough overlap for a split match to be whole again on the next check.
107
+ async function findInStream(
108
+ stream: ReadableStream<Uint8Array>,
109
+ entries: SecretEntry[],
110
+ ): Promise<SecretEntry[]> {
111
+ const maxLen = Math.max(...entries.map((entry) => entry.value.length))
112
+ const remaining = new Map(entries.map((entry) => [entry.value, entry] as const))
113
+ const found: SecretEntry[] = []
114
+ const decoder = new TextDecoder()
115
+ let carry = ''
116
+ const reader = stream.getReader()
117
+ try {
118
+ for (;;) {
119
+ const { done, value } = await reader.read()
120
+ const chunkText = done ? decoder.decode() : decoder.decode(value, { stream: true })
121
+ const combined = carry + chunkText
122
+ for (const [secretValue, entry] of remaining) {
123
+ if (combined.includes(secretValue)) {
124
+ found.push(entry)
125
+ remaining.delete(secretValue)
126
+ }
127
+ }
128
+ if (done || remaining.size === 0) break
129
+ carry = combined.slice(-(maxLen - 1))
130
+ }
131
+ } finally {
132
+ await reader.cancel().catch(() => {})
133
+ }
134
+ return found
135
+ }
136
+
137
+ // git log -S<secret> would put the raw value on the process argument list,
138
+ // readable by any other local user via ps or /proc for the life of the call.
139
+ // A module whose entire purpose is stopping credentials from escaping must
140
+ // not do that itself, so history is streamed instead: one subprocess for the
141
+ // whole run, no secret in its argv, matched against the streamed diff text
142
+ // in-process. --all covers a secret that only ever landed on a branch other
143
+ // than the one currently checked out, not just an older commit on this one.
144
+ async function scanGitHistory(
145
+ root: string,
146
+ gitBin: string,
147
+ entries: SecretEntry[],
148
+ ): Promise<Violation[]> {
149
+ if (!existsSync(`${root}/.git`)) return []
150
+
151
+ let proc: Bun.Subprocess<'ignore', 'pipe', 'ignore'>
152
+ try {
153
+ proc = Bun.spawn([gitBin, 'log', '-p', '--all'], {
154
+ cwd: root,
155
+ stdin: 'ignore',
156
+ stdout: 'pipe',
157
+ stderr: 'ignore',
158
+ })
159
+ } catch {
160
+ return [{ gate: 'leaks', message: 'could not run git to check history for leaked credentials' }]
161
+ }
162
+
163
+ let found: SecretEntry[]
164
+ try {
165
+ found = await findInStream(proc.stdout, entries)
166
+ } finally {
167
+ proc.kill()
168
+ await proc.exited.catch(() => {})
169
+ }
170
+
171
+ return found.map((entry) => violationFor(entry, 'git history'))
172
+ }
173
+
174
+ export async function scanLeaks(opts: { root: string; gitBin?: string }): Promise<Violation[]> {
175
+ const paths = storePaths(opts.root)
176
+ if (!existsSync(paths.env)) return []
177
+ const env = parseEnv(await readFile(paths.env, 'utf8'))
178
+ const entries = secretEntries(env)
179
+ if (entries.length === 0) return []
180
+
181
+ const artifactViolations = await scanArtifacts(paths.dir, opts.root, entries)
182
+ const historyViolations = await scanGitHistory(opts.root, opts.gitBin ?? 'git', entries)
183
+ return [...artifactViolations, ...historyViolations]
184
+ }
@@ -0,0 +1,188 @@
1
+ import { open, readFile, unlink } from 'node:fs/promises'
2
+ import { storePaths } from './paths.ts'
3
+
4
+ export type LockHolder = { pid: number; startedAt: string; cmd: string }
5
+
6
+ export class LockError extends Error {
7
+ constructor(
8
+ message: string,
9
+ readonly kind: 'timeout' | 'stale',
10
+ ) {
11
+ super(message)
12
+ this.name = 'LockError'
13
+ }
14
+ }
15
+
16
+ export type LockOptions = {
17
+ cmd: string
18
+ timeoutMs?: number
19
+ force?: boolean
20
+ alive?: (pid: number) => boolean
21
+ onWait?: (message: string) => void
22
+ }
23
+
24
+ // Thirty seconds is roughly two orders of magnitude above the expected hold
25
+ // time for a ten-row batch, so reaching it means something is wrong rather
26
+ // than merely busy.
27
+ const DEFAULT_TIMEOUT_MS = 30_000
28
+
29
+ // Five consecutive corrupt reads (see readHolder below for what counts as
30
+ // corrupt, as distinct from the merely transient absent state that never
31
+ // reaches this counter at all) is well past what a fluctuating filesystem
32
+ // state could produce by chance, so it means the lock file itself is broken,
33
+ // not merely between holders.
34
+ const UNREADABLE_TOLERANCE = 5
35
+
36
+ // signal 0 performs the permission and existence checks without delivering
37
+ // anything. ESRCH is the only code that proves the process is gone: EPERM
38
+ // means it exists but belongs to another user, which is still a live holder.
39
+ export function processIsAlive(pid: number): boolean {
40
+ try {
41
+ process.kill(pid, 0)
42
+ return true
43
+ } catch (e) {
44
+ return (e as { code?: string }).code === 'EPERM'
45
+ }
46
+ }
47
+
48
+ export function lockPath(root: string): string {
49
+ return `${storePaths(root).dir}/.lock`
50
+ }
51
+
52
+ // A lock file passes through two ordinary transient states that are not
53
+ // evidence of anything wrong: missing (ENOENT, the gap between one holder's
54
+ // unlink and the next one's create) and present-but-empty (the gap between
55
+ // O_EXCL create and the holder record actually being written). Both are
56
+ // reported as 'absent' here and must never spend any of
57
+ // UNREADABLE_TOLERANCE's budget: under real contention with several
58
+ // short-lived holders cycling quickly, a handful of unlucky consecutive
59
+ // polls landing in one gap or the other is ordinary noise, not a stuck lock,
60
+ // and charging it against the same counter a genuinely corrupt file uses
61
+ // produced exactly that false positive. A file that exists, is non-empty,
62
+ // and still fails to parse (or parses to something with no numeric pid) is
63
+ // the only state that means a lock nobody will ever finish writing or
64
+ // clean up, so that is the only state 'corrupt' covers.
65
+ type HolderRead = { status: 'present'; holder: LockHolder } | { status: 'absent' | 'corrupt' }
66
+
67
+ async function readHolder(path: string): Promise<HolderRead> {
68
+ let text: string
69
+ try {
70
+ text = await readFile(path, 'utf8')
71
+ } catch {
72
+ return { status: 'absent' }
73
+ }
74
+ if (text.length === 0) return { status: 'absent' }
75
+ try {
76
+ const parsed = JSON.parse(text) as LockHolder
77
+ if (typeof parsed?.pid === 'number') return { status: 'present', holder: parsed }
78
+ } catch {
79
+ // falls through to 'corrupt' below
80
+ }
81
+ return { status: 'corrupt' }
82
+ }
83
+
84
+ // Serialises the read-modify-write that `import` and `census` both perform
85
+ // over the whole store file. Without it two subagents importing concurrently
86
+ // each read the same base, and whichever renames last silently discards the
87
+ // other's rows. The lock is deliberately coarse: one lock for the whole
88
+ // store, held for milliseconds, rather than per-file locks that would have to
89
+ // agree on an ordering to stay deadlock-free.
90
+ export async function withStoreLock<T>(
91
+ root: string,
92
+ fn: () => Promise<T>,
93
+ opts: LockOptions,
94
+ ): Promise<T> {
95
+ const path = lockPath(root)
96
+ const alive = opts.alive ?? processIsAlive
97
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
98
+ const deadline = Date.now() + timeoutMs
99
+ if (opts.force) await unlink(path).catch(() => {})
100
+
101
+ let announced = false
102
+ let unreadable = 0
103
+ let delay = 25
104
+ for (;;) {
105
+ let handle: Awaited<ReturnType<typeof open>> | null = null
106
+ try {
107
+ handle = await open(path, 'wx')
108
+ } catch (e) {
109
+ if ((e as { code?: string }).code !== 'EEXIST') throw e
110
+ }
111
+ if (handle) {
112
+ let held = true
113
+ try {
114
+ const holder: LockHolder = {
115
+ pid: process.pid,
116
+ startedAt: new Date().toISOString(),
117
+ cmd: opts.cmd,
118
+ }
119
+ await handle.writeFile(`${JSON.stringify(holder)}\n`)
120
+ await handle.close()
121
+ held = false
122
+ return await fn()
123
+ } finally {
124
+ if (held) await handle.close().catch(() => {})
125
+ await unlink(path).catch(() => {})
126
+ }
127
+ }
128
+
129
+ const read = await readHolder(path)
130
+ if (read.status === 'corrupt') {
131
+ unreadable += 1
132
+ if (unreadable >= UNREADABLE_TOLERANCE) {
133
+ throw new LockError(
134
+ `store lock at ${path} is unreadable; re-run with --force-unlock after confirming no other agent is writing`,
135
+ 'stale',
136
+ )
137
+ }
138
+ } else if (read.status === 'present') {
139
+ unreadable = 0
140
+ const holder = read.holder
141
+ if (!alive(holder.pid)) {
142
+ // The holder read above may have already finished, unlinked the lock
143
+ // and exited by the time this check runs: that is a live release, not
144
+ // a stale one, and it must not be reported as the holder's pid being
145
+ // dead. Re-reading before concluding otherwise tells the two apart.
146
+ // If the file is now gone, or now names a different holder, the lock
147
+ // was released (or handed off) in the gap between the two reads, and
148
+ // the right move is to fall through and retry on the next iteration,
149
+ // not to throw about a holder that no longer holds anything. Only a
150
+ // second read that still shows the exact same dead pid means the
151
+ // holder actually exited without releasing.
152
+ const confirm = await readHolder(path)
153
+ if (confirm.status === 'present' && confirm.holder.pid === holder.pid) {
154
+ throw new LockError(
155
+ `store lock held by pid ${holder.pid}, which is not running. Re-run with --force-unlock after confirming no other agent is writing`,
156
+ 'stale',
157
+ )
158
+ }
159
+ } else if (!announced) {
160
+ opts.onWait?.(
161
+ `waiting for store lock (held by pid ${holder.pid} since ${holder.startedAt})`,
162
+ )
163
+ announced = true
164
+ }
165
+ }
166
+ // read.status === 'absent' falls straight through to here: see
167
+ // readHolder's comment for why this must not touch `unreadable`.
168
+ //
169
+ // The deadline applies unconditionally, not only while a live holder is
170
+ // in view: a lock file stuck 'absent' or 'corrupt' for the whole timeout
171
+ // (its creator crashed after the O_EXCL create but before writing, say)
172
+ // must still surface as a timeout rather than loop forever, since
173
+ // neither status alone throws on its own schedule the way a confirmed
174
+ // dead holder or five straight corrupt reads do. The message names the
175
+ // holder when one was last seen, and falls back to naming the lock path
176
+ // when the wait never got that far.
177
+ if (Date.now() >= deadline) {
178
+ throw new LockError(
179
+ read.status === 'present'
180
+ ? `timed out after ${timeoutMs}ms waiting for the store lock held by pid ${read.holder.pid} since ${read.holder.startedAt}`
181
+ : `timed out after ${timeoutMs}ms waiting for the store lock at ${path}`,
182
+ 'timeout',
183
+ )
184
+ }
185
+ await Bun.sleep(delay)
186
+ delay = Math.min(Math.ceil(delay * 1.5), 250)
187
+ }
188
+ }
@@ -0,0 +1,103 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { realpath } from 'node:fs/promises'
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
4
+
5
+ export const STORE_DIR = '.migrate'
6
+
7
+ export type StorePaths = {
8
+ dir: string
9
+ config: string
10
+ elements: string
11
+ requirements: string
12
+ capabilities: string
13
+ deltas: string
14
+ census: string
15
+ phases: string
16
+ queueDir: string
17
+ seamJson: string
18
+ seamMd: string
19
+ parityBasis: string
20
+ env: string
21
+ }
22
+
23
+ export function storePaths(root: string): StorePaths {
24
+ const dir = join(root, STORE_DIR)
25
+ return {
26
+ dir,
27
+ config: join(dir, 'config.toml'),
28
+ elements: join(dir, 'elements.jsonl'),
29
+ requirements: join(dir, 'requirements.jsonl'),
30
+ capabilities: join(dir, 'capabilities.jsonl'),
31
+ deltas: join(dir, 'deltas.jsonl'),
32
+ census: join(dir, 'census.jsonl'),
33
+ phases: join(dir, 'phases.json'),
34
+ queueDir: join(dir, 'queue'),
35
+ seamJson: join(dir, 'seam.json'),
36
+ seamMd: join(dir, 'seam.md'),
37
+ parityBasis: join(dir, 'parity-basis.md'),
38
+ env: join(dir, '.env'),
39
+ }
40
+ }
41
+
42
+ export async function findStoreRoot(startDir: string): Promise<string | null> {
43
+ let current = resolve(startDir)
44
+ for (;;) {
45
+ if (existsSync(join(current, STORE_DIR))) return current
46
+ const parent = dirname(current)
47
+ if (parent === current) return null
48
+ current = parent
49
+ }
50
+ }
51
+
52
+ // A shared string prefix is not containment: legacy-notes/ is not inside legacy/.
53
+ // relative() gives '..' as its first segment for anything outside, and '' for the
54
+ // root itself, which counts as inside. This is the one containment predicate
55
+ // both the write side (this file) and the read side (citations.ts) use, so the
56
+ // two cannot drift into disagreeing again about what "inside" means.
57
+ export function isContained(inner: string, outer: string): boolean {
58
+ const rel = relative(outer, inner)
59
+ return rel === '' || !(rel.startsWith(`..${sep}`) || rel === '..')
60
+ }
61
+
62
+ // Resolves a path to its canonical real form -- following symlinks and, on a
63
+ // case-insensitive filesystem, normalizing to the on-disk case -- even when
64
+ // the path itself does not exist yet. A write target is normally exactly
65
+ // that: a file `writeAtomically` is about to create. realpath() alone would
66
+ // fail on it with ENOENT, so this walks up to the nearest ancestor that does
67
+ // exist, resolves that ancestor for real, and reattaches the non-existent
68
+ // remainder unchanged (a path segment that does not exist cannot itself be a
69
+ // symlink or a case variant, so there is nothing further to resolve about it).
70
+ // If no ancestor at all exists (a bogus root), it falls back to the lexically
71
+ // resolved path rather than throwing out of what is meant to be a yes/no check.
72
+ async function realOrNearestAncestor(target: string): Promise<string> {
73
+ const resolved = resolve(target)
74
+ let current = resolved
75
+ const suffix: string[] = []
76
+ for (;;) {
77
+ try {
78
+ const real = await realpath(current)
79
+ return suffix.length > 0 ? join(real, ...suffix.reverse()) : real
80
+ } catch {
81
+ const parent = dirname(current)
82
+ if (parent === current) return resolved
83
+ suffix.push(basename(current))
84
+ current = parent
85
+ }
86
+ }
87
+ }
88
+
89
+ // Lexical comparison alone (the previous implementation) is fooled two ways:
90
+ // a symlink inside the target tree that points into the source tree changes
91
+ // what a path really resolves to without changing its literal text, and on a
92
+ // case-insensitive volume two differently-cased spellings of the same
93
+ // directory compare as unrelated strings even though they are the same
94
+ // physical location. Both are closed by resolving real paths (which follow
95
+ // symlinks and normalize case) before the containment check, exactly as
96
+ // citations.ts already does on the read side.
97
+ export async function assertNotUnderSource(target: string, sourcePath: string): Promise<void> {
98
+ const realRoot = await realOrNearestAncestor(resolve(sourcePath))
99
+ const realTarget = await realOrNearestAncestor(resolve(target))
100
+ if (isContained(realTarget, realRoot)) {
101
+ throw new Error(`refusing to write inside the read-only source tree: ${target}`)
102
+ }
103
+ }
@@ -0,0 +1,63 @@
1
+ import { loadConfig } from './config.ts'
2
+ import { LockError } from './lock.ts'
3
+ import { isPhase, loadPhases, PHASES, type PhaseState, setPhaseStatus } from './phases.ts'
4
+
5
+ const STATUSES: readonly PhaseState['status'][] = ['pending', 'running', 'blocked', 'done']
6
+
7
+ function line(name: string, state: PhaseState): string {
8
+ return `${name.padEnd(11)} ${state.status.padEnd(8)} ${state.batches.length} batch(es)\n`
9
+ }
10
+
11
+ // Reading is the default because phase state should be inspectable before it
12
+ // is writable: an orchestrator resuming a run needs to see where it stopped
13
+ // far more often than it needs to move the marker.
14
+ export async function runPhase(opts: {
15
+ root: string
16
+ name?: string
17
+ status?: string
18
+ forceUnlock?: boolean
19
+ }): Promise<number> {
20
+ const phases = await loadPhases(opts.root)
21
+ if (!opts.name) {
22
+ for (const p of PHASES) process.stdout.write(line(p, phases[p]))
23
+ return 0
24
+ }
25
+ if (!isPhase(opts.name)) {
26
+ process.stderr.write(`phase: unknown phase ${opts.name}; want one of ${PHASES.join(', ')}\n`)
27
+ return 2
28
+ }
29
+ const name = opts.name
30
+ if (!opts.status) {
31
+ process.stdout.write(line(name, phases[name]))
32
+ return 0
33
+ }
34
+ if (!(STATUSES as readonly string[]).includes(opts.status)) {
35
+ process.stderr.write(
36
+ `phase: unknown status ${opts.status}; want one of ${STATUSES.join(', ')}\n`,
37
+ )
38
+ return 2
39
+ }
40
+ const cfg = await loadConfig(opts.root)
41
+ try {
42
+ await setPhaseStatus(
43
+ opts.root,
44
+ name,
45
+ opts.status as PhaseState['status'],
46
+ cfg.source.path,
47
+ opts.forceUnlock,
48
+ )
49
+ } catch (e) {
50
+ // A lock failure is neither a bad status value nor an unknown phase: the
51
+ // request is fine and would succeed on retry, so it gets the same
52
+ // dedicated exit code census-cmd.ts and import-cmd.ts already use for it
53
+ // (3), not the generic usage-error code (2) a thrown LockError would
54
+ // otherwise fall through to in main()'s catch-all.
55
+ if (e instanceof LockError) {
56
+ process.stderr.write(`phase: ${e.message}\n`)
57
+ return 3
58
+ }
59
+ throw e
60
+ }
61
+ process.stdout.write(`phase: ${name} is now ${opts.status}\n`)
62
+ return 0
63
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { withStoreLock } from './lock.ts'
4
+ import { storePaths } from './paths.ts'
5
+ import { writeAtomically } from './store.ts'
6
+
7
+ export const PHASES = [
8
+ 'probe',
9
+ 'enumerate',
10
+ 'seam',
11
+ 'extract',
12
+ 'parity',
13
+ 'queue',
14
+ 'adjudicate',
15
+ 'handoff',
16
+ ] as const
17
+
18
+ export type Phase = (typeof PHASES)[number]
19
+
20
+ export type Batch = { id: string; count: number }
21
+
22
+ export type PhaseState = {
23
+ status: 'pending' | 'running' | 'blocked' | 'done'
24
+ batches: Batch[]
25
+ pending: string[]
26
+ }
27
+
28
+ export type PhasesFile = { version: 1; phases: Record<Phase, PhaseState> }
29
+
30
+ export function isPhase(s: string): s is Phase {
31
+ return (PHASES as readonly string[]).includes(s)
32
+ }
33
+
34
+ function empty(): Record<Phase, PhaseState> {
35
+ const out = {} as Record<Phase, PhaseState>
36
+ for (const p of PHASES) out[p] = { status: 'pending', batches: [], pending: [] }
37
+ return out
38
+ }
39
+
40
+ export async function loadPhases(root: string): Promise<Record<Phase, PhaseState>> {
41
+ const path = storePaths(root).phases
42
+ if (!existsSync(path)) return empty()
43
+ const text = await readFile(path, 'utf8')
44
+ let parsed: PhasesFile
45
+ try {
46
+ parsed = JSON.parse(text) as PhasesFile
47
+ } catch {
48
+ throw new Error(`${path}: malformed JSON`)
49
+ }
50
+ const state = empty()
51
+ for (const p of PHASES) {
52
+ const found = parsed.phases?.[p]
53
+ if (found)
54
+ state[p] = {
55
+ status: found.status,
56
+ batches: found.batches ?? [],
57
+ pending: found.pending ?? [],
58
+ }
59
+ }
60
+ return state
61
+ }
62
+
63
+ // Writes phases.json atomically using temp-plus-rename. Atomicity alone does
64
+ // not make a read-modify-write safe: two callers can still read the same base
65
+ // and one rename still discards the other's batches. That is closed by the
66
+ // store lock, not here. This function deliberately does not take it, because
67
+ // every caller already holds it -- recordBatch runs inside import's and
68
+ // census's critical sections, setPhaseStatus takes it itself, and reset-cmd.ts
69
+ // wraps its whole mutation in it -- and withStoreLock is not reentrant, so a
70
+ // lock taken here would deadlock all three.
71
+ export async function savePhases(
72
+ root: string,
73
+ phases: Record<Phase, PhaseState>,
74
+ sourcePath: string,
75
+ ): Promise<void> {
76
+ const path = storePaths(root).phases
77
+ const file: PhasesFile = { version: 1, phases }
78
+ await writeAtomically(path, `${JSON.stringify(file, null, 2)}\n`, sourcePath)
79
+ }
80
+
81
+ export async function recordBatch(
82
+ root: string,
83
+ phase: Phase,
84
+ batch: Batch,
85
+ sourcePath: string,
86
+ ): Promise<void> {
87
+ const phases = await loadPhases(root)
88
+ const state = phases[phase]
89
+ if (!state.batches.some((b) => b.id === batch.id)) state.batches.push(batch)
90
+ if (state.status !== 'done') state.status = 'running'
91
+ await savePhases(root, phases, sourcePath)
92
+ }
93
+
94
+ export async function setPhaseStatus(
95
+ root: string,
96
+ phase: Phase,
97
+ status: PhaseState['status'],
98
+ sourcePath: string,
99
+ forceUnlock?: boolean,
100
+ ): Promise<void> {
101
+ // Same read-modify-write hazard as recordBatch. The orchestrator is the
102
+ // only expected caller and is serial, but the cost of holding the lock for
103
+ // a status flip is a few milliseconds and it removes the question.
104
+ await withStoreLock(
105
+ root,
106
+ async () => {
107
+ const phases = await loadPhases(root)
108
+ phases[phase].status = status
109
+ await savePhases(root, phases, sourcePath)
110
+ },
111
+ { cmd: 'phase', ...(forceUnlock ? { force: true } : {}) },
112
+ )
113
+ }