Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .work/todo.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,15 @@
{"actor":"grok-build/wiki-publish","ev":"01M19SADGD9V8449YW7JZW4HW6","item":"01M19SADGDHJPCXX5MK4A76ZE5","op":"create","set":{"body":"Worklog-spec step 8: publish the GitHub wiki from docs/.index/publish-manifest.json after v0.9.0 and marketplace 0.4.4. Recast retrieval-ladder as a living guide so it does not collide with Code-Walkthrough.","kind":"ops","level":"story","priority":"P2","status":"todo","title":"Publish v0.9.0 wiki pages"},"ts":"2026-08-30T16:51:22Z"}
{"actor":"grok-build/wiki-publish","ev":"01M19SBCRR96E9536RS3WZ35DH","item":"01M19SADGDHJPCXX5MK4A76ZE5","op":"update","set":{"status":"in_progress"},"ts":"2026-08-30T16:51:54Z"}
{"actor":"grok-build/wiki-publish","ev":"01M19SD8CKVHA64PQG035Q3NQ5","item":"01M19SADGDHJPCXX5MK4A76ZE5","op":"close","set":{"resolution":"wiki published: https://github.com/SpillwaveSolutions/project-knowledge-capture/wiki/Retrieval-ladder (wiki rev ce98663); catalog 0.4.4 already pinned","status":"done"},"ts":"2026-08-30T16:52:55Z"}
{"actor":"richardhightower","ev":"01M1CEEP1E9HC22TFTDKNBAVM4","item":"01M1CEEP1EG2793CSYT4AJC8K5","op":"create","set":{"body":"Make materialization previews strictly side-effect-free while preserving clear planned-action output and regression coverage.","kind":"feature","level":"epic","plan":"docs/plans/2026-08-31-fix-issue-69-dry-run.md","priority":"P1","status":"todo","title":"Fix issue #69 dry-run writes"},"ts":"2026-08-31T17:39:11Z"}
{"actor":"richardhightower","ev":"01M1CEEP1EAXF85TTCVQ89NWW4","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"create","set":{"body":"Confirm concept, catalog, log, and write-event behavior before changing implementation.","kind":"feature","level":"task","parent":"01M1CEEP1EG2793CSYT4AJC8K5","plan":"docs/plans/2026-08-31-fix-issue-69-dry-run.md","priority":"P2","status":"todo","title":"Reproduce dry-run filesystem mutations"},"ts":"2026-08-31T17:39:11Z"}
{"actor":"richardhightower","ev":"01M1CEEP1EGEGT6W7J4AKEKB7W","item":"01M1CEEP1E29BXX7PS90ZQ44R8","op":"create","set":{"body":"Report planned actions without creating, updating, or deleting bundle content.","kind":"feature","level":"task","parent":"01M1CEEP1EG2793CSYT4AJC8K5","plan":"docs/plans/2026-08-31-fix-issue-69-dry-run.md","priority":"P2","status":"todo","title":"Implement side-effect-free dry-run behavior"},"ts":"2026-08-31T17:39:11Z"}
{"actor":"richardhightower","ev":"01M1CEEP1E7ZQJ5Y7D7BZGG8GT","item":"01M1CEEP1EBGFT5DR8TJRGBC5M","op":"create","set":{"body":"Snapshot the target tree and verify all dry-run output modes leave it unchanged.","kind":"feature","level":"task","parent":"01M1CEEP1EG2793CSYT4AJC8K5","plan":"docs/plans/2026-08-31-fix-issue-69-dry-run.md","priority":"P2","status":"todo","title":"Add and run regression coverage"},"ts":"2026-08-31T17:39:11Z"}
{"actor":"richardhightower","ev":"01M1CEFN30X99S04ZEJJVMHFYM","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:39:43Z"}
{"actor":"richardhightower","ev":"01M1CF0544EZ9W1M17ZNJRKQT8","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"}
{"actor":"richardhightower","ev":"01M1CF058FCJMMPWB7DTVFW7B8","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"}
{"actor":"richardhightower","ev":"01M1CF05DTWPCA4XJKQM3BBQFZ","item":"01M1CEEP1E29BXX7PS90ZQ44R8","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"}
{"actor":"richardhightower","ev":"01M1CF05M57TJM9X3XVZWJW0VP","item":"01M1CEEP1E29BXX7PS90ZQ44R8","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"}
{"actor":"richardhightower","ev":"01M1CF05SSATV2H6HDDYGTPGQP","item":"01M1CEEP1EBGFT5DR8TJRGBC5M","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"}
{"actor":"richardhightower","ev":"01M1CF05YVB15M9QDPG9644SKY","item":"01M1CEEP1EBGFT5DR8TJRGBC5M","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"}
{"actor":"richardhightower","ev":"01M1CF063XA8WBH3RWYYG4NRXM","item":"01M1CEEP1EG2793CSYT4AJC8K5","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:45Z"}
16 changes: 16 additions & 0 deletions docs/plans/2026-08-31-fix-issue-69-dry-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
date: 2026-08-31
slug: fix-issue-69-dry-run
title: Fix issue #69 dry-run writes
epic: 01M1CEEP1EG2793CSYT4AJC8K5
items: [01M1CEEP1EJMGSW2DP0PZHMM0B, 01M1CEEP1E29BXX7PS90ZQ44R8, 01M1CEEP1EBGFT5DR8TJRGBC5M]
---

## Tasks

- [ ] Reproduce dry-run filesystem mutations
Confirm concept, catalog, log, and write-event behavior before changing implementation.
- [ ] Implement side-effect-free dry-run behavior
Report planned actions without creating, updating, or deleting bundle content.
- [ ] Add and run regression coverage
Snapshot the target tree and verify all dry-run output modes leave it unchanged.
4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
wiki_key: roadmap
doc_type: roadmap
truth_state: current
source_hash: 8de8b50a
generated_at: 2026-08-30T16:52:55Z
source_hash: e86bd157
generated_at: 2026-08-31T17:48:45Z
---

<!-- GENERATED by worklog roadmap-render. DO NOT EDIT. -->
Expand Down
18 changes: 12 additions & 6 deletions scripts/pkc_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,13 +459,14 @@ def write_concept(
*,
merge: bool = True,
create_only: bool = False,
dry_run: bool = False,
) -> tuple[Path, str]:
"""Write a concept. Returns (path, action).

action is one of "created", "updated", "skipped", "exists", "refused".
With ``dry_run=True``, compute the same action without changing the filesystem.
"""
path = bundle / rel_path.lstrip("/")
path.parent.mkdir(parents=True, exist_ok=True)
if "timestamp" not in frontmatter:
frontmatter = {**frontmatter, "timestamp": utc_now()}
if path.is_file():
Expand All @@ -489,15 +490,19 @@ def write_concept(
content = dump_frontmatter(new_fm) + "\n" + (body.strip() or old_body).rstrip() + "\n"
if content == existing:
return path, "skipped"
path.write_text(content, encoding="utf-8")
if not dry_run:
path.write_text(content, encoding="utf-8")
return path, "updated"
content = dump_frontmatter(frontmatter) + "\n" + body.rstrip() + "\n"
if content == existing:
return path, "skipped"
path.write_text(content, encoding="utf-8")
if not dry_run:
path.write_text(content, encoding="utf-8")
return path, "updated"
fm = {k: v for k, v in frontmatter.items() if k not in ("force", "stable_timestamp")}
path.write_text(dump_frontmatter(fm) + "\n" + body.rstrip() + "\n", encoding="utf-8")
if not dry_run:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(dump_frontmatter(fm) + "\n" + body.rstrip() + "\n", encoding="utf-8")
return path, "created"


Expand Down Expand Up @@ -927,6 +932,7 @@ def write_knowledge(
merge: bool = True,
create_only: bool = False,
emit_event: bool = True,
dry_run: bool = False,
) -> tuple[Path, str]:
"""Stamp author, write via write_concept, emit WriteEvent on created/updated.

Expand All @@ -936,9 +942,9 @@ def write_knowledge(
resolve_author(author)
fm = {**frontmatter, "author": author}
path, action = write_concept(
bundle, rel_path, fm, body, merge=merge, create_only=create_only
bundle, rel_path, fm, body, merge=merge, create_only=create_only, dry_run=dry_run
)
if emit_event and action in ("created", "updated"):
if not dry_run and emit_event and action in ("created", "updated"):
emit_write_event(
bundle,
author=author,
Expand Down
119 changes: 95 additions & 24 deletions scripts/pkc_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def materialize_item(
include: set[str],
author: str,
force: bool = False,
dry_run: bool = False,
) -> list[tuple[str, str, str]]:
results: list[tuple[str, str, str]] = []
fingerprint = item_fingerprint(item)
Expand Down Expand Up @@ -195,7 +196,9 @@ def materialize_item(
body += f"- Worklog ULID: `{ulid}`\n"
if external_id:
body += f"- External: {external_system} `{external_id}`\n"
_, action = write_knowledge(bundle, feature_rel, fm, body, author=author)
_, action = write_knowledge(
bundle, feature_rel, fm, body, author=author, dry_run=dry_run
)
results.append((feature_rel, action, "Feature"))

trel: str | None = None
Expand Down Expand Up @@ -234,7 +237,9 @@ def materialize_item(
if external_id:
tbody += f"- System: {external_system}\n- ID: `{external_id}`\n"
tbody += f"\n## Status\n\n`{status}` · level `{level}` · kind `{kind}`\n"
_, action = write_knowledge(bundle, trel, tfm, tbody, author=author)
_, action = write_knowledge(
bundle, trel, tfm, tbody, author=author, dry_run=dry_run
)
results.append((trel, action, "TicketLink"))

work_type = work_concept_type(level, kind)
Expand Down Expand Up @@ -299,7 +304,9 @@ def materialize_item(
wbody += "\n" + body_src.strip() + "\n"
if ulid:
wbody += f"\n## Provenance\n\n- Worklog ULID: `{ulid}`\n"
_, action = write_knowledge(bundle, wrel, wfm, wbody, author=author)
_, action = write_knowledge(
bundle, wrel, wfm, wbody, author=author, dry_run=dry_run
)
results.append((wrel, action, work_type))

branch_name = item.get("branch")
Expand Down Expand Up @@ -332,7 +339,9 @@ def materialize_item(
if force:
bfm["force"] = True
bbody = f"# {branch_name}\n\nSource-control branch materialized from a work item.\n"
_, action = write_knowledge(bundle, brel, bfm, bbody, author=author)
_, action = write_knowledge(
bundle, brel, bfm, bbody, author=author, dry_run=dry_run
)
results.append((brel, action, "Branch"))

return results
Expand All @@ -345,6 +354,7 @@ def materialize_docs(
include: set[str],
author: str,
force: bool = False,
dry_run: bool = False,
) -> list[tuple[str, str, str]]:
results: list[tuple[str, str, str]] = []
docs = repo / "docs"
Expand All @@ -356,28 +366,40 @@ def materialize_docs(
for path in docs.glob(pattern):
if path.name.lower() in ("index.md", "readme.md"):
continue
results.extend(_doc_to_concept(path, bundle, "DecisionRecord", "decisions", force, author))
results.extend(
_doc_to_concept(
path, bundle, "DecisionRecord", "decisions", force, author, dry_run
)
)

if "designs" in include:
for pattern in ("**/design*/**/*.md", "**/walkthrough*/**/*.md"):
for path in docs.glob(pattern):
if path.name.lower() in ("index.md", "readme.md"):
continue
results.extend(_doc_to_concept(path, bundle, "Design", "designs", force, author))
results.extend(
_doc_to_concept(path, bundle, "Design", "designs", force, author, dry_run)
)

if "specs" in include:
for pattern in ("**/plans/**/*.md", "**/plan*.md"):
for path in docs.glob(pattern):
if path.name.lower() in ("index.md", "readme.md"):
continue
results.extend(_doc_to_concept(path, bundle, "Specification", "specs", force, author))
results.extend(
_doc_to_concept(
path, bundle, "Specification", "specs", force, author, dry_run
)
)

if "releases" in include:
for pattern in ("**/releases/**/*.md",):
for path in docs.glob(pattern):
if path.name.lower() in ("index.md", "readme.md"):
continue
results.extend(_doc_to_concept(path, bundle, "Release", "releases", force, author))
results.extend(
_doc_to_concept(path, bundle, "Release", "releases", force, author, dry_run)
)

# de-dupe by path (glob overlaps)
seen: set[str] = set()
Expand All @@ -397,6 +419,7 @@ def _doc_to_concept(
catalog: str,
force: bool,
author: str,
dry_run: bool,
) -> list[tuple[str, str, str]]:
text = path.read_text(encoding="utf-8")
body = text
Expand Down Expand Up @@ -427,7 +450,9 @@ def _doc_to_concept(
if force:
fm["force"] = True
content_body = f"# {title}\n\n> Source: `{path.as_posix()}`\n\n{body.strip()}\n"
_, action = write_knowledge(bundle, rel, fm, content_body, author=author)
_, action = write_knowledge(
bundle, rel, fm, content_body, author=author, dry_run=dry_run
)
return [(rel, action, concept_type)]


Expand All @@ -437,8 +462,27 @@ def _doc_to_concept(
# index would say, so none of them justify rewriting one.
WROTE = ("created", "updated")

DRY_RUN_ACTIONS = {
"created": "would_create",
"updated": "would_update",
"skipped": "would_skip",
"exists": "would_exist",
"refused": "would_refuse",
"unchanged": "unchanged",
}


def report_entry(
rel: str, action: str, kind: str, *, dry_run: bool
) -> dict[str, Any]:
result: dict[str, Any] = {"path": rel, "action": action, "type": kind}
if dry_run:
result["action"] = DRY_RUN_ACTIONS.get(action, f"would_{action}")
result["planned"] = True
return result


def catalogs_touched(report: list[dict[str, str]]) -> set[str]:
def catalogs_touched(report: list[dict[str, Any]]) -> set[str]:
cats: set[str] = set()
for r in report:
if r["action"] not in WROTE:
Expand Down Expand Up @@ -477,7 +521,7 @@ def main(argv: list[str] | None = None) -> int:
else:
author = args.author or "dry-run"

report: list[dict[str, str]] = []
report: list[dict[str, Any]] = []
if from_worklog:
fold = load_fold(args.fold)
for item in fold.get("items") or []:
Expand All @@ -486,15 +530,25 @@ def main(argv: list[str] | None = None) -> int:
if item_status(item) == "cancelled" and not args.force:
continue
for rel, action, kind in materialize_item(
bundle, item, include=include, author=author, force=args.force
bundle,
item,
include=include,
author=author,
force=args.force,
dry_run=args.dry_run,
):
report.append({"path": rel, "action": action, "type": kind})
report.append(report_entry(rel, action, kind, dry_run=args.dry_run))

if args.from_docs:
for rel, action, kind in materialize_docs(
repo, bundle, include=include, author=author, force=args.force
repo,
bundle,
include=include,
author=author,
force=args.force,
dry_run=args.dry_run,
):
report.append({"path": rel, "action": action, "type": kind})
report.append(report_entry(rel, action, kind, dry_run=args.dry_run))

if not args.dry_run:
for cat in sorted(catalogs_touched(report)):
Expand All @@ -515,21 +569,38 @@ def main(argv: list[str] | None = None) -> int:
)

if args.json:
print(json.dumps({"bundle": str(bundle), "results": report}, indent=2))
print(
json.dumps(
{"bundle": str(bundle), "dry_run": args.dry_run, "results": report},
indent=2,
)
)
else:
print(f"Bundle: {bundle}")
counts = {"created": 0, "updated": 0, "skipped": 0, "unchanged": 0, "refused": 0}
if args.dry_run:
print("Dry run: no changes applied")
counts: dict[str, int] = {}
for r in report:
counts[r["action"]] = counts.get(r["action"], 0) + 1
print(f" [{r['action']:9}] {r['type']:14} {r['path']}")
action = str(r["action"])
counts[action] = counts.get(action, 0) + 1
print(f" [{action:13}] {r['type']:14} {r['path']}")
# `unchanged` = short-circuited on fingerprint, never rendered.
# `skipped` = rendered, compared, found identical (or truth_state barrier).
# The split is what lets CI prove incremental materialize actually works.
print(
f"Summary: {counts.get('created', 0)} created, "
f"{counts.get('updated', 0)} updated, {counts.get('skipped', 0)} skipped, "
f"{counts.get('unchanged', 0)} unchanged"
)
if args.dry_run:
print(
f"Summary: {counts.get('would_create', 0)} would create, "
f"{counts.get('would_update', 0)} would update, "
f"{counts.get('would_skip', 0)} would skip, "
f"{counts.get('unchanged', 0)} unchanged"
)
else:
print(
f"Summary: {counts.get('created', 0)} created, "
f"{counts.get('updated', 0)} updated, "
f"{counts.get('skipped', 0)} skipped, "
f"{counts.get('unchanged', 0)} unchanged"
)
return 0


Expand Down
Loading
Loading