@ngockhoale/ukit 3.4.32 → 3.4.33
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/CHANGELOG.md +7 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/diffPlan.js +87 -8
- package/src/core/executionContracts.js +8 -2
- package/src/core/metadata.js +5 -0
- package/src/core/ompConfigMerge.js +60 -0
- package/src/core/ompProjectConfig.js +462 -0
- package/src/core/runInstallPipeline.js +25 -1
- package/src/core/uninstall.js +28 -1
- package/src/render/buildVariables.js +4 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +37 -2
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +67 -7
- package/template_project/.claude/ukit/index/route-resolver.mjs +19 -5
- package/template_project/.claude/ukit/index/route-task.mjs +97 -1
- package/template_project/.claude/ukit/runtime/context-budget-policy.mjs +309 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +28 -5
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +74 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +7 -0
- package/template_project/.omp/config.yml +30 -14
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,13 @@ All notable changes to UKit are documented here.
|
|
|
9
9
|
- New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
|
|
10
10
|
- Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
|
|
11
11
|
|
|
12
|
+
## 3.4.33 - 2026-10-11
|
|
13
|
+
|
|
14
|
+
**OMP token optimization, adaptive task decomposition and Completion Gate reliability.** Verified with the full suite and `yarn release:verify`.
|
|
15
|
+
- **OMP project compaction.** Project `.omp/config.yml` defaults `compaction.thresholdTokens` to 90K; legacy 350K values migrate; install/update merge safely, preserve user overrides and per-agent compaction thresholds; uninstall is ownership-aware. Global OMP config is never touched (OMP 18.8.9 compatible).
|
|
16
|
+
- **Adaptive task decomposition.** Context budget policy, cost-aware decomposition, runtime plan dispatch enforcement for tagged subtasks, dependency handling, resume/progress preservation and parent acceptance criteria; no redundant replanning.
|
|
17
|
+
- **Completion Gate.** Status/result lookups are classified READ_ONLY (no fake write/verification evidence); coding tasks still require write and verification evidence. Contracts are bound to task, session and source snapshot; a read-only or answer-only reroute parks the running implementation contract and a continuation restores it; new task ids never inherit old evidence.
|
|
18
|
+
|
|
12
19
|
## 3.4.32 - 2026-10-11
|
|
13
20
|
|
|
14
21
|
**UNIC-Decision integration P1: safety floors, ordinal score contract, request caps.** Verified with the full suite (566 files) and `yarn release:verify`; no live inference was run (offline fixtures plus an offline run of the Laya server translator).
|
|
@@ -418,6 +418,17 @@ entries:
|
|
|
418
418
|
validation: [none]
|
|
419
419
|
archive_policy: immutable
|
|
420
420
|
notes: C99 measurement-only sub-agent cost audit snapshot; registered in 3.4.25 (was unclassified on 3.4.24)
|
|
421
|
+
- id: docs-analysis-context-threshold-simulation
|
|
422
|
+
path: docs/analysis/CONTEXT-THRESHOLD-SIMULATION.md
|
|
423
|
+
class: archive
|
|
424
|
+
audience: [maintainer]
|
|
425
|
+
owner: product
|
|
426
|
+
source_of_truth: docs/analysis/CONTEXT-THRESHOLD-SIMULATION.md
|
|
427
|
+
merge_strategy: none
|
|
428
|
+
load_policy: never
|
|
429
|
+
validation: [none]
|
|
430
|
+
archive_policy: immutable
|
|
431
|
+
notes: Corrected compaction-threshold simulation (observed vs hypothetical, LIMITED validity)
|
|
421
432
|
- id: docs-analysis-handoff-root-cause-audit
|
|
422
433
|
path: docs/analysis/HANDOFF-ROOT-CAUSE-AUDIT.md
|
|
423
434
|
class: archive
|
package/package.json
CHANGED
package/src/core/diffPlan.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import YAML from 'yaml';
|
|
2
3
|
import { isSymlinkTo } from './fileOps.js';
|
|
3
4
|
import { GATEWAY_RESILIENCE_ENV_DEFAULTS } from './gatewayResilienceEnv.js';
|
|
4
5
|
import { isOmpConfigTarget, mergeOmpConfig } from './ompConfigMerge.js';
|
|
6
|
+
import { mergeOmpProjectConfig } from './ompProjectConfig.js';
|
|
5
7
|
import { mergeRuntimeConfigText } from './runtimeConfigMerge.js';
|
|
6
8
|
|
|
7
9
|
const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS));
|
|
@@ -163,13 +165,65 @@ function canonicalize(value) {
|
|
|
163
165
|
return value;
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
|
|
168
|
+
// What UKit itself ensured in the file: the merged text, but with compaction values
|
|
169
|
+
// the user overrode shown as UKit's rendered defaults — so uninstall can tell a
|
|
170
|
+
// user-owned override (differs → kept) from a UKit-installed value (same → removed).
|
|
171
|
+
function ukitInstalledText(mergedText, renderedText) {
|
|
172
|
+
try {
|
|
173
|
+
const merged = YAML.parseDocument(mergedText);
|
|
174
|
+
const defaults = YAML.parse(renderedText)?.compaction;
|
|
175
|
+
const current = merged.get('compaction');
|
|
176
|
+
if (!isPlainObject(defaults) || !current || typeof current.get !== 'function') return mergedText;
|
|
177
|
+
let differs = false;
|
|
178
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
179
|
+
if (current.get(key) !== value) {
|
|
180
|
+
differs = true;
|
|
181
|
+
current.set(key, value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Same rule for UKit's per-agent task compaction overrides: a user-set value is
|
|
185
|
+
// shown as the shipped default so uninstall treats it as user-owned.
|
|
186
|
+
const defaultOverrides = YAML.parse(renderedText)?.task?.agentCompactionThresholdOverrides;
|
|
187
|
+
if (isPlainObject(defaultOverrides)) {
|
|
188
|
+
for (const [agent, value] of Object.entries(defaultOverrides)) {
|
|
189
|
+
const currentValue = merged.getIn(['task', 'agentCompactionThresholdOverrides', agent]);
|
|
190
|
+
if (currentValue !== undefined && currentValue !== value) {
|
|
191
|
+
differs = true;
|
|
192
|
+
merged.setIn(['task', 'agentCompactionThresholdOverrides', agent], value);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Agents the user added are not UKit's: drop them from the installed view.
|
|
197
|
+
const userMap = merged.getIn(['task', 'agentCompactionThresholdOverrides']);
|
|
198
|
+
if (isPlainObject(defaultOverrides) && userMap && typeof userMap.items !== 'undefined') {
|
|
199
|
+
const extra = userMap.items.filter((p) => !(String(p.key?.value ?? p.key) in defaultOverrides));
|
|
200
|
+
for (const p of extra) {
|
|
201
|
+
differs = true;
|
|
202
|
+
merged.deleteIn(['task', 'agentCompactionThresholdOverrides', String(p.key?.value ?? p.key)]);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return differs ? String(merged) : mergedText;
|
|
206
|
+
} catch {
|
|
207
|
+
return mergedText;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveFileAction(entry, existingContent, ompOwnership = null) {
|
|
167
212
|
const exists = existingContent !== null;
|
|
168
213
|
const binaryEntry = Buffer.isBuffer(entry.renderedContent);
|
|
169
214
|
|
|
170
215
|
let action;
|
|
171
216
|
if (!exists) {
|
|
172
217
|
action = 'create';
|
|
218
|
+
if (isOmpConfigTarget(entry.targetPath) && typeof entry.renderedContent === 'string') {
|
|
219
|
+
return {
|
|
220
|
+
...entry,
|
|
221
|
+
exists,
|
|
222
|
+
action,
|
|
223
|
+
existingContent,
|
|
224
|
+
ompOwnership: { originalText: null, installedText: entry.renderedContent },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
173
227
|
} else if (entry.mergeStrategy === 'append' && !binaryEntry) {
|
|
174
228
|
action = existingContent.endsWith(entry.renderedContent) ? 'unchanged' : 'update';
|
|
175
229
|
} else if (entry.mergeStrategy === 'prepend' && !binaryEntry) {
|
|
@@ -184,9 +238,34 @@ function resolveFileAction(entry, existingContent) {
|
|
|
184
238
|
// SPEC §7 step 9: `.omp/config.yml` gets a semantic, comment-safe migration
|
|
185
239
|
// merge — the action compares the MERGED text against the existing file, so
|
|
186
240
|
// preserved user regions do not count as drift.
|
|
187
|
-
|
|
241
|
+
// Compaction first (ownership-aware, surgical, comment-preserving); the approval
|
|
242
|
+
// merge then runs over that text, so user compaction values survive verbatim.
|
|
243
|
+
let projectMerge = null;
|
|
244
|
+
try {
|
|
245
|
+
projectMerge = mergeOmpProjectConfig(existingContent, entry.renderedContent, ompOwnership);
|
|
246
|
+
} catch {
|
|
247
|
+
projectMerge = null; // unparsable → mergeOmpConfig reports parseError below
|
|
248
|
+
}
|
|
249
|
+
const merged = mergeOmpConfig(projectMerge ? projectMerge.text : existingContent, entry.renderedContent);
|
|
250
|
+
if (merged.report.parseError) {
|
|
251
|
+
// Unparseable existing file (or broken template): never replace the user's file
|
|
252
|
+
// with the template — leave it untouched and let install report it.
|
|
253
|
+
return { ...entry, renderedContent: existingContent, migrationReport: merged.report, exists, action: 'unchanged', existingContent };
|
|
254
|
+
}
|
|
188
255
|
action = merged.text === existingContent ? 'unchanged' : 'update';
|
|
189
|
-
|
|
256
|
+
const ownership = projectMerge
|
|
257
|
+
? { originalText: projectMerge.ownership.originalText, installedText: ukitInstalledText(merged.text, entry.renderedContent) }
|
|
258
|
+
: null;
|
|
259
|
+
return {
|
|
260
|
+
...entry,
|
|
261
|
+
renderedContent: merged.text,
|
|
262
|
+
migrationReport: merged.report,
|
|
263
|
+
ompOwnership: ownership,
|
|
264
|
+
ompOwnershipWarning: projectMerge?.warning ?? null,
|
|
265
|
+
exists,
|
|
266
|
+
action,
|
|
267
|
+
existingContent,
|
|
268
|
+
};
|
|
190
269
|
} else if (entry.mergeStrategy === 'merge_env_overwrite_with_backup' && typeof existingContent === 'string') {
|
|
191
270
|
// Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
|
|
192
271
|
// and may carry user overrides). All other top-level keys — and the rest of the env
|
|
@@ -222,7 +301,7 @@ function resolveFileAction(entry, existingContent) {
|
|
|
222
301
|
// write then crashes mid-install on a target that may have been there all along.
|
|
223
302
|
// Existence for skip entries is decided by lstat (no symlink follow, no device open —
|
|
224
303
|
// reading a FIFO would hang).
|
|
225
|
-
async function resolveSkipEntryAction(entry) {
|
|
304
|
+
async function resolveSkipEntryAction(entry, ompOwnership = null) {
|
|
226
305
|
let stat;
|
|
227
306
|
try {
|
|
228
307
|
stat = await fs.lstat(entry.targetPath);
|
|
@@ -250,10 +329,10 @@ async function resolveSkipEntryAction(entry) {
|
|
|
250
329
|
// means present-but-inaccessible. Either way: owner territory, skip it.
|
|
251
330
|
return { ...entry, exists: read.status === 'unreadable', action: 'skip', existingContent: null };
|
|
252
331
|
}
|
|
253
|
-
return resolveFileAction(entry, read.content);
|
|
332
|
+
return resolveFileAction(entry, read.content, ompOwnership);
|
|
254
333
|
}
|
|
255
334
|
|
|
256
|
-
export async function diffInstallPlan(plan) {
|
|
335
|
+
export async function diffInstallPlan(plan, { ompOwnership = null } = {}) {
|
|
257
336
|
// Check all entries in parallel
|
|
258
337
|
const results = await Promise.all(
|
|
259
338
|
plan.entries.map(async (entry) => {
|
|
@@ -263,7 +342,7 @@ export async function diffInstallPlan(plan) {
|
|
|
263
342
|
}
|
|
264
343
|
|
|
265
344
|
if (entry.mergeStrategy === 'skip') {
|
|
266
|
-
return resolveSkipEntryAction(entry);
|
|
345
|
+
return resolveSkipEntryAction(entry, ompOwnership);
|
|
267
346
|
}
|
|
268
347
|
|
|
269
348
|
const read = await readFileOrStatus(
|
|
@@ -273,7 +352,7 @@ export async function diffInstallPlan(plan) {
|
|
|
273
352
|
if (read.status === 'unreadable') {
|
|
274
353
|
return conflictResult(entry, read.error);
|
|
275
354
|
}
|
|
276
|
-
return resolveFileAction(entry, read.status === 'ok' ? read.content : null);
|
|
355
|
+
return resolveFileAction(entry, read.status === 'ok' ? read.content : null, ompOwnership);
|
|
277
356
|
}),
|
|
278
357
|
);
|
|
279
358
|
|
|
@@ -179,12 +179,17 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
|
|
|
179
179
|
&& /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
|
|
180
180
|
const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
|
|
181
181
|
const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
|
|
182
|
-
|
|
182
|
+
// A status/result lookup ("check the suite output and report") orders no change: it needs a
|
|
183
|
+
// lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
|
|
184
|
+
const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
|
|
185
|
+
const statusReport = !mutationWord && !operation && !config
|
|
186
|
+
&& /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
|
|
187
|
+
const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
|
|
183
188
|
&& !sourceOrder && !operation && !config;
|
|
184
189
|
const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
|
|
185
190
|
: config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
|
|
186
191
|
const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
|
|
187
|
-
return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
|
|
192
|
+
return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
|
|
188
193
|
...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
|
|
189
194
|
completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
|
|
190
195
|
}
|
|
@@ -196,5 +201,6 @@ export function applyTaskSemantics(contract, semantic) {
|
|
|
196
201
|
return { ...(contract || {}), ...semantic,
|
|
197
202
|
completionEvidence: operationOnly ? ['operation-verification']
|
|
198
203
|
: semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
|
|
204
|
+
: semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
|
|
199
205
|
: semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
|
|
200
206
|
}
|
package/src/core/metadata.js
CHANGED
|
@@ -68,6 +68,7 @@ export async function writeInstallMetadata({
|
|
|
68
68
|
projectRoot,
|
|
69
69
|
managedRelativePaths = null,
|
|
70
70
|
retainedManagedRelativePaths = [],
|
|
71
|
+
ompConfigOwnership = null,
|
|
71
72
|
lockStaleMs,
|
|
72
73
|
lockMaxWaitMs,
|
|
73
74
|
}) {
|
|
@@ -133,6 +134,10 @@ export async function writeInstallMetadata({
|
|
|
133
134
|
summary,
|
|
134
135
|
files: allFiles,
|
|
135
136
|
};
|
|
137
|
+
// Ownership record for .omp/config.yml (what UKit installed vs what was there
|
|
138
|
+
// before) — lets uninstall revert only UKit's changes. Kept across runs.
|
|
139
|
+
const ownership = ompConfigOwnership ?? (previousData?.tool === 'ukit' ? previousData.ompConfigOwnership : null);
|
|
140
|
+
if (ownership) metadata.ompConfigOwnership = ownership;
|
|
136
141
|
|
|
137
142
|
await ensureDir(path.dirname(installMetaPath));
|
|
138
143
|
await writeJson(installMetaPath, metadata);
|
|
@@ -21,6 +21,8 @@ function emptyReport() {
|
|
|
21
21
|
coercedPromptPatterns: [],
|
|
22
22
|
preservedModelRoles: false,
|
|
23
23
|
addedModelRoleKeys: [],
|
|
24
|
+
preservedCompactionKeys: [],
|
|
25
|
+
migratedCompactionKeys: [],
|
|
24
26
|
preservedTopLevelKeys: [],
|
|
25
27
|
preservedPatterns: [],
|
|
26
28
|
// TASK-002 / FR-002 — every `{{token}}` left in the merged output. A token
|
|
@@ -203,6 +205,64 @@ export function mergeOmpConfig(existingText, renderedText) {
|
|
|
203
205
|
}
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
// compaction per-key union: existing keys always win (user overrides, thresholdPercent,
|
|
209
|
+
// unknown keys). The legacy 350000 migration needs ownership proof and lives in
|
|
210
|
+
// ompProjectConfig.js (applied before this merge). Absent keys come from the defaults.
|
|
211
|
+
const renderedCompaction = extractScalarMapSpan(lines, 'compaction');
|
|
212
|
+
if (renderedCompaction && isPlainObject(existing.compaction) && isPlainObject(rendered?.compaction)) {
|
|
213
|
+
const merged = { ...rendered.compaction };
|
|
214
|
+
for (const [key, value] of Object.entries(existing.compaction)) {
|
|
215
|
+
// A non-scalar/unresolved-token value is never a deliberate user choice.
|
|
216
|
+
if (value !== null && typeof value === 'object' && key in merged) continue;
|
|
217
|
+
merged[key] = value;
|
|
218
|
+
if (key in rendered.compaction && value !== rendered.compaction[key]) {
|
|
219
|
+
report.preservedCompactionKeys.push(key);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const renderedKeys = Object.keys(rendered.compaction);
|
|
223
|
+
const mergedKeys = Object.keys(merged);
|
|
224
|
+
const identical = mergedKeys.length === renderedKeys.length
|
|
225
|
+
&& mergedKeys.every((k) => merged[k] === rendered.compaction[k]);
|
|
226
|
+
if (!identical) {
|
|
227
|
+
const fragment = YAML.stringify(merged, { indent: 2 })
|
|
228
|
+
.trimEnd()
|
|
229
|
+
.split('\n')
|
|
230
|
+
.map((line) => ` ${line}`);
|
|
231
|
+
lines.splice(renderedCompaction.start, renderedCompaction.end - renderedCompaction.start, 'compaction:', ...fragment);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// task per-key union: the only UKit-shipped task key is the per-agent compaction override
|
|
236
|
+
// map. User task keys (effort, isolation, …) and user-set agent entries win verbatim;
|
|
237
|
+
// agents UKit ships but the file lacks are added. A non-map user `task` value is kept.
|
|
238
|
+
const renderedTask = extractScalarMapSpan(lines, 'task');
|
|
239
|
+
if (renderedTask && isPlainObject(rendered?.task) && existing.task !== undefined) {
|
|
240
|
+
const baseTask = rendered.task;
|
|
241
|
+
const userTask = existing.task === null ? {} : existing.task;
|
|
242
|
+
let merged;
|
|
243
|
+
if (isPlainObject(userTask)) {
|
|
244
|
+
merged = { ...userTask };
|
|
245
|
+
for (const k of Object.keys(baseTask)) if (!(k in merged)) merged[k] = baseTask[k];
|
|
246
|
+
const userOverrides = userTask.agentCompactionThresholdOverrides;
|
|
247
|
+
if (userOverrides !== undefined) {
|
|
248
|
+
merged.agentCompactionThresholdOverrides = isPlainObject(userOverrides)
|
|
249
|
+
? { ...userOverrides, ...Object.fromEntries(Object.entries(baseTask.agentCompactionThresholdOverrides ?? {}).filter(([k]) => !(k in userOverrides))) }
|
|
250
|
+
: userOverrides === null
|
|
251
|
+
? { ...(baseTask.agentCompactionThresholdOverrides ?? {}) }
|
|
252
|
+
: userOverrides;
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
merged = userTask;
|
|
256
|
+
}
|
|
257
|
+
if (JSON.stringify(merged) !== JSON.stringify(baseTask)) {
|
|
258
|
+
const fragment = YAML.stringify(merged, { indent: 2 })
|
|
259
|
+
.trimEnd()
|
|
260
|
+
.split('\n')
|
|
261
|
+
.map((line) => ` ${line}`);
|
|
262
|
+
lines.splice(renderedTask.start, renderedTask.end - renderedTask.start, 'task:', ...fragment);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
206
266
|
// §7.2 splice — append preserved allow/deny keys to the rendered
|
|
207
267
|
// tools.approval map (before the first line that leaves the map's indent).
|
|
208
268
|
if (extraApprovalEntries.length > 0) {
|