meminqu 1.0.0__tar.gz

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.
meminqu-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Wesley Hitchcock
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
meminqu-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: meminqu
3
+ Version: 1.0.0
4
+ Summary: Guided capture interviews for a memory store: seven question registers, rotating across domains, answers captured verbatim through memdate's pure CAPTURE rules
5
+ Author: Ryan Wesley Hitchcock
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ohpersonperson/frontface
8
+ Keywords: memory,interview,capture,knowledge-management,journaling
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: memdate
16
+ Dynamic: license-file
17
+
18
+ # meminqu — guided capture interviews for a memory store
19
+
20
+ Seven questions, asked seven different ways, written down exactly as
21
+ answered. That is the whole product.
22
+
23
+ An interview cycle walks each memory domain in order. For each domain,
24
+ the interviewer poses 2–4 questions in deliberately different
25
+ *registers* — direct, reflective, structural, irreverent, sparse,
26
+ temporal, contrastive — receives the answers, and captures them
27
+ **verbatim** into that domain's `raw.md`. No synthesis, no
28
+ interpretation, no analysis. The registers rotate across the cycle so
29
+ no two domains get interrogated from the same angle twice in a row.
30
+
31
+ No prerequisites. You don't need to know anything about the project's
32
+ history. The mechanism is the whole product.
33
+
34
+ *(Lineage: front-facing conversion of the `meminqu-memory-interrogation`
35
+ skill from Ryan's skill-suite, MIT. What changed is documented in
36
+ CHANGELOG.md — the decoupling record.)*
37
+
38
+ ## What it does
39
+
40
+ - **Seven registers** (`registers.py`) — the question stances, kept
41
+ exactly as the skill defined them, each with a description and
42
+ example stems. They are data, so a caller can supply their own set;
43
+ the shipped seven are the complete original set.
44
+ - **Cycle planning** (`plan_cycle`) — assigns registers per domain
45
+ deterministically: consecutive domains never share a register set,
46
+ and a full cycle uses every register. No fixed order, no repetition
47
+ rut.
48
+ - **Verbatim entry format** (`entry.py`) — one `### Inquiry capture —
49
+ <domain> (registers: …)` record per domain, questions paired with
50
+ answers, answers embedded byte-identical. Skipped questions are
51
+ recorded as skips, not silently dropped.
52
+ - **Session runner** (`session.py`) — the record-keeping side of the
53
+ interview: domain walk, skip, explicit reassignment (material that
54
+ belongs elsewhere is captured under the announced domain *unless*
55
+ the operator explicitly reassigns it), and a completion report
56
+ listing which domains got entries and where they landed.
57
+
58
+ ## Why it works
59
+
60
+ Most "tell me about your week" systems fail in one of two places: they
61
+ ask every question the same way (so every answer comes from the same
62
+ angle), or they quietly rewrite what you said on the way in (so the
63
+ record is the interviewer's summary, not your words). This package
64
+ fixes both in code:
65
+
66
+ - **Register rotation** — `plan_cycle` guarantees angular variety. A
67
+ test asserts consecutive domains differ and a full cycle covers all
68
+ seven registers.
69
+ - **Verbatim preservation** — `format_entry` adds structure *around*
70
+ answers but never touches them. The load-bearing test feeds answers
71
+ with contradictions, typos, and markdown through formatting and
72
+ asserts byte-identical survival.
73
+ - **Capture purity** — answers go through memdate's `capture()`,
74
+ which raises `InterpretationError` if the text looks like synthesis
75
+ ("in summary…") instead of a raw record. The refusal is the feature:
76
+ it keeps capture and interpretation from mixing in the same motion.
77
+
78
+ ## Installation
79
+
80
+ `meminqu` is a thin runner over `memdate` — it does
81
+ not reimplement capture, config, or storage. Install the dependency
82
+ first:
83
+
84
+ ```bash
85
+ # from the staging tree
86
+ pip install -e ../memdate
87
+ pip install -e .
88
+ ```
89
+
90
+ or for a quick offline run, put both `src` trees on the path:
91
+
92
+ ```bash
93
+ PYTHONPATH=../memdate/src:src python3 -m unittest discover -s tests
94
+ PYTHONPATH=../memdate/src:src python3 examples/demo_interview.py
95
+ ```
96
+
97
+ `pyproject.toml` declares `memdate` as a dependency honestly —
98
+ this package cannot function without it.
99
+
100
+ ## Quickstart
101
+
102
+ ```python
103
+ from meminqu import InterviewSession, Response
104
+ from memdate import MemoryConfig
105
+
106
+ config = MemoryConfig(root="./my-memory") # domains from config, not from here
107
+ session = InterviewSession(config)
108
+
109
+ for domain in session.domains:
110
+ registers = session.start_domain(domain) # announce; get suggested registers
111
+ # ... the operator/model asks questions, collects answers ...
112
+ session.submit_answers(domain, [
113
+ Response("direct", "What is the key fact?", "The server migration finished Tuesday."),
114
+ Response("sparse", "This domain in one sentence.", "Quiet, for once."),
115
+ ])
116
+ # session.skip_domain(domain) # or skip it — nothing is written
117
+
118
+ print(session.render_report())
119
+ ```
120
+
121
+ ## Where the formats meet
122
+
123
+ The original skill specified captures under a `## YYYY-MM-DD`
124
+ day-heading. The converted memdate writes a `## YYYY-MM-DD
125
+ HH:MM TZ` heading per entry instead. This package defers to the
126
+ library's heading — one source of truth for the capture format — and
127
+ the `### Inquiry capture — <domain> (registers: …)` marker rides
128
+ inside the entry body, where the register list stays attached to the
129
+ answers it describes.
130
+
131
+ ## Evidence taxonomy
132
+
133
+ This package tags no evidence itself; its artifacts are `raw.md`
134
+ entries. memdate's README maps those onto the shared ten-tier
135
+ taxonomy (`raw.md` entries sit at OBSERVATION / CLAIM). No third
136
+ taxonomy was invented.
137
+
138
+ ## Worked example
139
+
140
+ - `examples/example-1-full-cycle.md` — one full annotated cycle on
141
+ neutral domains, register rotation shown.
142
+ - `examples/demo_interview.py` — scripted end-to-end run against a
143
+ temp store; no model, no network.
@@ -0,0 +1,126 @@
1
+ # meminqu — guided capture interviews for a memory store
2
+
3
+ Seven questions, asked seven different ways, written down exactly as
4
+ answered. That is the whole product.
5
+
6
+ An interview cycle walks each memory domain in order. For each domain,
7
+ the interviewer poses 2–4 questions in deliberately different
8
+ *registers* — direct, reflective, structural, irreverent, sparse,
9
+ temporal, contrastive — receives the answers, and captures them
10
+ **verbatim** into that domain's `raw.md`. No synthesis, no
11
+ interpretation, no analysis. The registers rotate across the cycle so
12
+ no two domains get interrogated from the same angle twice in a row.
13
+
14
+ No prerequisites. You don't need to know anything about the project's
15
+ history. The mechanism is the whole product.
16
+
17
+ *(Lineage: front-facing conversion of the `meminqu-memory-interrogation`
18
+ skill from Ryan's skill-suite, MIT. What changed is documented in
19
+ CHANGELOG.md — the decoupling record.)*
20
+
21
+ ## What it does
22
+
23
+ - **Seven registers** (`registers.py`) — the question stances, kept
24
+ exactly as the skill defined them, each with a description and
25
+ example stems. They are data, so a caller can supply their own set;
26
+ the shipped seven are the complete original set.
27
+ - **Cycle planning** (`plan_cycle`) — assigns registers per domain
28
+ deterministically: consecutive domains never share a register set,
29
+ and a full cycle uses every register. No fixed order, no repetition
30
+ rut.
31
+ - **Verbatim entry format** (`entry.py`) — one `### Inquiry capture —
32
+ <domain> (registers: …)` record per domain, questions paired with
33
+ answers, answers embedded byte-identical. Skipped questions are
34
+ recorded as skips, not silently dropped.
35
+ - **Session runner** (`session.py`) — the record-keeping side of the
36
+ interview: domain walk, skip, explicit reassignment (material that
37
+ belongs elsewhere is captured under the announced domain *unless*
38
+ the operator explicitly reassigns it), and a completion report
39
+ listing which domains got entries and where they landed.
40
+
41
+ ## Why it works
42
+
43
+ Most "tell me about your week" systems fail in one of two places: they
44
+ ask every question the same way (so every answer comes from the same
45
+ angle), or they quietly rewrite what you said on the way in (so the
46
+ record is the interviewer's summary, not your words). This package
47
+ fixes both in code:
48
+
49
+ - **Register rotation** — `plan_cycle` guarantees angular variety. A
50
+ test asserts consecutive domains differ and a full cycle covers all
51
+ seven registers.
52
+ - **Verbatim preservation** — `format_entry` adds structure *around*
53
+ answers but never touches them. The load-bearing test feeds answers
54
+ with contradictions, typos, and markdown through formatting and
55
+ asserts byte-identical survival.
56
+ - **Capture purity** — answers go through memdate's `capture()`,
57
+ which raises `InterpretationError` if the text looks like synthesis
58
+ ("in summary…") instead of a raw record. The refusal is the feature:
59
+ it keeps capture and interpretation from mixing in the same motion.
60
+
61
+ ## Installation
62
+
63
+ `meminqu` is a thin runner over `memdate` — it does
64
+ not reimplement capture, config, or storage. Install the dependency
65
+ first:
66
+
67
+ ```bash
68
+ # from the staging tree
69
+ pip install -e ../memdate
70
+ pip install -e .
71
+ ```
72
+
73
+ or for a quick offline run, put both `src` trees on the path:
74
+
75
+ ```bash
76
+ PYTHONPATH=../memdate/src:src python3 -m unittest discover -s tests
77
+ PYTHONPATH=../memdate/src:src python3 examples/demo_interview.py
78
+ ```
79
+
80
+ `pyproject.toml` declares `memdate` as a dependency honestly —
81
+ this package cannot function without it.
82
+
83
+ ## Quickstart
84
+
85
+ ```python
86
+ from meminqu import InterviewSession, Response
87
+ from memdate import MemoryConfig
88
+
89
+ config = MemoryConfig(root="./my-memory") # domains from config, not from here
90
+ session = InterviewSession(config)
91
+
92
+ for domain in session.domains:
93
+ registers = session.start_domain(domain) # announce; get suggested registers
94
+ # ... the operator/model asks questions, collects answers ...
95
+ session.submit_answers(domain, [
96
+ Response("direct", "What is the key fact?", "The server migration finished Tuesday."),
97
+ Response("sparse", "This domain in one sentence.", "Quiet, for once."),
98
+ ])
99
+ # session.skip_domain(domain) # or skip it — nothing is written
100
+
101
+ print(session.render_report())
102
+ ```
103
+
104
+ ## Where the formats meet
105
+
106
+ The original skill specified captures under a `## YYYY-MM-DD`
107
+ day-heading. The converted memdate writes a `## YYYY-MM-DD
108
+ HH:MM TZ` heading per entry instead. This package defers to the
109
+ library's heading — one source of truth for the capture format — and
110
+ the `### Inquiry capture — <domain> (registers: …)` marker rides
111
+ inside the entry body, where the register list stays attached to the
112
+ answers it describes.
113
+
114
+ ## Evidence taxonomy
115
+
116
+ This package tags no evidence itself; its artifacts are `raw.md`
117
+ entries. memdate's README maps those onto the shared ten-tier
118
+ taxonomy (`raw.md` entries sit at OBSERVATION / CLAIM). No third
119
+ taxonomy was invented.
120
+
121
+ ## Worked example
122
+
123
+ - `examples/example-1-full-cycle.md` — one full annotated cycle on
124
+ neutral domains, register rotation shown.
125
+ - `examples/demo_interview.py` — scripted end-to-end run against a
126
+ temp store; no model, no network.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "meminqu"
7
+ version = "1.0.0"
8
+ description = "Guided capture interviews for a memory store: seven question registers, rotating across domains, answers captured verbatim through memdate's pure CAPTURE rules"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = ["memdate"]
13
+ authors = [{ name = "Ryan Wesley Hitchcock" }]
14
+ keywords = ["memory", "interview", "capture", "knowledge-management", "journaling"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/ohpersonperson/frontface"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ """meminqu — guided capture interviews for a memory store.
2
+
3
+ Walks each memory domain, asks questions in deliberately different
4
+ registers (direct, reflective, structural, irreverent, sparse,
5
+ temporal, contrastive), and captures the answers verbatim into the
6
+ domain's raw.md through memdate's pure CAPTURE rules. No
7
+ synthesis, no interpretation, no analysis.
8
+
9
+ Front-facing conversion of the ``meminqu-memory-interrogation`` skill
10
+ (Ryan's skill-suite, MIT). The domain list is inherited from the
11
+ memdate config — this package declares none of its own.
12
+ """
13
+
14
+ from .entry import ENTRY_MARKER, Response, answers_preserved_verbatim, format_entry
15
+ from .registers import (
16
+ REGISTERS,
17
+ Register,
18
+ RegisterError,
19
+ get_register,
20
+ plan_cycle,
21
+ validate_register_set,
22
+ )
23
+ from .session import InterviewSession, SessionError
24
+
25
+ __all__ = [
26
+ "InterviewSession",
27
+ "SessionError",
28
+ "Response",
29
+ "Register",
30
+ "RegisterError",
31
+ "REGISTERS",
32
+ "get_register",
33
+ "plan_cycle",
34
+ "validate_register_set",
35
+ "format_entry",
36
+ "answers_preserved_verbatim",
37
+ "ENTRY_MARKER",
38
+ ]
39
+
40
+ __version__ = "1.0.0"
@@ -0,0 +1,63 @@
1
+ """Entry formatting: the inquiry-capture record.
2
+
3
+ The contract: answers are preserved verbatim — byte-identical. The
4
+ function adds structure around them (the inquiry marker, the register
5
+ list, question text) but never touches the answer text itself. If an
6
+ answer is empty (skipped question), the skip is recorded as a skip —
7
+ still capture, not silence.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from .registers import get_register
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Response:
19
+ """One question asked, and its answer (possibly skipped)."""
20
+
21
+ register: str
22
+ question: str
23
+ answer: str | None = None
24
+
25
+
26
+ ENTRY_MARKER = "### Inquiry capture"
27
+
28
+
29
+ def format_entry(domain: str, responses: list[Response]) -> str:
30
+ """Build the capture entry body for one domain.
31
+
32
+ Raises ValueError on an empty response list or unknown register
33
+ names. Answers are embedded verbatim — this function performs no
34
+ normalization, trimming, or interpretation on them.
35
+ """
36
+ if not responses:
37
+ raise ValueError("Cannot format an entry with no responses.")
38
+ registers_used: list[str] = []
39
+ for resp in responses:
40
+ reg = get_register(resp.register)
41
+ if reg.name not in registers_used:
42
+ registers_used.append(reg.name)
43
+
44
+ lines = [f"{ENTRY_MARKER} — {domain} (registers: {', '.join(registers_used)})", ""]
45
+ for resp in responses:
46
+ reg = get_register(resp.register)
47
+ lines.append(f"**[{reg.label}]** {resp.question.strip()}")
48
+ if resp.answer is None or not resp.answer.strip():
49
+ lines.append("_[no answer]_")
50
+ else:
51
+ # Verbatim: the answer text is appended exactly as given.
52
+ lines.append(resp.answer)
53
+ lines.append("")
54
+ return "\n".join(lines).rstrip() + "\n"
55
+
56
+
57
+ def answers_preserved_verbatim(body: str, responses: list[Response]) -> bool:
58
+ """Check that every non-empty answer appears byte-identical in the body."""
59
+ for resp in responses:
60
+ if resp.answer and resp.answer.strip():
61
+ if resp.answer not in body:
62
+ return False
63
+ return True
@@ -0,0 +1,177 @@
1
+ """The seven registers: deliberately different question stances.
2
+
3
+ This is the actual mechanism of the interview. Each register is a
4
+ distinct way of approaching the same domain — direct, reflective,
5
+ structural, irreverent, sparse, temporal, contrastive — so the answers
6
+ don't all come from the same angle. The registers are data: a caller
7
+ can supply their own set, but the shipped seven are the skill's
8
+ complete, unmodified set.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ class RegisterError(ValueError):
17
+ """Raised for unknown register names or bad register sets."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Register:
22
+ """One question stance."""
23
+
24
+ name: str
25
+ label: str
26
+ description: str
27
+ example_stems: tuple[str, ...]
28
+
29
+
30
+ REGISTERS: tuple[Register, ...] = (
31
+ Register(
32
+ name="direct",
33
+ label="Direct / forensic",
34
+ description=(
35
+ "No framing, no softening. Ask for the facts as they are, "
36
+ "the way a deposition would."
37
+ ),
38
+ example_stems=(
39
+ "State the single most important fact in this domain right now, with no qualifiers.",
40
+ "What happened here that you would swear to under oath?",
41
+ ),
42
+ ),
43
+ Register(
44
+ name="reflective",
45
+ label="Reflective / interior",
46
+ description=(
47
+ "How it feels from the inside. Subjective material the "
48
+ "forensic register can't reach."
49
+ ),
50
+ example_stems=(
51
+ "What does this domain feel like from the inside this week?",
52
+ "What has been sitting in the back of your mind about it?",
53
+ ),
54
+ ),
55
+ Register(
56
+ name="structural",
57
+ label="Structural / systems",
58
+ description=(
59
+ "The machinery underneath: routines, dependencies, "
60
+ "load-bearing parts, failure points."
61
+ ),
62
+ example_stems=(
63
+ "What system or routine governs this domain? Which parts are load-bearing?",
64
+ "If this domain were a machine, which part is under the most strain?",
65
+ ),
66
+ ),
67
+ Register(
68
+ name="irreverent",
69
+ label="Irreverent / cutting",
70
+ description=(
71
+ "Deliberately disrespectful of the domain's official story. "
72
+ "Politeness is a filter; this register removes it."
73
+ ),
74
+ example_stems=(
75
+ "Cut the diplomacy: what is actually going on here?",
76
+ "What is the most embarrassing truth about this domain you would rather not admit?",
77
+ ),
78
+ ),
79
+ Register(
80
+ name="sparse",
81
+ label="Sparse / minimal",
82
+ description=(
83
+ "Compression as a forcing function. What survives when "
84
+ "there is almost no room says what matters."
85
+ ),
86
+ example_stems=(
87
+ "This domain in one sentence.",
88
+ "Three words. No more.",
89
+ ),
90
+ ),
91
+ Register(
92
+ name="temporal",
93
+ label="Temporal / what's alive now",
94
+ description=(
95
+ "Time-indexed. What is true *now*, what changed, what is "
96
+ "moving — not the domain's permanent self-image."
97
+ ),
98
+ example_stems=(
99
+ "What is alive in this domain right now, today?",
100
+ "What changed here since the last time you looked at it?",
101
+ ),
102
+ ),
103
+ Register(
104
+ name="contrastive",
105
+ label="Contrastive / what is not true",
106
+ description=(
107
+ "Approach by negation. False appearances and near-misses "
108
+ "define the edges of what is real here."
109
+ ),
110
+ example_stems=(
111
+ "What is *not* true about this domain, though it might look that way?",
112
+ "What would surprise someone who only saw the surface?",
113
+ ),
114
+ ),
115
+ )
116
+
117
+ _REGISTER_INDEX = {r.name: r for r in REGISTERS}
118
+
119
+
120
+ def get_register(name: str) -> Register:
121
+ """Return the register with this name, or raise RegisterError."""
122
+ key = name.strip().lower()
123
+ if key not in _REGISTER_INDEX:
124
+ raise RegisterError(
125
+ f"Unknown register {name!r}. Known registers: "
126
+ f"{', '.join(r.name for r in REGISTERS)}."
127
+ )
128
+ return _REGISTER_INDEX[key]
129
+
130
+
131
+ def validate_register_set(registers: tuple[Register, ...]) -> tuple[Register, ...]:
132
+ """Check a caller-supplied register set: unique names, usable stems."""
133
+ if len(registers) < 2:
134
+ raise RegisterError("A register set needs at least two registers.")
135
+ seen: set[str] = set()
136
+ for r in registers:
137
+ key = r.name.strip().lower()
138
+ if not key:
139
+ raise RegisterError("Register names must not be blank.")
140
+ if key in seen:
141
+ raise RegisterError(f"Duplicate register: {r.name!r}.")
142
+ seen.add(key)
143
+ if not r.example_stems:
144
+ raise RegisterError(f"Register {r.name!r} has no example stems.")
145
+ return registers
146
+
147
+
148
+ def plan_cycle(
149
+ domains: tuple[str, ...] | list[str],
150
+ per_domain: int = 3,
151
+ registers: tuple[Register, ...] = REGISTERS,
152
+ ) -> dict[str, list[str]]:
153
+ """Assign registers to each domain for one interview cycle.
154
+
155
+ Rotation rule: consecutive domains never share the same register
156
+ set, and the offset advances each domain so every register gets
157
+ used across a full cycle. Deterministic — the same inputs always
158
+ produce the same plan.
159
+ """
160
+ registers = validate_register_set(tuple(registers))
161
+ domains = tuple(domains)
162
+ if not domains:
163
+ raise RegisterError("Need at least one domain to plan a cycle.")
164
+ if per_domain < 1:
165
+ raise RegisterError("per_domain must be at least 1.")
166
+ if per_domain > len(registers):
167
+ raise RegisterError(
168
+ f"per_domain ({per_domain}) exceeds the register count "
169
+ f"({len(registers)})."
170
+ )
171
+ names = [r.name for r in registers]
172
+ n = len(names)
173
+ plan: dict[str, list[str]] = {}
174
+ for i, domain in enumerate(domains):
175
+ start = (i * per_domain) % n
176
+ plan[domain] = [names[(start + j) % n] for j in range(per_domain)]
177
+ return plan
@@ -0,0 +1,162 @@
1
+ """The interview session: domain walk, capture calls, completion report.
2
+
3
+ The session is the record-keeping side of the interview. The actual
4
+ questions and answers happen outside it (an operator or a model runs
5
+ the conversation); this class tracks state — which domain is active,
6
+ which registers were suggested, what got captured, what got skipped —
7
+ formats entries, and makes the capture calls into memdate.
8
+
9
+ The domain list is inherited from the memdate config. This
10
+ package declares no domains of its own: single source of truth.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ try:
16
+ from memdate import (
17
+ CaptureResult,
18
+ InterpretationError,
19
+ MemoryConfig,
20
+ StorageAdapter,
21
+ capture,
22
+ )
23
+ except ImportError as exc: # pragma: no cover - dependency wiring
24
+ raise ImportError(
25
+ "meminqu requires the memdate package "
26
+ "(the memdate module). Install it first, e.g.: "
27
+ "pip install -e ../memdate # from this staging tree"
28
+ ) from exc
29
+
30
+ from .entry import Response, format_entry
31
+ from .registers import REGISTERS, Register, plan_cycle
32
+
33
+
34
+ class SessionError(ValueError):
35
+ """Raised for bad session operations (unknown domain, bad order, ...)."""
36
+
37
+
38
+ class InterviewSession:
39
+ """One guided-capture interview cycle over a memory store's domains."""
40
+
41
+ SOURCE_TAG = "meminqu"
42
+
43
+ def __init__(
44
+ self,
45
+ config: MemoryConfig,
46
+ *,
47
+ adapter: StorageAdapter | None = None,
48
+ per_domain: int = 3,
49
+ registers: tuple[Register, ...] = REGISTERS,
50
+ domains: tuple[str, ...] | list[str] | None = None,
51
+ ) -> None:
52
+ self.config = config
53
+ self.adapter = adapter
54
+ # Domains come from the config unless the caller narrows the
55
+ # cycle to a subset (which must still be config-known domains).
56
+ chosen = tuple(domains) if domains is not None else config.domains
57
+ for d in chosen:
58
+ config.require_domain(d)
59
+ self.domains: tuple[str, ...] = chosen
60
+ self.register_plan = plan_cycle(self.domains, per_domain, registers)
61
+ self._captured: dict[str, CaptureResult] = {}
62
+ self._skipped: list[str] = []
63
+ self._started: list[str] = []
64
+
65
+ # -- interview flow -------------------------------------------------
66
+
67
+ def start_domain(self, domain: str) -> list[str]:
68
+ """Announce a domain; return its suggested registers."""
69
+ name = self.config.require_domain(domain)
70
+ if name not in self.domains:
71
+ raise SessionError(f"Domain {domain!r} is not in this cycle.")
72
+ if name not in self._started:
73
+ self._started.append(name)
74
+ return list(self.register_plan[name])
75
+
76
+ def submit_answers(
77
+ self,
78
+ domain: str,
79
+ responses: list[Response],
80
+ *,
81
+ capture_domain: str | None = None,
82
+ ) -> CaptureResult:
83
+ """Format the answers and capture them via memdate.
84
+
85
+ Captures under the announced domain unless ``capture_domain``
86
+ is given — the explicit reassignment the skill requires before
87
+ material may land in a different domain's raw.md.
88
+
89
+ Answers are captured under memdate's pure CAPTURE rules:
90
+ if an answer trips an interpretation marker, capture() raises
91
+ InterpretationError rather than silently synthesizing. That is
92
+ enforcement, not a bug — rewrite the answer as raw record, or
93
+ pass force through the lower-level capture() yourself.
94
+ """
95
+ name = self.config.require_domain(domain)
96
+ target = (
97
+ self.config.require_domain(capture_domain)
98
+ if capture_domain is not None
99
+ else name
100
+ )
101
+ body = format_entry(name, responses)
102
+ result = capture(
103
+ self.config,
104
+ target,
105
+ body,
106
+ source=self.SOURCE_TAG,
107
+ adapter=self.adapter,
108
+ )
109
+ self._captured[name] = result
110
+ return result
111
+
112
+ def skip_domain(self, domain: str) -> None:
113
+ """Record a skipped domain. No capture is written."""
114
+ name = self.config.require_domain(domain)
115
+ if name not in self.domains:
116
+ raise SessionError(f"Domain {domain!r} is not in this cycle.")
117
+ if name not in self._skipped:
118
+ self._skipped.append(name)
119
+
120
+ # -- completion -----------------------------------------------------
121
+
122
+ def completion_report(self) -> dict:
123
+ """Structured report: what was captured, where, what was skipped."""
124
+ return {
125
+ "domains_captured": sorted(self._captured),
126
+ "paths": {d: r.path for d, r in sorted(self._captured.items())},
127
+ "domains_skipped": list(self._skipped),
128
+ "domains_untouched": [
129
+ d for d in self.domains
130
+ if d not in self._captured and d not in self._skipped
131
+ ],
132
+ "next_actions": ["continue", "deepen a domain", "distill", "exit"],
133
+ }
134
+
135
+ def render_report(self) -> str:
136
+ """Human-readable completion report."""
137
+ report = self.completion_report()
138
+ lines = ["Interview complete.", ""]
139
+ if report["domains_captured"]:
140
+ lines.append("Captured:")
141
+ for d in report["domains_captured"]:
142
+ lines.append(f" - {d}: {report['paths'][d]}")
143
+ else:
144
+ lines.append("Nothing captured.")
145
+ if report["domains_skipped"]:
146
+ lines.append("Skipped: " + ", ".join(report["domains_skipped"]))
147
+ if report["domains_untouched"]:
148
+ lines.append("Untouched: " + ", ".join(report["domains_untouched"]))
149
+ lines.append("")
150
+ lines.append(
151
+ "Next: " + " / ".join(report["next_actions"]) + "."
152
+ )
153
+ return "\n".join(lines) + "\n"
154
+
155
+
156
+ __all__ = [
157
+ "InterviewSession",
158
+ "SessionError",
159
+ "InterpretationError",
160
+ "CaptureResult",
161
+ "MemoryConfig",
162
+ ]
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: meminqu
3
+ Version: 1.0.0
4
+ Summary: Guided capture interviews for a memory store: seven question registers, rotating across domains, answers captured verbatim through memdate's pure CAPTURE rules
5
+ Author: Ryan Wesley Hitchcock
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ohpersonperson/frontface
8
+ Keywords: memory,interview,capture,knowledge-management,journaling
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: memdate
16
+ Dynamic: license-file
17
+
18
+ # meminqu — guided capture interviews for a memory store
19
+
20
+ Seven questions, asked seven different ways, written down exactly as
21
+ answered. That is the whole product.
22
+
23
+ An interview cycle walks each memory domain in order. For each domain,
24
+ the interviewer poses 2–4 questions in deliberately different
25
+ *registers* — direct, reflective, structural, irreverent, sparse,
26
+ temporal, contrastive — receives the answers, and captures them
27
+ **verbatim** into that domain's `raw.md`. No synthesis, no
28
+ interpretation, no analysis. The registers rotate across the cycle so
29
+ no two domains get interrogated from the same angle twice in a row.
30
+
31
+ No prerequisites. You don't need to know anything about the project's
32
+ history. The mechanism is the whole product.
33
+
34
+ *(Lineage: front-facing conversion of the `meminqu-memory-interrogation`
35
+ skill from Ryan's skill-suite, MIT. What changed is documented in
36
+ CHANGELOG.md — the decoupling record.)*
37
+
38
+ ## What it does
39
+
40
+ - **Seven registers** (`registers.py`) — the question stances, kept
41
+ exactly as the skill defined them, each with a description and
42
+ example stems. They are data, so a caller can supply their own set;
43
+ the shipped seven are the complete original set.
44
+ - **Cycle planning** (`plan_cycle`) — assigns registers per domain
45
+ deterministically: consecutive domains never share a register set,
46
+ and a full cycle uses every register. No fixed order, no repetition
47
+ rut.
48
+ - **Verbatim entry format** (`entry.py`) — one `### Inquiry capture —
49
+ <domain> (registers: …)` record per domain, questions paired with
50
+ answers, answers embedded byte-identical. Skipped questions are
51
+ recorded as skips, not silently dropped.
52
+ - **Session runner** (`session.py`) — the record-keeping side of the
53
+ interview: domain walk, skip, explicit reassignment (material that
54
+ belongs elsewhere is captured under the announced domain *unless*
55
+ the operator explicitly reassigns it), and a completion report
56
+ listing which domains got entries and where they landed.
57
+
58
+ ## Why it works
59
+
60
+ Most "tell me about your week" systems fail in one of two places: they
61
+ ask every question the same way (so every answer comes from the same
62
+ angle), or they quietly rewrite what you said on the way in (so the
63
+ record is the interviewer's summary, not your words). This package
64
+ fixes both in code:
65
+
66
+ - **Register rotation** — `plan_cycle` guarantees angular variety. A
67
+ test asserts consecutive domains differ and a full cycle covers all
68
+ seven registers.
69
+ - **Verbatim preservation** — `format_entry` adds structure *around*
70
+ answers but never touches them. The load-bearing test feeds answers
71
+ with contradictions, typos, and markdown through formatting and
72
+ asserts byte-identical survival.
73
+ - **Capture purity** — answers go through memdate's `capture()`,
74
+ which raises `InterpretationError` if the text looks like synthesis
75
+ ("in summary…") instead of a raw record. The refusal is the feature:
76
+ it keeps capture and interpretation from mixing in the same motion.
77
+
78
+ ## Installation
79
+
80
+ `meminqu` is a thin runner over `memdate` — it does
81
+ not reimplement capture, config, or storage. Install the dependency
82
+ first:
83
+
84
+ ```bash
85
+ # from the staging tree
86
+ pip install -e ../memdate
87
+ pip install -e .
88
+ ```
89
+
90
+ or for a quick offline run, put both `src` trees on the path:
91
+
92
+ ```bash
93
+ PYTHONPATH=../memdate/src:src python3 -m unittest discover -s tests
94
+ PYTHONPATH=../memdate/src:src python3 examples/demo_interview.py
95
+ ```
96
+
97
+ `pyproject.toml` declares `memdate` as a dependency honestly —
98
+ this package cannot function without it.
99
+
100
+ ## Quickstart
101
+
102
+ ```python
103
+ from meminqu import InterviewSession, Response
104
+ from memdate import MemoryConfig
105
+
106
+ config = MemoryConfig(root="./my-memory") # domains from config, not from here
107
+ session = InterviewSession(config)
108
+
109
+ for domain in session.domains:
110
+ registers = session.start_domain(domain) # announce; get suggested registers
111
+ # ... the operator/model asks questions, collects answers ...
112
+ session.submit_answers(domain, [
113
+ Response("direct", "What is the key fact?", "The server migration finished Tuesday."),
114
+ Response("sparse", "This domain in one sentence.", "Quiet, for once."),
115
+ ])
116
+ # session.skip_domain(domain) # or skip it — nothing is written
117
+
118
+ print(session.render_report())
119
+ ```
120
+
121
+ ## Where the formats meet
122
+
123
+ The original skill specified captures under a `## YYYY-MM-DD`
124
+ day-heading. The converted memdate writes a `## YYYY-MM-DD
125
+ HH:MM TZ` heading per entry instead. This package defers to the
126
+ library's heading — one source of truth for the capture format — and
127
+ the `### Inquiry capture — <domain> (registers: …)` marker rides
128
+ inside the entry body, where the register list stays attached to the
129
+ answers it describes.
130
+
131
+ ## Evidence taxonomy
132
+
133
+ This package tags no evidence itself; its artifacts are `raw.md`
134
+ entries. memdate's README maps those onto the shared ten-tier
135
+ taxonomy (`raw.md` entries sit at OBSERVATION / CLAIM). No third
136
+ taxonomy was invented.
137
+
138
+ ## Worked example
139
+
140
+ - `examples/example-1-full-cycle.md` — one full annotated cycle on
141
+ neutral domains, register rotation shown.
142
+ - `examples/demo_interview.py` — scripted end-to-end run against a
143
+ temp store; no model, no network.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/meminqu/__init__.py
5
+ src/meminqu/entry.py
6
+ src/meminqu/registers.py
7
+ src/meminqu/session.py
8
+ src/meminqu.egg-info/PKG-INFO
9
+ src/meminqu.egg-info/SOURCES.txt
10
+ src/meminqu.egg-info/dependency_links.txt
11
+ src/meminqu.egg-info/requires.txt
12
+ src/meminqu.egg-info/top_level.txt
13
+ tests/test_entry.py
14
+ tests/test_registers.py
15
+ tests/test_session.py
@@ -0,0 +1 @@
1
+ memdate
@@ -0,0 +1 @@
1
+ meminqu
@@ -0,0 +1,91 @@
1
+ """Tests for the capture-format contract.
2
+
3
+ The load-bearing property: answers survive formatting byte-identical —
4
+ contradictions, typos, markup, and all. The function may add structure
5
+ around the answers; it may never touch them.
6
+ """
7
+
8
+ import unittest
9
+
10
+ from meminqu.entry import (
11
+ ENTRY_MARKER,
12
+ Response,
13
+ answers_preserved_verbatim,
14
+ format_entry,
15
+ )
16
+ from meminqu.registers import RegisterError
17
+
18
+
19
+ def sample_responses():
20
+ return [
21
+ Response(
22
+ register="direct",
23
+ question="State the single most important fact in this domain right now.",
24
+ answer="We lost the Meridian contract on Tuesday. Nobody told me until Friday.",
25
+ ),
26
+ Response(
27
+ register="reflective",
28
+ question="What does this domain feel like from the inside this week?",
29
+ answer="Like treading water in a suit. Heavy. I keep checking my phone.",
30
+ ),
31
+ Response(
32
+ register="contrastive",
33
+ question="What is *not* true here, though it might look that way?",
34
+ # Deliberately messy: typo, contradiction, markdown, unicode.
35
+ answer="Its NOT true that were fine — we're not. Budget says **ok**; reality says ≠ ok. Fix teh report.",
36
+ ),
37
+ ]
38
+
39
+
40
+ class TestFormatEntry(unittest.TestCase):
41
+ def test_header_marker_and_domain(self):
42
+ body = format_entry("work", sample_responses())
43
+ self.assertIn(f"{ENTRY_MARKER} — work", body)
44
+
45
+ def test_register_list_matches_questions_asked(self):
46
+ body = format_entry("work", sample_responses())
47
+ first_line = body.splitlines()[0]
48
+ self.assertIn("direct", first_line)
49
+ self.assertIn("reflective", first_line)
50
+ self.assertIn("contrastive", first_line)
51
+ self.assertNotIn("sparse", first_line)
52
+
53
+ def test_answers_verbatim(self):
54
+ responses = sample_responses()
55
+ body = format_entry("work", responses)
56
+ self.assertTrue(answers_preserved_verbatim(body, responses))
57
+ for r in responses:
58
+ # Byte-identical containment, not fuzzy matching.
59
+ self.assertIn(r.answer, body)
60
+ self.assertEqual(body.count(r.answer), 1)
61
+
62
+ def test_contradiction_and_typos_survive(self):
63
+ messy = Response(
64
+ register="sparse",
65
+ question="Three words. No more.",
66
+ answer="tired tired tyrde", # typo kept, not "fixed"
67
+ )
68
+ body = format_entry("personal", [messy])
69
+ self.assertIn("tired tired tyrde", body)
70
+
71
+ def test_skipped_question_recorded_not_silent(self):
72
+ responses = [
73
+ Response(register="direct", question="Q1?", answer="A1."),
74
+ Response(register="temporal", question="Q2?", answer=""),
75
+ Response(register="temporal", question="Q3?", answer=None),
76
+ ]
77
+ body = format_entry("work", responses)
78
+ self.assertIn("A1.", body)
79
+ self.assertEqual(body.count("_[no answer]_"), 2)
80
+
81
+ def test_empty_responses_rejected(self):
82
+ with self.assertRaises(ValueError):
83
+ format_entry("work", [])
84
+
85
+ def test_unknown_register_rejected(self):
86
+ with self.assertRaises(RegisterError):
87
+ format_entry("work", [Response("nope", "Q?", "A.")])
88
+
89
+
90
+ if __name__ == "__main__":
91
+ unittest.main()
@@ -0,0 +1,107 @@
1
+ """Tests for the register set and cycle planning."""
2
+
3
+ import unittest
4
+
5
+ from meminqu.registers import (
6
+ REGISTERS,
7
+ Register,
8
+ RegisterError,
9
+ get_register,
10
+ plan_cycle,
11
+ validate_register_set,
12
+ )
13
+
14
+ EXPECTED_NAMES = (
15
+ "direct",
16
+ "reflective",
17
+ "structural",
18
+ "irreverent",
19
+ "sparse",
20
+ "temporal",
21
+ "contrastive",
22
+ )
23
+
24
+
25
+ class TestRegisterSet(unittest.TestCase):
26
+ def test_all_seven_registers_present(self):
27
+ self.assertEqual(tuple(r.name for r in REGISTERS), EXPECTED_NAMES)
28
+
29
+ def test_every_register_has_description_and_stems(self):
30
+ for r in REGISTERS:
31
+ self.assertTrue(r.label.strip(), r.name)
32
+ self.assertTrue(r.description.strip(), r.name)
33
+ self.assertGreaterEqual(len(r.example_stems), 2, r.name)
34
+ for stem in r.example_stems:
35
+ self.assertTrue(stem.strip(), r.name)
36
+
37
+ def test_get_register_case_insensitive(self):
38
+ self.assertEqual(get_register("Direct").name, "direct")
39
+ self.assertEqual(get_register("CONTRASTIVE").name, "contrastive")
40
+
41
+ def test_get_register_unknown_raises(self):
42
+ with self.assertRaises(RegisterError):
43
+ get_register("astrological")
44
+
45
+
46
+ class TestValidateRegisterSet(unittest.TestCase):
47
+ def test_custom_set_accepted(self):
48
+ custom = (
49
+ Register("a", "A", "desc", ("stem one", "stem two")),
50
+ Register("b", "B", "desc", ("stem one", "stem two")),
51
+ )
52
+ self.assertEqual(validate_register_set(custom), custom)
53
+
54
+ def test_single_register_rejected(self):
55
+ with self.assertRaises(RegisterError):
56
+ validate_register_set((REGISTERS[0],))
57
+
58
+ def test_duplicate_names_rejected(self):
59
+ with self.assertRaises(RegisterError):
60
+ validate_register_set((REGISTERS[0], REGISTERS[0]))
61
+
62
+ def test_register_without_stems_rejected(self):
63
+ bad = Register("x", "X", "desc", ())
64
+ with self.assertRaises(RegisterError):
65
+ validate_register_set((bad, REGISTERS[1]))
66
+
67
+
68
+ class TestPlanCycle(unittest.TestCase):
69
+ DOMAINS = ("personal", "work", "projects", "reference", "misc")
70
+
71
+ def test_per_domain_count(self):
72
+ plan = plan_cycle(self.DOMAINS, per_domain=3)
73
+ for domain, regs in plan.items():
74
+ self.assertEqual(len(regs), 3, domain)
75
+
76
+ def test_registers_distinct_within_domain(self):
77
+ plan = plan_cycle(self.DOMAINS, per_domain=4)
78
+ for domain, regs in plan.items():
79
+ self.assertEqual(len(set(regs)), len(regs), domain)
80
+
81
+ def test_consecutive_domains_differ(self):
82
+ plan = plan_cycle(self.DOMAINS, per_domain=3)
83
+ ordered = [plan[d] for d in self.DOMAINS]
84
+ for first, second in zip(ordered, ordered[1:]):
85
+ self.assertNotEqual(first, second)
86
+
87
+ def test_full_cycle_covers_all_registers(self):
88
+ plan = plan_cycle(self.DOMAINS, per_domain=3)
89
+ used = {r for regs in plan.values() for r in regs}
90
+ self.assertEqual(used, set(EXPECTED_NAMES))
91
+
92
+ def test_deterministic(self):
93
+ self.assertEqual(
94
+ plan_cycle(self.DOMAINS), plan_cycle(self.DOMAINS)
95
+ )
96
+
97
+ def test_per_domain_exceeding_register_count_rejected(self):
98
+ with self.assertRaises(RegisterError):
99
+ plan_cycle(self.DOMAINS, per_domain=99)
100
+
101
+ def test_empty_domains_rejected(self):
102
+ with self.assertRaises(RegisterError):
103
+ plan_cycle(())
104
+
105
+
106
+ if __name__ == "__main__":
107
+ unittest.main()
@@ -0,0 +1,172 @@
1
+ """Tests for the interview session against memdate.
2
+
3
+ Run with both packages importable, e.g. from this staging tree::
4
+
5
+ PYTHONPATH=../memdate/src:src python3 -m unittest discover -s tests
6
+
7
+ The session is a thin runner over memdate: these tests prove the
8
+ wiring — domains inherited from the config, verbatim capture through
9
+ the library, skips, reassignment, and the completion report.
10
+ """
11
+
12
+ import os
13
+ import tempfile
14
+ import unittest
15
+
16
+ from meminqu.entry import ENTRY_MARKER, Response
17
+ from meminqu.session import InterviewSession, SessionError
18
+
19
+ from memdate import (
20
+ ConfigError,
21
+ InterpretationError,
22
+ MemoryConfig,
23
+ capture,
24
+ read_raw,
25
+ )
26
+
27
+
28
+ def make_config(tmpdir, **kwargs):
29
+ return MemoryConfig(root=tmpdir, **kwargs)
30
+
31
+
32
+ def responses_for(registers):
33
+ return [
34
+ Response(
35
+ register=reg,
36
+ question=f"Sample question in the {reg} register?",
37
+ answer=f"Sample verbatim answer for {reg}. Kept exactly: teh typo stays.",
38
+ )
39
+ for reg in registers
40
+ ]
41
+
42
+
43
+ class SessionTestCase(unittest.TestCase):
44
+ def setUp(self):
45
+ self.tmp = tempfile.TemporaryDirectory()
46
+ self.addCleanup(self.tmp.cleanup)
47
+ self.config = make_config(self.tmp.name)
48
+
49
+
50
+ class TestDomainInheritance(SessionTestCase):
51
+ def test_domains_come_from_config(self):
52
+ session = InterviewSession(self.config)
53
+ # memdate's neutral defaults — no domain list declared here.
54
+ self.assertEqual(session.domains, self.config.domains)
55
+ self.assertNotIn("fhk", session.domains)
56
+ self.assertNotIn("tribunal", session.domains)
57
+
58
+ def test_custom_config_domains_used(self):
59
+ config = make_config(
60
+ self.tmp.name, domains=("alpha", "beta")
61
+ )
62
+ session = InterviewSession(config)
63
+ self.assertEqual(session.domains, ("alpha", "beta"))
64
+
65
+ def test_cycle_subset_must_be_config_known(self):
66
+ with self.assertRaises(ConfigError):
67
+ InterviewSession(self.config, domains=("work", "nope"))
68
+
69
+ def test_start_unknown_domain_rejected(self):
70
+ session = InterviewSession(self.config)
71
+ with self.assertRaises(ConfigError):
72
+ session.start_domain("fhk")
73
+
74
+ def test_start_domain_outside_cycle_rejected(self):
75
+ session = InterviewSession(self.config, domains=("work",))
76
+ with self.assertRaises(SessionError):
77
+ session.start_domain("personal")
78
+
79
+
80
+ class TestCaptureWiring(SessionTestCase):
81
+ def test_full_cycle_writes_raw_files(self):
82
+ session = InterviewSession(self.config, domains=("personal", "work"))
83
+ for domain in ("personal", "work"):
84
+ registers = session.start_domain(domain)
85
+ result = session.submit_answers(domain, responses_for(registers))
86
+ self.assertTrue(result.path.endswith(f"{domain}/raw.md"))
87
+ for domain in ("personal", "work"):
88
+ raw = read_raw(self.config, domain)
89
+ self.assertIsNotNone(raw)
90
+ self.assertIn(f"{ENTRY_MARKER} — {domain}", raw)
91
+
92
+ def test_answers_verbatim_in_raw(self):
93
+ session = InterviewSession(self.config, domains=("work",))
94
+ registers = session.start_domain("work")
95
+ answers = responses_for(registers)
96
+ session.submit_answers("work", answers)
97
+ raw = read_raw(self.config, "work")
98
+ for resp in answers:
99
+ self.assertIn(resp.answer, raw)
100
+
101
+ def test_start_domain_suggests_planned_registers(self):
102
+ session = InterviewSession(self.config, domains=("personal", "work"))
103
+ first = session.start_domain("personal")
104
+ second = session.start_domain("work")
105
+ self.assertEqual(len(first), 3)
106
+ self.assertNotEqual(first, second)
107
+
108
+ def test_skip_writes_nothing(self):
109
+ session = InterviewSession(self.config, domains=("personal", "work"))
110
+ session.start_domain("personal")
111
+ session.skip_domain("personal")
112
+ self.assertIsNone(read_raw(self.config, "personal"))
113
+ report = session.completion_report()
114
+ self.assertIn("personal", report["domains_skipped"])
115
+ self.assertNotIn("personal", report["domains_captured"])
116
+
117
+ def test_reassignment_captures_under_other_domain(self):
118
+ session = InterviewSession(self.config, domains=("personal", "work"))
119
+ registers = session.start_domain("work")
120
+ result = session.submit_answers(
121
+ "work", responses_for(registers), capture_domain="personal"
122
+ )
123
+ self.assertTrue(result.path.endswith("personal/raw.md"))
124
+ self.assertIsNone(read_raw(self.config, "work"))
125
+ raw = read_raw(self.config, "personal")
126
+ self.assertIn(f"{ENTRY_MARKER} — work", raw)
127
+
128
+ def test_reassignment_to_unknown_domain_rejected(self):
129
+ session = InterviewSession(self.config, domains=("work",))
130
+ session.start_domain("work")
131
+ with self.assertRaises(ConfigError):
132
+ session.submit_answers(
133
+ "work", responses_for(["direct"]), capture_domain="nope"
134
+ )
135
+
136
+ def test_interpretation_marker_propagates(self):
137
+ # Pure CAPTURE is enforced by the library: a synthesizing answer
138
+ # is refused rather than quietly captured.
139
+ session = InterviewSession(self.config, domains=("work",))
140
+ session.start_domain("work")
141
+ bad = [Response("direct", "Q?", "In summary, everything is fine.")]
142
+ with self.assertRaises(InterpretationError):
143
+ session.submit_answers("work", bad)
144
+
145
+
146
+ class TestCompletionReport(SessionTestCase):
147
+ def test_report_lists_paths_and_skips(self):
148
+ session = InterviewSession(
149
+ self.config, domains=("personal", "work", "projects")
150
+ )
151
+ session.start_domain("personal")
152
+ session.submit_answers("personal", responses_for(["direct", "sparse"]))
153
+ session.skip_domain("work")
154
+ report = session.completion_report()
155
+ self.assertEqual(report["domains_captured"], ["personal"])
156
+ self.assertEqual(
157
+ report["paths"], {"personal": "personal/raw.md"}
158
+ )
159
+ self.assertEqual(report["domains_skipped"], ["work"])
160
+ self.assertEqual(report["domains_untouched"], ["projects"])
161
+
162
+ def test_rendered_report_is_readable(self):
163
+ session = InterviewSession(self.config, domains=("personal",))
164
+ session.start_domain("personal")
165
+ session.submit_answers("personal", responses_for(["direct"]))
166
+ text = session.render_report()
167
+ self.assertIn("personal/raw.md", text)
168
+ self.assertIn("distill", text) # next-action offer present
169
+
170
+
171
+ if __name__ == "__main__":
172
+ unittest.main()