leos-agent 6.3.0 → 7.0.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -7
  3. package/adapters/cursor/agents/executor.md +1 -1
  4. package/adapters/cursor/agents/implementer.md +2 -2
  5. package/adapters/cursor/agents/review-lens.md +22 -0
  6. package/adapters/cursor/agents/reviewer.md +2 -2
  7. package/adapters/opencode/agents.json +43 -4
  8. package/adapters/opencode/plugin.js +325 -37
  9. package/config/MCP_PINS.md +17 -0
  10. package/config/models.json +276 -8
  11. package/hooks/bash-guard.py +51 -9
  12. package/package.json +3 -6
  13. package/roles/executor.md +1 -1
  14. package/roles/implementer.md +2 -2
  15. package/roles/review-lens.md +20 -0
  16. package/roles/reviewer.md +2 -2
  17. package/scripts/doctor.py +267 -31
  18. package/scripts/ghreview.py +7 -3
  19. package/scripts/jsonc_bridge.cjs +23 -0
  20. package/scripts/memory.py +74 -35
  21. package/scripts/render_adapters.py +57 -22
  22. package/scripts/resolve_attach_target.py +45 -13
  23. package/scripts/setup.py +1594 -2
  24. package/skills/brainstorming/SKILL.md +3 -1
  25. package/skills/debugging/SKILL.md +4 -2
  26. package/skills/delegation/SKILL.md +10 -8
  27. package/skills/doctor/SKILL.md +33 -14
  28. package/skills/executing-plans/SKILL.md +2 -1
  29. package/skills/finishing-a-branch/SKILL.md +4 -2
  30. package/skills/freshness/SKILL.md +23 -10
  31. package/skills/memory/SKILL.md +12 -2
  32. package/skills/resolve-ticket/SKILL.md +15 -9
  33. package/skills/review-pr/SKILL.md +26 -16
  34. package/skills/setup/SKILL.md +123 -9
  35. package/skills/setup/agents/openai.yaml +5 -0
  36. package/skills/test-first/SKILL.md +3 -1
  37. package/skills/using-leo/SKILL.md +11 -6
  38. package/skills/using-leo/references/claude-mapping.md +2 -1
  39. package/skills/using-leo/references/codex-mapping.md +4 -5
  40. package/skills/using-leo/references/cursor-mapping.md +2 -1
  41. package/skills/using-leo/references/hermes-mapping.md +2 -1
  42. package/skills/using-leo/references/opencode-mapping.md +6 -3
  43. package/skills/verification/SKILL.md +2 -1
  44. package/skills/visual-verification/SKILL.md +2 -1
  45. package/skills/watch-review/SKILL.md +17 -14
  46. package/skills/watch-review/agents/openai.yaml +5 -0
  47. package/skills/worktrees/SKILL.md +3 -1
  48. package/skills/writing-plans/SKILL.md +2 -1
  49. package/skills/writing-skills/SKILL.md +9 -2
  50. package/vendor/jsonc-parser-3.3.1/LICENSE.md +21 -0
  51. package/vendor/jsonc-parser-3.3.1/README.md +26 -0
  52. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +201 -0
  53. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +275 -0
  54. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +682 -0
  55. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +456 -0
  56. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +42 -0
  57. package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +351 -0
  58. package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +194 -0
  59. package/vendor/jsonc-parser-3.3.1/package.json +37 -0
  60. package/workflows/cost-tiered-fix.js +32 -4
package/scripts/doctor.py CHANGED
@@ -7,25 +7,40 @@ and correctly listed, and still have failed open this session. Only the running
7
7
  agent can see its own context, so leo:doctor pairs this output with three
8
8
  questions the model answers itself.
9
9
 
10
- The breadcrumb logs are reported as history with unknown provenance, never as a
11
- verdict about this session. They carry no timestamps and the test suite drives
12
- the failure paths deliberately, so "the log has errors" proves nothing on its
13
- own.
10
+ Most breadcrumb logs are reported as history with unknown provenance, never as
11
+ a verdict about this session. Some legacy entries carry no timestamps and the
12
+ test suite drives failure paths deliberately, so "the log has errors" usually
13
+ proves nothing on its own. `opencode-skills.log` is the capability exception:
14
+ its presence records a namespaced-skill registration failure and is reported
15
+ as degraded until it is intentionally cleared after repair.
14
16
 
15
17
  doctor.py human-readable report
16
18
  doctor.py --json the same facts as JSON
17
19
  doctor.py --harness <name> state the harness instead of detecting it
18
20
 
19
- Exit code is 0 unless the payload itself cannot be found.
21
+ Exit code is nonzero when required payload/bootstrap artifacts are missing or
22
+ invalid, or the running Python is unsupported. Degraded historical warnings
23
+ remain report-only.
20
24
  """
21
25
  import importlib.util
22
26
  import json
23
27
  import os
28
+ import re
24
29
  import sys
25
30
 
26
31
  _HERE = os.path.dirname(os.path.abspath(__file__))
27
32
  PAYLOAD = os.path.dirname(_HERE)
28
33
  TIERS = ("fable", "opus", "sonnet", "haiku")
34
+ MIN_PYTHON = (3, 9)
35
+ EXPECTED_HARNESSES = {"claude", "codex", "cursor", "hermes", "opencode"}
36
+ SEMVER_RE = re.compile(
37
+ r"^(0|[1-9]\d*)\."
38
+ r"(0|[1-9]\d*)\."
39
+ r"(0|[1-9]\d*)"
40
+ r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\."
41
+ r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
42
+ r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
43
+ )
29
44
 
30
45
 
31
46
  HARNESS_ENV = ("CURSOR_PLUGIN_ROOT", "CURSOR_VERSION", "PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT")
@@ -33,7 +48,8 @@ HARNESS_ENV = ("CURSOR_PLUGIN_ROOT", "CURSOR_VERSION", "PLUGIN_ROOT", "CLAUDE_PL
33
48
 
34
49
  def _known_harnesses():
35
50
  models = _read_json(os.path.join(PAYLOAD, "config", "models.json")) or {}
36
- return set(models.get("harnesses") or ())
51
+ harnesses = models.get("harnesses") if isinstance(models, dict) else None
52
+ return set(harnesses) if isinstance(harnesses, dict) else set()
37
53
 
38
54
 
39
55
  def _detect_harness(argv=()):
@@ -101,6 +117,78 @@ def _read_json(path):
101
117
  return None
102
118
 
103
119
 
120
+ def _manifest_valid(manifest):
121
+ """Validate the required portable manifest fields doctor reports."""
122
+ if not isinstance(manifest, dict):
123
+ return False
124
+ for key in ("name", "description", "homepage", "repository", "license"):
125
+ if not isinstance(manifest.get(key), str) or not manifest[key].strip():
126
+ return False
127
+ version = manifest.get("version")
128
+ if not isinstance(version, str) or SEMVER_RE.fullmatch(version) is None:
129
+ return False
130
+ author = manifest.get("author")
131
+ if not isinstance(author, dict) or not isinstance(author.get("name"), str) \
132
+ or not author["name"].strip():
133
+ return False
134
+ skills = manifest.get("skills")
135
+ return (isinstance(skills, list) and bool(skills) and
136
+ all(isinstance(item, str) and item.strip() for item in skills))
137
+
138
+
139
+ def _models_valid(models):
140
+ """Apply structural checks and the canonical renderer validation.
141
+
142
+ The renderer owns cross-field coherence, while these checks reject
143
+ skeletal documents that happen not to exercise one of its relationships.
144
+ Doctor must never call either shape healthy merely because it contains a
145
+ ``harnesses`` object.
146
+ """
147
+ if not isinstance(models, dict) or models.get("schemaVersion") != 4:
148
+ return False
149
+ for key in ("roles", "harnesses", "skills", "mcp", "memoryTarget", "visual"):
150
+ if not isinstance(models.get(key), dict) or not models[key]:
151
+ return False
152
+ capabilities = models.get("capabilities")
153
+ if not isinstance(capabilities, list) or not capabilities:
154
+ return False
155
+ harnesses = models["harnesses"]
156
+ if set(harnesses) != EXPECTED_HARNESSES:
157
+ return False
158
+ for rows in harnesses.values():
159
+ if not isinstance(rows, dict):
160
+ return False
161
+ for tier in TIERS:
162
+ mapping = rows.get(tier)
163
+ if (not isinstance(mapping, dict) or
164
+ not isinstance(mapping.get("model"), str) or
165
+ not mapping["model"].strip()):
166
+ return False
167
+ if "effort" in mapping and (not isinstance(mapping["effort"], str) or
168
+ not mapping["effort"].strip()):
169
+ return False
170
+ for spec in models["roles"].values():
171
+ if (not isinstance(spec, dict) or spec.get("tier") not in TIERS or
172
+ spec.get("access") not in ("read-only", "write")):
173
+ return False
174
+ for row in capabilities:
175
+ if (not isinstance(row, dict) or not isinstance(row.get("key"), str) or
176
+ not isinstance(row.get("modes"), list) or not row["modes"] or
177
+ not isinstance(row.get("values"), dict)):
178
+ return False
179
+
180
+ validator_path = os.path.join(PAYLOAD, "scripts", "render_adapters.py")
181
+ try:
182
+ spec = importlib.util.spec_from_file_location("leo_doctor_models_validator",
183
+ validator_path)
184
+ validator = importlib.util.module_from_spec(spec)
185
+ spec.loader.exec_module(validator)
186
+ validator._validate(models)
187
+ except Exception:
188
+ return False
189
+ return True
190
+
191
+
104
192
  def _local_root():
105
193
  return os.environ.get("LEOS_AGENT_LOCAL_PATH") or os.path.join(
106
194
  os.path.expanduser("~"), ".leos-agent-local"
@@ -113,8 +201,21 @@ def _memory_report():
113
201
  import memory
114
202
 
115
203
  root = memory.memory_root()
204
+ hermes_enabled = memory.hermes_enabled()
205
+ hermes_target = os.path.join(memory.hermes_home(), "SOUL.md")
206
+ hermes_projection = {
207
+ "enabled": hermes_enabled,
208
+ "path": hermes_target,
209
+ "status": (
210
+ "disabled" if not hermes_enabled else
211
+ "projected" if (os.path.isfile(hermes_target) and
212
+ memory.BEGIN in _slurp(hermes_target)) else
213
+ "not projected"
214
+ ),
215
+ }
116
216
  if not os.path.isdir(root):
117
- return {"store": root, "facts": 0, "present": False, "targets": []}
217
+ return {"store": root, "facts": 0, "present": False, "targets": [],
218
+ "hermes_projection": hermes_projection}
118
219
  index = memory._load_index() or {"facts": []}
119
220
  targets = [
120
221
  {"harness": h, "path": f, "present": os.path.exists(f),
@@ -123,7 +224,7 @@ def _memory_report():
123
224
  if os.path.isdir(gate)
124
225
  ]
125
226
  return {"store": root, "facts": len(index["facts"]), "present": True,
126
- "targets": targets}
227
+ "targets": targets, "hermes_projection": hermes_projection}
127
228
  except Exception as exc:
128
229
  return {"store": _local_root(), "error": f"{type(exc).__name__}: {exc}"}
129
230
 
@@ -138,7 +239,8 @@ def _slurp(path):
138
239
 
139
240
  def _breadcrumbs():
140
241
  out = {}
141
- for name in ("session-start.log", "hermes-policy.log", "opencode-guard.log"):
242
+ for name in ("session-start.log", "hermes-policy.log", "opencode-guard.log",
243
+ "opencode-skills.log"):
142
244
  path = os.path.join(_local_root(), name)
143
245
  if not os.path.exists(path):
144
246
  continue
@@ -148,6 +250,78 @@ def _breadcrumbs():
148
250
  return out
149
251
 
150
252
 
253
+ def _contains(path, needle):
254
+ return needle in _slurp(path)
255
+
256
+
257
+ def _hook_manifest_valid(data, harness):
258
+ """Validate the session-start registration, not a stray filename mention."""
259
+ if not isinstance(data, dict) or not isinstance(data.get("hooks"), dict):
260
+ return False
261
+ event = "sessionStart" if harness == "cursor" else "SessionStart"
262
+ groups = data["hooks"].get(event)
263
+ if not isinstance(groups, list):
264
+ return False
265
+ hooks = groups if harness == "cursor" else [
266
+ hook
267
+ for group in groups if isinstance(group, dict)
268
+ for hook in (group.get("hooks") or []) if isinstance(hook, dict)
269
+ ]
270
+ return any(
271
+ isinstance(hook, dict)
272
+ and isinstance(hook.get("command"), str)
273
+ and "hooks/session-start.py" in hook["command"]
274
+ for hook in hooks
275
+ )
276
+
277
+
278
+ def _bootstrap(harness):
279
+ """Describe the harness's actual policy delivery mechanism.
280
+
281
+ Presence is intentionally a payload check, not a claim that the host ran
282
+ it. Codex hook trust is visible only in its `/hooks` UI, and neither
283
+ Hermes nor OpenCode exposes a host-side registration ledger to inspect.
284
+ """
285
+ hook = os.path.join(PAYLOAD, "hooks", "session-start.py")
286
+ if harness in ("claude", "codex", "cursor"):
287
+ manifest = os.path.join(
288
+ PAYLOAD, "hooks", "hooks-cursor.json" if harness == "cursor" else "hooks.json"
289
+ )
290
+ manifest_data = _read_json(manifest)
291
+ data = {"kind": "session hook", "hook": hook,
292
+ "present": os.path.isfile(hook),
293
+ "executable": os.access(hook, os.X_OK),
294
+ "manifest": manifest,
295
+ "manifest_valid": _hook_manifest_valid(manifest_data, harness),
296
+ "required": ("present", "executable", "manifest_valid")}
297
+ if harness == "codex":
298
+ data["trust_instruction"] = (
299
+ "Codex cannot be verified from disk; run /hooks and confirm this plugin is trusted."
300
+ )
301
+ return data
302
+ if harness == "opencode":
303
+ plugin = os.path.join(PAYLOAD, "adapters", "opencode", "plugin.js")
304
+ return {"kind": "config.instructions", "plugin": plugin,
305
+ "present": os.path.isfile(plugin),
306
+ "instructions": _contains(plugin, "config.instructions"),
307
+ "required": ("present", "instructions")}
308
+ if harness == "hermes":
309
+ entry = os.path.join(os.path.dirname(PAYLOAD), "..", "__init__.py")
310
+ entry = os.path.abspath(entry)
311
+ return {"kind": "registration + first-tool-result fallback", "entrypoint": entry,
312
+ "present": os.path.isfile(entry),
313
+ "registration": _contains(entry, "def register(ctx)"),
314
+ "fallback": _contains(
315
+ entry, 'register_hook("transform_tool_result", _on_transform_tool_result)'
316
+ ),
317
+ "required": ("present", "registration", "fallback")}
318
+ return {"kind": "unknown", "present": True, "required": ()}
319
+
320
+
321
+ def _bootstrap_valid(bootstrap):
322
+ return all(bootstrap.get(key) for key in bootstrap.get("required", ("present",)))
323
+
324
+
151
325
  def _skills():
152
326
  shipped = {}
153
327
  for root in ("skills", "skills-claude"):
@@ -161,32 +335,74 @@ def _skills():
161
335
 
162
336
  def collect(argv=()):
163
337
  harness, source = _detect_harness(argv)
164
- manifest = _read_json(os.path.join(PAYLOAD, ".claude-plugin", "plugin.json")) or {}
165
- models = _read_json(os.path.join(PAYLOAD, "config", "models.json")) or {}
166
- config = (models.get("harnesses") or {}).get(harness) or {}
167
- hook = os.path.join(PAYLOAD, "hooks", "session-start.py")
338
+ manifest_path = os.path.join(PAYLOAD, ".claude-plugin", "plugin.json")
339
+ models_path = os.path.join(PAYLOAD, "config", "models.json")
340
+ manifest_raw = _read_json(manifest_path)
341
+ models_raw = _read_json(models_path)
342
+ manifest = manifest_raw if isinstance(manifest_raw, dict) else {}
343
+ models = models_raw if isinstance(models_raw, dict) else {}
344
+ harnesses = models.get("harnesses")
345
+ harnesses = harnesses if isinstance(harnesses, dict) else {}
346
+ config = harnesses.get(harness)
347
+ config = config if isinstance(config, dict) else {}
348
+ bootstrap = _bootstrap(harness)
168
349
  local = _local_root()
169
350
  skills = _skills()
170
- claude_only = set((models.get("skills") or {}).get("claudeOnly") or ())
171
- excluded = set(((models.get("skills") or {}).get("exclude") or {}).get(harness) or ())
351
+ skill_config = models.get("skills")
352
+ skill_config = skill_config if isinstance(skill_config, dict) else {}
353
+ claude_only_raw = skill_config.get("claudeOnly")
354
+ claude_only = ({item for item in claude_only_raw if isinstance(item, str)}
355
+ if isinstance(claude_only_raw, list) else set())
356
+ exclusions = skill_config.get("exclude")
357
+ exclusions = exclusions if isinstance(exclusions, dict) else {}
358
+ excluded_raw = exclusions.get(harness)
359
+ excluded = ({item for item in excluded_raw if isinstance(item, str)}
360
+ if isinstance(excluded_raw, list) else set())
172
361
 
173
362
  registered = [n for n in skills["skills"] if n not in excluded]
174
363
  if harness == "claude":
175
364
  registered += skills["skills-claude"]
365
+ tiers = {}
366
+ for tier in TIERS:
367
+ mapping = config.get(tier)
368
+ mapping = mapping if isinstance(mapping, dict) else {}
369
+ tiers[tier] = {"model": mapping.get("model"), "effort": mapping.get("effort")}
370
+ manifest_valid = _manifest_valid(manifest)
371
+ models_valid = _models_valid(models)
372
+ tier_mapping_valid = harness == "unknown" or all(
373
+ isinstance(value["model"], str) and bool(value["model"])
374
+ for value in tiers.values()
375
+ )
376
+ python_report = {
377
+ "running": ".".join(str(v) for v in sys.version_info[:3]),
378
+ "minimum": ".".join(str(v) for v in MIN_PYTHON),
379
+ "supported": sys.version_info[:2] >= MIN_PYTHON,
380
+ }
381
+ breadcrumbs = _breadcrumbs()
382
+ degraded_reasons = []
383
+ if harness == "unknown":
384
+ degraded_reasons.append("unknown harness")
385
+ if "opencode-skills.log" in breadcrumbs:
386
+ degraded_reasons.append("OpenCode namespaced skills previously failed to register")
387
+ if not python_report["supported"]:
388
+ degraded_reasons.append("unsupported Python")
389
+ if not (manifest_valid and models_valid and tier_mapping_valid):
390
+ degraded_reasons.append("invalid required payload")
391
+ if harness != "unknown" and not _bootstrap_valid(bootstrap):
392
+ degraded_reasons.append("missing or invalid required bootstrap")
176
393
 
177
394
  return {
178
395
  "harness": {"value": harness, "source": source},
179
- "payload": {"path": PAYLOAD, "version": manifest.get("version")},
180
- "bootstrap": {
181
- "hook": hook,
182
- "present": os.path.isfile(hook),
183
- "executable": os.access(hook, os.X_OK),
184
- },
185
- "tiers": {
186
- tier: {"model": (config.get(tier) or {}).get("model"),
187
- "effort": (config.get(tier) or {}).get("effort")}
188
- for tier in TIERS
396
+ "payload": {
397
+ "path": PAYLOAD,
398
+ "version": manifest.get("version"),
399
+ "manifest": manifest_path,
400
+ "models": models_path,
401
+ "valid": manifest_valid and models_valid and tier_mapping_valid,
189
402
  },
403
+ "bootstrap": bootstrap,
404
+ "python": python_report,
405
+ "tiers": tiers,
190
406
  "local_state": {
191
407
  "path": local,
192
408
  "present": os.path.isdir(local),
@@ -199,7 +415,13 @@ def collect(argv=()):
199
415
  "expected_here": sorted(registered),
200
416
  "excluded_here": sorted(excluded | (set() if harness == "claude" else claude_only)),
201
417
  },
202
- "breadcrumbs": _breadcrumbs(),
418
+ "breadcrumbs": breadcrumbs,
419
+ # Most breadcrumbs are deliberately untrusted history. The OpenCode
420
+ # shadow-skills log is different: it means this payload could not
421
+ # register the namespaced skill tree, so its presence is a durable
422
+ # degraded capability signal even though it cannot timestamp a turn.
423
+ "status": "degraded" if degraded_reasons else "healthy",
424
+ "degraded_reasons": degraded_reasons,
203
425
  }
204
426
 
205
427
 
@@ -222,10 +444,13 @@ def _render(data):
222
444
  row("version", data["payload"]["version"] or "unknown", "disk")
223
445
 
224
446
  boot = data["bootstrap"]
225
- state = "present" if boot["present"] else "MISSING"
226
- if boot["present"] and not boot["executable"]:
227
- state += ", not executable"
228
- row("bootstrap", state, "disk")
447
+ state = "present" if _bootstrap_valid(boot) else "MISSING or invalid"
448
+ row("bootstrap", f"{boot['kind']}: {state}", "disk")
449
+ if boot.get("trust_instruction"):
450
+ row("Codex hooks", boot["trust_instruction"], "manual check")
451
+ python = data["python"]
452
+ row("python", f"{python['running']} (minimum {python['minimum']})",
453
+ "supported" if python["supported"] else "UNSUPPORTED")
229
454
 
230
455
  tiers = " · ".join(
231
456
  f"{t.capitalize()} {v['model']}" + (f"/{v['effort']}" if v["effort"] else "")
@@ -248,6 +473,9 @@ def _render(data):
248
473
  for target in mem["targets"]:
249
474
  mark = "projected" if target["projected"] else "not projected"
250
475
  row("", f" {target['harness']}: {mark}", target["path"])
476
+ hermes = mem.get("hermes_projection")
477
+ if hermes:
478
+ row("Hermes memory", hermes["status"], hermes["path"])
251
479
 
252
480
  skills = data["skills"]
253
481
  row("skills shipped",
@@ -271,13 +499,21 @@ def _render(data):
271
499
  return "\n".join(lines)
272
500
 
273
501
 
502
+ def _exit_code(data):
503
+ if not data["payload"].get("valid") or not data["python"].get("supported"):
504
+ return 1
505
+ if data["harness"]["value"] != "unknown" and not _bootstrap_valid(data["bootstrap"]):
506
+ return 1
507
+ return 0
508
+
509
+
274
510
  def main(argv):
275
511
  data = collect(argv)
276
512
  if "--json" in argv:
277
513
  print(json.dumps(data, indent=1, sort_keys=True))
278
514
  else:
279
515
  print(_render(data))
280
- return 0
516
+ return _exit_code(data)
281
517
 
282
518
 
283
519
  if __name__ == "__main__":
@@ -265,7 +265,7 @@ query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
265
265
  reviewThreads(first: 50, after: $cursor) {
266
266
  pageInfo { hasNextPage endCursor }
267
267
  nodes {
268
- id isResolved isOutdated path line
268
+ id isResolved isOutdated path line originalLine
269
269
  comments(first: 100) {
270
270
  # 100 covers the overwhelming majority of threads without inner
271
271
  # pagination; a thread with >100 comments truncates here, so
@@ -387,6 +387,7 @@ def cmd_threads(a):
387
387
  "thread_id": node["id"],
388
388
  "path": node["path"],
389
389
  "line": node["line"],
390
+ "original_line": node.get("originalLine"),
390
391
  "is_resolved": node["isResolved"],
391
392
  "is_outdated": node["isOutdated"],
392
393
  "replies_after_mine": (last.get("author") or {}).get("login") != login,
@@ -483,8 +484,11 @@ def cmd_stage(a):
483
484
  # reflects the first pass's snapped output) so the reported snap is
484
485
  # the real one-hop mapping, not a fictitious second hop.
485
486
  staged, snapped2, dropped2 = validate_comments(comments, build_maps(files))
486
- report["snapped"] += snapped2
487
- report["dropped"] += dropped2
487
+ # The retry replaces the first attempt's outcome. Reporting both
488
+ # attempts as one result makes a comment appear both snapped and
489
+ # dropped even though only the final revalidation is stageable.
490
+ report["snapped"] = snapped2
491
+ report["dropped"] = dropped2
488
492
  if not staged:
489
493
  print(json.dumps({**report, "note": "nothing left to stage after revalidation"}))
490
494
  sys.exit(1)
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const parser = require(process.argv[2]);
4
+ try {
5
+ const request = JSON.parse(require("fs").readFileSync(0, "utf8"));
6
+ const errors = [];
7
+ const value = parser.parse(request.text, errors, { allowTrailingComma: true, disallowComments: false });
8
+ if (errors.length) throw new Error("invalid JSONC (parse error " + errors[0].error + ")");
9
+ if (request.mode === "parse") process.stdout.write(JSON.stringify({ value }));
10
+ else {
11
+ let text = request.text;
12
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
13
+ const indentation = text.match(/(?:\r?\n)([ \t]+)"/);
14
+ const indent = indentation ? indentation[1] : " ";
15
+ for (const edit of request.edits || []) {
16
+ text = parser.applyEdits(text, parser.modify(text, edit.path, edit.value, {
17
+ isArrayInsertion: false,
18
+ formattingOptions: { insertSpaces: !indent.includes("\t"), tabSize: indent.includes("\t") ? 1 : Math.max(1, indent.length), eol, keepLines: true },
19
+ }));
20
+ }
21
+ process.stdout.write(JSON.stringify({ text }));
22
+ }
23
+ } catch (error) { process.stderr.write(String(error && error.message || error)); process.exit(2); }