iterate-plugin 3.5.7 → 3.6.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 (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +55 -13
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +111 -27
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +14 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +396 -66
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +115 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +154 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. package/src/types.ts +29 -1
@@ -22,8 +22,12 @@
22
22
  */
23
23
  import { readdirSync } from 'node:fs';
24
24
  import { join, relative } from 'node:path';
25
- /** Relative-scope sentinel for whole-module findings. */
26
- export const WHOLE_FILE_LINE = 0;
25
+ // Single source of truth for the whole-file line sentinel: evidence.ts's gate
26
+ // and this module's inventory filters must agree on what `0` means. Evidence
27
+ // imports neither this module nor review.ts, so the edge is acyclic (review.ts
28
+ // → review-scope.ts → evidence.ts → types.ts).
29
+ import { WHOLE_FILE_LINE } from "./evidence.js";
30
+ export { WHOLE_FILE_LINE };
27
31
  /** Source extensions a full-scope walk includes. */
28
32
  const SOURCE_EXTENSIONS = new Set([
29
33
  '.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
@@ -82,13 +86,19 @@ function isIgnoredDir(name) {
82
86
  }
83
87
  /** Collect the sorted relative-path inventory for a review scope. */
84
88
  export function collectScopeFiles(root, opts) {
89
+ // `opts` / `scope` come from tool args: a missing or malformed options
90
+ // object must degrade to a full walk, never throw inside the inventory step.
91
+ if (!opts || typeof opts !== 'object')
92
+ return collectFull(root);
85
93
  if (opts.scope === 'changed-only')
86
94
  return collectChanged(opts.changedFiles ?? []);
87
95
  return collectFull(root);
88
96
  }
89
97
  function collectChanged(changedFiles) {
98
+ // A non-array `changedFiles` (JSON arg) must not be iterated char-by-char.
99
+ const list = Array.isArray(changedFiles) ? changedFiles : [];
90
100
  const out = new Set();
91
- for (const rel of changedFiles) {
101
+ for (const rel of list) {
92
102
  if (typeof rel !== 'string' || !rel.trim())
93
103
  continue;
94
104
  if (rel === String(WHOLE_FILE_LINE))
@@ -145,9 +155,17 @@ function collectFull(root) {
145
155
  /** Split `files` into stable batches, keeping directory runs together. */
146
156
  export function chunkFiles(files, perChunk) {
147
157
  // Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
148
- // chunk (current.length >= NaN is never true).
149
- const size = Number.isFinite(perChunk) && perChunk >= 1 ? perChunk : DEFAULT_SCOPE_CHUNK_SIZE;
150
- const ordered = [...files].sort();
158
+ // chunk (current.length >= NaN is never true). Math.floor: a fractional
159
+ // size (2.5) made chunks alternate between 2 and 3 members — the caller's
160
+ // "files per batch" is a count, so it must be an integer.
161
+ const size = Number.isFinite(perChunk) && perChunk >= 1
162
+ ? Math.floor(perChunk)
163
+ : DEFAULT_SCOPE_CHUNK_SIZE;
164
+ // A non-array / sparse-free copy: callers pass tool-arg JSON, and sort()
165
+ // on a non-array (or `rel.includes` on a number) would throw.
166
+ const ordered = (Array.isArray(files) ? files : [])
167
+ .filter((rel) => typeof rel === 'string')
168
+ .sort();
151
169
  const chunks = [];
152
170
  let current = [];
153
171
  let lastDir;
@@ -172,19 +190,24 @@ export function chunkFiles(files, perChunk) {
172
190
  }
173
191
  /** Score self-reported reads against the assigned inventory. */
174
192
  export function computeCoverage(assigned, readFiles) {
193
+ // Both lists are JSON-arg-derived: `for...of` over a bare number throws and
194
+ // over a bare string iterates single characters into the read set. Anything
195
+ // non-array behaves like "nothing" (empty inventory / nothing read).
196
+ const assignedList = Array.isArray(assigned) ? assigned : [];
197
+ const readList = Array.isArray(readFiles) ? readFiles : [];
175
198
  const readNorm = new Set();
176
- for (const p of readFiles ?? []) {
199
+ for (const p of readList) {
177
200
  if (typeof p === 'string' && p)
178
201
  readNorm.add(normalizePath(p));
179
202
  }
180
- const assignedSorted = [...assigned].sort();
203
+ const assignedSorted = [...assignedList].filter((rel) => typeof rel === 'string').sort();
181
204
  const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)));
182
205
  const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)));
183
206
  const rawRatio = assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length;
184
207
  const ratio = Math.round(rawRatio * 1000) / 1000;
185
208
  return {
186
209
  assigned: assignedSorted,
187
- read: [...new Set((readFiles ?? []).filter((p) => typeof p === 'string'))].sort(),
210
+ read: [...new Set(readList.filter((p) => typeof p === 'string'))].sort(),
188
211
  covered,
189
212
  uncovered,
190
213
  ratio,
package/dist/review.js CHANGED
@@ -44,9 +44,44 @@ export function sortFindings(findings) {
44
44
  return (Number(a?.line) || 0) - (Number(b?.line) || 0);
45
45
  });
46
46
  }
47
+ /**
48
+ * Hard ceiling on the round cap the aggregation math will honor.
49
+ *
50
+ * Mirrors `MAX_MAX_ROUNDS` in `config-loader.ts` (the config bomb guard) —
51
+ * `test/review.test.ts` asserts the two constants stay in sync. The tool
52
+ * boundary only clamps the LOWER bound of `maxReviewRounds`, so an absurd
53
+ * caller value (1e15) would otherwise drive the `findingsByRound` allocation
54
+ * bound (`roundCap * 2`) straight into OOM territory.
55
+ */
56
+ export const MAX_REVIEW_ROUNDS_CAP = 100;
57
+ /**
58
+ * Clamp an untrusted round cap to `[1, MAX_REVIEW_ROUNDS_CAP]`.
59
+ *
60
+ * Non-finite / non-numeric input (`NaN`, `Infinity`, a string) collapses to
61
+ * 1 — the smallest sane cap. Without this, `Math.max(1, NaN)` stayed `NaN`
62
+ * and `new Array(NaN)` threw `RangeError` out of `aggregateRounds`.
63
+ */
64
+ export function clampMaxReviewRounds(value) {
65
+ if (typeof value !== 'number' || !Number.isFinite(value))
66
+ return 1;
67
+ return Math.min(MAX_REVIEW_ROUNDS_CAP, Math.max(1, Math.floor(value)));
68
+ }
69
+ /**
70
+ * True when `n` is a round number the deterministic core accepts: a positive
71
+ * integer. Shared by `aggregateRounds` / `computeConvergence` /
72
+ * `buildReviewReport` (and re-read by the meta-review audit) so every reader
73
+ * of "the last round" agrees on which round numbers count — a fractional
74
+ * (`1.5`) or non-finite round is simply "not a round", never a convergence
75
+ * input that could be mis-indexed into `findingsByRound`.
76
+ */
77
+ export function isValidRoundNumber(n) {
78
+ return typeof n === 'number' && Number.isInteger(n) && n >= 1;
79
+ }
47
80
  /** Normalize a summary so near-identical duplicates collapse to one key. */
48
81
  export function normalizeSummary(summary) {
49
- return summary
82
+ // Coerce: `summary` is model-authored JSON when schema validation is off,
83
+ // so a numeric summary must not crash `.trim()` inside the dedupe key.
84
+ return String(summary ?? '')
50
85
  .trim()
51
86
  .toLowerCase()
52
87
  .replace(/[\s\n\t]+/g, ' ');
@@ -58,8 +93,12 @@ export function normalizeSummary(summary) {
58
93
  * neither side anchors one, i.e. whole-file findings).
59
94
  */
60
95
  export function findingKey(f) {
61
- const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0;
62
- return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`;
96
+ // Defensive reads: with `output_schema_validation: false` a finding may
97
+ // carry wrong-typed fields (and `f` itself may be a null list element), and
98
+ // this key feeds the dedupe Set on EVERY round — it must never throw.
99
+ const src = (f ?? {});
100
+ const line = typeof src.line === 'number' && src.line > 0 ? src.line : 0;
101
+ return `${String(src.file ?? '')}|${String(src.dimension ?? '')}|${line}|${normalizeSummary(src.summary)}`;
63
102
  }
64
103
  /**
65
104
  * Remove duplicate findings within a list.
@@ -69,6 +108,10 @@ export function dedupeFindings(findings) {
69
108
  const seen = new Set();
70
109
  const out = [];
71
110
  for (const f of findings) {
111
+ // A null / array entry (hostile or schema-validation-off JSON) must never
112
+ // reach `summarize`, which reads `f.severity` unguarded.
113
+ if (!f || typeof f !== 'object' || Array.isArray(f))
114
+ continue;
72
115
  const key = findingKey(f);
73
116
  if (seen.has(key))
74
117
  continue;
@@ -84,10 +127,19 @@ export function dedupeFindings(findings) {
84
127
  * - entry `line` is 0/undefined (whole file) OR equals the finding's line.
85
128
  */
86
129
  export function filterKnownIntentional(findings, known) {
87
- if (!known || known.length === 0)
130
+ // `known` is config/arg JSON: a non-array truthy value must behave like
131
+ // "no entries", never reach `known.some` and throw.
132
+ if (!Array.isArray(known) || known.length === 0)
88
133
  return findings;
89
134
  return findings.filter((f) => {
135
+ // A null / non-object finding has no file/dimension to match on; drop it
136
+ // here so it can never crash the comparator (aggregateRounds drops it too,
137
+ // so the report's counts stay consistent either way).
138
+ if (!f || typeof f !== 'object' || Array.isArray(f))
139
+ return false;
90
140
  const matched = known.some((k) => {
141
+ if (!k || typeof k !== 'object')
142
+ return false;
91
143
  const sameFile = k.file === f.file;
92
144
  const sameDim = k.dimension === f.dimension;
93
145
  if (!sameFile || !sameDim)
@@ -124,16 +176,25 @@ export function aggregateRounds(rounds, maxReviewRounds) {
124
176
  // 1e9) would otherwise allocate an array of that size below (OOM). Round
125
177
  // numbers above the configured cap are clamped to the cap.
126
178
  let maxRound = 0;
127
- const roundCap = Math.max(1, maxReviewRounds);
128
- for (const round of rounds) {
179
+ // Upper-bounded too: `Math.max(1, NaN)` used to stay NaN and blow up in
180
+ // `new Array(effectiveMax)`, and an unbounded cap scaled the allocation
181
+ // bound to whatever the caller asked for.
182
+ const roundCap = clampMaxReviewRounds(maxReviewRounds);
183
+ for (const round of Array.isArray(rounds) ? rounds : []) {
129
184
  if (!round || typeof round !== 'object')
130
185
  continue;
131
- if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
186
+ if (!isValidRoundNumber(round.round))
132
187
  continue;
133
188
  const findings = Array.isArray(round.findings) ? round.findings : [];
134
189
  if (round.round > maxRound)
135
190
  maxRound = round.round;
136
191
  for (const f of findings) {
192
+ // Null / non-object elements (schema validation off) would crash
193
+ // `findingKey`; dropping them here keeps `findingsByRound` summing to
194
+ // exactly the returned findings list (the meta-review's CONVERGENCE_SUM
195
+ // check reads that equality).
196
+ if (!f || typeof f !== 'object' || Array.isArray(f))
197
+ continue;
137
198
  const key = findingKey(f);
138
199
  if (seen.has(key))
139
200
  continue;
@@ -162,8 +223,9 @@ export function aggregateRounds(rounds, maxReviewRounds) {
162
223
  * Compute convergence statistics for a dry-run review.
163
224
  */
164
225
  export function computeConvergence(rounds, maxReviewRounds) {
165
- const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds);
166
- const totalRounds = rounds.length;
226
+ const list = Array.isArray(rounds) ? rounds : [];
227
+ const { findingsByRound } = aggregateRounds(list, maxReviewRounds);
228
+ const totalRounds = list.length;
167
229
  // `findingsByRound` is indexed by the actual round number (round r → index
168
230
  // r-1), sized to the highest present round (clamped). Convergence must read
169
231
  // the HIGHEST PRESENT round's count — not the last array element (rounds
@@ -171,15 +233,17 @@ export function computeConvergence(rounds, maxReviewRounds) {
171
233
  // 1..N). The count index is bounded by the array length aggregateRounds
172
234
  // actually allocated.
173
235
  let lastRound = 0;
174
- for (const round of rounds) {
175
- if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
236
+ for (const round of list) {
237
+ if (!isValidRoundNumber(round?.round))
176
238
  continue;
177
239
  if (round.round > lastRound)
178
240
  lastRound = round.round;
179
241
  }
180
242
  const idx = Math.min(lastRound, findingsByRound.length) - 1;
181
243
  const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
182
- const converged = totalRounds > 0 && lastRoundCount === 0;
244
+ // `lastRound === 0` means NO round carried an interpretable number: claiming
245
+ // `converged` there would bless a report the core could not actually read.
246
+ const converged = totalRounds > 0 && lastRound > 0 && lastRoundCount === 0;
183
247
  return {
184
248
  totalRounds,
185
249
  findingsByRound,
@@ -199,7 +263,15 @@ function summarize(findings) {
199
263
  high: 0,
200
264
  medium: 0,
201
265
  low: 0,
202
- byDimension: {},
266
+ // NULL-PROTOTYPE map: a model-authored `dimension` of `__proto__` or
267
+ // `constructor` would otherwise resolve against Object.prototype — the
268
+ // `__proto__` setter silently discards a primitive value and `constructor`
269
+ // string-concatenates the Object function — so the counter never
270
+ // accumulated and the meta-review's DIMENSION_SUM check false-positived.
271
+ // A null-prototype object has no inherited accessors, so every dimension
272
+ // (including those three names) is a plain own data property and
273
+ // JSON-serializes normally.
274
+ byDimension: Object.create(null),
203
275
  };
204
276
  for (const f of findings) {
205
277
  if (f.severity === 'critical')
@@ -223,7 +295,8 @@ function summarize(findings) {
223
295
  */
224
296
  export function buildReviewReport(input) {
225
297
  // 1. Filter known-intentional per round (before cross-round dedupe).
226
- const filteredRounds = input.rounds
298
+ const inputRounds = Array.isArray(input.rounds) ? input.rounds : [];
299
+ const filteredRounds = inputRounds
227
300
  .map((r) => ({
228
301
  round: typeof r?.round === 'number' ? r.round : 0,
229
302
  findings: filterKnownIntentional(Array.isArray(r?.findings) ? r.findings : [], input.knownIntentional),
@@ -246,7 +319,11 @@ export function buildReviewReport(input) {
246
319
  // non-contiguous round sets would otherwise read the wrong count).
247
320
  let lastRound = 0;
248
321
  for (const r of filteredRounds) {
249
- if (typeof r.round === 'number' && r.round > lastRound)
322
+ // Same validity rule as aggregateRounds: a fractional / NaN round is not
323
+ // a round, so it must never be used as an index into `findingsByRound`
324
+ // (Math.min(1.5, len) - 1 = 0.5 → reads `undefined` → counts as 0 new →
325
+ // a round that really did report findings would be read as "converged").
326
+ if (isValidRoundNumber(r.round) && r.round > lastRound)
250
327
  lastRound = r.round;
251
328
  }
252
329
  // `lastRound` can exceed the array length when over-cap rounds were FOLDED
@@ -255,7 +332,7 @@ export function buildReviewReport(input) {
255
332
  // new findings (which would falsely report "converged").
256
333
  const idx = Math.min(lastRound, findingsByRound.length) - 1;
257
334
  const lastRoundCount = lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
258
- const converged = filteredRounds.length > 0 && lastRoundCount === 0;
335
+ const converged = filteredRounds.length > 0 && lastRound > 0 && lastRoundCount === 0;
259
336
  // Attach the normal-mode fix count to the summary (dry-run leaves it absent).
260
337
  const computed = summarize(sorted);
261
338
  if (input.mode === 'normal' && typeof input.fixedCount === 'number' && Number.isInteger(input.fixedCount)) {
@@ -659,7 +736,9 @@ export function buildReviewPlan(input) {
659
736
  goal,
660
737
  scope,
661
738
  dimensions: dimensionTasks,
662
- maxReviewRounds: input.maxReviewRounds,
739
+ // Clamped with the SAME rule aggregateRounds honors, so the cap this plan
740
+ // advertises is never a number the aggregation math would silently clamp.
741
+ maxReviewRounds: clampMaxReviewRounds(input.maxReviewRounds),
663
742
  knownIntentional: input.knownIntentional ?? [],
664
743
  changedFiles: effectiveChangedOnly ? changedFiles : [],
665
744
  fallbackToFull,
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Wires the {@link decideApproval} policy gate to dsh's `tools/pre-execute`
5
5
  * waterfall. This is the AUTHORITATIVE approval seam for destructive iterate
6
- * tools (`iterate_fix` / `iterate_rollback` / `iterate_prune` with dryRun:false):
6
+ * tools (`iterate_fix` / `iterate_rollback` / `iterate_prune` with dryRun:false
7
+ * / `iterate_config` with operation:"write"):
7
8
  *
8
9
  * - `allow` policy → the call runs.
9
10
  * - `deny` policy → the call is refused (fail-closed), surfaced as an
@@ -13,11 +14,19 @@
13
14
  * plus the human-readable reason) so durable
14
15
  * projections can route it distinctly from a normal
15
16
  * tool failure.
16
- * - `ask` policy → return `{ kind: 'ask', reason }`; dsh's own
17
- * scheduler routes it through the `approval` service
18
- * (see `@deepseek-ai/dsh-user-approval`), which
19
- * prompts the human and audits an approve/deny pair
20
- * on the session.
17
+ * - `ask` policy → return `{ kind: 'ask', reason, displayReason }`;
18
+ * dsh's own scheduler routes it through the
19
+ * `approval` service (see
20
+ * `@deepseek-ai/dsh-user-approval`), which prompts
21
+ * the human and audits an approve/deny pair on the
22
+ * session. `reason` is the audited English summary;
23
+ * `displayReason` (dsh 0.2.x) is the localized
24
+ * prompt text. LANGUAGE RULE: human-facing text
25
+ * follows the project `language` (zh|en); every
26
+ * structured/durable field (deny reason,
27
+ * `info.reason`, decision-log entries, console
28
+ * logs) stays English so machine matchers never
29
+ * depend on a locale.
21
30
  * - canceled → if the caller aborted the invocation before
22
31
  * dispatch, return `{ kind: 'cancel' }` (the
23
32
  * canonical 0.1.6-alpha.1 cancellation result) so a
@@ -31,7 +40,15 @@
31
40
  *
32
41
  * Safety properties:
33
42
  * - Read-only tools and non-iterate tools are always allowed (the gate only
34
- * inspects the three destructive iterate toolnames).
43
+ * inspects the four gated iterate toolnames, and of those only prune
44
+ * `dryRun:false` and `iterate_config` `operation:"write"` — a config read
45
+ * or a dry-run prune short-circuits to allow inside the policy gate).
46
+ * - The approval policy is read from the SESSION workspace whenever a
47
+ * session cwd is known — the model-controlled `path` argument can never
48
+ * choose where the human's policy comes from (it is only consulted when
49
+ * there is no session cwd at all). Otherwise `path:` could point at an
50
+ * `approval: allow` directory to self-grant, or dodge a `deny` in the
51
+ * workspace.
35
52
  * - If the project root / observatory config cannot be resolved, the policy
36
53
  * degrades to `ask` (fail-safe: destructive writes always require consent).
37
54
  */
@@ -77,8 +94,14 @@ export function gateDecision(exec) {
77
94
  if (signal && typeof signal.aborted === 'boolean' && signal.aborted) {
78
95
  return { kind: 'cancel' };
79
96
  }
80
- // Resolve the project root (use the call's own `path` arg, else the agent's
81
- // session cwd) to read the effective observatory policy.
97
+ // Resolve the project root whose observatory policy governs this call.
98
+ // SECURITY: the policy source must NOT be model-controlled. `path` is a
99
+ // model argument, so reading the policy from the path-resolved root let a
100
+ // call point `path:` at a directory with `approval: allow` (self-grant) or
101
+ // dodge a human's `deny` in the session workspace. When the session cwd is
102
+ // known, the SESSION workspace's config is authoritative and `path` is
103
+ // ignored; the path-resolved root is only the fallback when there is no
104
+ // session cwd at all (headless/tests). The `ask` default stays fail-closed.
82
105
  let argPath;
83
106
  let sessionCwd;
84
107
  try {
@@ -93,8 +116,15 @@ export function gateDecision(exec) {
93
116
  catch {
94
117
  // hostile/proxied exec — fall through with both undefined (defaults to ask)
95
118
  }
96
- const resolved = resolveProjectRoot(argPath, sessionCwd);
119
+ // Passing sessionCwd as BOTH the input and the anchor makes the root the
120
+ // session cwd itself (no server-cwd fallback can sneak in), anchored there.
121
+ const resolved = sessionCwd
122
+ ? resolveProjectRoot(sessionCwd, sessionCwd)
123
+ : resolveProjectRoot(argPath, sessionCwd);
97
124
  let policy = 'ask';
125
+ // Project language for the HUMAN-FACING ask prompt only (see the header's
126
+ // language rule: structured deny/audit text stays English).
127
+ let language = 'en';
98
128
  if (resolved.ok) {
99
129
  const { config } = loadEffectiveConfig(resolved.root);
100
130
  const p = config.observatory?.approval;
@@ -103,8 +133,10 @@ export function gateDecision(exec) {
103
133
  else if (p === 'allow')
104
134
  policy = 'allow';
105
135
  // anything else (including a corrupt/missing `ask`) → 'ask'
136
+ if (config.language === 'zh')
137
+ language = 'zh';
106
138
  }
107
- const decision = decideApproval(exec, policy);
139
+ const decision = decideApproval(exec, policy, language);
108
140
  if (decision.kind === 'deny') {
109
141
  return {
110
142
  kind: 'deny',
@@ -113,12 +145,19 @@ export function gateDecision(exec) {
113
145
  ...DENY_INFO,
114
146
  // ToolErrorInfo.reason (0.1.6-alpha.1): raw user-facing detail kept in
115
147
  // durable state; the model-facing text still only sees `reason` above.
148
+ // Deliberately the ENGLISH structured reason, never the localized
149
+ // prompt — durable projections must match on a stable string.
116
150
  reason: decision.reason,
117
151
  },
118
152
  };
119
153
  }
120
- if (decision.kind === 'ask')
121
- return { kind: 'ask', reason: decision.reason };
154
+ // dsh 0.2.x: `displayReason` is the localized prompt text (en + the
155
+ // configured locale); `reason` stays the audited English summary. Both are
156
+ // forwarded so the approval UI can show the full localized explanation
157
+ // (including the WARNING line for gate-disabling config writes).
158
+ if (decision.kind === 'ask') {
159
+ return { kind: 'ask', reason: decision.reason, displayReason: decision.displayReason };
160
+ }
122
161
  return { kind: 'allow' };
123
162
  }
124
163
  /**
@@ -139,6 +178,9 @@ export function registerSessionHooks(ctx) {
139
178
  return Promise.resolve({
140
179
  kind: 'ask',
141
180
  reason: 'iterate approval gate unavailable — require consent',
181
+ // Fail-safe path: no config was read, so the localized prompt falls
182
+ // back to English (displayReason requires at least an `en` entry).
183
+ displayReason: { en: 'iterate approval gate unavailable — require consent' },
142
184
  });
143
185
  }
144
186
  if (decision.kind === 'ask') {