@1aboveio/skills 1.0.15 → 1.0.17
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/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +156 -152
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +30 -1
- package/skills/app-dev/android-development/references/diagnosis.md +50 -0
- package/skills/app-dev/android-development/references/feature-work.md +3 -2
- package/skills/app-dev/android-development/references/gotchas.md +13 -0
- package/skills/app-dev/android-development/references/init.md +31 -2
- package/skills/app-dev/android-development/references/setup.md +16 -7
- package/skills/app-dev/android-development/references/ui-journeys.md +130 -0
- package/skills/app-dev/android-development/scripts/device-run.sh +93 -2
- package/skills/app-dev/android-development/scripts/failure_capture.py +331 -0
- package/skills/app-dev/android-development/scripts/init.mjs +129 -9
- package/skills/app-dev/android-development/scripts/maestro_failure.py +401 -0
- package/skills/app-dev/android-development/scripts/test_failure_capture.py +378 -0
- package/skills/app-dev/android-development/scripts/test_maestro_failure.py +462 -0
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +4 -3
- package/skills/engineering/harness-runtime/agent-catalog.mjs +929 -0
- package/skills/engineering/harness-runtime/contracts.md +2 -0
- package/skills/engineering/harness-runtime/references/model-catalog.md +7 -1
- package/skills/engineering/resolve-issues/SKILL.md +18 -13
- package/skills/engineering/resolve-issues/references/delivery-progress.md +2 -0
- package/skills/engineering/resolve-issues/references/expire.md +6 -3
- package/skills/engineering/resolve-issues/references/exploration.md +60 -18
- package/skills/engineering/resolve-issues/references/implementation.md +7 -2
- package/skills/engineering/resolve-issues/references/preflight.md +65 -31
- package/skills/engineering/resolve-issues/references/review.md +22 -9
- package/skills/engineering/resolve-issues/references/shared-modules.md +142 -20
- package/skills/engineering/resolve-issues/references/task-plan.md +62 -8
- package/skills/engineering/resolve-issues/scripts/findings-reuse.mjs +1 -1
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +7 -7
- package/skills/engineering/resolve-issues/scripts/plan-sync.mjs +1 -1
- package/skills/engineering/resolve-issues/scripts/preflight-models.mjs +382 -592
- package/skills/engineering/resolve-issues/scripts/size-check.mjs +6 -5
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +4 -4
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/scripts/discover-models.mjs +0 -9
- package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +0 -9
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Record the evidence of a failed Android device case.
|
|
2
|
+
|
|
3
|
+
Journey code wraps the steps it drives:
|
|
4
|
+
|
|
5
|
+
from failure_capture import FailureRecorder
|
|
6
|
+
|
|
7
|
+
recorder = FailureRecorder(case="checkout smoke", evidence_dir=os.environ["LOG_DIR"])
|
|
8
|
+
|
|
9
|
+
with recorder.step("tap Pay"):
|
|
10
|
+
tap(...)
|
|
11
|
+
wait_for("Thank you")
|
|
12
|
+
|
|
13
|
+
The first step exception captures the screen, the UI hierarchy and a bounded logcat
|
|
14
|
+
tail into the evidence directory, writes ``failure.json``, prints exactly one
|
|
15
|
+
``NATIVE_FAILURE {...}`` line and lets the original exception propagate unchanged.
|
|
16
|
+
``recorder.step`` is a context manager and a decorator, so a plain callable step can
|
|
17
|
+
be wrapped the same way.
|
|
18
|
+
|
|
19
|
+
Record shape, shared with the ``device-run.sh`` fallback and with the framework
|
|
20
|
+
adapters described in ``references/diagnosis.md``:
|
|
21
|
+
|
|
22
|
+
case, step, error, screenshot, uiDump, logcatTail, evidenceDir, cleanupErrors
|
|
23
|
+
|
|
24
|
+
``screenshot``, ``uiDump`` and ``logcatTail`` are file names relative to
|
|
25
|
+
``evidenceDir`` and are ``null`` when that capture failed; the reason goes to
|
|
26
|
+
``cleanupErrors`` and never replaces the original ``error``. A record is written
|
|
27
|
+
once: the first failure is kept, and a later failure or a cleanup problem never
|
|
28
|
+
overwrites it.
|
|
29
|
+
|
|
30
|
+
Other languages implement the same contract. A repo that already uses Maestro or
|
|
31
|
+
Detox maps that framework's failing-step artifacts to this record instead of adding
|
|
32
|
+
this helper.
|
|
33
|
+
|
|
34
|
+
If the evidence directory cannot be written, the reason goes to stderr and the
|
|
35
|
+
``NATIVE_FAILURE`` line is still printed with its path; the original exception
|
|
36
|
+
always propagates unchanged.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import contextlib
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
import tempfile
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
|
|
49
|
+
RECORD_NAME = "failure.json"
|
|
50
|
+
NATIVE_FAILURE_PREFIX = "NATIVE_FAILURE "
|
|
51
|
+
SCREENSHOT_NAME = "screenshot.png"
|
|
52
|
+
UI_DUMP_NAME = "ui-dump.xml"
|
|
53
|
+
LOGCAT_NAME = "logcat-tail.txt"
|
|
54
|
+
UI_DUMP_REMOTE = "/sdcard/failure-capture.xml"
|
|
55
|
+
|
|
56
|
+
RECORD_KEYS = (
|
|
57
|
+
"case",
|
|
58
|
+
"step",
|
|
59
|
+
"error",
|
|
60
|
+
"screenshot",
|
|
61
|
+
"uiDump",
|
|
62
|
+
"logcatTail",
|
|
63
|
+
"evidenceDir",
|
|
64
|
+
"cleanupErrors",
|
|
65
|
+
)
|
|
66
|
+
NATIVE_FAILURE_KEYS = ("case", "step", "error", "evidenceDir")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CaptureError(RuntimeError):
|
|
70
|
+
"""A bounded capture command failed, timed out or returned no usable output."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def describe_error(error: BaseException) -> str:
|
|
74
|
+
"""A one-value error summary; JSON encoding keeps the line single-line."""
|
|
75
|
+
return "%s: %s" % (type(error).__name__, error)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write_bytes(path: str, payload: bytes) -> None:
|
|
79
|
+
"""Replace `path` atomically so a partial capture is never read as evidence."""
|
|
80
|
+
directory = os.path.dirname(path) or "."
|
|
81
|
+
handle, temporary = tempfile.mkstemp(dir=directory, prefix=".capture-", suffix=".part")
|
|
82
|
+
try:
|
|
83
|
+
with os.fdopen(handle, "wb") as stream:
|
|
84
|
+
stream.write(payload)
|
|
85
|
+
os.replace(temporary, path)
|
|
86
|
+
except BaseException:
|
|
87
|
+
if os.path.exists(temporary):
|
|
88
|
+
os.unlink(temporary)
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _text(value) -> str:
|
|
93
|
+
if value is None:
|
|
94
|
+
return ""
|
|
95
|
+
if isinstance(value, bytes):
|
|
96
|
+
return value.decode("utf-8", "replace").strip()
|
|
97
|
+
return str(value).strip()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AdbClient:
|
|
101
|
+
"""Bounded adb captures for one device.
|
|
102
|
+
|
|
103
|
+
`serial` selects the device with `-s`, so the caller's private ADB endpoint and
|
|
104
|
+
device mapping stay authoritative. `adb` is the injectable runner: it receives
|
|
105
|
+
the full argv and the per-command timeout and must return an object with
|
|
106
|
+
`returncode`, `stdout` and `stderr` (like `subprocess.CompletedProcess`). Tests
|
|
107
|
+
pass a fake here; production uses `subprocess.run`.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, serial=None, adb=None, executable="adb"):
|
|
111
|
+
self.serial = serial
|
|
112
|
+
self.executable = executable
|
|
113
|
+
self._adb = adb if adb is not None else self._run_subprocess
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _run_subprocess(command, timeout):
|
|
117
|
+
return subprocess.run(command, capture_output=True, timeout=timeout, check=False)
|
|
118
|
+
|
|
119
|
+
def command(self, *args):
|
|
120
|
+
serial_args = ("-s", str(self.serial)) if self.serial else ()
|
|
121
|
+
return [self.executable, *serial_args, *args]
|
|
122
|
+
|
|
123
|
+
def _call(self, *args, timeout):
|
|
124
|
+
label = "adb %s" % " ".join(str(arg) for arg in args)
|
|
125
|
+
try:
|
|
126
|
+
result = self._adb(self.command(*args), timeout)
|
|
127
|
+
except subprocess.TimeoutExpired as error:
|
|
128
|
+
raise CaptureError("%s timed out after %ss" % (label, timeout)) from error
|
|
129
|
+
except OSError as error:
|
|
130
|
+
raise CaptureError("%s could not run: %s" % (label, error)) from error
|
|
131
|
+
status = getattr(result, "returncode", 0)
|
|
132
|
+
if status != 0:
|
|
133
|
+
detail = _text(getattr(result, "stderr", None))
|
|
134
|
+
raise CaptureError("%s exited %s%s" % (label, status, ": " + detail if detail else ""))
|
|
135
|
+
return getattr(result, "stdout", b"") or b""
|
|
136
|
+
|
|
137
|
+
def screencap(self, destination, timeout):
|
|
138
|
+
payload = self._call("exec-out", "screencap", "-p", timeout=timeout)
|
|
139
|
+
if not payload:
|
|
140
|
+
raise CaptureError("adb exec-out screencap -p returned no image")
|
|
141
|
+
_write_bytes(destination, payload)
|
|
142
|
+
|
|
143
|
+
def ui_dump(self, destination, timeout):
|
|
144
|
+
self._call("shell", "uiautomator", "dump", UI_DUMP_REMOTE, timeout=timeout)
|
|
145
|
+
payload = self._call("exec-out", "cat", UI_DUMP_REMOTE, timeout=timeout)
|
|
146
|
+
if b"<hierarchy" not in payload:
|
|
147
|
+
raise CaptureError("uiautomator dump returned no hierarchy")
|
|
148
|
+
_write_bytes(destination, payload)
|
|
149
|
+
|
|
150
|
+
def logcat_tail(self, destination, lines, timeout):
|
|
151
|
+
payload = self._call("logcat", "-d", "-t", str(lines), timeout=timeout)
|
|
152
|
+
_write_bytes(destination, payload)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class Failure:
|
|
157
|
+
"""One failed device case; also the payload of `failure.json`."""
|
|
158
|
+
|
|
159
|
+
case: str = None
|
|
160
|
+
step: str = None
|
|
161
|
+
error: str = ""
|
|
162
|
+
screenshot: str = None
|
|
163
|
+
uiDump: str = None
|
|
164
|
+
logcatTail: str = None
|
|
165
|
+
evidenceDir: str = None
|
|
166
|
+
cleanupErrors: list = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
def as_dict(self):
|
|
169
|
+
return {key: getattr(self, key) for key in RECORD_KEYS}
|
|
170
|
+
|
|
171
|
+
def native_failure_line(self):
|
|
172
|
+
compact = {key: getattr(self, key) for key in NATIVE_FAILURE_KEYS}
|
|
173
|
+
return NATIVE_FAILURE_PREFIX + json.dumps(compact, separators=(",", ":"))
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_dict(cls, payload):
|
|
177
|
+
values = {key: payload[key] for key in RECORD_KEYS if key in payload}
|
|
178
|
+
errors = values.get("cleanupErrors")
|
|
179
|
+
values["cleanupErrors"] = list(errors) if isinstance(errors, list) else []
|
|
180
|
+
return cls(**values)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def read_record(evidence_dir):
|
|
184
|
+
"""The record already in `evidence_dir`, or None when there is no usable one."""
|
|
185
|
+
path = os.path.join(evidence_dir, RECORD_NAME)
|
|
186
|
+
try:
|
|
187
|
+
with open(path, "r", encoding="utf-8") as stream:
|
|
188
|
+
payload = json.load(stream)
|
|
189
|
+
except (OSError, ValueError):
|
|
190
|
+
return None
|
|
191
|
+
if not isinstance(payload, dict) or "error" not in payload:
|
|
192
|
+
return None
|
|
193
|
+
return Failure.from_dict(payload)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def write_record(failure, evidence_dir):
|
|
197
|
+
"""Store `failure` unless a record already exists; return the record on disk.
|
|
198
|
+
|
|
199
|
+
A valid earlier record wins, so a cleanup error or a second failing step cannot
|
|
200
|
+
replace the first failure. An unreadable file is not a record and is replaced,
|
|
201
|
+
with the replacement noted on the returned record's `cleanupErrors`.
|
|
202
|
+
"""
|
|
203
|
+
os.makedirs(evidence_dir, exist_ok=True)
|
|
204
|
+
existing = read_record(evidence_dir)
|
|
205
|
+
if existing is not None:
|
|
206
|
+
return existing
|
|
207
|
+
path = os.path.join(evidence_dir, RECORD_NAME)
|
|
208
|
+
if os.path.exists(path):
|
|
209
|
+
failure.cleanupErrors.append("%s: replaced an unreadable record" % RECORD_NAME)
|
|
210
|
+
payload = json.dumps(failure.as_dict(), indent=2) + "\n"
|
|
211
|
+
handle, temporary = tempfile.mkstemp(dir=evidence_dir, prefix=".failure-", suffix=".json")
|
|
212
|
+
try:
|
|
213
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
214
|
+
stream.write(payload)
|
|
215
|
+
os.replace(temporary, path)
|
|
216
|
+
except BaseException:
|
|
217
|
+
if os.path.exists(temporary):
|
|
218
|
+
os.unlink(temporary)
|
|
219
|
+
raise
|
|
220
|
+
return failure
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class FailureRecorder:
|
|
224
|
+
"""Capture the first failing step of one device case.
|
|
225
|
+
|
|
226
|
+
`case` names the case; it defaults to `DEVICE_CASE`. `evidence_dir` defaults to
|
|
227
|
+
`LOG_DIR` so the record sits where the launcher and the harness already look.
|
|
228
|
+
Capture commands are bounded by `capture_timeout` each; a capture problem is
|
|
229
|
+
recorded on `cleanupErrors` and never replaces the original error.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
case=None,
|
|
235
|
+
evidence_dir=None,
|
|
236
|
+
*,
|
|
237
|
+
client=None,
|
|
238
|
+
serial=None,
|
|
239
|
+
adb=None,
|
|
240
|
+
logcat_lines=400,
|
|
241
|
+
capture_timeout=20.0,
|
|
242
|
+
stream=None,
|
|
243
|
+
):
|
|
244
|
+
self.case = case if case is not None else os.environ.get("DEVICE_CASE")
|
|
245
|
+
self.evidence_dir = os.path.abspath(
|
|
246
|
+
evidence_dir or os.environ.get("LOG_DIR") or os.path.join(".cache", "logs")
|
|
247
|
+
)
|
|
248
|
+
self.client = client if client is not None else AdbClient(serial=serial, adb=adb)
|
|
249
|
+
self.logcat_lines = logcat_lines
|
|
250
|
+
self.capture_timeout = capture_timeout
|
|
251
|
+
self.stream = stream if stream is not None else sys.stdout
|
|
252
|
+
self.failure = None
|
|
253
|
+
self._recorded = False
|
|
254
|
+
|
|
255
|
+
def step(self, name):
|
|
256
|
+
return _Step(self, name)
|
|
257
|
+
|
|
258
|
+
def _captures(self):
|
|
259
|
+
timeout = self.capture_timeout
|
|
260
|
+
return (
|
|
261
|
+
("screenshot", SCREENSHOT_NAME, lambda path: self.client.screencap(path, timeout)),
|
|
262
|
+
("uiDump", UI_DUMP_NAME, lambda path: self.client.ui_dump(path, timeout)),
|
|
263
|
+
(
|
|
264
|
+
"logcatTail",
|
|
265
|
+
LOGCAT_NAME,
|
|
266
|
+
lambda path: self.client.logcat_tail(path, self.logcat_lines, timeout),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def record(self, step, error):
|
|
271
|
+
"""Capture evidence for `error` at `step`, once per recorder, then keep going."""
|
|
272
|
+
if self._recorded:
|
|
273
|
+
return self.failure
|
|
274
|
+
self._recorded = True
|
|
275
|
+
try:
|
|
276
|
+
directory_problem = None
|
|
277
|
+
os.makedirs(self.evidence_dir, exist_ok=True)
|
|
278
|
+
except OSError as problem:
|
|
279
|
+
directory_problem = problem
|
|
280
|
+
# An earlier record owns this directory: its evidence files are not replaced
|
|
281
|
+
# either, so the first failure stays readable.
|
|
282
|
+
kept = read_record(self.evidence_dir)
|
|
283
|
+
if kept is not None:
|
|
284
|
+
self.failure = kept
|
|
285
|
+
print(kept.native_failure_line(), file=self.stream, flush=True)
|
|
286
|
+
return kept
|
|
287
|
+
failure = Failure(
|
|
288
|
+
case=self.case,
|
|
289
|
+
step=step,
|
|
290
|
+
error=error,
|
|
291
|
+
evidenceDir=self.evidence_dir,
|
|
292
|
+
)
|
|
293
|
+
if directory_problem is not None:
|
|
294
|
+
failure.cleanupErrors.append("evidence dir %s: %s" % (self.evidence_dir, directory_problem))
|
|
295
|
+
for attribute, name, capture in self._captures():
|
|
296
|
+
try:
|
|
297
|
+
capture(os.path.join(self.evidence_dir, name))
|
|
298
|
+
except (CaptureError, OSError) as problem:
|
|
299
|
+
failure.cleanupErrors.append("%s: %s" % (attribute, problem))
|
|
300
|
+
else:
|
|
301
|
+
setattr(failure, attribute, name)
|
|
302
|
+
try:
|
|
303
|
+
failure = write_record(failure, self.evidence_dir)
|
|
304
|
+
except OSError as problem:
|
|
305
|
+
print(
|
|
306
|
+
"failure record not written: %s" % problem,
|
|
307
|
+
file=sys.stderr,
|
|
308
|
+
)
|
|
309
|
+
self.failure = failure
|
|
310
|
+
print(failure.native_failure_line(), file=self.stream, flush=True)
|
|
311
|
+
return failure
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class _Step(contextlib.ContextDecorator):
|
|
315
|
+
"""A named journey step: capture on the first exception, then re-raise."""
|
|
316
|
+
|
|
317
|
+
def __init__(self, recorder, name):
|
|
318
|
+
self.recorder = recorder
|
|
319
|
+
self.name = name
|
|
320
|
+
|
|
321
|
+
def __enter__(self):
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def __exit__(self, error_type, error, traceback):
|
|
325
|
+
if error is None or error_type is None:
|
|
326
|
+
return False
|
|
327
|
+
# An interrupted or exited run is a cancellation, not a failed case.
|
|
328
|
+
if issubclass(error_type, (KeyboardInterrupt, SystemExit)):
|
|
329
|
+
return False
|
|
330
|
+
self.recorder.record(self.name, describe_error(error))
|
|
331
|
+
return False
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Explicit Android project setup. Preview by default; adds only missing guidance.
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import fs from 'node:fs'
|
|
3
4
|
import os from 'node:os'
|
|
4
5
|
import path from 'node:path'
|
|
@@ -15,15 +16,29 @@ read and apply the installed \`android-development\` skill before choosing
|
|
|
15
16
|
Android-specific commands. In issue workflows, load it during unit preflight.
|
|
16
17
|
Delegated Android workers must load it too. After compaction, reload it if its
|
|
17
18
|
instructions are no longer in context.`
|
|
18
|
-
const usage = 'node init.mjs [--repo-root <dir>] [--write]'
|
|
19
|
+
const usage = 'node init.mjs [--repo-root <dir>] [--install-scripts [<dir>]] [--write]'
|
|
20
|
+
// The generic layer ships in the skill and is referenced from there by default.
|
|
21
|
+
const defaultScriptsDir = 'scripts/android'
|
|
22
|
+
const installableScripts = [
|
|
23
|
+
{ name: 'device-run.sh', mode: 0o755 },
|
|
24
|
+
{ name: 'failure_capture.py', mode: 0o644 },
|
|
25
|
+
]
|
|
26
|
+
const scriptHeaderPattern = /^# android-development skill (\S+) sha256:([0-9a-f]{64}) (\S+)$/
|
|
19
27
|
|
|
20
28
|
function parseArgs(argv) {
|
|
21
|
-
const options = { write: false }
|
|
29
|
+
const options = { write: false, scripts: null }
|
|
22
30
|
for (let i = 0; i < argv.length; i++) {
|
|
23
31
|
const arg = argv[i]
|
|
24
32
|
if (arg === '--write') options.write = true
|
|
25
33
|
else if (arg === '--help') options.help = true
|
|
26
|
-
else if (arg === '--
|
|
34
|
+
else if (arg === '--install-scripts') {
|
|
35
|
+
// The optional target directory never starts with `--`; the flag alone copies
|
|
36
|
+
// the generic layer to its default location.
|
|
37
|
+
const value = argv[i + 1]
|
|
38
|
+
const explicit = value !== undefined && !value.startsWith('--')
|
|
39
|
+
options.scripts = explicit ? value : ''
|
|
40
|
+
if (explicit) i++
|
|
41
|
+
} else if (arg === '--repo-root') {
|
|
27
42
|
const value = argv[++i]
|
|
28
43
|
if (!value || value.startsWith('--') || /[\r\n]/.test(value)) throw new Error(`missing or invalid value for ${arg}`)
|
|
29
44
|
options.repoRoot = path.resolve(value)
|
|
@@ -392,7 +407,7 @@ function addTaskEntry(current, name) {
|
|
|
392
407
|
return append(current, `${start}\n${taskEntryBody}\n${end}`, name)
|
|
393
408
|
}
|
|
394
409
|
|
|
395
|
-
function planInitialization(root) {
|
|
410
|
+
function planInitialization(root, options = { scripts: null }) {
|
|
396
411
|
const claude = inspectClaude(root)
|
|
397
412
|
const [agents, contributing] = ['AGENTS.md', 'CONTRIBUTING.md'].map(name => resolveTarget(root, name))
|
|
398
413
|
if (agents.target === contributing.target) {
|
|
@@ -424,6 +439,7 @@ function planInitialization(root) {
|
|
|
424
439
|
const plan = [
|
|
425
440
|
{ ...agentsEntry, next: agentText },
|
|
426
441
|
{ ...contributingEntry, next: contributingText },
|
|
442
|
+
...scriptPlan(root, options.scripts),
|
|
427
443
|
]
|
|
428
444
|
|
|
429
445
|
for (const entry of plan) {
|
|
@@ -439,10 +455,103 @@ function planInitialization(root) {
|
|
|
439
455
|
return plan
|
|
440
456
|
}
|
|
441
457
|
|
|
458
|
+
function skillVersion() {
|
|
459
|
+
const text = fs.readFileSync(fileURLToPath(new URL('../SKILL.md', import.meta.url)), 'utf8')
|
|
460
|
+
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? ''
|
|
461
|
+
return /^release-version:\s*(\S+)\s*$/m.exec(frontmatter)?.[1] ?? 'unknown'
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function scriptSource(name) {
|
|
465
|
+
return fs.readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), 'utf8')
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function scriptHeader(name, version, source) {
|
|
469
|
+
return `# android-development skill ${version} sha256:${createHash('sha256').update(source).digest('hex')} ${name}`
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// A copy is identifiable by its header, and its body stays byte-identical to the
|
|
473
|
+
// shipped script, so an unmodified copy is recognisable and a hand edit is drift.
|
|
474
|
+
// The header follows a shebang or a module docstring, so neither stops being first.
|
|
475
|
+
function withHeader(source, header) {
|
|
476
|
+
const lines = source.split('\n')
|
|
477
|
+
let index = 0
|
|
478
|
+
if (lines[0].startsWith('#!')) index = 1
|
|
479
|
+
else if (/^("""|''')/.test(lines[0])) {
|
|
480
|
+
const quote = lines[0].slice(0, 3)
|
|
481
|
+
const closing = lines[0].length > 3 && lines[0].trimEnd().endsWith(quote)
|
|
482
|
+
? 0
|
|
483
|
+
: lines.findIndex((line, position) => position > 0 && line.trimEnd().endsWith(quote))
|
|
484
|
+
if (closing >= 0) index = closing + 1
|
|
485
|
+
}
|
|
486
|
+
return [...lines.slice(0, index), header, ...lines.slice(index)].join('\n')
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function splitHeader(text) {
|
|
490
|
+
const lines = text.split('\n')
|
|
491
|
+
// Only the preamble can hold the installed header; the body's own docs cannot.
|
|
492
|
+
const limit = Math.min(lines.length, 40)
|
|
493
|
+
for (let index = 0; index < limit; index++) {
|
|
494
|
+
if (scriptHeaderPattern.test(lines[index])) {
|
|
495
|
+
return { header: lines[index], body: [...lines.slice(0, index), ...lines.slice(index + 1)].join('\n') }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return { header: null, body: text }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function scriptsTarget(value) {
|
|
502
|
+
if (value === null || value === undefined) return null
|
|
503
|
+
const relative = (value || defaultScriptsDir).trim()
|
|
504
|
+
const segments = relative.split(/[\\/]+/).filter(segment => segment && segment !== '.')
|
|
505
|
+
if (!segments.length || path.isAbsolute(relative)
|
|
506
|
+
|| segments.includes('..') || /[\r\n]/.test(relative)) {
|
|
507
|
+
throw new Error(`--install-scripts target must be a relative directory inside the project: ${relative}`)
|
|
508
|
+
}
|
|
509
|
+
return segments.join('/')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function scriptPlan(root, requested) {
|
|
513
|
+
const target = scriptsTarget(requested)
|
|
514
|
+
if (target === null) return []
|
|
515
|
+
const version = skillVersion()
|
|
516
|
+
return installableScripts.map(script => {
|
|
517
|
+
const file = path.join(root, target, script.name)
|
|
518
|
+
assertScriptDirectory(root, file)
|
|
519
|
+
const source = scriptSource(script.name)
|
|
520
|
+
const next = withHeader(source, scriptHeader(script.name, version, source))
|
|
521
|
+
const entry = readGuidance(resolveTarget(root, path.posix.join(target, script.name)))
|
|
522
|
+
const base = { ...entry, mode: script.mode, script: script.name }
|
|
523
|
+
if (!entry.exists) return { ...base, next, action: 'create' }
|
|
524
|
+
if (entry.current === next) return { ...base, next, action: 'unchanged' }
|
|
525
|
+
if (splitHeader(entry.current).body === source) return { ...base, next, action: 'update' }
|
|
526
|
+
// A local edit wins: report it, never overwrite it, and do not claim the skill version.
|
|
527
|
+
return { ...base, next: entry.current, action: 'drift' }
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// The copy's directory is created, so only its nearest existing ancestor can be checked:
|
|
532
|
+
// it must be a directory that stays inside the selected project.
|
|
533
|
+
function assertScriptDirectory(root, file) {
|
|
534
|
+
const directory = nearestExisting(path.dirname(file))
|
|
535
|
+
const resolved = fs.lstatSync(directory).isSymbolicLink() ? fs.realpathSync(directory) : directory
|
|
536
|
+
if (!contains(root, resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
537
|
+
throw new Error(`${path.relative(root, path.dirname(file))} is not a directory inside the project; no scripts were copied`)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function nearestExisting(target) {
|
|
542
|
+
let current = target
|
|
543
|
+
while (!fs.existsSync(current)) {
|
|
544
|
+
const parent = path.dirname(current)
|
|
545
|
+
if (parent === current) return current
|
|
546
|
+
current = parent
|
|
547
|
+
}
|
|
548
|
+
return current
|
|
549
|
+
}
|
|
550
|
+
|
|
442
551
|
function assertWritable(plan) {
|
|
443
552
|
for (const entry of plan) {
|
|
444
553
|
if (entry.current === entry.next) continue
|
|
445
|
-
const target = entry.current === null ? path.dirname(entry.target) : entry.target
|
|
554
|
+
const target = entry.current === null ? nearestExisting(path.dirname(entry.target)) : entry.target
|
|
446
555
|
try {
|
|
447
556
|
fs.accessSync(target, fs.constants.W_OK)
|
|
448
557
|
} catch {
|
|
@@ -458,13 +567,24 @@ function main(argv) {
|
|
|
458
567
|
return
|
|
459
568
|
}
|
|
460
569
|
const { root } = selectRoot(options)
|
|
461
|
-
const plan = planInitialization(root)
|
|
570
|
+
const plan = planInitialization(root, options)
|
|
462
571
|
if (options.write) assertWritable(plan)
|
|
463
572
|
console.log(`android-development init: ${options.write ? 'write' : 'preview only'} (${root})`)
|
|
464
573
|
for (const entry of plan) {
|
|
465
|
-
const action = entry.current === entry.next ? 'unchanged' : entry.current === null ? 'create' : 'update'
|
|
466
|
-
|
|
467
|
-
|
|
574
|
+
const action = entry.action ?? (entry.current === entry.next ? 'unchanged' : entry.current === null ? 'create' : 'update')
|
|
575
|
+
const planned = action === 'create' || action === 'update'
|
|
576
|
+
if (options.write && planned) {
|
|
577
|
+
fs.mkdirSync(path.dirname(entry.target), { recursive: true })
|
|
578
|
+
fs.writeFileSync(entry.target, entry.next)
|
|
579
|
+
if (entry.mode) fs.chmodSync(entry.target, entry.mode)
|
|
580
|
+
}
|
|
581
|
+
const label = !options.write && planned ? `would ${action}` : action
|
|
582
|
+
console.log(` ${entry.name}: ${label}`)
|
|
583
|
+
}
|
|
584
|
+
if (options.scripts === null) {
|
|
585
|
+
console.log(` scripts: referenced from the skill (pass --install-scripts [<dir>] to copy ${installableScripts.map(script => script.name).join(' and ')} into ${defaultScriptsDir})`)
|
|
586
|
+
} else if (plan.some(entry => entry.action === 'drift')) {
|
|
587
|
+
console.log(' scripts: a locally modified copy was kept; refresh it by hand rather than overwriting the edit')
|
|
468
588
|
}
|
|
469
589
|
console.log('Existing sections are preserved; initialization does not refresh project policy, install tools, provision devices or authorize delivery actions.')
|
|
470
590
|
}
|