@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.
Files changed (105) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +164 -160
  3. package/package.json +4 -4
  4. package/skills/app-dev/airflow/SKILL.md +1 -1
  5. package/skills/app-dev/android-development/SKILL.md +30 -2
  6. package/skills/app-dev/android-development/references/diagnosis.md +55 -0
  7. package/skills/app-dev/android-development/references/feature-work.md +15 -0
  8. package/skills/app-dev/android-development/references/init.md +57 -6
  9. package/skills/app-dev/android-development/references/setup.md +8 -2
  10. package/skills/app-dev/android-development/references/ui-journeys.md +58 -0
  11. package/skills/app-dev/android-development/scripts/device-run.sh +93 -2
  12. package/skills/app-dev/android-development/scripts/failure_capture.py +331 -0
  13. package/skills/app-dev/android-development/scripts/init.mjs +234 -17
  14. package/skills/app-dev/android-development/scripts/maestro_failure.py +401 -0
  15. package/skills/app-dev/android-development/scripts/test_failure_capture.py +378 -0
  16. package/skills/app-dev/android-development/scripts/test_maestro_failure.py +462 -0
  17. package/skills/app-dev/app-debug/SKILL.md +1 -1
  18. package/skills/app-dev/better-auth/SKILL.md +1 -1
  19. package/skills/app-dev/data-catalog/SKILL.md +53 -16
  20. package/skills/app-dev/data-catalog/scripts/persist-conventions-core.mjs +231 -21
  21. package/skills/app-dev/data-catalog/scripts/persist-conventions.mjs +12 -1
  22. package/skills/app-dev/data-quality/SKILL.md +1 -1
  23. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  24. package/skills/app-dev/monorepo/SKILL.md +1 -1
  25. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  26. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  27. package/skills/app-dev/pyspark/SKILL.md +1 -1
  28. package/skills/app-dev/python-backend/SKILL.md +1 -1
  29. package/skills/app-dev/shadcn/SKILL.md +1 -1
  30. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  31. package/skills/cicd/cloud-build/SKILL.md +1 -1
  32. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  33. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  34. package/skills/cicd/docker/SKILL.md +1 -1
  35. package/skills/cicd/github-actions/SKILL.md +1 -1
  36. package/skills/cicd/github-flow/SKILL.md +10 -1
  37. package/skills/cicd/github-flow/scripts/persist-conventions-core.mjs +231 -21
  38. package/skills/cicd/github-flow/scripts/persist-conventions.mjs +18 -1
  39. package/skills/cicd/google-cloud/SKILL.md +1 -1
  40. package/skills/cicd/mergify/SKILL.md +1 -1
  41. package/skills/cicd/podman/SKILL.md +1 -1
  42. package/skills/content/article-review/SKILL.md +1 -1
  43. package/skills/content/article-to-image/SKILL.md +1 -1
  44. package/skills/content/flowchart-svg/SKILL.md +1 -1
  45. package/skills/content/image-gen/SKILL.md +1 -1
  46. package/skills/content/mermaid/SKILL.md +1 -1
  47. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  48. package/skills/content/wechat-publisher/SKILL.md +1 -1
  49. package/skills/content/wechat-search/SKILL.md +1 -1
  50. package/skills/content/xai-search/SKILL.md +1 -1
  51. package/skills/core/distill-writing/SKILL.md +7 -1
  52. package/skills/core/doc-to-md/SKILL.md +1 -1
  53. package/skills/core/editorial-review/SKILL.md +1 -1
  54. package/skills/core/house-style/SKILL.md +1 -1
  55. package/skills/core/house-style/references/persistence.md +8 -3
  56. package/skills/core/house-style/scripts/init-guidance.mjs +1 -1
  57. package/skills/core/plain-language/SKILL.md +1 -1
  58. package/skills/core/polish-cn/SKILL.md +1 -1
  59. package/skills/core/reg-parser/SKILL.md +1 -1
  60. package/skills/core/terminology/SKILL.md +1 -1
  61. package/skills/core/terminology/references/init.md +11 -0
  62. package/skills/core/write-doc/SKILL.md +1 -1
  63. package/skills/core/write-report/SKILL.md +1 -1
  64. package/skills/engineering/better-review/SKILL.md +1 -1
  65. package/skills/engineering/e2e-test/SKILL.md +1 -1
  66. package/skills/engineering/engineering/SKILL.md +19 -4
  67. package/skills/engineering/engineering/references/init-scope.md +5 -2
  68. package/skills/engineering/engineering/references/scope-management.md +4 -2
  69. package/skills/engineering/engineering/scripts/init.mjs +34 -9
  70. package/skills/engineering/engineering/scripts/persist-conventions-core.mjs +231 -21
  71. package/skills/engineering/engineering/scripts/persist-conventions.mjs +48 -21
  72. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  73. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  74. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  75. package/skills/engineering/golden-path/SKILL.md +1 -1
  76. package/skills/engineering/harness-runtime/SKILL.md +4 -3
  77. package/skills/engineering/harness-runtime/agent-catalog.mjs +928 -0
  78. package/skills/engineering/harness-runtime/contracts.md +2 -0
  79. package/skills/engineering/harness-runtime/references/model-catalog.md +7 -1
  80. package/skills/engineering/resolve-issues/SKILL.md +9 -6
  81. package/skills/engineering/resolve-issues/references/delivery-progress.md +2 -0
  82. package/skills/engineering/resolve-issues/references/expire.md +6 -3
  83. package/skills/engineering/resolve-issues/references/implementation.md +6 -1
  84. package/skills/engineering/resolve-issues/references/preflight.md +26 -17
  85. package/skills/engineering/resolve-issues/references/review.md +39 -4
  86. package/skills/engineering/resolve-issues/references/shared-modules.md +88 -20
  87. package/skills/engineering/resolve-issues/references/task-plan.md +35 -1
  88. package/skills/engineering/resolve-issues/scripts/preflight-models.mjs +382 -592
  89. package/skills/engineering/resolve-release/SKILL.md +1 -1
  90. package/skills/engineering/rush-issues/SKILL.md +4 -4
  91. package/skills/engineering/rush-release/SKILL.md +1 -1
  92. package/skills/engineering/test-quality/SKILL.md +3 -1
  93. package/skills/engineering/test-quality/scripts/persist-conventions-core.mjs +231 -21
  94. package/skills/engineering/test-quality/scripts/persist-conventions.mjs +13 -1
  95. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  96. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  97. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  98. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  99. package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
  100. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  101. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
  102. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  103. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
  104. package/skills/engineering/resolve-issues/scripts/discover-models.mjs +0 -9
  105. 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 usage = 'node init.mjs [--repo-root <dir>] [--write]'
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 === '--repo-root') {
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 markdownView(current) {
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
- return lines
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
- function append(current, body) {
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 planInitialization(root) {
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
- if (options.write && action !== 'unchanged') fs.writeFileSync(entry.target, entry.next)
370
- console.log(` ${entry.name}: ${!options.write && action !== 'unchanged' ? 'would ' : ''}${action}`)
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
  }