iterate-plugin 3.8.0 → 3.9.1

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/src/file-lock.ts CHANGED
@@ -133,15 +133,21 @@ export function acquireProjectLockChecked(
133
133
  fd = openSync(lockPath, 'wx')
134
134
  } catch (err) {
135
135
  if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
136
- // Lock exists — steal it when the owner is gone or the lock is ancient.
136
+ // Lock exists — steal it when the owner is VERIFIABLY gone, or when the
137
+ // lock file is ancient. The stamp is read defensively: a lock is created
138
+ // empty (`openSync(..., 'wx')`) and the holder's pid lands a moment later,
139
+ // so an empty/garbled stamp means "just created, holder still writing" —
140
+ // treating `Number('') === 0` as a dead owner would let a contender steal
141
+ // a live lock and enter the same critical section. Only a parseable
142
+ // positive pid that is dead (or an ancient mtime) is stolen.
137
143
  let stale = false
138
144
  try {
139
145
  const st = statSync(lockPath)
140
- if (Date.now() - st.mtimeMs > staleMs) stale = true
141
- else {
142
- const pid = Number(readFileSync(lockPath, 'utf-8'))
143
- if (!processAlive(pid)) stale = true
144
- }
146
+ const ancient = Date.now() - st.mtimeMs > staleMs
147
+ const raw = readFileSync(lockPath, 'utf-8').trim()
148
+ const pid = /^\d+$/.test(raw) ? Number(raw) : 0
149
+ if (pid > 0) stale = !processAlive(pid) || ancient
150
+ else stale = ancient
145
151
  } catch {
146
152
  stale = true // unreadable/vanishing lock → retry as stale
147
153
  }
@@ -265,17 +271,49 @@ export function makeLockRelease(lockPath: string, ino?: number): () => void {
265
271
  * Run `fn` while holding the advisory lock named `name`.
266
272
  * The release is guaranteed via `finally`; `fn`'s value or error propagates
267
273
  * unchanged. Not reentrant for the same (projectRoot, name) — see header.
274
+ *
275
+ * `fn` may return a promise: in that case the lock is held until it settles, so
276
+ * a read-modify-write that spans `await`ed fs calls is actually serialized
277
+ * (releasing when `fn()` *returns its promise* would defeat the lock entirely).
268
278
  */
269
279
  export function withProjectLock<T>(
270
280
  projectRoot: string,
271
281
  name: string,
272
282
  fn: () => T,
283
+ options?: LockOptions,
284
+ ): T
285
+ export function withProjectLock<T>(
286
+ projectRoot: string,
287
+ name: string,
288
+ fn: () => Promise<T>,
289
+ options?: LockOptions,
290
+ ): Promise<T>
291
+ export function withProjectLock<T>(
292
+ projectRoot: string,
293
+ name: string,
294
+ fn: () => T | Promise<T>,
273
295
  options: LockOptions = {},
274
- ): T {
296
+ ): T | Promise<T> {
275
297
  const release = acquireProjectLock(projectRoot, name, options)
298
+ let out: T | Promise<T>
276
299
  try {
277
- return fn()
278
- } finally {
300
+ out = fn()
301
+ } catch (err) {
279
302
  release()
303
+ throw err
304
+ }
305
+ if (out && typeof (out as Promise<T>).then === 'function') {
306
+ return (out as Promise<T>).then(
307
+ (value) => {
308
+ release()
309
+ return value
310
+ },
311
+ (err) => {
312
+ release()
313
+ throw err
314
+ },
315
+ )
280
316
  }
317
+ release()
318
+ return out
281
319
  }
package/src/git-scope.ts CHANGED
@@ -177,21 +177,35 @@ export function runGit(
177
177
  opts: { signal?: AbortSignal } = {},
178
178
  ): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
179
179
  return new Promise((resolve) => {
180
- execFile(
181
- 'git',
182
- args,
183
- {
184
- cwd,
185
- timeout: 30_000,
186
- maxBuffer: 10 * 1024 * 1024,
187
- env: { ...process.env, PAGER: 'cat' },
188
- signal: opts.signal,
189
- },
190
- (error, stdout, stderr) => {
191
- const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0
192
- resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode })
193
- },
194
- )
180
+ // execFile can THROW SYNCHRONOUSLY (rather than call back with an error)
181
+ // when an argument cannot be encoded — e.g. a NUL or lone surrogate in the
182
+ // config-supplied target branch. Without this guard that throw escapes the
183
+ // promise executor, rejecting `runGit` and propagating out of the tool call
184
+ // instead of degrading to the documented full-scope fallback.
185
+ try {
186
+ execFile(
187
+ 'git',
188
+ args,
189
+ {
190
+ cwd,
191
+ timeout: 30_000,
192
+ maxBuffer: 10 * 1024 * 1024,
193
+ env: { ...process.env, PAGER: 'cat' },
194
+ signal: opts.signal,
195
+ },
196
+ (error, stdout, stderr) => {
197
+ const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0
198
+ resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode })
199
+ },
200
+ )
201
+ } catch (err) {
202
+ resolve({
203
+ ok: false,
204
+ stdout: '',
205
+ stderr: err instanceof Error ? err.message : String(err),
206
+ exitCode: 1,
207
+ })
208
+ }
195
209
  })
196
210
  }
197
211
 
package/src/index.ts CHANGED
@@ -2,16 +2,18 @@
2
2
  * iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
3
3
  *
4
4
  * Architecture:
5
- * - The plugin registers 17 tools (14 original + 3 v3.0 quality command center tools)
5
+ * - The plugin registers 18 tools (14 original + 3 v3.0 quality command center
6
+ * tools + 1 v3.9 report export)
6
7
  * Original: config, validate, decision-log, context, review, triage, fix, diff,
7
8
  * rollback, checkpoint, status, history, prune, transcript
8
9
  * v3.0: experience, quality_gate, defense_events
10
+ * v3.9: report
9
11
  * - The plugin injects a system prompt section teaching the iterate workflow pattern
10
12
  * - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
11
13
  * - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
12
- * - Subagents use the 17 tools to do real work (read config, run validation, log decisions,
14
+ * - Subagents use the 18 tools to do real work (read config, run validation, log decisions,
13
15
  * review, triage, apply/rollback/fixing, checkpoint, status, history, prune, transcript,
14
- * query experience bank, check quality gates, query defense events)
16
+ * query experience bank, check quality gates, query defense events, export a report)
15
17
  * - A `tools/pre-execute` hook gates destructive iterate calls behind human approval
16
18
  * (F8 observatory approval policy: ask / deny / allow).
17
19
  *
@@ -23,7 +25,7 @@
23
25
  *
24
26
  * Key files:
25
27
  * - src/index.ts — Plugin entry: register tools + inject skill prompt
26
- * - src/tools/ — 17 tool implementations (14 original + 3 v3.0)
28
+ * - src/tools/ — 18 tool implementations (14 original + 3 v3.0 + 1 v3.9)
27
29
  * - src/config-loader.ts — YAML config loading
28
30
  * - src/types.ts — Shared types
29
31
  */
@@ -43,6 +45,7 @@ import { registerTranscriptTool } from './tools/transcript.ts'
43
45
  import { registerExperienceBankTool } from './tools/experience-bank.ts'
44
46
  import { registerQualityGateTool } from './tools/quality-gate.ts'
45
47
  import { registerDefenseEventsTool } from './tools/defense-events.ts'
48
+ import { registerReportTool } from './tools/report.ts'
46
49
  import { registerSessionHooks } from './session-hooks.ts'
47
50
  import { registerLiveCapture } from './live.ts'
48
51
  import { installWorkingDirectoryContext } from './working-directory.ts'
@@ -92,6 +95,8 @@ export function apply(ctx: Context): void {
92
95
  ['iterate_experience', () => registerExperienceBankTool(ctx)],
93
96
  ['iterate_quality_gate', () => registerQualityGateTool(ctx)],
94
97
  ['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
98
+ // v3.9: PR-ready / CI-ready report export
99
+ ['iterate_report', () => registerReportTool(ctx)],
95
100
  ]
96
101
  for (const [toolName, register] of toolRegistrations) {
97
102
  try {