@seanyao/roll 2026.522.2 → 2026.523.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.
@@ -153,7 +153,11 @@ def load_backlog(project_root: Optional[Path] = None) -> Dict[str, str]:
153
153
  # ════════════════════════════════════════════════════════════════════════════
154
154
  # Cycle aggregation — group events by cycle label; attach cron + story id
155
155
  # ════════════════════════════════════════════════════════════════════════════
156
- _STORY_ID_PAT = re.compile(r"\b([A-Z]+(?:-[A-Z]+)*-\d+)\b")
156
+ # FIX-108: each segment was [A-Z]+ (letters only), so alphanumeric segments
157
+ # like I18N / K8S / D2 / S3 / 2FA failed to match — dashboard silently dropped
158
+ # any story id with a mixed-letter-digit segment (US-I18N-001 etc.). First
159
+ # char must still be a letter so "001-002" doesn't false-positive as an id.
160
+ _STORY_ID_PAT = re.compile(r"\b([A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-\d+)\b")
157
161
  _PR_NUM_PAT = re.compile(r"/pull/(\d+)")
158
162
 
159
163
  def _extract_story_id(ev_detail: str) -> Optional[str]:
@@ -356,21 +360,29 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
356
360
  # Path 1: usage event written by loop-fmt at result time.
357
361
  ue = cy.get("usage_event")
358
362
  if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")):
359
- cy["input_tokens"] = int(ue.get("input_tokens") or 0)
360
- cy["output_tokens"] = int(ue.get("output_tokens") or 0)
363
+ cy["input_tokens"] = int(ue.get("input_tokens") or 0)
364
+ cy["output_tokens"] = int(ue.get("output_tokens") or 0)
365
+ cy["cache_creation_tokens"] = int(ue.get("cache_creation_tokens") or 0)
366
+ cy["cache_read_tokens"] = int(ue.get("cache_read_tokens") or 0)
361
367
  cy["model"] = ue.get("model")
362
- # US-VIEW-010: aggregate now sums per-turn usage tokens, so the
363
- # totals in `ue` reflect the whole cycle. Always compute cost at
364
- # list price for cross-account comparabilitysupersedes FIX-060
365
- # which preferred cost_reported_usd as a workaround for
366
- # last-event-only token counts (that root cause is now gone).
367
- cy["cost_list"] = mp.compute_list_cost(
368
- ue.get("model"),
369
- input_tokens=ue.get("input_tokens", 0),
370
- output_tokens=ue.get("output_tokens", 0),
371
- cache_creation_tokens=ue.get("cache_creation_tokens", 0),
372
- cache_read_tokens=ue.get("cache_read_tokens", 0),
373
- )
368
+ # US-VIEW-014: prefer the cost frozen at cycle_end so a later
369
+ # prices refresh never rewrites a historical cycle's cost. Only
370
+ # legacy events (pre-US-VIEW-014) fall back to recomputing and
371
+ # the row gets a muted [legacy] tag so it can't be mistaken for
372
+ # the authoritative value.
373
+ persisted = ue.get("cost_list_usd")
374
+ if persisted is not None:
375
+ cy["cost_list"] = float(persisted)
376
+ cy["cost_list_legacy"] = False
377
+ else:
378
+ cy["cost_list"] = mp.compute_list_cost(
379
+ ue.get("model"),
380
+ input_tokens=ue.get("input_tokens", 0),
381
+ output_tokens=ue.get("output_tokens", 0),
382
+ cache_creation_tokens=ue.get("cache_creation_tokens", 0),
383
+ cache_read_tokens=ue.get("cache_read_tokens", 0),
384
+ )
385
+ cy["cost_list_legacy"] = True
374
386
  if ue.get("duration_ms") and not cy.get("duration_s"):
375
387
  cy["duration_s"] = int(ue["duration_ms"] / 1000)
376
388
  continue
@@ -380,8 +392,10 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
380
392
  u = load_claude_session_usage(cy.get("label", ""), slug)
381
393
  if not u:
382
394
  continue
383
- cy["input_tokens"] = int(u.get("input_tokens") or 0)
384
- cy["output_tokens"] = int(u.get("output_tokens") or 0)
395
+ cy["input_tokens"] = int(u.get("input_tokens") or 0)
396
+ cy["output_tokens"] = int(u.get("output_tokens") or 0)
397
+ cy["cache_creation_tokens"] = int(u.get("cache_creation_tokens") or 0)
398
+ cy["cache_read_tokens"] = int(u.get("cache_read_tokens") or 0)
385
399
  cy["model"] = u["model"]
386
400
  cy["cost_list"] = mp.compute_list_cost(
387
401
  u["model"],
@@ -390,25 +404,39 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
390
404
  cache_creation_tokens=u["cache_creation_tokens"],
391
405
  cache_read_tokens=u["cache_read_tokens"],
392
406
  )
407
+ # US-VIEW-014: session salvage never has a frozen cycle_end cost, so
408
+ # this path is always legacy.
409
+ cy["cost_list_legacy"] = True
393
410
  if u.get("duration_ms") and not cy.get("duration_s"):
394
411
  cy["duration_s"] = int(u["duration_ms"] / 1000)
395
412
 
396
413
  def load_pr_merges_from_git(days: int) -> Dict[str, Dict[str, Any]]:
397
414
  """Repair fallback: when events.ndjson dropped the pr / cycle_end events
398
- for a cycle (events writer regressions), git log still has the merge
399
- commit `Merge pull request #N from seanyao/loop/cycle-LABEL`. Extract
400
- PR number + story IDs from the merge subject + body so orphan cycles
401
- can be reclassified done instead of permanently '⏵ running'."""
415
+ for a cycle (events writer regressions, or cycle_end fired before PR
416
+ merged), git log still has the merge commit. Two known subject formats:
417
+
418
+ - Branch-named (Merge commit / older squash): "Merge pull request #N
419
+ from seanyao/loop/cycle-LABEL" — the branch name carries the label.
420
+ - Squash with default-title (newer GitHub UI / `gh pr merge --squash`):
421
+ "loop cycle LABEL (#N)" — space-separated, no slash.
422
+
423
+ FIX-107: the old --grep="loop/cycle-" + label_re missed the squash
424
+ subject entirely, so PRs merged AFTER cycle_end never got their
425
+ pr_outcome promoted to 'merged' on the dashboard.
426
+ """
402
427
  try:
403
428
  out = subprocess.check_output(
404
429
  ["git", "log", f"--since={days + 1} days ago",
405
- "--grep=loop/cycle-", "--format=%H|||%s|||%b<<<END>>>"],
430
+ "--grep=loop[ /]cycle", "--extended-regexp",
431
+ "--format=%H|||%s|||%b<<<END>>>"],
406
432
  text=True, errors="ignore"
407
433
  )
408
434
  except Exception:
409
435
  return {}
410
436
  result: Dict[str, Dict[str, Any]] = {}
411
- label_re = re.compile(r"loop/cycle-([A-Za-z0-9-]+)")
437
+ # Accept both `loop/cycle-LABEL` and `loop cycle LABEL` (with or without
438
+ # the leading `-` separator after `cycle`). LABEL = YYYYMMDD-HHMMSS-PID.
439
+ label_re = re.compile(r"loop[ /]cycle[-\s](\d{8}-\d+-\d+)")
412
440
  pr_re = re.compile(r"#(\d+)")
413
441
  story_re = re.compile(r"\b([A-Z]+(?:-[A-Z]+)*-\d+)\b")
414
442
  for chunk in out.split("<<<END>>>"):
@@ -557,7 +585,8 @@ def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
557
585
  # reads all 4 fields), but they don't represent the model's actual work.
558
586
  r = {"cycles": len(day_cycles), "prs": 0, "failed": 0,
559
587
  "duration_s": 0, "cost": 0.0,
560
- "input_tokens": 0, "output_tokens": 0}
588
+ "input_tokens": 0, "output_tokens": 0,
589
+ "cache_creation_tokens": 0, "cache_read_tokens": 0}
561
590
  for cy in day_cycles:
562
591
  if cy.get("outcome") == "fail":
563
592
  r["failed"] += 1
@@ -567,6 +596,10 @@ def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
567
596
  r["input_tokens"] += cy["input_tokens"]
568
597
  if cy.get("output_tokens"):
569
598
  r["output_tokens"] += cy["output_tokens"]
599
+ if cy.get("cache_creation_tokens"):
600
+ r["cache_creation_tokens"] += cy["cache_creation_tokens"]
601
+ if cy.get("cache_read_tokens"):
602
+ r["cache_read_tokens"] += cy["cache_read_tokens"]
570
603
  # US-VIEW-011: rollup only counts cycles whose PR actually merged.
571
604
  # Backward compat: rows where pr_outcome is missing but pr URL exists
572
605
  # (no `pr` event after the writer upgrade ran for that cycle) are
@@ -634,10 +667,13 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
634
667
  c("dim", "run ") + c("fg", "roll loop on", bold=True) +
635
668
  c("dim", " to enable"))
636
669
  eb_zh = c("dim", " 未安装 · 运行 ") + c("fg", "roll loop on") + c("dim", " 启用")
637
- elif install_state == "disabled":
638
- eb_l = (c("amber", "◌ installed/off", bold=True) + c("muted", " ") +
639
- c("dim", "loop disabled run ") + c("fg", "roll loop on", bold=True))
640
- eb_zh = c("dim", " 未启用 · 运行 ") + c("fg", "roll loop on") + c("dim", " 启用")
670
+ elif install_state in ("stale", "disabled"):
671
+ # FIX-098: 'stale' = plist on disk but agent not registered in launchd.
672
+ # 'disabled' kept for back-compat (old install_state values). Both mean
673
+ # the user needs to run 'roll loop on' to bootstrap the agent.
674
+ eb_l = (c("amber", "◌ STALE — plist present, not loaded", bold=True) + c("muted", " ") +
675
+ c("dim", "run ") + c("fg", "roll loop on", bold=True) + c("dim", " to repair"))
676
+ eb_zh = c("dim", " Plist 存在但未加载 · 运行 ") + c("fg", "roll loop on") + c("dim", " 修复")
641
677
  else:
642
678
  eb_l = (c("blue", "● IDLE", bold=True) + c("muted", " · ") +
643
679
  c("dim", "enabled · next run ") + c("fg", _next_cron_hint(state), bold=True))
@@ -723,11 +759,12 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
723
759
  yest_color="amber" if yest["failed"] > 0 else "dim",
724
760
  yest_suffix="⚠" if yest["failed"] > 0 else "")
725
761
  metric_dur("duration", today["duration_s"], yest["duration_s"], d2["duration_s"], partial=is_partial)
726
- # US-VIEW-012: input + output as two separate rows. cache_read no longer
727
- # surfaces here true cost is on the "cost" line below (computed from all
728
- # 4 token kinds via list price). This row labels what the model actually
729
- # processed and generated for this cycle.
762
+ # US-VIEW-017: show all 4 token components so the cost is explainable.
763
+ # cache_creation (↑) and cache_read (↓) typically account for 80-90% of
764
+ # cost hiding them makes the cost line incomprehensible.
730
765
  metric_tokens("input tokens", today["input_tokens"], yest["input_tokens"], d2["input_tokens"], partial=is_partial)
766
+ metric_tokens("cache writes", today["cache_creation_tokens"], yest["cache_creation_tokens"], d2["cache_creation_tokens"], partial=is_partial)
767
+ metric_tokens("cache reads", today["cache_read_tokens"], yest["cache_read_tokens"], d2["cache_read_tokens"], partial=is_partial)
731
768
  metric_tokens("output tokens", today["output_tokens"], yest["output_tokens"], d2["output_tokens"], partial=is_partial)
732
769
  metric_dollar("cost", today["cost"], yest["cost"], d2["cost"], partial=is_partial)
733
770
 
@@ -784,15 +821,18 @@ def _read_plist_loop_minute() -> int:
784
821
 
785
822
 
786
823
  def _detect_install_state() -> str:
787
- """FIX-095: classify the launchd install state of the loop service.
824
+ """FIX-095 / FIX-098: classify the launchd install state of the loop service.
788
825
 
789
826
  Returns one of:
790
827
  'not-installed' — no plist for com.roll.loop.<slug> in ~/Library/LaunchAgents/
791
- 'disabled' — plist exists but launchctl print-disabled shows '=> disabled'
792
- 'enabled' — plist exists and no disable override is set
793
-
794
- Pre-FIX-095, the v2 view rendered '● IDLE' for all three states, leaving
795
- users unable to tell whether the loop was actually installed/enabled.
828
+ 'stale' — plist on disk but agent NOT registered in launchd
829
+ (happens after roll loop off + roll update without roll loop on)
830
+ 'enabled' — plist on disk AND registered in launchd
831
+
832
+ FIX-098: switched from `launchctl print-disabled` (disabled-overrides DB) to
833
+ `launchctl print gui/<uid>/<label>` which probes the actual launchd registry.
834
+ The old approach returned false-positive 'enabled' when the disabled-overrides
835
+ DB had no entry for the label (empty = not explicitly disabled, not loaded).
796
836
  """
797
837
  slug = project_slug()
798
838
  label = f"com.roll.loop.{slug}"
@@ -801,17 +841,17 @@ def _detect_install_state() -> str:
801
841
  return "not-installed"
802
842
  try:
803
843
  uid = os.getuid()
804
- out = subprocess.run(
805
- ["launchctl", "print-disabled", f"gui/{uid}"],
806
- capture_output=True, text=True, timeout=2,
807
- ).stdout or ""
808
- for line in out.splitlines():
809
- if f'"{label}"' in line and "=> disabled" in line:
810
- return "disabled"
844
+ result = subprocess.run(
845
+ ["launchctl", "print", f"gui/{uid}/{label}"],
846
+ capture_output=True, timeout=2,
847
+ )
848
+ if result.returncode == 0:
849
+ return "enabled"
850
+ return "stale"
811
851
  except Exception:
812
- # launchctl missing or timed out — best-effort fall through to enabled.
813
- pass
814
- return "enabled"
852
+ # launchctl missing or timed out — assume stale (safe: user sees STALE
853
+ # banner and is told to run 'roll loop on' to repair).
854
+ return "stale"
815
855
 
816
856
 
817
857
  def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
@@ -298,12 +298,19 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
298
298
  from datetime import datetime as _dt, timezone as _tz
299
299
  dur_s = int((_dt.now(_tz.utc) - cy["start"]).total_seconds())
300
300
  dur = fmt_dur(dur_s) if dur_s else "—"
301
- # US-VIEW-012: token column shows model's real work as input/output. Cache
302
- # creation / cache read are kept in events.ndjson for cost math but never
303
- # surface in the UI they would inflate the visible number to 10–100× the
304
- # "real" work done by the model on this cycle. fmt_tokens(0) already
305
- # returns "—", so a cycle missing usage_event prints as "—/—".
306
- tok = f"{fmt_tokens(cy.get('input_tokens') or 0)}/{fmt_tokens(cy.get('output_tokens') or 0)}"
301
+ # US-VIEW-017: show all 4 token components when cache data is available.
302
+ # Format: "in/cw↑ cr↓/out" (cache writes ↑, cache reads ↓).
303
+ # Falls back to "in/out" for cycles that predate cache tracking.
304
+ inp = cy.get('input_tokens') or 0
305
+ out_tok = cy.get('output_tokens') or 0
306
+ cw = cy.get('cache_creation_tokens') or 0
307
+ cr = cy.get('cache_read_tokens') or 0
308
+ if cw or cr:
309
+ tok = (f"{fmt_tokens(inp)}"
310
+ f"/{fmt_tokens(cw)}↑ {fmt_tokens(cr)}↓"
311
+ f"/{fmt_tokens(out_tok)}")
312
+ else:
313
+ tok = f"{fmt_tokens(inp)}/{fmt_tokens(out_tok)}"
307
314
  # cost prefers the backfilled list-price; falls back to cron.log when
308
315
  # the claude session log isn't available (only the latest cycle).
309
316
  if cy.get("cost_list") is not None:
@@ -343,14 +350,19 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
343
350
  "open": ("dim", "…"),
344
351
  }.get(pr_outcome, ("dim", "…"))
345
352
  pr_marker = " " + c(mark_c, f"#{pr_num} {mark_sym}")
353
+ # US-VIEW-014: pre-US-VIEW-014 events (no frozen cost_list_usd at
354
+ # cycle_end) get a muted [legacy] suffix — the number is recomputed on
355
+ # the fly and can shift with future price changes, unlike the frozen
356
+ # values written by current loop-fmt.
357
+ legacy_marker = " " + c("muted", "[legacy]") if cy.get("cost_list_legacy") else ""
346
358
  inner = (
347
359
  " " + c(glyph_c, glyph, bold=True) + " " +
348
360
  c(time_c, pad(time_str, 5), bold=(outcome == "fail")) + " " +
349
361
  c("muted", pad(dur, 4, "r")) + " " +
350
- c("muted", pad(tok, 11, "r")) + " " +
362
+ c("muted", pad(tok, 26)) + " " +
351
363
  model_seg +
352
364
  c("muted", pad(cost, 7, "r")) + " " +
353
- c(sid_c, ids_str, bold=True) + pr_marker
365
+ c(sid_c, ids_str, bold=True) + pr_marker + legacy_marker
354
366
  )
355
367
  # Subtle red bg on failure rows so a fail can't be missed at a glance.
356
368
  if outcome == "fail" and USE_COLOR:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.522.2",
3
+ "version": "2026.523.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -224,6 +224,65 @@ Add after `## 文档覆盖度` section:
224
224
  {发现内容列表 或 "文档新鲜度良好,无滞后或缺失项。"}
225
225
  ```
226
226
 
227
+ ### Scan 7 — Test Quality (rubric-driven)
228
+
229
+ Apply the test-quality rubric at [guide/en/testing/quality-rubric.md](../../guide/en/testing/quality-rubric.md)
230
+ (Chinese: [quality-rubric.zh.md](../../guide/zh/testing/quality-rubric.md)) against every file under
231
+ `tests/`. The rubric publishes six anti-pattern categories (❶..❻); each has a
232
+ **Signals** subsection that lists the matching heuristics. Scan 7 is purely a
233
+ mechanical apply-the-rubric step — no new logic.
234
+
235
+ **Per-category signals** — read from the rubric, summarized here:
236
+
237
+ | Marker | Anti-pattern | Cheapest signal |
238
+ |--------|--------------|-----------------|
239
+ | ❶ | Hardcoded business data | Bare numeric / version / pricing literal inside `[[ "$output" == *"..."*` that matches a value also defined in `lib/` |
240
+ | ❷ | Over-mocking real boundaries | `function git() {` / `function gh() {` overrides at the top of a unit test |
241
+ | ❸ | Asserting implementation details | `grep '_internal_helper'` against output; assertions on `.roll/internal/*` paths |
242
+ | ❹ | Fixture order coupling | `setup_file` writes shared mutable state without per-test reset |
243
+ | ❺ | Testing private functions | Test sources a `lib/` file and calls a `_underscore_prefixed` helper directly |
244
+ | ❻ | Asserting framework behavior | References to `$BATS_TEST_NUMBER`, `$BATS_SUITE_NAME` in assertions |
245
+
246
+ **Rate cap — 每轮 ≤ 5 条 test-quality REFACTOR entries**. Same dream cycle may
247
+ emit more than 5 findings; the dream scan must rank by severity (❶ > ❷ > ❸ > ❹ > ❺ > ❻
248
+ and within a class, by occurrence count) and only persist the top 5 to BACKLOG.
249
+ Remaining findings go into the dream log under `## 测试质量` but are not made
250
+ into REFACTOR rows — this prevents the backlog from being drowned in test-debt
251
+ on the first scan after rubric publication.
252
+
253
+ **REFACTOR entry format** — same as other scans, but tagged with category:
254
+
255
+ ```markdown
256
+ | REFACTOR-XXX | docs: <one-line description> [test-quality:❶] — flagged by dream YYYY-MM-DD | 📋 Todo |
257
+ ```
258
+
259
+ The `[test-quality:❶]` (through `❻`) tag is **required** so downstream filtering
260
+ (e.g. "show me all ❶ items still open") is mechanical. The marker character must
261
+ match the rubric exactly.
262
+
263
+ **Optional helper** — `bin/dream-test-quality-scan` is a thin shell script
264
+ maintainers can invoke ad-hoc to dry-run the ❶ detector against a single file
265
+ or directory (see `bin/dream-test-quality-scan --help`). The dream skill itself
266
+ does **not** depend on the helper — Scan 7 is the AI agent applying the rubric.
267
+ The helper just exists so a maintainer (or this skill's smoke test) can confirm
268
+ the ❶ heuristic still finds known instances.
269
+
270
+ #### Dream Log Section (Scan 7)
271
+
272
+ Add after `## 文档新鲜度` section:
273
+
274
+ ```markdown
275
+ ## 测试质量
276
+ - 本轮发现 {N} 项(写入 BACKLOG 的前 5 项见下;剩余 {M} 项仅记录于本日志)
277
+ - ❶ 硬编码业务数据:{count}
278
+ - ❷ 过度 mock:{count}
279
+ - ❸ 断言实现细节:{count}
280
+ - ❹ Fixture 顺序耦合:{count}
281
+ - ❺ 测私有函数:{count}
282
+ - ❻ 断言框架行为:{count}
283
+ {命中文件列表 或 "未发现可治理的测试反模式。"}
284
+ ```
285
+
227
286
  ## Output
228
287
 
229
288
  ### REFACTOR Entry (.roll/backlog.md)
@@ -118,9 +118,10 @@ Document structure (two-layer separation):
118
118
  **Important rules:**
119
119
  1. Plan files go in `.roll/features/<feature>-plan.md` (**no longer using** `docs/plans/`)
120
120
  2. US details go in the corresponding `.roll/features/<feature>.md`
121
- 3. .roll/backlog.md only contains index rows (one row per US), **do not write** AC / Files / Notes
122
- 4. Domain model files go in `.roll/domain/` create on first greenfield design, update incrementally
123
- 5. **Do not** write to `~/.kimi/` or any global config directory
121
+ 3. **FIX / IDEA detail files use ID-prefixed filenames**: `.roll/features/<epic>/FIX-097.md`, not `.roll/features/<epic>/some-descriptive-slug.md`. Reason: a single FIX is one card, not a long-lived feature; the ID is the most stable handle, descriptive slugs date quickly and break links. US can keep feature-slug naming (US lives inside a multi-Story feature file). Quick lookup: `ls .roll/features/<epic>/FIX-*.md` finds all bugs in that area without grepping content.
122
+ 4. .roll/backlog.md only contains index rows (one row per US), **do not write** AC / Files / Notes
123
+ 5. Domain model files go in `.roll/domain/` create on first greenfield design, update incrementally
124
+ 6. **Do not** write to `~/.kimi/` or any global config directory
124
125
 
125
126
  **File path resolution order:**
126
127
  1. Determine Feature ownership (based on the requirement domain: compiler / ingest / qa / ...)
@@ -29,7 +29,7 @@ $roll-notes 今天的 code review 给了很好的反馈
29
29
 
30
30
  ## Behavior
31
31
 
32
- 1. **Determine file path**: `notes/YYYY-MM-DD.md` relative to project root
32
+ 1. **Determine file path**: `.roll/notes/YYYY-MM-DD.md` relative to project root (parallel to `.roll/dream/` and `.roll/briefs/` — notes is project metadata, not source)
33
33
  2. **Get current time**: Use `Asia/Shanghai` timezone (`TZ=Asia/Shanghai date`)
34
34
  3. **Read existing entries for style**: Before writing, read the last 2–3 entries
35
35
  in the same file. Analyze their style: heading format, voice/tone,
@@ -95,6 +95,9 @@ $roll-notes 今天的 code review 给了很好的反馈
95
95
  ## File location
96
96
 
97
97
  ```
98
- notes/
99
- └── YYYY-MM-DD.md
98
+ .roll/
99
+ └── notes/
100
+ └── YYYY-MM-DD.md
100
101
  ```
102
+
103
+ 注:notes 是项目元数据(与 `.roll/dream/` / `.roll/briefs/` 同级),不入 git;由 dream/brief 等下游 skill 跨日聚合。