diff --git a/src/pkgmgr/actions/archive/__init__.py b/src/pkgmgr/actions/archive/__init__.py
new file mode 100644
index 0000000..2d3f841
--- /dev/null
+++ b/src/pkgmgr/actions/archive/__init__.py
@@ -0,0 +1,31 @@
+"""Archive fully-checked Markdown files into a README index, then delete them.
+
+The archive action walks a directory for numbered ``NNN-topic.md`` files
+(default pattern ``^\\d{3}-[^/]+\\.md$``), promotes every file with zero
+unchecked ``- [ ]`` task-list markers into a ``## Archive`` index inside
+the directory README, and deletes the per-file source. Useful for
+keeping ``docs/requirements/`` (or any other task-tracked spec folder)
+short and focused on open work.
+
+The module was extracted from
+``cli/contributing/requirements/archive`` in infinito-nexus-core so
+every kpmx-managed repository can rely on the same archival convention
+without copy-pasting the helpers.
+"""
+
+from __future__ import annotations
+
+from .discovery import iter_archivable_files
+from .inspect import count_unchecked_items, extract_h1
+from .readme import existing_archive_entries, merge_archive_section
+from .workflow import ArchivePlan, run_archive
+
+__all__ = [
+ "ArchivePlan",
+ "count_unchecked_items",
+ "existing_archive_entries",
+ "extract_h1",
+ "iter_archivable_files",
+ "merge_archive_section",
+ "run_archive",
+]
diff --git a/src/pkgmgr/actions/archive/discovery.py b/src/pkgmgr/actions/archive/discovery.py
new file mode 100644
index 0000000..dc5cf40
--- /dev/null
+++ b/src/pkgmgr/actions/archive/discovery.py
@@ -0,0 +1,53 @@
+"""Locate archivable Markdown files under a target directory."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Iterable
+
+DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
+TEMPLATE_FILENAME = "000-template.md"
+
+
+def iter_archivable_files(
+ directory: Path,
+ *,
+ include_template: bool = False,
+ pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
+ template_filename: str = TEMPLATE_FILENAME,
+) -> list[Path]:
+ """Return all files in *directory* whose name matches *pattern*, sorted.
+
+ ``000-template.md`` (or whatever *template_filename* matches) is
+ excluded unless *include_template* is true. The check is filename
+ based; nested directories are not traversed.
+ """
+ if not directory.is_dir():
+ return []
+ files: list[Path] = []
+ for path in sorted(directory.iterdir()):
+ if not path.is_file() or not pattern.match(path.name):
+ continue
+ if not include_template and path.name == template_filename:
+ continue
+ files.append(path)
+ return files
+
+
+def filter_archivable_files(
+ paths: Iterable[Path],
+ *,
+ include_template: bool = False,
+ pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
+ template_filename: str = TEMPLATE_FILENAME,
+) -> list[Path]:
+ """Same predicate as :func:`iter_archivable_files`, applied to an iterable."""
+ result: list[Path] = []
+ for path in paths:
+ if not path.is_file() or not pattern.match(path.name):
+ continue
+ if not include_template and path.name == template_filename:
+ continue
+ result.append(path)
+ return result
diff --git a/src/pkgmgr/actions/archive/inspect.py b/src/pkgmgr/actions/archive/inspect.py
new file mode 100644
index 0000000..c8e1bbe
--- /dev/null
+++ b/src/pkgmgr/actions/archive/inspect.py
@@ -0,0 +1,35 @@
+"""Parse a single Markdown file: H1 heading and task-list completeness."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+H1_RE = re.compile(r"^#\s+(?P
\S.*?)\s*$")
+UNCHECKED_TASK_RE = re.compile(r"^\s*[-*+]\s+\[\s\]\s")
+
+
+def extract_h1(path: Path) -> str | None:
+ """Return the first H1 title in *path* or ``None`` if there is none."""
+ try:
+ with path.open(encoding="utf-8") as fh:
+ for line in fh:
+ match = H1_RE.match(line.rstrip("\n"))
+ if match:
+ return match.group("title")
+ except OSError:
+ return None
+ return None
+
+
+def count_unchecked_items(path: Path) -> int:
+ """Return the number of ``- [ ]`` task-list markers anywhere in *path*.
+
+ A non-zero count means the file is not yet fully complete and MUST
+ NOT be archived.
+ """
+ try:
+ with path.open(encoding="utf-8") as fh:
+ return sum(1 for line in fh if UNCHECKED_TASK_RE.match(line))
+ except OSError:
+ return 0
diff --git a/src/pkgmgr/actions/archive/readme.py b/src/pkgmgr/actions/archive/readme.py
new file mode 100644
index 0000000..9b73826
--- /dev/null
+++ b/src/pkgmgr/actions/archive/readme.py
@@ -0,0 +1,76 @@
+"""Read and update the ``## Archive`` section of a directory README."""
+
+from __future__ import annotations
+
+import re
+
+ARCHIVE_HEADING = "## Archive"
+LIST_ITEM_RE = re.compile(r"^\s*-\s+(?P\S.*)$")
+
+
+def existing_archive_entries(readme_text: str) -> set[str]:
+ """Return the deduplicated set of list-item bodies under ``## Archive``."""
+ lines = readme_text.splitlines()
+ in_archive = False
+ entries: set[str] = set()
+ for line in lines:
+ stripped = line.rstrip()
+ if stripped == ARCHIVE_HEADING:
+ in_archive = True
+ continue
+ if in_archive and stripped.startswith("## "):
+ break
+ if not in_archive:
+ continue
+ match = LIST_ITEM_RE.match(stripped)
+ if match:
+ entries.add(match.group("body").strip())
+ return entries
+
+
+def merge_archive_section(readme_text: str, new_entries: list[str]) -> str:
+ """Return ``readme_text`` with *new_entries* appended under ``## Archive``.
+
+ Existing entries are preserved verbatim. If the section is missing it
+ is created at the end of the document.
+ """
+ if not new_entries:
+ return readme_text
+
+ lines = readme_text.splitlines()
+ archive_index = next(
+ (i for i, line in enumerate(lines) if line.rstrip() == ARCHIVE_HEADING),
+ None,
+ )
+
+ if archive_index is None:
+ suffix = [""] if (lines and lines[-1] != "") else []
+ suffix.append(ARCHIVE_HEADING)
+ suffix.append("")
+ suffix.extend(f"- {entry}" for entry in new_entries)
+ merged = lines + suffix
+ return "\n".join(merged) + "\n"
+
+ section_end = next(
+ (i for i in range(archive_index + 1, len(lines)) if lines[i].startswith("## ")),
+ len(lines),
+ )
+
+ body_start = archive_index + 1
+ while body_start < section_end and not lines[body_start].strip():
+ body_start += 1
+
+ last_item = body_start - 1
+ for i in range(body_start, section_end):
+ if LIST_ITEM_RE.match(lines[i]):
+ last_item = i
+
+ insertion_point = (last_item + 1) if last_item >= body_start else body_start
+ if insertion_point == body_start and body_start == archive_index + 1:
+ new_block = ["", *[f"- {entry}" for entry in new_entries]]
+ else:
+ new_block = [f"- {entry}" for entry in new_entries]
+
+ merged = lines[:insertion_point] + new_block + lines[insertion_point:]
+ trailing = "\n" if readme_text.endswith("\n") else ""
+ return "\n".join(merged) + trailing
diff --git a/src/pkgmgr/actions/archive/workflow.py b/src/pkgmgr/actions/archive/workflow.py
new file mode 100644
index 0000000..0c1b72b
--- /dev/null
+++ b/src/pkgmgr/actions/archive/workflow.py
@@ -0,0 +1,115 @@
+"""Orchestrator for archiving fully-checked Markdown files."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+from .discovery import iter_archivable_files
+from .inspect import count_unchecked_items, extract_h1
+from .readme import existing_archive_entries, merge_archive_section
+
+
+@dataclass(frozen=True)
+class ArchivePlan:
+ """Outcome of an archive analysis run.
+
+ Attributes:
+ archived: ``(source_path, title)`` for every file that was (or
+ would be) archived. Order matches the original directory
+ listing.
+ skipped_incomplete: ``(source_path, unchecked_count)`` for files
+ that still hold ``- [ ]`` markers.
+ skipped_without_h1: files that had no H1 heading to use as title.
+ new_entries: titles that will be appended to the README index.
+ existing_entries: titles already present in the README index.
+ """
+
+ archived: list[tuple[Path, str]]
+ skipped_incomplete: list[tuple[Path, int]]
+ skipped_without_h1: list[Path]
+ new_entries: list[str]
+ existing_entries: set[str]
+
+
+def _bucket_files(
+ files: list[Path],
+) -> tuple[
+ list[tuple[Path, str]],
+ list[tuple[Path, int]],
+ list[Path],
+]:
+ plan: list[tuple[Path, str]] = []
+ skipped_incomplete: list[tuple[Path, int]] = []
+ skipped_without_h1: list[Path] = []
+ for path in files:
+ unchecked = count_unchecked_items(path)
+ if unchecked > 0:
+ skipped_incomplete.append((path, unchecked))
+ continue
+ title = extract_h1(path)
+ if title is None:
+ skipped_without_h1.append(path)
+ continue
+ plan.append((path, title))
+ return plan, skipped_incomplete, skipped_without_h1
+
+
+def _dedupe_titles(
+ plan: list[tuple[Path, str]], already_archived: set[str]
+) -> list[str]:
+ new_entries: list[str] = []
+ for _path, title in plan:
+ if title in already_archived or title in new_entries:
+ continue
+ new_entries.append(title)
+ return new_entries
+
+
+def run_archive(
+ directory: Path,
+ readme_path: Path,
+ *,
+ dry_run: bool = False,
+ include_template: bool = False,
+) -> ArchivePlan:
+ """Walk *directory* and archive every fully-checked file into *readme_path*.
+
+ Returns an :class:`ArchivePlan` describing the outcome. When
+ ``dry_run`` is true no files are deleted and the README is not
+ rewritten — the plan still reflects what *would* happen.
+
+ Raises ``FileNotFoundError`` if *directory* or *readme_path* does
+ not exist.
+ """
+ if not directory.is_dir():
+ raise FileNotFoundError(f"Archive directory not found: {directory}")
+ if not readme_path.is_file():
+ raise FileNotFoundError(f"README not found: {readme_path}")
+
+ files = iter_archivable_files(directory, include_template=include_template)
+ readme_text = readme_path.read_text(encoding="utf-8")
+ already_archived = existing_archive_entries(readme_text)
+
+ archived, skipped_incomplete, skipped_without_h1 = _bucket_files(files)
+ new_entries = _dedupe_titles(archived, already_archived)
+
+ if not dry_run and new_entries:
+ merged_text = merge_archive_section(readme_text, new_entries)
+ if merged_text != readme_text:
+ readme_path.write_text(merged_text, encoding="utf-8")
+
+ if not dry_run:
+ for path, _title in archived:
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ pass
+
+ return ArchivePlan(
+ archived=archived,
+ skipped_incomplete=skipped_incomplete,
+ skipped_without_h1=skipped_without_h1,
+ new_entries=new_entries,
+ existing_entries=already_archived,
+ )
diff --git a/src/pkgmgr/cli/commands/__init__.py b/src/pkgmgr/cli/commands/__init__.py
index b8a723a..faf8cde 100644
--- a/src/pkgmgr/cli/commands/__init__.py
+++ b/src/pkgmgr/cli/commands/__init__.py
@@ -1,3 +1,4 @@
+from .archive import handle_archive
from .repos import handle_repos_command
from .config import handle_config
from .tools import handle_tools_command
@@ -10,6 +11,7 @@ from .branch import handle_branch
from .mirror import handle_mirror_command
__all__ = [
+ "handle_archive",
"handle_repos_command",
"handle_config",
"handle_tools_command",
diff --git a/src/pkgmgr/cli/commands/archive.py b/src/pkgmgr/cli/commands/archive.py
new file mode 100644
index 0000000..3273335
--- /dev/null
+++ b/src/pkgmgr/cli/commands/archive.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+from pkgmgr.actions.archive import ArchivePlan, run_archive
+from pkgmgr.cli.context import CLIContext
+
+
+def _print_summary(
+ directory: Path, readme: Path, plan: ArchivePlan, dry_run: bool
+) -> None:
+ print(f"[archive] Directory: {directory}")
+ print(f"[archive] README: {readme}")
+ print(f"[archive] Files to process: {len(plan.archived)}")
+ print(f"[archive] Skipped (incomplete): {len(plan.skipped_incomplete)}")
+ print(f"[archive] New archive entries: {len(plan.new_entries)}")
+ print(f"[archive] Dry-run: {dry_run}")
+
+
+def _print_skips(plan: ArchivePlan, cwd: Path) -> None:
+ if plan.skipped_incomplete:
+ print(
+ "[archive] SKIP: files with unchecked `- [ ]` items "
+ "(not archived, not deleted):"
+ )
+ for path, count in plan.skipped_incomplete:
+ suffix = "s" if count != 1 else ""
+ rel = _rel_or_abs(path, cwd)
+ print(f" - {rel} ({count} unchecked item{suffix})")
+ if plan.skipped_without_h1:
+ print("[archive] WARN: skipped files without an H1 heading:")
+ for path in plan.skipped_without_h1:
+ print(f" - {_rel_or_abs(path, cwd)}")
+
+
+def _print_actions(plan: ArchivePlan, cwd: Path, dry_run: bool) -> None:
+ verb = "would archive" if dry_run else "archived"
+ rm_verb = "would delete" if dry_run else "deleted"
+ for path, title in plan.archived:
+ rel = _rel_or_abs(path, cwd)
+ print(f"[archive] {verb}: {rel} -> '{title}'")
+ if not dry_run:
+ print(f"[archive] {rm_verb}: {rel}")
+
+
+def _rel_or_abs(path: Path, cwd: Path) -> str:
+ try:
+ return path.resolve().relative_to(cwd).as_posix()
+ except ValueError:
+ return path.as_posix()
+
+
+def handle_archive(args, _ctx: CLIContext) -> None:
+ directory = Path(args.directory).resolve()
+ readme = (
+ Path(args.readme).resolve()
+ if args.readme
+ else (directory / "README.md").resolve()
+ )
+
+ try:
+ plan = run_archive(
+ directory=directory,
+ readme_path=readme,
+ dry_run=args.dry_run,
+ include_template=args.include_template,
+ )
+ except FileNotFoundError as exc:
+ print(f"[archive] ERROR: {exc}", file=sys.stderr)
+ sys.exit(1)
+
+ cwd = Path.cwd().resolve()
+ _print_summary(directory, readme, plan, args.dry_run)
+ _print_skips(plan, cwd)
+ _print_actions(plan, cwd, args.dry_run)
diff --git a/src/pkgmgr/cli/dispatch.py b/src/pkgmgr/cli/dispatch.py
index 2f92308..94f54de 100644
--- a/src/pkgmgr/cli/dispatch.py
+++ b/src/pkgmgr/cli/dispatch.py
@@ -10,6 +10,7 @@ from pkgmgr.core.repository.selected import get_selected_repos
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.cli.commands import (
+ handle_archive,
handle_repos_command,
handle_tools_command,
handle_release,
@@ -60,6 +61,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
if maybe_handle_proxy(args, ctx):
return
+ if args.command == "archive":
+ handle_archive(args, ctx)
+ return
+
commands_with_selection = {
"install",
"update",
diff --git a/src/pkgmgr/cli/parser/__init__.py b/src/pkgmgr/cli/parser/__init__.py
index 2c9917c..63307b5 100644
--- a/src/pkgmgr/cli/parser/__init__.py
+++ b/src/pkgmgr/cli/parser/__init__.py
@@ -4,6 +4,7 @@ import argparse
from pkgmgr.cli.proxy import register_proxy_commands
+from .archive_cmd import add_archive_subparser
from .branch_cmd import add_branch_subparsers
from .changelog_cmd import add_changelog_subparser
from .common import SortedSubParsersAction
@@ -65,6 +66,7 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
add_make_subparsers(subparsers)
add_mirror_subparsers(subparsers)
+ add_archive_subparser(subparsers)
register_proxy_commands(subparsers)
return parser
diff --git a/src/pkgmgr/cli/parser/archive_cmd.py b/src/pkgmgr/cli/parser/archive_cmd.py
new file mode 100644
index 0000000..039a2a1
--- /dev/null
+++ b/src/pkgmgr/cli/parser/archive_cmd.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import argparse
+
+
+def add_archive_subparser(subparsers: argparse._SubParsersAction) -> None:
+ """Register the archive subcommand.
+
+ Walks a directory of numbered ``NNN-topic.md`` files, promotes every
+ file whose ``- [ ]`` checklist is fully checked into the directorys
+ README ``## Archive`` section, then deletes the source file.
+ """
+ parser = subparsers.add_parser(
+ "archive",
+ help=(
+ "Archive fully-checked Markdown spec files (NNN-topic.md) "
+ "into the directorys README and delete them."
+ ),
+ description=(
+ "Walk DIR for files that match the numbered "
+ "NNN-topic.md naming, promote every file with zero "
+ "unchecked `- [ ]` items into READMEs ## Archive section, "
+ "then delete the source file. Files with unchecked items are "
+ "skipped. Use --dry-run to preview without writing."
+ ),
+ )
+ parser.add_argument(
+ "directory",
+ nargs="?",
+ default="docs/requirements",
+ help=(
+ "Directory to scan for archivable Markdown files. "
+ "Defaults to docs/requirements (relative to the current "
+ "working directory)."
+ ),
+ )
+ parser.add_argument(
+ "--readme",
+ default=None,
+ help=(
+ "Path to the README that holds the ## Archive index. "
+ "Defaults to /README.md."
+ ),
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Print planned changes without modifying or deleting anything.",
+ )
+ parser.add_argument(
+ "--include-template",
+ action="store_true",
+ help=(
+ "Also archive and delete 000-template.md. Off by default "
+ "because contributor guides typically reference it."
+ ),
+ )
diff --git a/tests/unit/pkgmgr/actions/archive/__init__.py b/tests/unit/pkgmgr/actions/archive/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/unit/pkgmgr/actions/archive/test_discovery.py b/tests/unit/pkgmgr/actions/archive/test_discovery.py
new file mode 100644
index 0000000..3a06b65
--- /dev/null
+++ b/tests/unit/pkgmgr/actions/archive/test_discovery.py
@@ -0,0 +1,49 @@
+"""Unit tests for `pkgmgr.actions.archive.discovery`."""
+
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from pkgmgr.actions.archive.discovery import iter_archivable_files
+
+
+class TestIterArchivableFiles(unittest.TestCase):
+ def test_only_numbered_markdown_files_are_returned_and_sorted(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ (d / "001-alpha.md").write_text("# 001 - A")
+ (d / "002-beta.md").write_text("# 002 - B")
+ (d / "README.md").write_text("# X")
+ (d / "notes.md").write_text("# X")
+ (d / "001-alpha.txt").write_text("x")
+
+ result = [p.name for p in iter_archivable_files(d, include_template=True)]
+ self.assertEqual(result, ["001-alpha.md", "002-beta.md"])
+
+ def test_template_is_skipped_unless_opted_in(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ (d / "000-template.md").write_text("# 000 - Template")
+ (d / "001-alpha.md").write_text("# 001 - A")
+
+ self.assertEqual(
+ [p.name for p in iter_archivable_files(d, include_template=False)],
+ ["001-alpha.md"],
+ )
+ self.assertEqual(
+ [p.name for p in iter_archivable_files(d, include_template=True)],
+ ["000-template.md", "001-alpha.md"],
+ )
+
+ def test_missing_directory_returns_empty(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ self.assertEqual(
+ iter_archivable_files(Path(tmp) / "missing", include_template=False),
+ [],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/pkgmgr/actions/archive/test_inspect.py b/tests/unit/pkgmgr/actions/archive/test_inspect.py
new file mode 100644
index 0000000..fced13b
--- /dev/null
+++ b/tests/unit/pkgmgr/actions/archive/test_inspect.py
@@ -0,0 +1,62 @@
+"""Unit tests for `pkgmgr.actions.archive.inspect`."""
+
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from pkgmgr.actions.archive.inspect import count_unchecked_items, extract_h1
+
+
+class TestExtractH1(unittest.TestCase):
+ def test_returns_first_h1_title(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "001-x.md"
+ path.write_text("\n# 001 - Title\n\n## Subsection\n# Later H1\n")
+ self.assertEqual(extract_h1(path), "001 - Title")
+
+ def test_returns_none_when_no_h1(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "001-x.md"
+ path.write_text("no heading here\n## h2 only\n")
+ self.assertIsNone(extract_h1(path))
+
+
+class TestCountUncheckedItems(unittest.TestCase):
+ def test_zero_when_all_checked(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "001-x.md"
+ path.write_text(
+ "# 001 - Done\n\n## Acceptance Criteria\n\n- [x] A\n- [x] B\n"
+ )
+ self.assertEqual(count_unchecked_items(path), 0)
+
+ def test_counts_unchecked_anywhere_in_file(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "001-x.md"
+ path.write_text(
+ "# 001 - In progress\n\n"
+ "## Acceptance Criteria\n\n"
+ "- [x] A\n"
+ "- [ ] B\n\n"
+ "## Notes\n\n"
+ "- [ ] still tracking this one too\n"
+ " - [ ] nested unchecked\n"
+ )
+ self.assertEqual(count_unchecked_items(path), 3)
+
+ def test_ignores_non_task_dashes(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "001-x.md"
+ path.write_text(
+ "# 001 - X\n\n"
+ "- plain list item\n"
+ "- [x] checked\n"
+ "- not [ ] not a task marker\n"
+ )
+ self.assertEqual(count_unchecked_items(path), 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/pkgmgr/actions/archive/test_readme.py b/tests/unit/pkgmgr/actions/archive/test_readme.py
new file mode 100644
index 0000000..2ccdf66
--- /dev/null
+++ b/tests/unit/pkgmgr/actions/archive/test_readme.py
@@ -0,0 +1,73 @@
+"""Unit tests for `pkgmgr.actions.archive.readme`."""
+
+from __future__ import annotations
+
+import unittest
+
+from pkgmgr.actions.archive.readme import (
+ existing_archive_entries,
+ merge_archive_section,
+)
+
+
+class TestExistingArchiveEntries(unittest.TestCase):
+ def test_parses_only_archive_section_items(self) -> None:
+ readme = (
+ "# Requirements\n\n"
+ "Intro paragraph.\n\n"
+ "## Other\n\n"
+ "- not an archive entry\n\n"
+ "## Archive\n\n"
+ "- 001 - One\n"
+ "- 002 - Two\n\n"
+ "## Trailing\n\n"
+ "- ignored too\n"
+ )
+ self.assertEqual(
+ existing_archive_entries(readme),
+ {"001 - One", "002 - Two"},
+ )
+
+ def test_returns_empty_when_section_missing(self) -> None:
+ self.assertEqual(existing_archive_entries("# Title\n\nBody.\n"), set())
+
+
+class TestMergeArchiveSection(unittest.TestCase):
+ def test_creates_section_when_missing(self) -> None:
+ readme = "# Requirements\n\nIntro.\n"
+ merged = merge_archive_section(readme, ["001 - One", "002 - Two"])
+ self.assertEqual(
+ merged,
+ "# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n- 002 - Two\n",
+ )
+
+ def test_appends_to_existing_section_preserving_entries(self) -> None:
+ readme = "# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n"
+ merged = merge_archive_section(readme, ["002 - Two", "003 - Three"])
+ self.assertEqual(
+ merged,
+ (
+ "# Requirements\n\n"
+ "Intro.\n\n"
+ "## Archive\n\n"
+ "- 001 - One\n"
+ "- 002 - Two\n"
+ "- 003 - Three\n"
+ ),
+ )
+
+ def test_appends_before_trailing_section(self) -> None:
+ readme = "## Archive\n\n- 001 - One\n\n## Trailing\n\nfooter\n"
+ merged = merge_archive_section(readme, ["002 - Two"])
+ self.assertEqual(
+ merged,
+ "## Archive\n\n- 001 - One\n- 002 - Two\n\n## Trailing\n\nfooter\n",
+ )
+
+ def test_empty_entries_returns_unchanged(self) -> None:
+ readme = "# X\n\nBody\n"
+ self.assertEqual(merge_archive_section(readme, []), readme)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/pkgmgr/actions/archive/test_workflow.py b/tests/unit/pkgmgr/actions/archive/test_workflow.py
new file mode 100644
index 0000000..27f0e86
--- /dev/null
+++ b/tests/unit/pkgmgr/actions/archive/test_workflow.py
@@ -0,0 +1,91 @@
+"""Unit tests for `pkgmgr.actions.archive.workflow.run_archive`."""
+
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from pkgmgr.actions.archive.workflow import run_archive
+
+
+class TestRunArchive(unittest.TestCase):
+ def test_dry_run_does_not_touch_files_or_readme(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ done = d / "001-done.md"
+ done.write_text("# 001 - Done\n\n- [x] ok\n")
+ readme = d / "README.md"
+ readme.write_text("# Specs\n\n## Archive\n")
+
+ plan = run_archive(d, readme, dry_run=True)
+
+ self.assertTrue(done.exists())
+ self.assertEqual(readme.read_text(), "# Specs\n\n## Archive\n")
+ self.assertEqual(plan.new_entries, ["001 - Done"])
+ self.assertEqual(plan.archived, [(done, "001 - Done")])
+
+ def test_real_run_archives_and_deletes_completed_file(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ done = d / "001-done.md"
+ done.write_text("# 001 - Done\n\n- [x] ok\n")
+ readme = d / "README.md"
+ readme.write_text("# Specs\n\n## Archive\n")
+
+ plan = run_archive(d, readme)
+
+ self.assertFalse(done.exists())
+ self.assertIn("- 001 - Done", readme.read_text())
+ self.assertEqual(plan.new_entries, ["001 - Done"])
+
+ def test_incomplete_files_are_skipped(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ wip = d / "002-wip.md"
+ wip.write_text("# 002 - WIP\n\n- [ ] open\n")
+ readme = d / "README.md"
+ readme.write_text("# Specs\n\n## Archive\n")
+
+ plan = run_archive(d, readme)
+
+ self.assertTrue(wip.exists())
+ self.assertEqual(plan.archived, [])
+ self.assertEqual(plan.skipped_incomplete, [(wip, 1)])
+
+ def test_already_archived_title_is_not_duplicated(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ done = d / "001-done.md"
+ done.write_text("# 001 - Done\n\n- [x] ok\n")
+ readme = d / "README.md"
+ readme.write_text("# Specs\n\n## Archive\n\n- 001 - Done\n")
+
+ plan = run_archive(d, readme)
+
+ self.assertEqual(plan.new_entries, [])
+ self.assertEqual(plan.archived, [(done, "001 - Done")])
+ # File still deleted; README unchanged.
+ self.assertFalse(done.exists())
+ self.assertEqual(
+ readme.read_text(),
+ "# Specs\n\n## Archive\n\n- 001 - Done\n",
+ )
+
+ def test_missing_directory_raises_filenotfound(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ readme = Path(tmp) / "README.md"
+ readme.write_text("# X\n## Archive\n")
+ with self.assertRaises(FileNotFoundError):
+ run_archive(Path(tmp) / "no-such-dir", readme)
+
+ def test_missing_readme_raises_filenotfound(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ (d / "001-done.md").write_text("# 001 - Done\n- [x] ok\n")
+ with self.assertRaises(FileNotFoundError):
+ run_archive(d, d / "missing.md")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/pkgmgr/cli/commands/test_release.py b/tests/unit/pkgmgr/cli/commands/test_release.py
index 0882c84..111a00b 100644
--- a/tests/unit/pkgmgr/cli/commands/test_release.py
+++ b/tests/unit/pkgmgr/cli/commands/test_release.py
@@ -112,6 +112,7 @@ class TestReleaseCommand(unittest.TestCase):
preview=False,
force=True,
close=True,
+ retry=False,
)
@patch("pkgmgr.cli.commands.release.os.path.isdir", return_value=True)
@@ -160,6 +161,7 @@ class TestReleaseCommand(unittest.TestCase):
preview=True,
force=False,
close=False,
+ retry=False,
)
@patch("pkgmgr.cli.commands.release.run_release")