diff --git a/skills/shadow-frog-dream/dream-reconcile.py b/skills/shadow-frog-dream/dream-reconcile.py index 524fe7f..ded8f1b 100755 --- a/skills/shadow-frog-dream/dream-reconcile.py +++ b/skills/shadow-frog-dream/dream-reconcile.py @@ -824,6 +824,67 @@ def _resolve_tip_commit(repo_root, branch): return 'unknown' +def _manifest_lineage_values(manifest, key): + """Return non-empty string values for one manifest lineage key.""" + value = manifest.get(key) or '' + values = value if isinstance(value, list) else [value] + return [item.strip() for item in values + if isinstance(item, str) and item.strip()] + + +def _resolve_remote_parent_branch(repo_root, parent_ref): + """Resolve a dream ID or branch reference to one remote dream branch.""" + dream_id = parent_ref.rsplit('/', 1)[-1] + raw = git( + 'for-each-ref', + '--format=%(refname:strip=3)', + f'refs/remotes/origin/dream/*/{dream_id}', + cwd=repo_root, + check=False, + ) + matches = [line.strip() for line in raw.splitlines() if line.strip()] + if parent_ref.startswith('dream/') and parent_ref in matches: + return parent_ref + return matches[0] if len(matches) == 1 else '' + + +def _report_parent_branch(report): + """Read parent_branch from report frontmatter.""" + if not report: + return '' + frontmatter = re.match(r'^\ufeff?\s*---\r?\n(.*?)\r?\n---', report, re.S) + if not frontmatter: + return '' + match = re.search( + r'^parent_branch:\s*["\']?([^"\'\r\n]+)', + frontmatter.group(1), + re.M, + ) + parent = match.group(1).strip() if match else '' + return '' if parent.lower() in ('null', '~') else parent + + +def _resolve_parent_branch(repo_root, branch, dream_id, manifest): + """Resolve manifest/report lineage to the canonical parent branch.""" + for key in ('parent_branch', 'base_branch'): + values = _manifest_lineage_values(manifest, key) + if values: + return values[0] + + for key in ('parent_dream_id', 'builds_on'): + for parent_ref in _manifest_lineage_values(manifest, key): + parent = _resolve_remote_parent_branch(repo_root, parent_ref) + if parent: + return parent + + report = git_show( + f'origin/{branch}', + f'.shadow/_dreams/{dream_id}/report.md', + cwd=repo_root, + ) + return _report_parent_branch(report) or 'main' + + def update_index(repo_root, manifests, dry_run=False): """Add entries to _dreams/_index.md for reconciled branches.""" index_path = os.path.join(repo_root, '.shadow', '_dreams', '_index.md') @@ -845,12 +906,18 @@ def update_index(repo_root, manifests, dry_run=False): with open(index_path, 'a', encoding="utf-8") as f: for branch, dream_id, manifest in manifests: tip = _resolve_tip_commit(repo_root, branch) - cat = re.sub(r'\s*\(.*\)\s*$', '', manifest.get('category', 'unknown').lower().strip()) - verdict = manifest.get('verdict', 'unknown').lower().strip() - parent = manifest.get('parent_branch', 'main').strip() + cat = re.sub( + r'\s*\(.*\)\s*$', + '', + (manifest.get('category') or 'unknown').lower().strip(), + ) + verdict = (manifest.get('verdict') or 'unknown').lower().strip() + parent = _resolve_parent_branch( + repo_root, branch, dream_id, manifest + ) # Get title from manifest or report heading - title = manifest.get('title', '') + title = manifest.get('title') or '' if not title: report = git_show(f'origin/{branch}', f'.shadow/_dreams/{dream_id}/report.md', cwd=repo_root) diff --git a/skills/shadow-frog-meditate/meditate-repair.py b/skills/shadow-frog-meditate/meditate-repair.py index 7b632f3..9e64d50 100755 --- a/skills/shadow-frog-meditate/meditate-repair.py +++ b/skills/shadow-frog-meditate/meditate-repair.py @@ -23,10 +23,10 @@ signals so 'not useful' doesn't match 'useful'. 4. Title repair: replace generic titles (raw slug, "Dream Report: ", "Dream t##: ") with the first H1 or first ## Summary line. -5. Parent linkage from manifest: for rows where parent is 'main', check - manifest.json and report.md frontmatter for a different parent_branch. - Validate the parent exists in the index (match by slug if timestamps - differ). +5. Parent linkage from metadata: for rows where parent is 'main', check + manifest parent_branch, base_branch, parent_dream_id/builds_on, then + report.md frontmatter parent_branch. Validate the parent exists in the + index (match by slug if timestamps differ). 6. Parent linkage from slug heuristics: for rows still parented to 'main' with no manifest info, infer from compounding suffixes (-extend, -fix, -deeper, -improve, -integration, -cleanup, -metrics, -remaining). @@ -178,6 +178,28 @@ def resolve_parent_in_index( return None +def manifest_lineage_values(manifest: dict, key: str) -> list[str]: + """Return non-empty string values for one manifest lineage key.""" + value = manifest.get(key) or '' + values = value if isinstance(value, list) else [value] + return [item.strip() for item in values + if isinstance(item, str) and item.strip()] + + +def report_parent_branch(content: str) -> str: + """Read parent_branch from report frontmatter.""" + frontmatter = re.match(r'^\ufeff?\s*---\r?\n(.*?)\r?\n---', content, re.S) + if not frontmatter: + return '' + match = re.search( + r'^parent_branch:\s*["\']?([^"\'\r\n]+)', + frontmatter.group(1), + re.M, + ) + parent = match.group(1).strip() if match else '' + return '' if parent.lower() in ('null', '~') else parent + + def repair_parent( parts: list[str], dreams_dir: str, all_dream_ids: list[str], branch_by_dream_id: dict[str, str], @@ -201,33 +223,43 @@ def repair_parent( # Step 10: manifest.json lookup manifest_path = os.path.join(dream_dir, 'manifest.json') - parent_branch_raw: str | None = None if os.path.exists(manifest_path): try: with open(manifest_path, encoding="utf-8") as mf: mdata = json.load(mf) - pb = mdata.get('parent_branch', '').strip() - if pb and pb != 'main': - parent_branch_raw = pb + if isinstance(mdata, dict): + for key in ( + 'parent_branch', 'base_branch', + 'parent_dream_id', 'builds_on', + ): + for parent_ref in manifest_lineage_values(mdata, key): + if parent_ref == 'main': + return False + candidate_id = parent_ref.rsplit('/', 1)[-1] + resolved_did = resolve_parent_in_index( + candidate_id, all_dream_ids + ) + if resolved_did is not None: + break + if resolved_did is not None: + break except (OSError, json.JSONDecodeError): pass # Fallback: report.md frontmatter - if parent_branch_raw is None: + if resolved_did is None: report_path = os.path.join(dream_dir, 'report.md') if os.path.exists(report_path): with open(report_path, encoding="utf-8") as rf: rcontent = rf.read() - fm_match = re.search(r'parent_branch:\s*["\']?([^"\'\n]+)', rcontent) - if fm_match: - pb = fm_match.group(1).strip() - if pb and pb != 'main': - parent_branch_raw = pb - - if parent_branch_raw: - # Extract dream_id from branch path (last /-separated segment) - candidate_id = parent_branch_raw.split('/')[-1] - resolved_did = resolve_parent_in_index(candidate_id, all_dream_ids) + parent_ref = report_parent_branch(rcontent) + if parent_ref == 'main': + return False + if parent_ref: + candidate_id = parent_ref.rsplit('/', 1)[-1] + resolved_did = resolve_parent_in_index( + candidate_id, all_dream_ids + ) # Step 11: slug heuristic (only if step 10 didn't resolve anything) if resolved_did is None: diff --git a/tests/skills/shadow_frog_dream/test_dream_reconcile.py b/tests/skills/shadow_frog_dream/test_dream_reconcile.py index 6c83b00..c13b9fa 100644 --- a/tests/skills/shadow_frog_dream/test_dream_reconcile.py +++ b/tests/skills/shadow_frog_dream/test_dream_reconcile.py @@ -1898,6 +1898,123 @@ def test_update_index_normalizes_category_with_parens( assert "(notes" not in row +@pytest.mark.slow +def test_update_index_uses_base_branch_when_parent_branch_is_null( + dream_reconcile, tmp_git_repo +): + env = _seed_repo(tmp_git_repo) + _add_bare_remote(tmp_git_repo, env) + parent_id = "20260420-030800Z-parent" + child_id = "20260420-030900Z-child" + parent_branch = make_dream_branch( + tmp_git_repo, env, "proj", parent_id, _default_manifest(parent_id) + ) + child_manifest = _default_manifest(child_id) + child_manifest["parent_branch"] = None + child_manifest["base_branch"] = parent_branch + child_branch = make_dream_branch( + tmp_git_repo, env, "proj", child_id, child_manifest + ) + + dream_reconcile.update_index( + str(tmp_git_repo), [(child_branch, child_id, child_manifest)] + ) + + body = (tmp_git_repo / ".shadow" / "_dreams" / "_index.md").read_text( + encoding="utf-8" + ) + row = [line for line in body.splitlines() if child_id in line][0] + assert f"| {child_branch} | {parent_branch} |" in row + + +@pytest.mark.slow +@pytest.mark.parametrize( + ("lineage_key", "as_list"), + [("parent_dream_id", False), ("builds_on", True)], +) +def test_update_index_resolves_parent_id_from_remote_branch( + dream_reconcile, tmp_git_repo, lineage_key, as_list +): + env = _seed_repo(tmp_git_repo) + _add_bare_remote(tmp_git_repo, env) + parent_id = "20260420-031000Z-parent" + child_id = "20260420-031100Z-child" + parent_branch = make_dream_branch( + tmp_git_repo, env, "proj", parent_id, _default_manifest(parent_id) + ) + child_manifest = _default_manifest(child_id) + del child_manifest["parent_branch"] + child_manifest[lineage_key] = [parent_id] if as_list else parent_id + child_branch = make_dream_branch( + tmp_git_repo, env, "proj", child_id, child_manifest + ) + + dream_reconcile.update_index( + str(tmp_git_repo), [(child_branch, child_id, child_manifest)] + ) + + body = (tmp_git_repo / ".shadow" / "_dreams" / "_index.md").read_text( + encoding="utf-8" + ) + row = [line for line in body.splitlines() if child_id in line][0] + assert f"| {child_branch} | {parent_branch} |" in row + + +@pytest.mark.slow +def test_update_index_falls_back_to_report_parent( + dream_reconcile, tmp_git_repo +): + env = _seed_repo(tmp_git_repo) + _add_bare_remote(tmp_git_repo, env) + parent_id = "20260420-031200Z-parent" + child_id = "20260420-031300Z-child" + parent_branch = make_dream_branch( + tmp_git_repo, env, "proj", parent_id, _default_manifest(parent_id) + ) + child_manifest = _default_manifest(child_id) + del child_manifest["parent_branch"] + report = _default_report(child_id).replace( + "---\n\n", f'parent_branch: "{parent_branch}"\n---\n\n' + ) + child_branch = make_dream_branch( + tmp_git_repo, env, "proj", child_id, child_manifest, report=report + ) + + dream_reconcile.update_index( + str(tmp_git_repo), [(child_branch, child_id, child_manifest)] + ) + + body = (tmp_git_repo / ".shadow" / "_dreams" / "_index.md").read_text( + encoding="utf-8" + ) + row = [line for line in body.splitlines() if child_id in line][0] + assert f"| {child_branch} | {parent_branch} |" in row + + +@pytest.mark.slow +def test_update_index_without_lineage_records_main( + dream_reconcile, tmp_git_repo +): + env = _seed_repo(tmp_git_repo) + _add_bare_remote(tmp_git_repo, env) + dream_id = "20260420-031400Z-root" + manifest = _default_manifest(dream_id) + del manifest["parent_branch"] + branch = make_dream_branch( + tmp_git_repo, env, "proj", dream_id, manifest, report=None + ) + + dream_reconcile.update_index( + str(tmp_git_repo), [(branch, dream_id, manifest)] + ) + + body = (tmp_git_repo / ".shadow" / "_dreams" / "_index.md").read_text( + encoding="utf-8" + ) + row = [line for line in body.splitlines() if dream_id in line][0] + assert f"| {branch} | main |" in row + + # =========================================================================== # update_state # =========================================================================== diff --git a/tests/skills/shadow_frog_meditate/test_meditate_repair.py b/tests/skills/shadow_frog_meditate/test_meditate_repair.py index a861181..41a66c8 100644 --- a/tests/skills/shadow_frog_meditate/test_meditate_repair.py +++ b/tests/skills/shadow_frog_meditate/test_meditate_repair.py @@ -265,6 +265,119 @@ def test_manifest_provides_parent(self, meditate_repair, tmp_path): # Parent column is a BRANCH NAME (the resolved row's branch), not a dream_id. assert parts[6] == f"dream/proj/{parent_did}" + def test_null_parent_branch_falls_back_to_base_branch( + self, meditate_repair, tmp_path + ): + dreams = tmp_path / "_dreams" + parent_did = "20250101-120000Z-base" + child_did = "20250102-120000Z-child" + make_dream(dreams, parent_did) + make_dream( + dreams, + child_did, + manifest={ + "parent_branch": None, + "base_branch": f"dream/proj/{parent_did}", + }, + ) + + all_ids = [parent_did, child_did] + bmap = {d: f"dream/proj/{d}" for d in all_ids} + parts = ["", child_did, "investigation", "useful", "Child", + f"dream/proj/{child_did}", "main", "abc"] + + changed = meditate_repair.repair_parent( + parts, str(dreams), all_ids, bmap + ) + + assert changed + assert parts[6] == f"dream/proj/{parent_did}" + + @pytest.mark.parametrize( + ("lineage_key", "as_list"), + [("parent_dream_id", False), ("builds_on", True)], + ) + def test_manifest_parent_id_fields_resolve_through_index( + self, meditate_repair, tmp_path, lineage_key, as_list + ): + dreams = tmp_path / "_dreams" + parent_did = "20250101-120000Z-base" + child_did = "20250102-120000Z-child" + make_dream(dreams, parent_did) + parent_ref = [parent_did] if as_list else parent_did + make_dream( + dreams, child_did, manifest={lineage_key: parent_ref} + ) + + all_ids = [parent_did, child_did] + bmap = {d: f"dream/proj/{d}" for d in all_ids} + parts = ["", child_did, "investigation", "useful", "Child", + f"dream/proj/{child_did}", "main", "abc"] + + changed = meditate_repair.repair_parent( + parts, str(dreams), all_ids, bmap + ) + + assert changed + assert parts[6] == f"dream/proj/{parent_did}" + + def test_report_parent_branch_is_last_metadata_fallback( + self, meditate_repair, tmp_path + ): + dreams = tmp_path / "_dreams" + parent_did = "20250101-120000Z-base" + child_did = "20250102-120000Z-child" + make_dream(dreams, parent_did) + make_dream( + dreams, + child_did, + manifest={"parent_branch": None}, + report=( + "---\n" + f'dream_id: "{child_did}"\n' + f'parent_branch: "dream/proj/{parent_did}"\n' + "---\n# Child\n" + ), + ) + + all_ids = [parent_did, child_did] + bmap = {d: f"dream/proj/{d}" for d in all_ids} + parts = ["", child_did, "investigation", "useful", "Child", + f"dream/proj/{child_did}", "main", "abc"] + + changed = meditate_repair.repair_parent( + parts, str(dreams), all_ids, bmap + ) + + assert changed + assert parts[6] == f"dream/proj/{parent_did}" + + def test_unconfirmed_manifest_parent_keeps_main( + self, meditate_repair, tmp_path + ): + dreams = tmp_path / "_dreams" + child_did = "20250102-120000Z-child" + make_dream( + dreams, + child_did, + manifest={ + "parent_branch": None, + "base_branch": "dream/proj/20250101-120000Z-missing", + }, + ) + parts = ["", child_did, "investigation", "useful", "Child", + f"dream/proj/{child_did}", "main", "abc"] + + changed = meditate_repair.repair_parent( + parts, + str(dreams), + [child_did], + {child_did: f"dream/proj/{child_did}"}, + ) + + assert not changed + assert parts[6] == "main" + def test_missing_manifest_and_slug_heuristic(self, meditate_repair, tmp_path): """Step 11: slug heuristic with compounding suffix.""" dreams = tmp_path / "_dreams"