wiki-studio 0.1.0__py3-none-any.whl

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,5 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """wiki_studio — Karpathy-pattern LLM wiki maintainer (ingest, promote, query, lint, audit-flush)."""
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from wiki_studio.cli import main
4
+
5
+ raise SystemExit(main())
wiki_studio/api.py ADDED
@@ -0,0 +1,410 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """wiki_studio.api — the library surface: ``ingest``, ``promote``, ``query``, ``lint``, ``audit_flush``.
4
+
5
+ Each function takes a :class:`~wiki_studio.paths.WikiPaths`, returns a typed result
6
+ and raises the exceptions in :mod:`wiki_studio.errors`. Nothing here prints; the
7
+ CLI in :mod:`wiki_studio.cli` is a thin parser over these functions that renders
8
+ results and maps exceptions to exit codes.
9
+
10
+ Example::
11
+
12
+ from pathlib import Path
13
+ from wiki_studio import api
14
+ from wiki_studio.paths import WikiPaths
15
+
16
+ paths = WikiPaths.for_root(Path("."))
17
+ result = api.ingest(Path("notes/incident.md"), paths, backend="test")
18
+ api.promote(result.written, paths)
19
+ print(api.query("what caused the outage?", paths, backend="test").answer.answer)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import datetime
25
+ import os
26
+ import shutil
27
+ from dataclasses import dataclass, replace
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING
30
+
31
+ from .audit import append_ingest_log, build_log_record
32
+ from .audit_flush import FlushResult, flush_state
33
+ from .audit_lint import validate_audit_tree
34
+ from .backends import resolve_backend
35
+ from .backends.copilot_cli import CopilotCliBackend
36
+ from .errors import OutputWriteError, PathOutsideRootError, SourceMissingError
37
+ from .health_lint import lint_health
38
+ from .ingestor import DefaultIngestor
39
+ from .ingestor_multi import DefaultMultiIngestor, write_patch_set_dir
40
+ from .lint import lint_wiki, list_pages
41
+ from .promoter import PromoteResult
42
+ from .promoter import promote as _promote
43
+ from .proposal import IngestLogRecord, IngestProposal, IngestRequest, QueryAnswer, WikiPatchSet, render_proposal_file
44
+ from .querier import DefaultQuerier
45
+
46
+ if TYPE_CHECKING:
47
+ from .health_lint import HealthLintResult
48
+ from .ingestor import Backend
49
+ from .paths import WikiPaths
50
+
51
+ # The env var the legacy single-source ingestor reads its task hint from.
52
+ _TASK_ENV = "WIKI_INGEST_TASK"
53
+ _GATE_ONLY_TASK = "gate-only"
54
+ _SNAPSHOT_DIR_NAME = ".ingest-snapshot"
55
+ _FILE_BACK_SLUG = "query-fileback"
56
+
57
+ ALLOW_OUT_OF_REPO_HINT = "Pass --allow-out-of-repo to override (only when ingesting external artifacts deliberately)."
58
+ ALLOW_OUT_OF_REPO_PROMOTE_HINT = (
59
+ "Pass --allow-out-of-repo to override (only when the proposal dir comes from a trusted source you've audited)."
60
+ )
61
+
62
+
63
+ # ── Results ───────────────────────────────────────────────────
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class IngestResult:
68
+ """What ``ingest`` produced.
69
+
70
+ ``written`` is the proposal file (legacy single-source) or the patch-set
71
+ directory (multi-page); None in check mode. ``edits`` lists the
72
+ wiki-relative pages the proposal touches.
73
+ """
74
+
75
+ disposition: str
76
+ written: Path | None
77
+ edits: tuple[str, ...]
78
+ rationale: str
79
+ dry_run: bool
80
+ check: bool
81
+ patch: WikiPatchSet | None
82
+ proposal: IngestProposal | None
83
+ record: IngestLogRecord | None
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class QueryResult:
88
+ """An answer with citations; with ``file_back`` also the patch-set and where it was written."""
89
+
90
+ answer: QueryAnswer
91
+ patch: WikiPatchSet | None
92
+ file_back_dir: Path | None
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class LintResult:
97
+ """Structural violations, audit-format errors and (optionally) knowledge-health findings."""
98
+
99
+ violations: tuple[str, ...]
100
+ pages_checked: int
101
+ audit_errors: tuple[str, ...]
102
+ health: HealthLintResult | None
103
+ strict: bool
104
+
105
+ @property
106
+ def passed(self) -> bool:
107
+ """Structural violations always fail; audit and health findings fail only under ``strict``."""
108
+ if self.violations:
109
+ return False
110
+ if not self.strict:
111
+ return True
112
+ if self.audit_errors:
113
+ return False
114
+ return not (self.health is not None and self.health.findings)
115
+
116
+
117
+ AuditFlushResult = FlushResult
118
+
119
+
120
+ # ── Backends ──────────────────────────────────────────────────
121
+
122
+
123
+ def prepare_backend(
124
+ backend: str | Backend | None,
125
+ paths: WikiPaths,
126
+ *,
127
+ redact_output: bool = True,
128
+ ) -> Backend:
129
+ """Resolve a backend by name (or env/auto-detect when None) and apply per-run settings.
130
+
131
+ A :class:`Backend` instance is used as given; a copilot-CLI backend gets a
132
+ copy carrying the stderr redaction setting and the wiki label used in
133
+ rendered prompts.
134
+ """
135
+ resolved: Backend = backend if backend is not None and not isinstance(backend, str) else resolve_backend(backend)
136
+ if isinstance(resolved, CopilotCliBackend):
137
+ return resolved.with_options(redact_output=redact_output, wiki_label=paths.wiki_label)
138
+ return resolved
139
+
140
+
141
+ def backend_display_name(backend: object, requested: str | None) -> str:
142
+ """Best-effort name for the resolved backend, recorded in proposal frontmatter."""
143
+ if requested:
144
+ return requested
145
+ cli_name = getattr(backend, "_cli_name", None)
146
+ if cli_name:
147
+ return str(cli_name)
148
+ cls_name = backend.__class__.__name__
149
+ return cls_name.removesuffix("Backend").lower() or "unknown"
150
+
151
+
152
+ # ── ingest ────────────────────────────────────────────────────
153
+
154
+
155
+ def _proposal_filename(proposal: IngestProposal) -> str:
156
+ """``<YYYY-MM-DD>-<slug>.md``."""
157
+ today = datetime.date.today().isoformat()
158
+ base = proposal.slug or f"unslugged-{proposal.disposition}"
159
+ return f"{today}-{base}.md"
160
+
161
+
162
+ def _write_proposal_file(proposal: IngestProposal, request: IngestRequest, backend_name: str, output_dir: Path) -> Path:
163
+ proposal_path = output_dir / _proposal_filename(proposal)
164
+ try:
165
+ output_dir.mkdir(parents=True, exist_ok=True)
166
+ proposal_path.write_text(render_proposal_file(proposal, request, backend_name), encoding="utf-8")
167
+ except OSError as exc:
168
+ raise OutputWriteError(f"Could not write proposal file at {proposal_path}: {exc}") from exc
169
+ return proposal_path.resolve()
170
+
171
+
172
+ def _write_ingest_snapshot(proposal_dir: Path) -> None:
173
+ """Immutable copy of plan.json + preview/ so promote can diff the curator's edits (never on dry-run or check)."""
174
+ snapshot_dir = proposal_dir / _SNAPSHOT_DIR_NAME
175
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
176
+ plan = proposal_dir / "plan.json"
177
+ if plan.is_file():
178
+ shutil.copy2(str(plan), str(snapshot_dir / "plan.json"))
179
+ preview = proposal_dir / "preview"
180
+ if preview.is_dir():
181
+ preview_snapshot = snapshot_dir / "preview"
182
+ if preview_snapshot.exists():
183
+ shutil.rmtree(str(preview_snapshot))
184
+ shutil.copytree(str(preview), str(preview_snapshot))
185
+
186
+
187
+ def _require_within_root(kind: str, path: Path, paths: WikiPaths, allow: bool, hint: str) -> None:
188
+ if allow or paths.within_root(path):
189
+ return
190
+ raise PathOutsideRootError(f"{kind} is outside the repository tree: {path}\n Repo root: {paths.root}\n {hint}")
191
+
192
+
193
+ def ingest(
194
+ source: Path,
195
+ paths: WikiPaths,
196
+ *,
197
+ backend: str | Backend | None = None,
198
+ legacy_single_source: bool = False,
199
+ dry_run: bool = False,
200
+ check: bool = False,
201
+ output_dir: Path | None = None,
202
+ allow_out_of_repo: bool = False,
203
+ redact_output: bool = True,
204
+ ) -> IngestResult:
205
+ """Run the ingest gate over ``source`` and write the proposal.
206
+
207
+ Multi-page by default (a patch-set directory under the proposals dir);
208
+ ``legacy_single_source`` runs the v1 single-page generator. ``check``
209
+ runs the gate with zero filesystem side effects; ``dry_run`` skips the
210
+ draft body (legacy) or marks the audit record (multi).
211
+
212
+ Raises ``ValueError`` when ``check`` and ``dry_run`` are combined, and the
213
+ :mod:`wiki_studio.errors` exceptions for everything else.
214
+ """
215
+ if check and dry_run:
216
+ raise ValueError("--check and --dry-run are mutually exclusive")
217
+ source_path = Path(source)
218
+ if not source_path.exists():
219
+ raise SourceMissingError(f"source file not found: {source_path}")
220
+ _require_within_root("source path", source_path, paths, allow_out_of_repo, ALLOW_OUT_OF_REPO_HINT)
221
+
222
+ requested = backend if isinstance(backend, str) else None
223
+ resolved = prepare_backend(backend, paths, redact_output=redact_output)
224
+ backend_name = backend_display_name(resolved, requested)
225
+ request = IngestRequest(source_path=source_path, source_kind="file", backend_name=backend_name)
226
+ proposals_root = output_dir if output_dir is not None else paths.proposals_dir
227
+
228
+ if legacy_single_source:
229
+ return _ingest_legacy(request, paths, resolved, backend_name, proposals_root, dry_run, check, allow_out_of_repo)
230
+ return _ingest_multi(request, paths, resolved, backend_name, proposals_root, dry_run, check, allow_out_of_repo)
231
+
232
+
233
+ def _ingest_legacy(
234
+ request: IngestRequest,
235
+ paths: WikiPaths,
236
+ backend: Backend,
237
+ backend_name: str,
238
+ output_dir: Path,
239
+ dry_run: bool,
240
+ check: bool,
241
+ allow_out_of_repo: bool,
242
+ ) -> IngestResult:
243
+ ingestor = DefaultIngestor(backend=backend, paths=paths)
244
+ # The legacy prompt reads the dry-run intent from the environment; the
245
+ # render side still strips the body as a safety net for stubs that
246
+ # ignore the task hint.
247
+ env_backup = os.environ.get(_TASK_ENV)
248
+ if dry_run:
249
+ os.environ[_TASK_ENV] = _GATE_ONLY_TASK
250
+ try:
251
+ proposal = ingestor.ingest(request)
252
+ finally:
253
+ if env_backup is None:
254
+ os.environ.pop(_TASK_ENV, None)
255
+ else:
256
+ os.environ[_TASK_ENV] = env_backup
257
+
258
+ edits = (proposal.slug,) if proposal.slug else ()
259
+ if check:
260
+ return IngestResult(proposal.disposition, None, edits, proposal.reason, False, True, None, proposal, None)
261
+ if dry_run:
262
+ proposal = replace(proposal, draft_markdown=None)
263
+
264
+ proposal_path = _write_proposal_file(proposal, request, backend_name, output_dir)
265
+ if not dry_run:
266
+ _write_ingest_snapshot(proposal_path.parent)
267
+ record = build_log_record(
268
+ source_file=request.source_path,
269
+ paths=paths,
270
+ backend_name=backend_name,
271
+ proposal_dir_path=proposal_path.parent,
272
+ allow_out_of_repo=allow_out_of_repo,
273
+ legacy_proposal=proposal,
274
+ dry_run=dry_run,
275
+ )
276
+ append_ingest_log(paths, record)
277
+ return IngestResult(
278
+ proposal.disposition, proposal_path, edits, proposal.reason, dry_run, False, None, proposal, record
279
+ )
280
+
281
+
282
+ def _ingest_multi(
283
+ request: IngestRequest,
284
+ paths: WikiPaths,
285
+ backend: Backend,
286
+ backend_name: str,
287
+ proposals_root: Path,
288
+ dry_run: bool,
289
+ check: bool,
290
+ allow_out_of_repo: bool,
291
+ ) -> IngestResult:
292
+ multi = DefaultMultiIngestor(backend=backend, paths=paths)
293
+ patch = multi.ingest_multi(request)
294
+ disposition = "accept" if patch.edits else "reject"
295
+ edits = tuple(e.path for e in patch.edits)
296
+ if check:
297
+ return IngestResult(disposition, None, edits, patch.rationale, False, True, patch, None, None)
298
+
299
+ today = datetime.date.today().isoformat()
300
+ output_dir = proposals_root / f"{today}-{request.source_path.stem or 'source'}"
301
+ try:
302
+ written = write_patch_set_dir(patch, output_dir)
303
+ except OSError as exc:
304
+ raise OutputWriteError(f"could not write patch-set dir at {output_dir}: {exc}") from exc
305
+ if not dry_run and patch.edits:
306
+ _write_ingest_snapshot(written)
307
+ record = build_log_record(
308
+ source_file=request.source_path,
309
+ paths=paths,
310
+ backend_name=backend_name,
311
+ proposal_dir_path=written,
312
+ allow_out_of_repo=allow_out_of_repo,
313
+ patch=patch,
314
+ dry_run=dry_run,
315
+ )
316
+ append_ingest_log(paths, record)
317
+ return IngestResult(disposition, written, edits, patch.rationale, dry_run, False, patch, None, record)
318
+
319
+
320
+ # ── promote ───────────────────────────────────────────────────
321
+
322
+
323
+ def promote(
324
+ proposal_dir: Path,
325
+ paths: WikiPaths,
326
+ *,
327
+ dry_run: bool = False,
328
+ allow_out_of_repo: bool = False,
329
+ ) -> PromoteResult:
330
+ """Apply a patch-set directory to the wiki atomically (the only write into the wiki).
331
+
332
+ Raises ``SourceMissingError`` when the directory does not exist,
333
+ ``PathOutsideRootError`` when it lies outside the root without
334
+ ``allow_out_of_repo``, and the promoter's validation/apply errors.
335
+ """
336
+ proposal_path = Path(proposal_dir)
337
+ if not proposal_path.exists():
338
+ raise SourceMissingError(f"proposals dir not found: {proposal_path}")
339
+ _require_within_root("proposal dir", proposal_path, paths, allow_out_of_repo, ALLOW_OUT_OF_REPO_PROMOTE_HINT)
340
+ return _promote(proposals_dir=proposal_path, paths=paths, dry_run=dry_run)
341
+
342
+
343
+ # ── query ─────────────────────────────────────────────────────
344
+
345
+
346
+ def query(
347
+ question: str,
348
+ paths: WikiPaths,
349
+ *,
350
+ backend: str | Backend | None = None,
351
+ file_back: bool = False,
352
+ max_pages: int | None = None,
353
+ output_dir: Path | None = None,
354
+ redact_output: bool = True,
355
+ ) -> QueryResult:
356
+ """Answer ``question`` index-first over the wiki; with ``file_back`` also write a patch-set for the answer."""
357
+ resolved = prepare_backend(backend, paths, redact_output=redact_output)
358
+ querier = (
359
+ DefaultQuerier(backend=resolved, paths=paths)
360
+ if max_pages is None
361
+ else DefaultQuerier(resolved, paths, max_pages)
362
+ )
363
+ if not file_back:
364
+ return QueryResult(answer=querier.query(question), patch=None, file_back_dir=None)
365
+
366
+ answer, patch = querier.query_with_file_back(question)
367
+ if not patch.edits:
368
+ return QueryResult(answer=answer, patch=patch, file_back_dir=None)
369
+ today = datetime.date.today().isoformat()
370
+ out = (output_dir if output_dir is not None else paths.proposals_dir) / f"{today}-{_FILE_BACK_SLUG}"
371
+ try:
372
+ written = write_patch_set_dir(patch, out)
373
+ except OSError as exc:
374
+ raise OutputWriteError(f"could not write file-back dir: {exc}") from exc
375
+ return QueryResult(answer=answer, patch=patch, file_back_dir=written)
376
+
377
+
378
+ # ── lint ──────────────────────────────────────────────────────
379
+
380
+
381
+ def lint(
382
+ paths: WikiPaths,
383
+ *,
384
+ health: bool = False,
385
+ strict: bool = False,
386
+ only: list[str] | None = None,
387
+ backend: str | Backend | None = None,
388
+ redact_output: bool = True,
389
+ ) -> LintResult:
390
+ """Structural lint plus the audit-format pass; ``health`` adds the knowledge-health checks.
391
+
392
+ ``backend`` is only consulted with ``health`` (for the contradiction
393
+ check); when it is None, contradictions are skipped.
394
+ """
395
+ violations = tuple(lint_wiki(paths.wiki_dir))
396
+ pages_checked = len(list_pages(paths.wiki_dir))
397
+ audit_errors = tuple(validate_audit_tree(paths))
398
+ health_result: HealthLintResult | None = None
399
+ if health:
400
+ resolved = prepare_backend(backend, paths, redact_output=redact_output) if backend is not None else None
401
+ health_result = lint_health(paths, only=only, backend=resolved)
402
+ return LintResult(violations, pages_checked, audit_errors, health_result, strict)
403
+
404
+
405
+ # ── audit-flush ───────────────────────────────────────────────
406
+
407
+
408
+ def audit_flush(paths: WikiPaths, *, dry_run: bool = False) -> AuditFlushResult:
409
+ """Commit pending ingest-log lines (or report them with ``dry_run``); raises ``AuditFlushError``."""
410
+ return flush_state(paths, dry_run)
wiki_studio/audit.py ADDED
@@ -0,0 +1,276 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # wiki-studio.audit — the ONLY module that writes ingest-log.md.
4
+ #
5
+ # Contains the helpers for computing source_sha, proposal_hash, and
6
+ # truncate_reason, plus build_log_record (derives a patch-set-oriented
7
+ # IngestLogRecord from a WikiPatchSet or IngestProposal) and
8
+ # append_ingest_log (the fail-closed, marker-prefixed writer).
9
+ #
10
+ # append_ingest_log is called from cli.py ingest handlers AFTER the
11
+ # proposal dir is materialized + dry-run stripped (Design Decision 8), NOT
12
+ # from ingestor.py / ingestor_multi.py.
13
+ #
14
+ # See schema/audit-rules.md for the normative format spec.
15
+
16
+ from __future__ import annotations
17
+
18
+ import datetime
19
+ import hashlib
20
+ import json
21
+ import re
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from .audit_lint import INGEST_LOG_MARKER
26
+ from .errors import OutputWriteError
27
+ from .proposal import IngestLogRecord, IngestProposal, WikiPatchSet
28
+
29
+ if TYPE_CHECKING:
30
+ from .paths import WikiPaths
31
+
32
+ # ── Helpers ────────────────────────────────────────────────────────────────
33
+
34
+
35
+ def source_sha(source_file: Path) -> str:
36
+ """Return the SHA-256 lowercase-hex digest of source_file's raw bytes."""
37
+ return hashlib.sha256(source_file.read_bytes()).hexdigest()
38
+
39
+
40
+ def proposal_hash(proposal_dir: Path) -> str:
41
+ """Return the SHA-256 canonical hash of the proposal payload.
42
+
43
+ Canonicalization recipe (from audit-rules.md / spec.md DD3):
44
+ 1. Collect plan.json + every file under preview/ recursively.
45
+ Exclude .ingest-snapshot/ and any path component beginning with '.'.
46
+ 2. Sort by repo-relative POSIX path under LC_ALL=C (byte order).
47
+ 3. For each file, append:
48
+ <rel_path UTF-8> + b'\\n' + raw bytes + b'\\n--\\n'
49
+ 4. SHA-256 the concatenated buffer; return lowercase hex.
50
+ """
51
+ files: list[Path] = []
52
+
53
+ plan = proposal_dir / "plan.json"
54
+ if plan.is_file():
55
+ files.append(plan)
56
+
57
+ preview = proposal_dir / "preview"
58
+ if preview.is_dir():
59
+ for p in preview.rglob("*"):
60
+ if p.is_file():
61
+ files.append(p)
62
+
63
+ def _is_excluded(p: Path) -> bool:
64
+ """True if any path component begins with '.' (dotfile/dotdir)."""
65
+ # Check each component relative to proposal_dir.
66
+ try:
67
+ rel = p.relative_to(proposal_dir)
68
+ except ValueError:
69
+ return True
70
+ return any(part.startswith(".") for part in rel.parts)
71
+
72
+ included = [f for f in files if not _is_excluded(f)]
73
+
74
+ # Sort by repo-relative POSIX path, byte order (LC_ALL=C).
75
+ def _sort_key(p: Path) -> bytes:
76
+ return p.relative_to(proposal_dir).as_posix().encode("utf-8")
77
+
78
+ included.sort(key=_sort_key)
79
+
80
+ buf = bytearray()
81
+ for f in included:
82
+ rel_posix = f.relative_to(proposal_dir).as_posix()
83
+ buf += rel_posix.encode("utf-8")
84
+ buf += b"\n"
85
+ buf += f.read_bytes()
86
+ buf += b"\n--\n"
87
+
88
+ return hashlib.sha256(bytes(buf)).hexdigest()
89
+
90
+
91
+ def truncate_reason(s: str) -> str:
92
+ """Collapse whitespace/newlines to single spaces; truncate to 240 codepoints.
93
+
94
+ If the result exceeds 240 codepoints, it is truncated to 239 codepoints
95
+ followed by U+2026 (HORIZONTAL ELLIPSIS) — total exactly 240.
96
+ """
97
+ # Collapse runs of whitespace (including newlines) to a single space.
98
+ collapsed = re.sub(r"\s+", " ", s).strip()
99
+ if len(collapsed) <= 240:
100
+ return collapsed
101
+ return collapsed[:239] + "…"
102
+
103
+
104
+ # ── Record builder ─────────────────────────────────────────────────────────
105
+
106
+
107
+ def _page_type_from_path(path: str) -> str:
108
+ """Derive a page type from a wiki-relative path (best-effort)."""
109
+ # Most wiki paths are <page_type_dir>/<slug>.md; the directory name
110
+ # is the canonical page type string. E.g. "concepts/foo.md" → "concept"
111
+ # but the directory is "concepts" not "concept". We want the
112
+ # *directory name* as the type label for audit purposes.
113
+ parts = Path(path).parts
114
+ if len(parts) >= 2:
115
+ return parts[0]
116
+ return "unknown"
117
+
118
+
119
+ def build_log_record(
120
+ *,
121
+ source_file: Path,
122
+ paths: WikiPaths,
123
+ backend_name: str,
124
+ proposal_dir_path: Path | None,
125
+ allow_out_of_repo: bool = False,
126
+ patch: WikiPatchSet | None = None,
127
+ legacy_proposal: IngestProposal | None = None,
128
+ dry_run: bool = False,
129
+ ) -> IngestLogRecord:
130
+ """Derive an IngestLogRecord from a WikiPatchSet or IngestProposal.
131
+
132
+ Exactly one of ``patch`` or ``legacy_proposal`` must be supplied.
133
+
134
+ Parameters
135
+ ----------
136
+ source_file : Path
137
+ The source file on disk (used to compute source_sha and derive
138
+ source_path).
139
+ paths : WikiPaths
140
+ The resolved layout; ``paths.root`` anchors the repo-relative POSIX source_path.
141
+ backend_name : str
142
+ Backend display name (``"test"``, ``"claude"``, etc.).
143
+ proposal_dir_path : Path | None
144
+ Path to the materialized proposal directory (used as the basename
145
+ for ``proposal_dir`` and to compute ``proposal_hash``). None only
146
+ when the dir was not materialized (impossible for a normal or
147
+ --dry-run run; --check never calls this).
148
+ allow_out_of_repo : bool
149
+ When True, source_path is recorded verbatim and
150
+ source_repo_relative is False.
151
+ patch : WikiPatchSet | None
152
+ Multi-page ingest result. Disposition is "accept" iff edits
153
+ non-empty; reason is patch.rationale.
154
+ legacy_proposal : IngestProposal | None
155
+ Legacy single-source result. Disposition and reason taken directly.
156
+ dry_run : bool
157
+ When True, proposal_hash is forced to None (body was stripped).
158
+ """
159
+ if patch is None and legacy_proposal is None:
160
+ raise ValueError("build_log_record: must supply patch or legacy_proposal")
161
+ if patch is not None and legacy_proposal is not None:
162
+ raise ValueError("build_log_record: supply exactly one of patch/legacy_proposal")
163
+
164
+ # source_path and source_repo_relative.
165
+ try:
166
+ rel = source_file.resolve().relative_to(paths.root.resolve())
167
+ src_path = rel.as_posix()
168
+ src_repo_relative = True
169
+ except ValueError:
170
+ src_path = str(source_file)
171
+ src_repo_relative = False
172
+
173
+ sha = source_sha(source_file)
174
+
175
+ now_ts = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
176
+
177
+ if patch is not None:
178
+ # Multi-page (WikiPatchSet).
179
+ disp = "accept" if patch.edits else "reject"
180
+ raw_reason = patch.rationale or ""
181
+ if patch.edits:
182
+ tgt_paths = sorted({e.path for e in patch.edits})
183
+ pg_types = sorted({_page_type_from_path(e.path) for e in patch.edits})
184
+ else:
185
+ tgt_paths = []
186
+ pg_types = []
187
+ else:
188
+ # Legacy (IngestProposal).
189
+ assert legacy_proposal is not None
190
+ disp = legacy_proposal.disposition
191
+ raw_reason = legacy_proposal.reason or ""
192
+ if legacy_proposal.slug:
193
+ page_type = legacy_proposal.page_type or "unknown"
194
+ page_dir = _LEGACY_PAGE_TYPE_DIR.get(page_type, page_type + "s")
195
+ tgt_paths = [f"{page_dir}/{legacy_proposal.slug}.md"]
196
+ pg_types = [page_type]
197
+ else:
198
+ tgt_paths = []
199
+ pg_types = []
200
+
201
+ reason = truncate_reason(raw_reason)
202
+
203
+ prop_dir_name: str | None = proposal_dir_path.name if proposal_dir_path is not None else None
204
+
205
+ # proposal_hash: None on reject (empty edits), --dry-run, or no proposal dir.
206
+ p_hash: str | None = None
207
+ if not dry_run and disp == "accept" and proposal_dir_path is not None and proposal_dir_path.is_dir():
208
+ p_hash = proposal_hash(proposal_dir_path)
209
+
210
+ return IngestLogRecord(
211
+ v=1,
212
+ ts=now_ts,
213
+ source_path=src_path,
214
+ source_repo_relative=src_repo_relative,
215
+ source_sha=sha,
216
+ backend=backend_name,
217
+ disposition=disp,
218
+ reason=reason,
219
+ proposal_dir=prop_dir_name,
220
+ target_paths=tgt_paths,
221
+ page_types=pg_types,
222
+ proposal_hash=p_hash,
223
+ )
224
+
225
+
226
+ # Map legacy page_type values to their wiki directory names.
227
+ _LEGACY_PAGE_TYPE_DIR: dict[str, str] = {
228
+ "concept": "concepts",
229
+ "workflow": "workflows",
230
+ "incident": "incidents",
231
+ "decision": "decisions",
232
+ "playbook": "playbooks",
233
+ "glossary": "glossary",
234
+ "open-question": "open-questions",
235
+ }
236
+
237
+
238
+ # ── Append writer (the only writer of ingest-log.md) ──────────────────────
239
+
240
+
241
+ def append_ingest_log(paths: WikiPaths, record: IngestLogRecord) -> None:
242
+ """Append one NDJSON line to the ingest log (``paths.audit_log``).
243
+
244
+ Creates the file with the marker preamble if it does not exist.
245
+ Fail-closed: any OSError is re-raised so the caller can exit via
246
+ OutputWriteError (exit code 6).
247
+ """
248
+ log_path = paths.audit_log
249
+ try:
250
+ log_path.parent.mkdir(parents=True, exist_ok=True)
251
+
252
+ # Serialize the record to a JSON object (one line, no trailing newline yet).
253
+ # We use the dataclass fields in definition order to match the spec table.
254
+ import dataclasses
255
+
256
+ obj = dataclasses.asdict(record)
257
+ line = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
258
+
259
+ if not log_path.exists():
260
+ # First write: create with marker + blank line.
261
+ content = INGEST_LOG_MARKER + "\n\n" + line + "\n"
262
+ log_path.write_text(content, encoding="utf-8")
263
+ return
264
+
265
+ # Append a single line. We open in binary append mode to avoid
266
+ # any implicit re-encoding of the existing content.
267
+ existing = log_path.read_bytes()
268
+ # Ensure the file ends with a newline before we append.
269
+ if existing and existing[-1:] != b"\n":
270
+ existing += b"\n"
271
+ new_content = existing + (line + "\n").encode("utf-8")
272
+ # Write atomically by replacing the file contents; this avoids a
273
+ # partial-line race (the curator-driven pipeline is single-process).
274
+ log_path.write_bytes(new_content)
275
+ except OSError as exc:
276
+ raise OutputWriteError(f"audit log append failed for {log_path}: {exc}") from exc