dev-memory-cli 0.18.2

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.
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ PACKAGE_ROOT = Path(__file__).resolve().parents[2]
12
+ REPO_ROOT = Path(
13
+ os.environ.get("DEV_MEMORY_HOOK_REPO_ROOT")
14
+ or os.environ.get("DEV_ASSETS_HOOK_REPO_ROOT")
15
+ or "."
16
+ ).expanduser().resolve()
17
+ LIB_ROOT = PACKAGE_ROOT / "lib"
18
+ if str(LIB_ROOT) not in sys.path:
19
+ sys.path.insert(0, str(LIB_ROOT))
20
+
21
+ from dev_memory_common import (
22
+ AUTO_END,
23
+ AUTO_START,
24
+ PLACEHOLDER_MARKERS,
25
+ asset_paths,
26
+ detect_no_git_mode,
27
+ detect_workspace_mode,
28
+ get_branch_paths,
29
+ list_repos_in_workspace,
30
+ )
31
+
32
+
33
+ CONTEXT_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_context.py"
34
+ # v2: sync/update merged into capture. All auto-block refresh and
35
+ # record-head calls now go through the capture script.
36
+ CAPTURE_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_capture.py"
37
+
38
+
39
+ def run_python(script_path, *args, cwd=None):
40
+ work_cwd = cwd if cwd is not None else REPO_ROOT
41
+ result = subprocess.run(
42
+ ["python3", str(script_path), *args],
43
+ cwd=work_cwd,
44
+ check=False,
45
+ capture_output=True,
46
+ text=True,
47
+ )
48
+ if result.returncode != 0:
49
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"command failed: {script_path}")
50
+ return result.stdout.strip()
51
+
52
+
53
+ def log(message):
54
+ print(message, file=sys.stderr)
55
+
56
+
57
+ def resolve_assets_for(repo_root):
58
+ """Resolve asset paths for an explicit repo root (workspace-mode friendly)."""
59
+ repo_root_str = str(repo_root)
60
+ root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir = get_branch_paths(repo_root_str)
61
+ return {
62
+ "repo_root": root,
63
+ "branch_name": branch_name,
64
+ "branch_key": branch_key,
65
+ "storage_root": storage_root,
66
+ "repo_key": repo_key,
67
+ "repo_dir": repo_dir,
68
+ "branch_dir": branch_dir,
69
+ "paths": asset_paths(repo_dir, branch_dir),
70
+ }
71
+
72
+
73
+ def resolve_assets():
74
+ return resolve_assets_for(REPO_ROOT)
75
+
76
+
77
+ def is_workspace_mode():
78
+ return detect_workspace_mode(str(REPO_ROOT))
79
+
80
+
81
+ def is_no_git_mode():
82
+ return detect_no_git_mode(str(REPO_ROOT))
83
+
84
+
85
+ def list_workspace_repos():
86
+ return list_repos_in_workspace(str(REPO_ROOT))
87
+
88
+
89
+ def primary_repo_name():
90
+ """Basename of the focus repo from env; None if unset."""
91
+ value = (
92
+ os.environ.get("DEV_MEMORY_PRIMARY_REPO", "").strip()
93
+ or os.environ.get("DEV_ASSETS_PRIMARY_REPO", "").strip()
94
+ )
95
+ return value or None
96
+
97
+
98
+ def strip_managed_markers(text):
99
+ return text.replace(AUTO_START, "").replace(AUTO_END, "").replace("_尚未同步_", "").strip()
100
+
101
+
102
+ # Sentinels produced by render_bullets/build_auto_block when git introspection
103
+ # finds nothing to report. Not added to lib PLACEHOLDER_MARKERS because
104
+ # list_missing_docs would then false-flag these as "section missing" — the user
105
+ # can't fill them in, they're auto-derived. Filtered only at injection time.
106
+ EMPTY_SENTINELS = (
107
+ "当前未检测到改动目录",
108
+ "当前未检测到改动范围",
109
+ "未检测到 origin/HEAD",
110
+ "尚未检测到 HEAD",
111
+ )
112
+
113
+
114
+ def is_placeholder(text):
115
+ stripped = strip_managed_markers(text)
116
+ if not stripped:
117
+ return True
118
+ if any(marker in stripped for marker in PLACEHOLDER_MARKERS):
119
+ return True
120
+ lines = [line.strip() for line in stripped.splitlines() if line.strip()]
121
+ if lines and all(any(sentinel in line for sentinel in EMPTY_SENTINELS) for line in lines):
122
+ return True
123
+ return False
124
+
125
+
126
+ def extract_section(path, title):
127
+ if not path.exists():
128
+ return None
129
+ content = path.read_text(encoding="utf-8")
130
+ match = re.search(rf"^## {re.escape(title)}\n\n(.*?)(?=^## |\Z)", content, flags=re.MULTILINE | re.DOTALL)
131
+ if not match:
132
+ return None
133
+ body = strip_managed_markers(match.group(1)).strip()
134
+ return None if is_placeholder(body) else body
135
+
136
+
137
+ def compact_body(text, max_lines=8, max_chars=700):
138
+ """Compact a section body. Returns (compacted_text, was_truncated). The
139
+ caller uses `was_truncated` to decide whether to append a "see full file"
140
+ hint so the AI doesn't mistake the trimmed snippet for the whole story.
141
+ """
142
+ normalized = "\n".join(line.rstrip() for line in text.splitlines()).strip()
143
+ lines = [line for line in normalized.splitlines() if line.strip()]
144
+ truncated = False
145
+ if len(lines) > max_lines:
146
+ lines = lines[:max_lines]
147
+ if not lines[-1].endswith("..."):
148
+ lines.append("...")
149
+ truncated = True
150
+ compacted = "\n".join(lines)
151
+ if len(compacted) > max_chars:
152
+ compacted = compacted[: max_chars - 3].rstrip() + "..."
153
+ truncated = True
154
+ return compacted, truncated
155
+
156
+
157
+ def sync_context_for(repo_root):
158
+ return json.loads(
159
+ run_python(CONTEXT_SCRIPT, "sync", "--repo", str(repo_root), cwd=str(repo_root))
160
+ )
161
+
162
+
163
+ def sync_working_tree_for(repo_root):
164
+ return json.loads(
165
+ run_python(CAPTURE_SCRIPT, "sync-working-tree", "--repo", str(repo_root), cwd=str(repo_root))
166
+ )
167
+
168
+
169
+ def record_head_for(repo_root):
170
+ return json.loads(
171
+ run_python(CAPTURE_SCRIPT, "record-head", "--repo", str(repo_root), cwd=str(repo_root))
172
+ )
173
+
174
+
175
+ def maybe_sync_context():
176
+ return sync_context_for(REPO_ROOT)
177
+
178
+
179
+ def maybe_sync_working_tree():
180
+ return sync_working_tree_for(REPO_ROOT)
181
+
182
+
183
+ def maybe_record_head():
184
+ return record_head_for(REPO_ROOT)
185
+
186
+
187
+ # v2 section map: branch files split by domain. progress.md carries
188
+ # "建议优先查看的目录", "当前进展", "下一步"; risks.md carries "阻塞与注意点" and
189
+ # "后续继续前要注意"; decisions.md carries "关键决策与原因"; glossary.md carries
190
+ # "当前有效上下文".
191
+ _FULL_SECTION_KEYS = (
192
+ ("overview", "当前目标"),
193
+ ("overview", "范围边界"),
194
+ ("overview", "当前阶段"),
195
+ ("overview", "关键约束"),
196
+ ("progress", "建议优先查看的目录"),
197
+ ("progress", "当前进展"),
198
+ ("risks", "阻塞与注意点"),
199
+ ("progress", "下一步"),
200
+ ("glossary", "当前有效上下文"),
201
+ ("decisions", "关键决策与原因"),
202
+ ("risks", "后续继续前要注意"),
203
+ ("repo_overview", "长期目标与边界"),
204
+ ("repo_overview", "仓库级关键约束"),
205
+ ("repo_glossary", "共享入口"),
206
+ )
207
+
208
+ _BRIEF_SECTION_KEYS = (
209
+ ("overview", "当前目标"),
210
+ ("overview", "当前阶段"),
211
+ ("progress", "当前进展"),
212
+ ("progress", "下一步"),
213
+ )
214
+
215
+
216
+ def _extract_sections(paths, keys):
217
+ out = []
218
+ for file_key, title in keys:
219
+ body = extract_section(paths[file_key], title)
220
+ out.append((title, body, file_key))
221
+ return out
222
+
223
+
224
+ def _build_context_from_assets(assets, *, full=True, heading=None):
225
+ if not assets["branch_dir"].exists():
226
+ # v2: capture lazy-inits on first write, so branch_dir typically
227
+ # exists after any real interaction. Missing here just means no
228
+ # write has happened yet — no need to push setup.
229
+ if heading is None:
230
+ return (
231
+ "当前仓库+分支还没有 dev-memory 记忆。"
232
+ "下一次 `dev-memory-capture` 写入时会自动 lazy init;现有结论若值得记一笔,直接走 capture。"
233
+ )
234
+ return None
235
+
236
+ paths = assets["paths"]
237
+ keys = _FULL_SECTION_KEYS if full else _BRIEF_SECTION_KEYS
238
+ sections = _extract_sections(paths, keys)
239
+ max_lines, max_chars = (8, 700) if full else (3, 200)
240
+
241
+ parts = []
242
+ no_git = assets.get("branch_name") is None
243
+ if heading is not None:
244
+ # Workspace mode: caller passes a per-repo heading (e.g. "## [PRIMARY]
245
+ # repo @ branch") so multi-repo blocks are still distinguishable.
246
+ parts.append(heading)
247
+ elif no_git:
248
+ # No-git has nothing in the footer paths to identify scope by, keep a
249
+ # minimal label.
250
+ parts.append("已加载 dev-memory(no-git 模式)。")
251
+ # Single-repo + git: skip the heading. Branch identity is derivable from
252
+ # the footer's directory header (.../branches/<branch>/).
253
+
254
+ any_truncated = False
255
+ for title, body, file_key in sections:
256
+ if not body:
257
+ continue
258
+ compacted, truncated = compact_body(body, max_lines=max_lines, max_chars=max_chars)
259
+ block = f"{title}:\n{compacted}"
260
+ if truncated:
261
+ file_path = paths.get(file_key)
262
+ if file_path is not None:
263
+ # Plain-text anchor (no markdown italic). Filename-only — the
264
+ # absolute prefix lives in the footer's directory header so we
265
+ # avoid printing the same prefix on every truncation.
266
+ block += f"\n↪ 完整: {file_path.name}"
267
+ any_truncated = True
268
+ parts.append(block)
269
+
270
+ # Footer: dump the authoritative memory layout so the agent can Read files
271
+ # directly. Replaces the retired dev-memory-context skill. Path layout is
272
+ # "directory header + relative filenames" to keep the footer compact.
273
+ if not no_git:
274
+ archive_root = paths.get("repo_artifacts")
275
+ archive_dir = (
276
+ archive_root.parent.parent / "branches" / "_archived"
277
+ if archive_root is not None
278
+ else None
279
+ )
280
+
281
+ branch_specs = (
282
+ ("progress", "hot 层:当前进展 + 下一步 + 自动同步区"),
283
+ ("risks", "hot 层:阻塞 + 后续注意点"),
284
+ ("decisions", "决策背景(为什么这么做)"),
285
+ ("glossary", "术语 + 源资料入口"),
286
+ ("overview", "分支概览(目标 / 范围 / 阶段 / 约束)"),
287
+ ("log", "事件日志(append-only;`grep '^## \\[' log.md | tail -20` 看最近事件)"),
288
+ )
289
+ repo_specs = (
290
+ ("repo_overview", "长期目标 + 跨分支约束"),
291
+ ("repo_decisions", "跨分支通用决策"),
292
+ ("repo_glossary", "长期背景 + 共享入口"),
293
+ ("repo_log", "仓库事件日志(graduate / 共享层 capture)"),
294
+ )
295
+
296
+ def _group(specs):
297
+ lines = []
298
+ common_dir = None
299
+ for key, label in specs:
300
+ p = paths.get(key)
301
+ if p is None:
302
+ continue
303
+ if common_dir is None:
304
+ common_dir = p.parent
305
+ lines.append(f"- {p.name} — {label}")
306
+ return common_dir, lines
307
+
308
+ branch_dir, branch_lines = _group(branch_specs)
309
+ repo_dir, repo_lines = _group(repo_specs)
310
+
311
+ footer_lines = ["---"]
312
+ if full:
313
+ opening = (
314
+ "SessionStart 注入的浓缩摘要 — "
315
+ + (
316
+ "上文标注 ↪ 的段落已截断,详情 Read 对应文件。"
317
+ if any_truncated
318
+ else "需要更多细节时直接 Read 下面列出的文件。"
319
+ )
320
+ )
321
+ else:
322
+ opening = "Brief 摘要。本 repo 完整记忆见以下文件:"
323
+ footer_lines.append(opening)
324
+ if branch_lines and branch_dir:
325
+ footer_lines.extend(["", f"分支层 `{branch_dir}/`:", *branch_lines])
326
+ if repo_lines and repo_dir:
327
+ footer_lines.extend(["", f"repo 共享层 `{repo_dir}/`:", *repo_lines])
328
+ if archive_dir is not None:
329
+ footer_lines.extend([
330
+ "",
331
+ f"归档分支查询:`grep -r 'KEYWORD' {archive_dir}/` (体量大时派 Task 子 agent)",
332
+ ])
333
+ footer_lines.extend(["", "新决策 / 进展 / 阻塞 → `dev-memory-capture` 写入。"])
334
+ parts.append("\n".join(footer_lines))
335
+
336
+ return "\n\n".join(parts)
337
+
338
+
339
+ def build_session_start_context():
340
+ assets = resolve_assets()
341
+ # no-git mode skips maybe_sync_context() because that path runs git commands
342
+ # (working-tree diffing, focus-area detection) that don't apply here.
343
+ if assets.get("branch_name") is not None:
344
+ try:
345
+ maybe_sync_context()
346
+ except Exception as exc:
347
+ log(f"[dev-memory][SessionStart] refresh skipped: {exc}")
348
+ return _build_context_from_assets(assets, full=True)
349
+
350
+
351
+ def build_context_for_repo(repo_path, *, full=True, is_primary=False):
352
+ """Build a per-repo context block for workspace-mode SessionStart injection.
353
+ Returns None when the repo has no initialized branch memory or resolution fails.
354
+ """
355
+ try:
356
+ assets = resolve_assets_for(repo_path)
357
+ except Exception as exc:
358
+ log(f"[dev-memory] resolve failed for {Path(repo_path).name}: {exc}")
359
+ return None
360
+ try:
361
+ sync_context_for(repo_path)
362
+ except Exception as exc:
363
+ log(f"[dev-memory] context sync skipped for {Path(repo_path).name}: {exc}")
364
+ tag = "[PRIMARY] " if is_primary else ""
365
+ heading = (
366
+ f"## {tag}`{Path(repo_path).name}` @ branch `{assets['branch_name']}`"
367
+ )
368
+ return _build_context_from_assets(assets, full=full, heading=heading)
369
+
370
+
371
+ def build_workspace_start_context():
372
+ """SessionStart context for workspace mode. Primary repo gets full memory;
373
+ others get a brief overview only. Returns None if no initialized repos.
374
+
375
+ Fallback when DEV_MEMORY_PRIMARY_REPO is unset:
376
+ - Single-repo workspace → that repo is full (user's intent is obvious).
377
+ - Multi-repo workspace → all brief, so N full dumps can't drown the
378
+ session. Header tells the agent how to promote one to full.
379
+ """
380
+ repos = list_workspace_repos()
381
+ if not repos:
382
+ return None
383
+ primary = primary_repo_name()
384
+ only_one_repo = len(repos) == 1
385
+ primary_hit = False
386
+ has_brief = False
387
+ sections = []
388
+ for repo_path in repos:
389
+ if primary is not None:
390
+ is_primary = (repo_path.name == primary)
391
+ else:
392
+ is_primary = only_one_repo
393
+ if is_primary:
394
+ primary_hit = True
395
+ else:
396
+ has_brief = True
397
+ ctx = build_context_for_repo(repo_path, full=is_primary, is_primary=is_primary)
398
+ if ctx:
399
+ sections.append(ctx)
400
+ if not sections:
401
+ return None
402
+ header_parts = [
403
+ f"已加载 dev-memory workspace 模式:共 {len(repos)} 个仓库 @ `{REPO_ROOT}`"
404
+ ]
405
+ if primary:
406
+ status = "命中" if primary_hit else "未在 workspace 中找到"
407
+ header_parts.append(f"Primary 仓库:`{primary}` ({status})")
408
+ if has_brief:
409
+ header_parts.append(
410
+ "_其它仓库按 brief 摘要注入;每个 brief 末尾列出该仓库的完整记忆文件路径,"
411
+ "聚焦时直接 Read 即可(如需 CLI:`dev-memory-cli context show --repo <name>`)。_"
412
+ )
413
+ header = "\n".join(header_parts)
414
+ return header + "\n\n---\n\n" + "\n\n---\n\n".join(sections)
415
+
416
+
417
+ def record_head_all_repos():
418
+ """Stop/SessionEnd hook helper for workspace mode. Iterates all repos; logs per-repo
419
+ outcome; swallows per-repo failures.
420
+ """
421
+ results = []
422
+ for repo_path in list_workspace_repos():
423
+ try:
424
+ assets = resolve_assets_for(repo_path)
425
+ if not assets["branch_dir"].exists():
426
+ log(f"[dev-memory] {repo_path.name}: branch memory not initialized, skip")
427
+ continue
428
+ payload = record_head_for(repo_path)
429
+ log(
430
+ f"[dev-memory] {repo_path.name}: recorded HEAD "
431
+ f"{payload.get('last_seen_head')} for {payload.get('branch')}"
432
+ )
433
+ results.append((repo_path.name, payload))
434
+ except Exception as exc:
435
+ log(f"[dev-memory] {repo_path.name}: record-head skipped: {exc}")
436
+ return results
437
+
438
+
439
+ def sync_working_tree_all_repos():
440
+ """PreCompact hook helper for workspace mode. Iterates all repos."""
441
+ results = []
442
+ for repo_path in list_workspace_repos():
443
+ try:
444
+ assets = resolve_assets_for(repo_path)
445
+ if not assets["branch_dir"].exists():
446
+ log(f"[dev-memory] {repo_path.name}: branch memory not initialized, skip")
447
+ continue
448
+ payload = sync_working_tree_for(repo_path)
449
+ log(
450
+ f"[dev-memory] {repo_path.name}: refreshed working-tree navigation for "
451
+ f"{payload.get('branch')} ({payload.get('files_considered')} files)"
452
+ )
453
+ results.append((repo_path.name, payload))
454
+ except Exception as exc:
455
+ log(f"[dev-memory] {repo_path.name}: working-tree sync skipped: {exc}")
456
+ return results
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python3
2
+ """PreCompact hook — intentionally a no-op.
3
+
4
+ In earlier versions this hook refreshed progress.md's auto-sync block via
5
+ `capture sync-working-tree`, but SessionStart already does the same refresh
6
+ at the start of every conversation. Running it again right before context
7
+ compaction adds no signal (the agent's transcript already carries the prior
8
+ sync) and burns extra git commands. Kept as a stub so existing hook
9
+ installations don't error out — safe to remove from settings if you want.
10
+ """
11
+
12
+ from _common import log
13
+
14
+
15
+ def main():
16
+ log("[dev-memory][PreCompact] no-op (refresh handled at SessionStart)")
17
+ return 0
18
+
19
+
20
+ if __name__ == "__main__":
21
+ raise SystemExit(main())
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from _common import (
4
+ is_no_git_mode,
5
+ is_workspace_mode,
6
+ log,
7
+ maybe_record_head,
8
+ record_head_all_repos,
9
+ resolve_assets,
10
+ )
11
+
12
+
13
+ def main():
14
+ try:
15
+ if is_no_git_mode():
16
+ log("[dev-memory][SessionEnd] no-git mode: nothing to finalize (no HEAD)")
17
+ return 0
18
+ if is_workspace_mode():
19
+ results = record_head_all_repos()
20
+ if not results:
21
+ log("[dev-memory][SessionEnd] workspace mode: no initialized repos finalized")
22
+ return 0
23
+ assets = resolve_assets()
24
+ if not assets["branch_dir"].exists():
25
+ log("[dev-memory][SessionEnd] branch memory not initialized, skip")
26
+ return 0
27
+ payload = maybe_record_head()
28
+ log(f"[dev-memory][SessionEnd] finalized HEAD marker {payload['last_seen_head']} for {payload['branch']}")
29
+ except Exception as exc:
30
+ log(f"[dev-memory][SessionEnd] skipped: {exc}")
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ raise SystemExit(main())
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import sys
5
+
6
+ from _common import (
7
+ build_session_start_context,
8
+ build_workspace_start_context,
9
+ is_workspace_mode,
10
+ log,
11
+ )
12
+
13
+
14
+ def _resolve_context():
15
+ if is_workspace_mode():
16
+ ctx = build_workspace_start_context()
17
+ if ctx:
18
+ return ctx
19
+ return "dev-memory workspace 模式:当前 workspace 下未发现已初始化的仓库记忆。"
20
+ return build_session_start_context()
21
+
22
+
23
+ def main():
24
+ try:
25
+ additional_context = _resolve_context()
26
+ payload = {
27
+ "hookSpecificOutput": {
28
+ "hookEventName": "SessionStart",
29
+ "additionalContext": additional_context,
30
+ }
31
+ }
32
+ print(json.dumps(payload, ensure_ascii=False))
33
+ return 0
34
+ except Exception as exc:
35
+ log(f"[dev-memory][SessionStart] skipped: {exc}")
36
+ print(
37
+ json.dumps(
38
+ {
39
+ "hookSpecificOutput": {
40
+ "hookEventName": "SessionStart",
41
+ "additionalContext": "dev-memory SessionStart hook 未能加载上下文,本轮按普通会话继续。",
42
+ }
43
+ },
44
+ ensure_ascii=False,
45
+ )
46
+ )
47
+ return 0
48
+
49
+
50
+ if __name__ == "__main__":
51
+ raise SystemExit(main())
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from _common import (
4
+ is_no_git_mode,
5
+ is_workspace_mode,
6
+ log,
7
+ maybe_record_head,
8
+ record_head_all_repos,
9
+ resolve_assets,
10
+ )
11
+
12
+
13
+ def main():
14
+ try:
15
+ if is_no_git_mode():
16
+ log("[dev-memory][Stop] no-git mode: nothing to record (no HEAD)")
17
+ return 0
18
+ if is_workspace_mode():
19
+ results = record_head_all_repos()
20
+ if not results:
21
+ log("[dev-memory][Stop] workspace mode: no initialized repos recorded")
22
+ return 0
23
+ assets = resolve_assets()
24
+ if not assets["branch_dir"].exists():
25
+ log("[dev-memory][Stop] branch memory not initialized, skip")
26
+ return 0
27
+ payload = maybe_record_head()
28
+ log(f"[dev-memory][Stop] recorded HEAD {payload['last_seen_head']} for {payload['branch']}")
29
+ except Exception as exc:
30
+ log(f"[dev-memory][Stop] skipped: {exc}")
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ raise SystemExit(main())
@@ -0,0 +1,26 @@
1
+ {
2
+ "suite_name": "dev-memory-skill-suite",
3
+ "skills": [
4
+ "dev-memory-setup",
5
+ "dev-memory-capture",
6
+ "dev-memory-graduate",
7
+ "dev-memory-tidy"
8
+ ],
9
+ "legacy_skills": [
10
+ "branch-context",
11
+ "branch-context-setup",
12
+ "dev-asset-setup",
13
+ "dev-asset-context",
14
+ "dev-asset-sync",
15
+ "dev-assets-sync",
16
+ "dev-assets-update",
17
+ "using-dev-assets",
18
+ "using-dev-memory",
19
+ "dev-memory-context",
20
+ "dev-assets-setup",
21
+ "dev-assets-context",
22
+ "dev-assets-capture",
23
+ "dev-assets-graduate",
24
+ "dev-assets-tidy"
25
+ ]
26
+ }