@1aboveio/skills 1.0.14 → 1.0.16
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 +164 -160
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +30 -2
- package/skills/app-dev/android-development/references/diagnosis.md +55 -0
- package/skills/app-dev/android-development/references/feature-work.md +15 -0
- package/skills/app-dev/android-development/references/init.md +57 -6
- package/skills/app-dev/android-development/references/setup.md +8 -2
- package/skills/app-dev/android-development/references/ui-journeys.md +58 -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 +234 -17
- 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 +53 -16
- package/skills/app-dev/data-catalog/scripts/persist-conventions-core.mjs +231 -21
- package/skills/app-dev/data-catalog/scripts/persist-conventions.mjs +12 -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 +10 -1
- package/skills/cicd/github-flow/scripts/persist-conventions-core.mjs +231 -21
- package/skills/cicd/github-flow/scripts/persist-conventions.mjs +18 -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 +7 -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/house-style/references/persistence.md +8 -3
- package/skills/core/house-style/scripts/init-guidance.mjs +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/terminology/references/init.md +11 -0
- 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 +19 -4
- package/skills/engineering/engineering/references/init-scope.md +5 -2
- package/skills/engineering/engineering/references/scope-management.md +4 -2
- package/skills/engineering/engineering/scripts/init.mjs +34 -9
- package/skills/engineering/engineering/scripts/persist-conventions-core.mjs +231 -21
- package/skills/engineering/engineering/scripts/persist-conventions.mjs +48 -21
- 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 +928 -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 +9 -6
- 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/implementation.md +6 -1
- package/skills/engineering/resolve-issues/references/preflight.md +26 -17
- package/skills/engineering/resolve-issues/references/review.md +39 -4
- package/skills/engineering/resolve-issues/references/shared-modules.md +88 -20
- package/skills/engineering/resolve-issues/references/task-plan.md +35 -1
- package/skills/engineering/resolve-issues/scripts/preflight-models.mjs +382 -592
- 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 +3 -1
- package/skills/engineering/test-quality/scripts/persist-conventions-core.mjs +231 -21
- package/skills/engineering/test-quality/scripts/persist-conventions.mjs +13 -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'
|
|
@@ -7,15 +8,37 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
7
8
|
|
|
8
9
|
const sectionTitle = 'Android development'
|
|
9
10
|
const sectionId = 'android'
|
|
10
|
-
const
|
|
11
|
+
const taskEntryTitle = 'Android task entry'
|
|
12
|
+
const taskEntryBody = `## ${taskEntryTitle}
|
|
13
|
+
|
|
14
|
+
For Android implementation, runtime debugging, native builds, or device validation,
|
|
15
|
+
read and apply the installed \`android-development\` skill before choosing
|
|
16
|
+
Android-specific commands. In issue workflows, load it during unit preflight.
|
|
17
|
+
Delegated Android workers must load it too. After compaction, reload it if its
|
|
18
|
+
instructions are no longer in context.`
|
|
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+)$/
|
|
11
27
|
|
|
12
28
|
function parseArgs(argv) {
|
|
13
|
-
const options = { write: false }
|
|
29
|
+
const options = { write: false, scripts: null }
|
|
14
30
|
for (let i = 0; i < argv.length; i++) {
|
|
15
31
|
const arg = argv[i]
|
|
16
32
|
if (arg === '--write') options.write = true
|
|
17
33
|
else if (arg === '--help') options.help = true
|
|
18
|
-
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') {
|
|
19
42
|
const value = argv[++i]
|
|
20
43
|
if (!value || value.startsWith('--') || /[\r\n]/.test(value)) throw new Error(`missing or invalid value for ${arg}`)
|
|
21
44
|
options.repoRoot = path.resolve(value)
|
|
@@ -174,7 +197,7 @@ function stripHtmlComments(line, depth = 0) {
|
|
|
174
197
|
|
|
175
198
|
// Markdown-aware view: inactive lines are fenced code, indented code or HTML comments.
|
|
176
199
|
// Inline code and HTML comments are blanked from active text so examples never count.
|
|
177
|
-
function
|
|
200
|
+
function scanMarkdown(current) {
|
|
178
201
|
const lines = []
|
|
179
202
|
let fence = null
|
|
180
203
|
let commentDepth = 0
|
|
@@ -217,10 +240,63 @@ function markdownView(current) {
|
|
|
217
240
|
previousBlank = blank
|
|
218
241
|
lines.push({ raw: line, text: active ? text : '', active, comments })
|
|
219
242
|
}
|
|
220
|
-
|
|
243
|
+
// An open fence or comment at the end of the file would swallow anything appended after it.
|
|
244
|
+
return { lines, fence, commentDepth }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function markdownView(current) {
|
|
248
|
+
return scanMarkdown(current).lines
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// End of the paragraph starting at `start`; a span never pairs across a blank line.
|
|
252
|
+
function paragraphEnd(text, start) {
|
|
253
|
+
const blank = /\n[ \t]*\n/g
|
|
254
|
+
blank.lastIndex = start
|
|
255
|
+
const match = blank.exec(text)
|
|
256
|
+
return match ? match.index : text.length
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Marks inline code spans so a documented example is never read as a project rule.
|
|
260
|
+
// Runs of equal length pair up inside one paragraph; an unmatched run is literal text, so it
|
|
261
|
+
// never consumes a later rule. The rule text itself stays readable, only its span is marked.
|
|
262
|
+
function inlineCodeMask(text) {
|
|
263
|
+
const mask = new Uint8Array(text.length)
|
|
264
|
+
const runs = [...text.matchAll(/`+/g)]
|
|
265
|
+
let index = 0
|
|
266
|
+
while (index < runs.length) {
|
|
267
|
+
const opening = runs[index]
|
|
268
|
+
const limit = paragraphEnd(text, opening.index)
|
|
269
|
+
let closing = index + 1
|
|
270
|
+
while (closing < runs.length && runs[closing].index < limit
|
|
271
|
+
&& runs[closing][0].length !== opening[0].length) closing++
|
|
272
|
+
if (closing === runs.length || runs[closing].index >= limit) {
|
|
273
|
+
index += 1
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
mask.fill(1, opening.index, runs[closing].index + runs[closing][0].length)
|
|
277
|
+
index = closing + 1
|
|
278
|
+
}
|
|
279
|
+
return mask
|
|
221
280
|
}
|
|
222
281
|
|
|
223
|
-
|
|
282
|
+
// Only a match that starts outside inline code is an instruction rather than an example.
|
|
283
|
+
// The rule itself keeps its text, because the skill name is normally written as code.
|
|
284
|
+
function matchesOutsideInlineCode(text, pattern) {
|
|
285
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`
|
|
286
|
+
const global = new RegExp(pattern.source, flags)
|
|
287
|
+
const mask = inlineCodeMask(text)
|
|
288
|
+
for (const match of text.matchAll(global)) {
|
|
289
|
+
if (!mask[match.index]) return true
|
|
290
|
+
}
|
|
291
|
+
return false
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function append(current, body, name) {
|
|
295
|
+
const { fence, commentDepth } = scanMarkdown(current)
|
|
296
|
+
if (fence || commentDepth) {
|
|
297
|
+
const construct = fence ? 'an unclosed fenced code block' : 'an unclosed HTML comment'
|
|
298
|
+
throw new Error(`${name} ends inside ${construct}; appended Android guidance would be inactive and appended again on every run. Close it and rerun init; nothing was changed`)
|
|
299
|
+
}
|
|
224
300
|
const separator = current.endsWith('\n\n') ? '' : current.endsWith('\n') ? '\n' : '\n\n'
|
|
225
301
|
return `${current}${separator}${body.trim()}\n`
|
|
226
302
|
}
|
|
@@ -258,7 +334,7 @@ function countMarkers(lines, start, end) {
|
|
|
258
334
|
return { starts, ends }
|
|
259
335
|
}
|
|
260
336
|
|
|
261
|
-
function preserveOrAdd(current, title, id, body) {
|
|
337
|
+
function preserveOrAdd(current, name, title, id, body) {
|
|
262
338
|
const start = `<!-- conventions:${id}:start -->`
|
|
263
339
|
const end = `<!-- conventions:${id}:end -->`
|
|
264
340
|
const { starts, ends } = countMarkers(markdownView(current), start, end)
|
|
@@ -266,7 +342,7 @@ function preserveOrAdd(current, title, id, body) {
|
|
|
266
342
|
throw new Error(`malformed ${title} section markers; preserve and repair existing guidance first`)
|
|
267
343
|
}
|
|
268
344
|
if (starts.length || hasHeading(current, title)) return current
|
|
269
|
-
return append(current, `${start}\n${body}\n${end}
|
|
345
|
+
return append(current, `${start}\n${body}\n${end}`, name)
|
|
270
346
|
}
|
|
271
347
|
|
|
272
348
|
function linkFrom(from, to) {
|
|
@@ -295,7 +371,43 @@ function hasLoader(current, from, to) {
|
|
|
295
371
|
return false
|
|
296
372
|
}
|
|
297
373
|
|
|
298
|
-
function
|
|
374
|
+
function addTaskEntry(current, name) {
|
|
375
|
+
const start = '<!-- conventions:android-task-entry:start -->'
|
|
376
|
+
const end = '<!-- conventions:android-task-entry:end -->'
|
|
377
|
+
const lines = markdownView(current)
|
|
378
|
+
const { starts, ends } = countMarkers(lines, start, end)
|
|
379
|
+
if (starts.length !== ends.length || starts.length > 1 || (starts.length && starts[0] > ends[0])) {
|
|
380
|
+
throw new Error('malformed Android task entry markers; preserve and repair existing guidance first')
|
|
381
|
+
}
|
|
382
|
+
// Only active prose can hold a rule: fenced code, indented code, HTML comments, inline code
|
|
383
|
+
// and fenced examples are documentation. A rule keeps its own inline code, so the skill name
|
|
384
|
+
// written as `android-development` is still matched by its position outside a code span.
|
|
385
|
+
const prose = lines.map(line => line.active && line.text.trim()
|
|
386
|
+
? stripHtmlComments(line.raw).text : '')
|
|
387
|
+
const active = prose.join('\n')
|
|
388
|
+
// Recognize an existing direct loader, not a mere skill mention or guide link.
|
|
389
|
+
const loader = /^[ \t]*(?:(?:[-+*]|\d+[.)])\s+)?(?:read|load)\s+(?:and\s+apply\s+)?(?:the\s+)?(?:installed\s+)?`?android-development`?\s+skill\s+before\b/im
|
|
390
|
+
const negation = /\b(?:do not|don't|never|must not)\s+(?:automatically\s+)?(?:read|load|apply|use|follow)\b[^.]{0,120}\bandroid-development\b/i
|
|
391
|
+
if (matchesOutsideInlineCode(active, negation)) {
|
|
392
|
+
throw new Error('existing Android skill-loading rule conflicts with task entry; nothing was changed')
|
|
393
|
+
}
|
|
394
|
+
if (starts.length) {
|
|
395
|
+
const from = lines.findIndex(line => line.comments.includes(start))
|
|
396
|
+
const to = lines.findIndex(line => line.comments.includes(end))
|
|
397
|
+
const body = prose.slice(from + 1, to).join('\n')
|
|
398
|
+
if (!matchesOutsideInlineCode(body, loader)) {
|
|
399
|
+
throw new Error('preserved Android task entry has no active loader; nothing was changed')
|
|
400
|
+
}
|
|
401
|
+
return current
|
|
402
|
+
}
|
|
403
|
+
if (matchesOutsideInlineCode(active, loader)) return current
|
|
404
|
+
if (hasHeading(current, taskEntryTitle)) {
|
|
405
|
+
throw new Error('existing Android task entry needs manual reconciliation; nothing was changed')
|
|
406
|
+
}
|
|
407
|
+
return append(current, `${start}\n${taskEntryBody}\n${end}`, name)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function planInitialization(root, options = { scripts: null }) {
|
|
299
411
|
const claude = inspectClaude(root)
|
|
300
412
|
const [agents, contributing] = ['AGENTS.md', 'CONTRIBUTING.md'].map(name => resolveTarget(root, name))
|
|
301
413
|
if (agents.target === contributing.target) {
|
|
@@ -316,17 +428,18 @@ function planInitialization(root) {
|
|
|
316
428
|
const agentsEntry = readGuidance(agents)
|
|
317
429
|
const contributingEntry = readGuidance(contributing)
|
|
318
430
|
|
|
319
|
-
let agentText = agentsEntry.current ?? '# Agent instructions\n'
|
|
431
|
+
let agentText = addTaskEntry(agentsEntry.current ?? '# Agent instructions\n', agentsEntry.name)
|
|
320
432
|
if (!hasLoader(agentText, agentsEntry, contributingEntry)) {
|
|
321
433
|
const heading = hasHeading(agentText, sectionTitle) ? '' : `## ${sectionTitle}\n\n`
|
|
322
|
-
agentText = append(agentText, `${heading}Follow [CONTRIBUTING.md](${linkFrom(agentsEntry, contributingEntry)}) for this project's Android validation ladder, device proof and required gates
|
|
434
|
+
agentText = append(agentText, `${heading}Follow [CONTRIBUTING.md](${linkFrom(agentsEntry, contributingEntry)}) for this project's Android validation ladder, device proof and required gates.`, agentsEntry.name)
|
|
323
435
|
}
|
|
324
436
|
|
|
325
|
-
const contributingText = preserveOrAdd(contributingEntry.current ?? '# Contributing\n', sectionTitle, sectionId, sectionBody())
|
|
437
|
+
const contributingText = preserveOrAdd(contributingEntry.current ?? '# Contributing\n', contributingEntry.name, sectionTitle, sectionId, sectionBody())
|
|
326
438
|
|
|
327
439
|
const plan = [
|
|
328
440
|
{ ...agentsEntry, next: agentText },
|
|
329
441
|
{ ...contributingEntry, next: contributingText },
|
|
442
|
+
...scriptPlan(root, options.scripts),
|
|
330
443
|
]
|
|
331
444
|
|
|
332
445
|
for (const entry of plan) {
|
|
@@ -342,10 +455,103 @@ function planInitialization(root) {
|
|
|
342
455
|
return plan
|
|
343
456
|
}
|
|
344
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
|
+
|
|
345
551
|
function assertWritable(plan) {
|
|
346
552
|
for (const entry of plan) {
|
|
347
553
|
if (entry.current === entry.next) continue
|
|
348
|
-
const target = entry.current === null ? path.dirname(entry.target) : entry.target
|
|
554
|
+
const target = entry.current === null ? nearestExisting(path.dirname(entry.target)) : entry.target
|
|
349
555
|
try {
|
|
350
556
|
fs.accessSync(target, fs.constants.W_OK)
|
|
351
557
|
} catch {
|
|
@@ -361,13 +567,24 @@ function main(argv) {
|
|
|
361
567
|
return
|
|
362
568
|
}
|
|
363
569
|
const { root } = selectRoot(options)
|
|
364
|
-
const plan = planInitialization(root)
|
|
570
|
+
const plan = planInitialization(root, options)
|
|
365
571
|
if (options.write) assertWritable(plan)
|
|
366
572
|
console.log(`android-development init: ${options.write ? 'write' : 'preview only'} (${root})`)
|
|
367
573
|
for (const entry of plan) {
|
|
368
|
-
const action = entry.current === entry.next ? 'unchanged' : entry.current === null ? 'create' : 'update'
|
|
369
|
-
|
|
370
|
-
|
|
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')
|
|
371
588
|
}
|
|
372
589
|
console.log('Existing sections are preserved; initialization does not refresh project policy, install tools, provision devices or authorize delivery actions.')
|
|
373
590
|
}
|