iterate-plugin 3.9.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/dist/file-lock.js CHANGED
@@ -103,17 +103,23 @@ export function acquireProjectLockChecked(projectRoot, name, options = {}) {
103
103
  catch (err) {
104
104
  if (err.code !== 'EEXIST')
105
105
  return { acquired: false, release: () => { } };
106
- // Lock exists — steal it when the owner is gone or the lock is ancient.
106
+ // Lock exists — steal it when the owner is VERIFIABLY gone, or when the
107
+ // lock file is ancient. The stamp is read defensively: a lock is created
108
+ // empty (`openSync(..., 'wx')`) and the holder's pid lands a moment later,
109
+ // so an empty/garbled stamp means "just created, holder still writing" —
110
+ // treating `Number('') === 0` as a dead owner would let a contender steal
111
+ // a live lock and enter the same critical section. Only a parseable
112
+ // positive pid that is dead (or an ancient mtime) is stolen.
107
113
  let stale = false;
108
114
  try {
109
115
  const st = statSync(lockPath);
110
- if (Date.now() - st.mtimeMs > staleMs)
111
- stale = true;
112
- else {
113
- const pid = Number(readFileSync(lockPath, 'utf-8'));
114
- if (!processAlive(pid))
115
- stale = true;
116
- }
116
+ const ancient = Date.now() - st.mtimeMs > staleMs;
117
+ const raw = readFileSync(lockPath, 'utf-8').trim();
118
+ const pid = /^\d+$/.test(raw) ? Number(raw) : 0;
119
+ if (pid > 0)
120
+ stale = !processAlive(pid) || ancient;
121
+ else
122
+ stale = ancient;
117
123
  }
118
124
  catch {
119
125
  stale = true; // unreadable/vanishing lock → retry as stale
@@ -238,17 +244,25 @@ export function makeLockRelease(lockPath, ino) {
238
244
  }
239
245
  };
240
246
  }
241
- /**
242
- * Run `fn` while holding the advisory lock named `name`.
243
- * The release is guaranteed via `finally`; `fn`'s value or error propagates
244
- * unchanged. Not reentrant for the same (projectRoot, name) — see header.
245
- */
246
247
  export function withProjectLock(projectRoot, name, fn, options = {}) {
247
248
  const release = acquireProjectLock(projectRoot, name, options);
249
+ let out;
248
250
  try {
249
- return fn();
251
+ out = fn();
250
252
  }
251
- finally {
253
+ catch (err) {
252
254
  release();
255
+ throw err;
256
+ }
257
+ if (out && typeof out.then === 'function') {
258
+ return out.then((value) => {
259
+ release();
260
+ return value;
261
+ }, (err) => {
262
+ release();
263
+ throw err;
264
+ });
253
265
  }
266
+ release();
267
+ return out;
254
268
  }
package/dist/git-scope.js CHANGED
@@ -172,16 +172,31 @@ export function decideScope(changedFiles) {
172
172
  */
173
173
  export function runGit(args, cwd, opts = {}) {
174
174
  return new Promise((resolve) => {
175
- execFile('git', args, {
176
- cwd,
177
- timeout: 30_000,
178
- maxBuffer: 10 * 1024 * 1024,
179
- env: { ...process.env, PAGER: 'cat' },
180
- signal: opts.signal,
181
- }, (error, stdout, stderr) => {
182
- const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
183
- resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
184
- });
175
+ // execFile can THROW SYNCHRONOUSLY (rather than call back with an error)
176
+ // when an argument cannot be encoded — e.g. a NUL or lone surrogate in the
177
+ // config-supplied target branch. Without this guard that throw escapes the
178
+ // promise executor, rejecting `runGit` and propagating out of the tool call
179
+ // instead of degrading to the documented full-scope fallback.
180
+ try {
181
+ execFile('git', args, {
182
+ cwd,
183
+ timeout: 30_000,
184
+ maxBuffer: 10 * 1024 * 1024,
185
+ env: { ...process.env, PAGER: 'cat' },
186
+ signal: opts.signal,
187
+ }, (error, stdout, stderr) => {
188
+ const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
189
+ resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
190
+ });
191
+ }
192
+ catch (err) {
193
+ resolve({
194
+ ok: false,
195
+ stdout: '',
196
+ stderr: err instanceof Error ? err.message : String(err),
197
+ exitCode: 1,
198
+ });
199
+ }
185
200
  });
186
201
  }
187
202
  /**
@@ -138,6 +138,10 @@ function asString(value) {
138
138
  function asNumber(value) {
139
139
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
140
140
  }
141
+ /** Non-negative integer (floors fractions); 0 for anything else. */
142
+ function asInt(value) {
143
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
144
+ }
141
145
  function asArray(value) {
142
146
  return Array.isArray(value) ? value : [];
143
147
  }
@@ -184,7 +188,7 @@ export function buildRunReportData(manifest, extra) {
184
188
  const findings = asArray(get(src, 'findings')).map((f) => ({
185
189
  dimension: asString(get(f, 'dimension')),
186
190
  file: asString(get(f, 'file')),
187
- line: asNumber(get(f, 'line')),
191
+ line: asInt(get(f, 'line')),
188
192
  severity: findingSeverity(f),
189
193
  summary: asString(get(f, 'summary')),
190
194
  failureScenario: asString(get(f, 'failure_scenario')),
@@ -205,7 +209,7 @@ export function buildRunReportData(manifest, extra) {
205
209
  const validations = asArray(get(src, 'validations')).map((v) => ({
206
210
  round: asNumber(get(v, 'round')),
207
211
  command: asString(get(v, 'command')),
208
- exitCode: typeof get(v, 'exitCode') === 'number' ? asNumber(get(v, 'exitCode')) : null,
212
+ exitCode: typeof get(v, 'exitCode') === 'number' ? asInt(get(v, 'exitCode')) : null,
209
213
  allowed: get(v, 'allowed') === true,
210
214
  rejectReason: asString(get(v, 'rejectReason')),
211
215
  }));
@@ -225,7 +229,11 @@ export function buildRunReportData(manifest, extra) {
225
229
  taskMode: taskMode === 'code' || taskMode === 'iterate' ? taskMode : null,
226
230
  round: asNumber(get(src, 'round')),
227
231
  maxRounds: asNumber(get(src, 'maxRounds')),
228
- active: get(src, 'active') !== false,
232
+ // An ABSENT `active` means "no run / unknown", not "running": a missing or
233
+ // empty transcript used to render a live, pulsing run at round 0. The
234
+ // transcript builder always writes an explicit boolean, so a strict `true`
235
+ // only ever loses the phantom run, never a real one.
236
+ active: get(src, 'active') === true,
229
237
  stoppedReason: typeof stoppedReason === 'string' && stoppedReason ? stoppedReason : null,
230
238
  updatedAt: asString(get(src, 'updatedAt')),
231
239
  convergence: asArray(get(src, 'convergence')).filter((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0),
@@ -296,7 +304,7 @@ export function renderRunReportMarkdown(manifest, extra, opts) {
296
304
  lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`);
297
305
  lines.push('| --- | --- | --- | --- |');
298
306
  for (const f of data.findings) {
299
- const loc = f.file ? `${f.file}${f.line > 1 ? `:${f.line}` : ''}` : '';
307
+ const loc = f.file ? `${f.file}${f.line > 0 ? `:${f.line}` : ''}` : '';
300
308
  lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`);
301
309
  }
302
310
  }
@@ -395,7 +403,9 @@ export function renderRunReportSarif(manifest, extra, opts) {
395
403
  result.fixes = [{ description: { text: f.suggestedFix } }];
396
404
  return result;
397
405
  });
398
- const failedValidations = data.validations.filter((v) => v.exitCode !== 0).length;
406
+ // `exitCode: null` is the documented "unknown / no exit code" value, not a
407
+ // failure: only a concrete non-zero code makes the run unsuccessful.
408
+ const failedValidations = data.validations.filter((v) => v.exitCode !== null && v.exitCode !== 0).length;
399
409
  return {
400
410
  $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
401
411
  version: '2.1.0',
@@ -100,7 +100,9 @@ export function normalizeRunRecord(data) {
100
100
  const d = data;
101
101
  // A record written by a future/incompatible build is skipped rather than
102
102
  // coerced: the version gate is the contract that lets the payload evolve.
103
- if (nonNegInt(d.version) !== ITERATE_RUN_RECORD_VERSION)
103
+ // The check is exact (an integer compare, not a floor) so a payload stamped
104
+ // `1.9` by a future build is rejected instead of rounded down to `1`.
105
+ if (!Number.isInteger(d.version) || d.version !== ITERATE_RUN_RECORD_VERSION)
104
106
  return null;
105
107
  const findings = (d.findings && typeof d.findings === 'object' && !Array.isArray(d.findings)
106
108
  ? d.findings
@@ -132,12 +134,17 @@ export function normalizeRunRecord(data) {
132
134
  let cachedApi;
133
135
  /** Test override; when set (including `null`) the dynamic import is bypassed. */
134
136
  let testApiOverride;
137
+ /** Module loader; replaceable for tests to exercise import failure/late availability. */
138
+ let moduleImporter = () => import('@deepseek-ai/dsh-session');
135
139
  /** Resolve the `@deepseek-ai/dsh-session` record API, or `null` when absent. */
136
140
  export function loadSessionRecordApi() {
137
141
  if (testApiOverride !== undefined)
138
142
  return Promise.resolve(testApiOverride);
139
143
  if (cachedApi === undefined) {
140
- cachedApi = import('@deepseek-ai/dsh-session')
144
+ // `Promise.resolve().then(...)` (not a bare call) so a loader that throws
145
+ // SYNCHRONOUSLY still lands in the `.catch` instead of escaping.
146
+ cachedApi = Promise.resolve()
147
+ .then(() => moduleImporter())
141
148
  .then((mod) => {
142
149
  const m = mod;
143
150
  if (typeof m.appendPluginRecord !== 'function' || typeof m.pluginRecordOf !== 'function') {
@@ -149,9 +156,24 @@ export function loadSessionRecordApi() {
149
156
  };
150
157
  })
151
158
  .catch(() => null);
159
+ // A `null` resolution (module/API absent, or a transient import failure) is
160
+ // NOT memoized: it is cleared so the next call retries. Otherwise one
161
+ // unlucky first import would disable session linking for the whole process.
162
+ void cachedApi.then((api) => {
163
+ if (!api)
164
+ cachedApi = undefined;
165
+ });
152
166
  }
153
167
  return cachedApi;
154
168
  }
169
+ /**
170
+ * Replace the module loader for tests (to exercise the import-failure and
171
+ * late-availability paths). Pass `undefined` to restore the real dynamic import.
172
+ */
173
+ export function setSessionRecordImporterForTests(loader) {
174
+ moduleImporter = loader ?? (() => import('@deepseek-ai/dsh-session'));
175
+ cachedApi = undefined;
176
+ }
155
177
  /**
156
178
  * Override the session-record API for tests. Pass `undefined` to restore the
157
179
  * real dynamic import, or an object / `null` to force a deterministic result.
@@ -4,8 +4,8 @@
4
4
  * iterate_report — render the current iterate run into a portable report.
5
5
  *
6
6
  * Two formats, one normalized data model (shared with the client export
7
- * buttons via `lib/parse.js`, so the on-screen export and this tool can never
8
- * drift):
7
+ * buttons via `src/report-render.ts`, so the on-screen export and this tool
8
+ * can never drift):
9
9
  *
10
10
  * - `markdown` — a PR-pasteable run report: header (mode/round/status), the
11
11
  * quality-gate table, the findings table, the fixes table,
@@ -29,6 +29,7 @@ import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loade
29
29
  import { transcriptPath } from "../paths.js";
30
30
  import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from "../transcript.js";
31
31
  import { readDecisionLogDetailed } from "./decision-log.js";
32
+ import { readQualityGate } from "./quality-store.js";
32
33
  import { readLive } from "../live.js";
33
34
  import { buildRunRecord, appendRunRecord, readRunRecords, sessionOf, } from "../session-record.js";
34
35
  /** Severity tally over a manifest's (globally deduped) findings. */
@@ -656,7 +657,18 @@ export function registerTranscriptTool(ctx) {
656
657
  // session. Best-effort — a link failure is reported, never fatal (the
657
658
  // on-disk capture above already succeeded).
658
659
  let sessionRecord;
659
- const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest));
660
+ // Snapshot the CURRENT quality-gate verdict into the record so a
661
+ // recovered run keeps its PASS/FAIL badge. An absent gate reads as the
662
+ // store's default (`pending`); a corrupt/throwing read degrades to
663
+ // `null` and never fails the capture.
664
+ let qualityStatus = null;
665
+ try {
666
+ qualityStatus = readQualityGate(projectRoot).overallStatus ?? null;
667
+ }
668
+ catch {
669
+ qualityStatus = null;
670
+ }
671
+ const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest, qualityStatus));
660
672
  sessionRecord = (linkSession.ok
661
673
  ? { linked: true, seq: linkSession.seq }
662
674
  : { linked: false, supported: linkSession.supported, reason: linkSession.error });
@@ -682,6 +694,12 @@ function rehydrateBuilder(manifest, approval) {
682
694
  goal: manifest.goal,
683
695
  maxRounds: manifest.maxRounds,
684
696
  });
697
+ // Phase breadcrumbs are part of the run narrative the F1 stage tracker shows;
698
+ // replay them before the rounds so an edit round-trip keeps the stage order.
699
+ for (const p of Array.isArray(manifest.phases) ? manifest.phases : []) {
700
+ if (typeof p === 'string')
701
+ builder.phase(p);
702
+ }
685
703
  for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
686
704
  // A malformed/null round row must be skipped, never a throw (the caller
687
705
  // wraps rehydrate in a fallback, but preserving sibling rows is better).
@@ -689,6 +707,10 @@ function rehydrateBuilder(manifest, approval) {
689
707
  continue;
690
708
  builder.roundStart(r.round, manifest.maxRounds);
691
709
  for (const t of Array.isArray(r.threads) ? r.threads : []) {
710
+ // A null/garbled thread row must be skipped too: reading `t.dimension`
711
+ // off it would throw and cost the WHOLE manifest its history.
712
+ if (!t || typeof t !== 'object')
713
+ continue;
692
714
  // restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
693
715
  // message ARRAY boundaries and the thread's own dimension: rehydration
694
716
  // previously collapsed each thread's messages into one string and, once a
@@ -708,6 +730,15 @@ function rehydrateBuilder(manifest, approval) {
708
730
  if (typeof n === 'number' && n >= 0)
709
731
  builder.snapshotConvergence(idx + 1, n);
710
732
  }
733
+ // Top-level findings live in the global newest-wins list (the F3 panel reads
734
+ // `manifest.findings`); restoreThread only rebuilds the per-round threads, so
735
+ // without this a nudge/rollback re-persist blanked the findings panel.
736
+ if (Array.isArray(manifest.findings))
737
+ builder.restoreFindings(manifest.findings);
738
+ // Move the round marker forward past the captured rows: `roundStart` reopens
739
+ // rows, but a manifest whose `round` outruns its rows (advanceRound semantics)
740
+ // would otherwise rewind the reported round.
741
+ builder.advanceRound(manifest.round, manifest.maxRounds);
711
742
  if (manifest.checkpoint)
712
743
  builder.recordCheckpoint(manifest.checkpoint);
713
744
  if (Array.isArray(manifest.fixes))
@@ -428,6 +428,24 @@ export class ReviewTranscriptBuilder {
428
428
  }
429
429
  this.touch();
430
430
  }
431
+ /**
432
+ * Re-insert top-level findings when rehydrating a persisted manifest (the
433
+ * nudge / rollback edit round-trip). `restoreThread` already rebuilds the
434
+ * per-round thread copy of each finding but never the global list, so an edit
435
+ * that only rewrites steering text used to blank `manifest.findings` and the
436
+ * F3 findings panel. This refills the global newest-wins list (and its dedupe
437
+ * set), mirroring `reviewerFindings` without needing a live thread.
438
+ */
439
+ restoreFindings(findings) {
440
+ if (Array.isArray(findings)) {
441
+ for (const raw of findings) {
442
+ const f = normalizeFinding(raw);
443
+ if (f)
444
+ this.addGlobal(f);
445
+ }
446
+ }
447
+ this.touch();
448
+ }
431
449
  // ─── Convergence (F2) ───────────────────────────────────────────────────
432
450
  /** Record a round's new-finding count for the convergence series. */
433
451
  snapshotConvergence(round, newCount) {
package/lib/client.js CHANGED
@@ -1,4 +1,4 @@
1
- /* iterate-plugin client source-digest: 50774433818a03e70b4ac5341a6683c1ccb3d3a5ff93561a29007232a03b3e01 */
1
+ /* iterate-plugin client source-digest: 2819f2e6f5f182bb31b9e47c356f60e0b8d97fcdbd232c31b89438619623cf93 */
2
2
  window.__ModuleLoader__.load({ id: "iterate-plugin", factory: (require) => {
3
3
  var module = { exports: {} };
4
4
  var exports = module.exports;
@@ -2023,6 +2023,9 @@ function asString(value) {
2023
2023
  function asNumber(value) {
2024
2024
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
2025
2025
  }
2026
+ function asInt(value) {
2027
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
2028
+ }
2026
2029
  function asArray(value) {
2027
2030
  return Array.isArray(value) ? value : [];
2028
2031
  }
@@ -2056,7 +2059,7 @@ function buildRunReportData(manifest, extra) {
2056
2059
  const findings = asArray(get(src, "findings")).map((f) => ({
2057
2060
  dimension: asString(get(f, "dimension")),
2058
2061
  file: asString(get(f, "file")),
2059
- line: asNumber(get(f, "line")),
2062
+ line: asInt(get(f, "line")),
2060
2063
  severity: findingSeverity(f),
2061
2064
  summary: asString(get(f, "summary")),
2062
2065
  failureScenario: asString(get(f, "failure_scenario")),
@@ -2078,7 +2081,7 @@ function buildRunReportData(manifest, extra) {
2078
2081
  const validations = asArray(get(src, "validations")).map((v) => ({
2079
2082
  round: asNumber(get(v, "round")),
2080
2083
  command: asString(get(v, "command")),
2081
- exitCode: typeof get(v, "exitCode") === "number" ? asNumber(get(v, "exitCode")) : null,
2084
+ exitCode: typeof get(v, "exitCode") === "number" ? asInt(get(v, "exitCode")) : null,
2082
2085
  allowed: get(v, "allowed") === true,
2083
2086
  rejectReason: asString(get(v, "rejectReason"))
2084
2087
  }));
@@ -2097,7 +2100,11 @@ function buildRunReportData(manifest, extra) {
2097
2100
  taskMode: taskMode === "code" || taskMode === "iterate" ? taskMode : null,
2098
2101
  round: asNumber(get(src, "round")),
2099
2102
  maxRounds: asNumber(get(src, "maxRounds")),
2100
- active: get(src, "active") !== false,
2103
+ // An ABSENT `active` means "no run / unknown", not "running": a missing or
2104
+ // empty transcript used to render a live, pulsing run at round 0. The
2105
+ // transcript builder always writes an explicit boolean, so a strict `true`
2106
+ // only ever loses the phantom run, never a real one.
2107
+ active: get(src, "active") === true,
2101
2108
  stoppedReason: typeof stoppedReason === "string" && stoppedReason ? stoppedReason : null,
2102
2109
  updatedAt: asString(get(src, "updatedAt")),
2103
2110
  convergence: asArray(get(src, "convergence")).filter(
@@ -2170,7 +2177,7 @@ function renderRunReportMarkdown(manifest, extra, opts) {
2170
2177
  lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`);
2171
2178
  lines.push("| --- | --- | --- | --- |");
2172
2179
  for (const f of data.findings) {
2173
- const loc = f.file ? `${f.file}${f.line > 1 ? `:${f.line}` : ""}` : "";
2180
+ const loc = f.file ? `${f.file}${f.line > 0 ? `:${f.line}` : ""}` : "";
2174
2181
  lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`);
2175
2182
  }
2176
2183
  }
@@ -2269,7 +2276,7 @@ function renderRunReportSarif(manifest, extra, opts) {
2269
2276
  if (f.suggestedFix) result.fixes = [{ description: { text: f.suggestedFix } }];
2270
2277
  return result;
2271
2278
  });
2272
- const failedValidations = data.validations.filter((v) => v.exitCode !== 0).length;
2279
+ const failedValidations = data.validations.filter((v) => v.exitCode !== null && v.exitCode !== 0).length;
2273
2280
  return {
2274
2281
  $schema: "https://json.schemastore.org/sarif-2.1.0.json",
2275
2282
  version: "2.1.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.9.0",
3
+ "version": "3.9.1",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
 
@@ -205,6 +205,11 @@ function asNumber(value: unknown): number {
205
205
  return typeof value === 'number' && Number.isFinite(value) ? value : 0
206
206
  }
207
207
 
208
+ /** Non-negative integer (floors fractions); 0 for anything else. */
209
+ function asInt(value: unknown): number {
210
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
211
+ }
212
+
208
213
  function asArray(value: unknown): unknown[] {
209
214
  return Array.isArray(value) ? value : []
210
215
  }
@@ -258,7 +263,7 @@ export function buildRunReportData(
258
263
  const findings: ReportFinding[] = asArray(get(src, 'findings')).map((f) => ({
259
264
  dimension: asString(get(f, 'dimension')),
260
265
  file: asString(get(f, 'file')),
261
- line: asNumber(get(f, 'line')),
266
+ line: asInt(get(f, 'line')),
262
267
  severity: findingSeverity(f),
263
268
  summary: asString(get(f, 'summary')),
264
269
  failureScenario: asString(get(f, 'failure_scenario')),
@@ -284,7 +289,7 @@ export function buildRunReportData(
284
289
  const validations: ReportValidation[] = asArray(get(src, 'validations')).map((v) => ({
285
290
  round: asNumber(get(v, 'round')),
286
291
  command: asString(get(v, 'command')),
287
- exitCode: typeof get(v, 'exitCode') === 'number' ? asNumber(get(v, 'exitCode')) : null,
292
+ exitCode: typeof get(v, 'exitCode') === 'number' ? asInt(get(v, 'exitCode')) : null,
288
293
  allowed: get(v, 'allowed') === true,
289
294
  rejectReason: asString(get(v, 'rejectReason')),
290
295
  }))
@@ -306,7 +311,11 @@ export function buildRunReportData(
306
311
  taskMode: taskMode === 'code' || taskMode === 'iterate' ? taskMode : null,
307
312
  round: asNumber(get(src, 'round')),
308
313
  maxRounds: asNumber(get(src, 'maxRounds')),
309
- active: get(src, 'active') !== false,
314
+ // An ABSENT `active` means "no run / unknown", not "running": a missing or
315
+ // empty transcript used to render a live, pulsing run at round 0. The
316
+ // transcript builder always writes an explicit boolean, so a strict `true`
317
+ // only ever loses the phantom run, never a real one.
318
+ active: get(src, 'active') === true,
310
319
  stoppedReason: typeof stoppedReason === 'string' && stoppedReason ? stoppedReason : null,
311
320
  updatedAt: asString(get(src, 'updatedAt')),
312
321
  convergence: asArray(get(src, 'convergence')).filter(
@@ -392,7 +401,7 @@ export function renderRunReportMarkdown(
392
401
  lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`)
393
402
  lines.push('| --- | --- | --- | --- |')
394
403
  for (const f of data.findings) {
395
- const loc = f.file ? `${f.file}${f.line > 1 ? `:${f.line}` : ''}` : ''
404
+ const loc = f.file ? `${f.file}${f.line > 0 ? `:${f.line}` : ''}` : ''
396
405
  lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`)
397
406
  }
398
407
  }
@@ -513,7 +522,9 @@ export function renderRunReportSarif(
513
522
  if (f.suggestedFix) result.fixes = [{ description: { text: f.suggestedFix } }]
514
523
  return result
515
524
  })
516
- const failedValidations = data.validations.filter((v) => v.exitCode !== 0).length
525
+ // `exitCode: null` is the documented "unknown / no exit code" value, not a
526
+ // failure: only a concrete non-zero code makes the run unsuccessful.
527
+ const failedValidations = data.validations.filter((v) => v.exitCode !== null && v.exitCode !== 0).length
517
528
  return {
518
529
  $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
519
530
  version: '2.1.0',
@@ -63,7 +63,7 @@ export interface IterateRunRecord {
63
63
  }
64
64
  /** Applied (successful) fixes recorded so far. */
65
65
  fixedCount: number
66
- /** Snapshot of the quality-gate verdict at write time, when known. */
66
+ /** Quality-gate verdict snapshotted at write time (`pending` when undecided). */
67
67
  qualityStatus: 'pass' | 'fail' | 'pending' | null
68
68
  /** ISO timestamp the record was written. */
69
69
  updatedAt: string
@@ -193,7 +193,9 @@ export function normalizeRunRecord(data: unknown): IterateRunRecord | null {
193
193
  const d = data as Record<string, unknown>
194
194
  // A record written by a future/incompatible build is skipped rather than
195
195
  // coerced: the version gate is the contract that lets the payload evolve.
196
- if (nonNegInt(d.version) !== ITERATE_RUN_RECORD_VERSION) return null
196
+ // The check is exact (an integer compare, not a floor) so a payload stamped
197
+ // `1.9` by a future build is rejected instead of rounded down to `1`.
198
+ if (!Number.isInteger(d.version) || d.version !== ITERATE_RUN_RECORD_VERSION) return null
197
199
  const findings = (d.findings && typeof d.findings === 'object' && !Array.isArray(d.findings)
198
200
  ? (d.findings as Record<string, unknown>)
199
201
  : {}) as Record<string, unknown>
@@ -226,12 +228,17 @@ export function normalizeRunRecord(data: unknown): IterateRunRecord | null {
226
228
  let cachedApi: Promise<SessionRecordApi | null> | undefined
227
229
  /** Test override; when set (including `null`) the dynamic import is bypassed. */
228
230
  let testApiOverride: SessionRecordApi | null | undefined
231
+ /** Module loader; replaceable for tests to exercise import failure/late availability. */
232
+ let moduleImporter: () => Promise<unknown> = () => import('@deepseek-ai/dsh-session')
229
233
 
230
234
  /** Resolve the `@deepseek-ai/dsh-session` record API, or `null` when absent. */
231
235
  export function loadSessionRecordApi(): Promise<SessionRecordApi | null> {
232
236
  if (testApiOverride !== undefined) return Promise.resolve(testApiOverride)
233
237
  if (cachedApi === undefined) {
234
- cachedApi = import('@deepseek-ai/dsh-session')
238
+ // `Promise.resolve().then(...)` (not a bare call) so a loader that throws
239
+ // SYNCHRONOUSLY still lands in the `.catch` instead of escaping.
240
+ cachedApi = Promise.resolve()
241
+ .then(() => moduleImporter())
235
242
  .then((mod) => {
236
243
  const m = mod as unknown as Partial<SessionRecordApi>
237
244
  if (typeof m.appendPluginRecord !== 'function' || typeof m.pluginRecordOf !== 'function') {
@@ -243,10 +250,25 @@ export function loadSessionRecordApi(): Promise<SessionRecordApi | null> {
243
250
  }
244
251
  })
245
252
  .catch(() => null)
253
+ // A `null` resolution (module/API absent, or a transient import failure) is
254
+ // NOT memoized: it is cleared so the next call retries. Otherwise one
255
+ // unlucky first import would disable session linking for the whole process.
256
+ void cachedApi.then((api) => {
257
+ if (!api) cachedApi = undefined
258
+ })
246
259
  }
247
260
  return cachedApi
248
261
  }
249
262
 
263
+ /**
264
+ * Replace the module loader for tests (to exercise the import-failure and
265
+ * late-availability paths). Pass `undefined` to restore the real dynamic import.
266
+ */
267
+ export function setSessionRecordImporterForTests(loader: (() => Promise<unknown>) | undefined): void {
268
+ moduleImporter = loader ?? (() => import('@deepseek-ai/dsh-session'))
269
+ cachedApi = undefined
270
+ }
271
+
250
272
  /**
251
273
  * Override the session-record API for tests. Pass `undefined` to restore the
252
274
  * real dynamic import, or an object / `null` to force a deterministic result.
@@ -4,8 +4,8 @@
4
4
  * iterate_report — render the current iterate run into a portable report.
5
5
  *
6
6
  * Two formats, one normalized data model (shared with the client export
7
- * buttons via `lib/parse.js`, so the on-screen export and this tool can never
8
- * drift):
7
+ * buttons via `src/report-render.ts`, so the on-screen export and this tool
8
+ * can never drift):
9
9
  *
10
10
  * - `markdown` — a PR-pasteable run report: header (mode/round/status), the
11
11
  * quality-gate table, the findings table, the fixes table,
@@ -34,6 +34,7 @@ import {
34
34
  import { transcriptPath } from '../paths.ts'
35
35
  import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from '../transcript.ts'
36
36
  import { readDecisionLogDetailed } from './decision-log.ts'
37
+ import { readQualityGate } from './quality-store.ts'
37
38
  import { readLive } from '../live.ts'
38
39
  import {
39
40
  buildRunRecord,
@@ -685,7 +686,17 @@ export function registerTranscriptTool(ctx: {
685
686
  // session. Best-effort — a link failure is reported, never fatal (the
686
687
  // on-disk capture above already succeeded).
687
688
  let sessionRecord: JsonValue
688
- const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest))
689
+ // Snapshot the CURRENT quality-gate verdict into the record so a
690
+ // recovered run keeps its PASS/FAIL badge. An absent gate reads as the
691
+ // store's default (`pending`); a corrupt/throwing read degrades to
692
+ // `null` and never fails the capture.
693
+ let qualityStatus: 'pass' | 'fail' | 'pending' | null = null
694
+ try {
695
+ qualityStatus = readQualityGate(projectRoot).overallStatus ?? null
696
+ } catch {
697
+ qualityStatus = null
698
+ }
699
+ const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest, qualityStatus))
689
700
  sessionRecord = (
690
701
  linkSession.ok
691
702
  ? { linked: true, seq: linkSession.seq }
@@ -715,12 +726,20 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
715
726
  goal: manifest.goal,
716
727
  maxRounds: manifest.maxRounds,
717
728
  })
729
+ // Phase breadcrumbs are part of the run narrative the F1 stage tracker shows;
730
+ // replay them before the rounds so an edit round-trip keeps the stage order.
731
+ for (const p of Array.isArray(manifest.phases) ? manifest.phases : []) {
732
+ if (typeof p === 'string') builder.phase(p)
733
+ }
718
734
  for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
719
735
  // A malformed/null round row must be skipped, never a throw (the caller
720
736
  // wraps rehydrate in a fallback, but preserving sibling rows is better).
721
737
  if (!r || typeof r !== 'object') continue
722
738
  builder.roundStart(r.round, manifest.maxRounds)
723
739
  for (const t of Array.isArray(r.threads) ? r.threads : []) {
740
+ // A null/garbled thread row must be skipped too: reading `t.dimension`
741
+ // off it would throw and cost the WHOLE manifest its history.
742
+ if (!t || typeof t !== 'object') continue
724
743
  // restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
725
744
  // message ARRAY boundaries and the thread's own dimension: rehydration
726
745
  // previously collapsed each thread's messages into one string and, once a
@@ -739,6 +758,14 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
739
758
  const n = manifest.convergence[idx]
740
759
  if (typeof n === 'number' && n >= 0) builder.snapshotConvergence(idx + 1, n)
741
760
  }
761
+ // Top-level findings live in the global newest-wins list (the F3 panel reads
762
+ // `manifest.findings`); restoreThread only rebuilds the per-round threads, so
763
+ // without this a nudge/rollback re-persist blanked the findings panel.
764
+ if (Array.isArray(manifest.findings)) builder.restoreFindings(manifest.findings)
765
+ // Move the round marker forward past the captured rows: `roundStart` reopens
766
+ // rows, but a manifest whose `round` outruns its rows (advanceRound semantics)
767
+ // would otherwise rewind the reported round.
768
+ builder.advanceRound(manifest.round, manifest.maxRounds)
742
769
  if (manifest.checkpoint) builder.recordCheckpoint(manifest.checkpoint)
743
770
  if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
744
771
  // Validation rows survive a nudge-edit re-persist (same defensive
package/src/transcript.ts CHANGED
@@ -486,6 +486,24 @@ export class ReviewTranscriptBuilder {
486
486
  this.touch()
487
487
  }
488
488
 
489
+ /**
490
+ * Re-insert top-level findings when rehydrating a persisted manifest (the
491
+ * nudge / rollback edit round-trip). `restoreThread` already rebuilds the
492
+ * per-round thread copy of each finding but never the global list, so an edit
493
+ * that only rewrites steering text used to blank `manifest.findings` and the
494
+ * F3 findings panel. This refills the global newest-wins list (and its dedupe
495
+ * set), mirroring `reviewerFindings` without needing a live thread.
496
+ */
497
+ restoreFindings(findings: readonly unknown[]): void {
498
+ if (Array.isArray(findings)) {
499
+ for (const raw of findings) {
500
+ const f = normalizeFinding(raw)
501
+ if (f) this.addGlobal(f)
502
+ }
503
+ }
504
+ this.touch()
505
+ }
506
+
489
507
  // ─── Convergence (F2) ───────────────────────────────────────────────────
490
508
 
491
509
  /** Record a round's new-finding count for the convergence series. */