You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(planning): bring the one-on-one interview page in line with the shared page #8633
The shared interview page (plugins/planning/shared) now has the patterns the owner settled during live dogfooding: the one-column question screen, the folded Discussion and Claude sections, the composer kit, the Inbox, and phone keyboard handling. The one-on-one interview web page did not get them. The owner said this would need a separate session to tailor the existing page.
Proposal
One session that audits the one-on-one page against the shared page and moves the shared primitives into it (or makes both pages use one copy), then works through the open follow-ups listed below.
Acceptance
Both pages use the same question-screen layout, composer and phone keyboard behavior, or the differences are written down with their reason.
Proposed work class: work-class: structural
Basis: automated intake triage; the lanes App applied this class.
Size: Large
Complexity: Medium
Uncertainty: Known
Flags: none
Blocked by: none
Decision recorded. The owner answered in the Refinement session (2026-10-25), deciding the open items below on the facts:
go with whatever the facts/data/metrics + /discovery:research says and ensure we're aligned with SOLID, DRY/SSOT principles
I should only ever be in the loop ONLY when necessary (intent/vision changed, something came up during implementation that impacts trajectory/alignment, OR a high security risk) - I've covered this all before
[quote truncated; the full decision is at the link]
This decision settles the open questions: the issue is split per phase with #8633 as tracker, Phase 4 uses the union markdown dialect, the #8089 SHA source is out of scope, and out-of-scope findings close here with a written reason. The work is large, so it is tracked here and done in the split issues.
Scope addition: move the shared validator the one-on-one page and the shared page both use into lib/, so the two pages enforce the same question shape from one copy.
Design record for this effort, moved here from a local working file so the issue holds it. Line numbers cite main as of 2026-10-22 and may have drifted.
DESIGN.md
One source of truth for question-shape knowledge (#8633)
Base: origin/main bd05b7b973. Every file:line below is against that commit and relative to plugins/planning/ unless it starts with docs/. Read-only design; nothing is built yet.
Owner decision (2026-10-22, in session): "true dedupe, SSOT, progressive disclosure, whatever that
shakes out to". Goal: each piece of question-shape knowledge has one authoritative representation
that every lane and page consumes.
Grounding
DRY: "Every piece of knowledge must have a single, unambiguous, authoritative representation
within a system"; "DRY is about the duplication of knowledge, of intent", tested by whether one
change needs edits in several places. Hunt & Thomas, The Pragmatic Programmer, 20th anniversary
ed., DRY topic, publisher excerpt https://media.pragprog.com/titles/tpp20/dry.pdf (read in .work/owner-questions-research/shared-ui-code/RESEARCH-findings.md:154-163, claim 4, HIGH).
So the unit is knowledge, not code shape: two functions with the same name and different meaning
stay apart (section 1, "not the same knowledge").
Qualifier: Metz, "The Wrong Abstraction" https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
(same findings file:180): a shared primitive that grows per-page flags is the wrong abstraction.
Page-specific behavior stays on its page.
In-plugin sharing: ADR 0019 "Intra-plugin sharing: one committed copy, no sync script"
(docs/adr/0019-share-code-across-plugins-by-vendoring-with-a-sync-gate.md:75-86): one committed
copy, no sync script, no drift gate, a plugin version bump per change. The 2026-10-02 amendment
(same file:88-102, generated copies with --check) is for cross-plugin copies and does not apply
here.
Progressive disclosure: model-facing rules live once in skills/interview/context/question-shape.md;
lane docs point to it. skills/interview/SKILL.md stays byte-identical (standing items), so the
Stance rules it already holds (Withheld SKILL.md:141,189; Basis SKILL.md:140) remain their
source and question-shape.md points to them.
Python and the browser cannot share one file
A served JS file cannot dedupe a rule Python also encodes. Each row in section 2 picks one of:
(S) server-derived: the rule lives in one Python function; each server ships its result as a
field in the page's data, and the browser only reads it. One implementation. Precedent: the 1:1
server already derives state, revising, upstreamChanged, answered in memory before
sending (surface/server.py:866-890); the shared server builds a per-viewer snapshot
(shared/session.py:6290).
(B) served browser file: knowledge only the browser has (layout, composer, keyboard, rendering)
lives in one classic script exposing one global (as shared/web/core.js does), served by both
servers (Q2 decision: STATIC entry shared/server.py:75-85, dispatch :774-775; one route in surface/server.pyroute_get:1117-1155, whose CSP already allows 'self' scripts and styles :76-77).
(J) JSON Schema: types, enums and limits live in shared/schema/question-shape.schema.json;
other schemas $ref it (the shared state and ops schemas already do: state.schema.json:572-579, ops.schema.json:250-257).
(D) skill doc: rules the model follows when writing a question.
Python home for (S): surface/question_shape.py, next to surface/schema.py. Both servers already
import from surface/ (shared/session.py:44-50 puts it on sys.path for schema.py; round.py:95
imports it), so this adds no new path. Basis: fewest moving parts, judgment.
1. Inventory
Agree: Y = every representation says the same; N = they disagree; 1 = one representation only.
a-d enum + tradeoff: question-shape.schema.jsonalternative, question-shape.md:28, shared/session.py:2413-2420. Any string key, no tradeoff, additionalProperties: false: surface/schema/questions.schema.json:189-197
N
K5
Alternative count
Shared: 2-3, shared/session.py:2409-2412, question-shape.md:41. Page: at least 2, exactly 1 if binary, surface/round.py:354-373, round.py:56,64, surface.md:217 (R-I). Schema maxItems: 4 in K1
Y per lane (lane-specific, documented)
K6
An alternative must not restate the recommendation
Doc: question-shape.md:28. Three code definitions: refuse on whole-word containment surface/round.py:262-280 (check_rec_alts, reply/revise); warn on equality after folding (recommended)surface/round.py:585-595 + :626-630 (norm_alt, add); fold into Accept on the page surface/index.html:1141-1145 (normAlt, altsOf). Shared lane: none (shared/session.py:2404-2420 accepts any distinct-key alternatives; each becomes a card :2520)
N (three rules, one lane without one)
K7
Withheld: recommendation = asked open, never on a card
Rule: SKILL.md:141,189; question-shape.md:26. Page: surface/index.html:719 (withheld), used :1146-1151 (no Rec/Hedged choices) and :1199 heading. Shared: none; shared/session.py:2516-2520 makes a rec card from any recommendation and shared/web/app.js:5722 labels it "Claude recommends"
N (shared lane breaks the rule)
K8
basis content and how it reads
Rule: question-shape.md:27 (opens Checked against:, then Basis: and reasoning), SKILL.md:140. Page: "Why" fold, markdown, surface/index.html:1200. Shared: prefixes Basis: to the whole field, shared/web/app.js:5730; fixtures hold "basis": "Checked against: …" (shared/tests/fixtures) so the page shows Basis: Checked against: …
N (shared label precedes the Checked against: line the rule puts first)
K9
Something else / OTHER option
shared/session.py:186-187 (OTHER_OPTION, label "Something else…"), :434-467 (rule check), :2454-2455 (refuse if written); state.schema.jsonoption description + id enum; question-shape.md:102-104; browser reads the server label and hard-codes the id "other" (shared/web/core.js:168,192,1222,1226). Page lane: no OTHER; "Own answer" choice instead (surface/index.html:1149)
Y within the shared lane; the page's Own answer is a different decision kind (see K15)
K10
Option counts per format
question-shape.md:95-98 (choice 2-4, multi 2-8, rank 3-6); shared/session.py:185OPTION_COUNTS; state.schema.jsonformat (stored, OTHER included: choice 2-5, multi 2-9, rank 3-7; ops.schema.json:259 reuses it for input)
Y on the numbers, stated three times; the stored minItems is slack by one (stored options always add OTHER)
K11
Formats and purposes
question-shape.md:80-99; shared/session.py:182-183; state.schema.jsonpurpose, format; shared page branches on q.format (app.jsisChoice, :490). Page lane: no formats (decisions only)
Not the same knowledge (no SSOT; same name, different meaning): answerLine, inline, grow
(K12 merges the behavior, not these two helpers as written), who, step, post, select, row, num (the 21-name intersection of the two pages' function names). Merging them would be
Metz's wrong abstraction.
2. Source of truth per piece, and how the rest derive
#
SSOT
Kind
Consumers derive by
K1
shared/schema/question-shape.schema.json
J
surface/schema/questions.schema.json question properties $ref its $defs for types and limits; lane extras (facts, commits, binary, state fields) stay local. Blocker: surface/schema.py:46-51_by_id searches only loaded registries, so round.py and surface/server.py must load shared/schema once (schema.registry(SHARED_SCHEMA_DIR)) before validating. Moving the file to a plugin-level schema/ instead would touch shared/tests/test_schema_shapes.py:27, shared/tests/tracer.test.sh:8 and every $id; not recommended
K2
question-shape.md "Per lane"
D
No rename. Renaming title→text on the page would break files the page lane still reads (surface/test_schema.py:114, v2.1 files without a schema version). The mapping table is the single representation
K3
the schema short.maxLength
J
Page schema refs it; round.py:133 drops its own short cap (schema enforces). Value is an owner choice (P1)
K4
the schema alternative
J
Page schema refs it; tradeoff becomes allowed on the page, and the page shows it under Alternatives. Key enum on old page files is an owner choice (P4)
K5
lane rules stay lane-local
D + code
surface.md:217 R-I and lane-shared.md keep their counts; question-shape.md:41 keeps the shared count once. No change
K6
one function restates(alt_text, rec) in surface/question_shape.py
S
round.pycheck_rec_alts and restates_recommendation/norm_alt call it; shared/session.py input parsing calls it; both servers ship alternatives[i].restates: true in page data; surface/index.htmlaltsOf reads the flag instead of normAlt. Which definition, and refuse vs warn, is an owner choice (P2)
K7
one function withheld(rec) in surface/question_shape.py; rule text stays SKILL.md:189
S
1:1 server ships withheld: true; surface/index.html:719 reads it. shared/session.py:2516-2520 makes no rec card for a withheld recommendation; the shared snapshot ships withheld; app.js shows the Withheld: text as the question's open note. Alternatives on a withheld shared question: owner choice (P3)
K8
question-shape.md:27
D
Shared page drops its Basis: prefix and renders the field as written (it already opens Checked against: and carries its own Basis: label), through the K14 renderer, folded like the page's "Why". Follows from the written rule
K9
shared/session.py:186OTHER_OPTION
S
Already the source; the browser reads the label from options. The "other" id literal in core.js becomes one constant in the served file (B). Page lane: no change
K10
shared/session.py:185OPTION_COUNTS + the schema
S/J
question-shape.md:95-98 keeps the model-facing numbers (D, the model needs them); a Python test asserts the doc table, OPTION_COUNTS and the stored schema bounds agree (stored = counts + 1). Tighten stored minItems to counts + 1
composer() and grow() move out of app.js into the served file; app.js calls the global; surface/index.html loads both files (new route + <link>) and wires its answer and note textareas to the same composer. Page-specific send actions stay as callbacks
K13
same served file
B
fitViewport moves out of core.js:1605-1630 into the served file; both pages call it. The 1:1 page's layout must consume --vv-h/--kb
K14
same served file: one safe DOM renderer
B
One dialect covering both pages' needs (headings and quotes from the page; mentions and bare links from the shared page; issue refs when the session names a repo). surface/index.htmlmd/inline and app.jsrich/inline/links are removed. Dialect is an owner choice (P6)
K15
page-lane only
—
No SSOT change; written down as a page-specific difference (#8633 acceptance allows it)
K16
not the same knowledge
—
No change
K17
shared/session.py:253REPO
S
Shared page reads a server-validated value instead of its own regex; state schema keeps its pattern (J, validation); page issue links use the K14 renderer with the same pattern
K18
schema + question-shape.md
J/D
No change
Docs (progressive disclosure): question-shape.md holds every model-facing rule once and points to SKILL.md Stance for Withheld and Basis. lane-page.md, surface.md, lane-shared.md point to it
and keep only lane-specific rules (R-I count, op fields, purpose/format inference already in
question-shape.md). Today they mostly point already (lane-shared.md:153,328,334); surface.md:93
restates a cap that K3 resolves.
3. Behavior changes unification forces
#
Change
Kind
C1
Shared lane: a Withheld: text question shows no "Claude recommends" card; its text shows as an open note
Follows from SKILL.md:189 ("asked open, never on a card"; "on the page its recommendation field carries the Withheld: text"). Fixes the bug at shared/session.py:2516-2520
C2
Shared lane: whether a withheld question keeps 2-3 alternative cards, or takes none (session.py:2409-2410 requires them with any recommendation)
New product choice (P3)
C3
Shared lane: an alternative that restates the recommendation is refused (or warned, or folded) like the page lane
Rule exists (question-shape.md:28 "none restates the recommendation"); which definition and refuse vs warn is a new product choice (P2)
C4
Page lane: one restates definition replaces three; files that passed the old warn-only check may now be refused on revise, and the page's fold uses the server flag
Part of P2
C5
Shared page shows basis as written (no extra Basis: prefix), rendered and folded
Follows from question-shape.md:27
C6
Page lane: short cap becomes one number
New product choice (P1): 80 (question-shape.md:23) vs 500 (surface.md:93); two written rules conflict
C7
Page lane: alternatives accept tradeoff and show it
Follows from question-shape.md:28 ({key, text, tradeoff})
C8
Page lane: alternative keys limited to a-d
New product choice (P4): old page files with other keys would fail validation
C9
1:1 page adopts the shared composer (send key hint, counter, drafts per question, growth relative to the pane)
Follows from #8633 acceptance ("same … composer … or the differences written down") and the owner's dedupe decision; the 1:1 page already sends with Ctrl/Cmd+Enter (surface/index.html:2267) and runs single-key shortcuts only outside text boxes (:522), so the keys mostly agree; whether the composer's per-field send replaces the page's one document-level Ctrl+Enter handler (:2263-2283) is a new product choice (P5)
C10
1:1 page adopts visual-viewport pinning on phones
Follows from #8633 acceptance ("phone keyboard behavior")
C11
Both pages render markdown with one dialect
New product choice (P6): the page loses or the shared page gains headings, quotes and issue refs
C12
Question-screen layout (one-column, folded Discussion and Claude sections) on the 1:1 page
Named in #8633 acceptance but it is page layout, not question-shape knowledge; out of this design's scope; scope it with P5/P6 or as its own phase (P7)
4. Phased build plan (one PR each)
Common to every phase: worktree under <worktrees>/ccp-shared-<slug>; planning version
bump per ADR 0019:75-86 only if the release flow needs it (check how .changes/planning fragments
drive versions first); a .changes/planning fragment; SKILL.md untouched. Python suites: plugins/planning/shared/tests/test_*.py and plugins/planning/surface/test_*.py. Browser suites one
at a time host-wide, only when pgrep -x chrome-headless prints nothing: shared as env UI_JOBS=2 nice node plugins/planning/shared/tests/<suite>.js; 1:1 as bash plugins/planning/surface/surface-checks.sh --part main|ui-c|journey. Any interview doc edit
also runs tests/interview-defenses.test.sh and tests/reattach-slice.test.sh. Every phase that
edits surface/ is the other lane's: check ListAgents and message its owner first; say so in the PR.
Acceptance: surface/question_shape.py holds restates and withheld; round.py and shared/session.py call them and keep no other definition (grep shows no norm_alt/norm_text
restates logic outside it); both servers ship withheld and alternatives[i].restates; a withheld
shared text question has no rec card; surface/index.html reads the flags.
Tests: both Python suites (new units for both functions and the shared card rule); shared
browser ui_claude_proposals.js, ui_discussion.js; 1:1 --part ui-c (ui_c.js covers folding), --part journey, --part main (ui_e.js covers withheld).
Blast radius: round.py add/reply/revise, session.py question input and _add_question,
snapshot view, surface/index.htmlchoicesOf/altsOf, exporters.py if it reads alternatives.
Phase 3: served file and route (K9 id constant, K12, K13). Needs P5.
Acceptance: shared/web/question-shape.js and .css exist, served by both servers (STATIC
entry; one surface/server.py route); composer, grow, fitViewport exist only there; shared/web/index.html and surface/index.html load them; the 1:1 answer and note boxes use the
composer; the 1:1 page pins to the visual viewport.
Tests: both Python suites (shared/tests/test_server.py static list; surface/test_server.py
new route and CSP); shared browser ui_composer.js, ui_keyboard.js, ui_phone.js, ui_askbox.js; 1:1 all three parts.
Blast radius: every composer( call in app.js (:1080,1090,1347,1376,1389,1390,1448, more
below), core.js viewport consumers (core-viewport event), 1:1 answer box and keyboard handler.
Phase 4: one renderer and basis (K8, K14, K17, C5). Needs P6.
Acceptance: one safe DOM markdown renderer in the served file; surface/index.htmlmd/inline
and app.jsrich/inline/links removed; shared page shows basis as written, folded; REPO read from the server value.
Tests: both Python suites; shared ui_formats.js, ui_reflinks.js, ui_claude_proposals.js;
1:1 all three parts.
Blast radius: every rendered text field on both pages (question text, context/facts, chat, notes,
history, Claude replies). Highest visual risk; consider /testing:check-visual-parity baselines.
Acceptance: question-shape.md states each rule once and points to SKILL.md Stance for Withheld
and Basis; surface.md, lane-page.md, lane-shared.md point instead of restating
(surface.md:93 short cap); written list of remaining page-specific differences (K15, K16, C12)
with reasons, as feat(planning): bring the one-on-one interview page in line with the shared page #8633 acceptance asks.
Blast radius: model behavior in all three lanes; digest pins (SKILL.md untouched).
Not covered by this design: #7303, #5795, #5893 (the other half of #8633's acceptance) and C12.
Every PR here opens Refs: #8633 until those land.
New product choices for the owner
P1short cap: 80 (question-shape.md:23, schema, shared server) or 500 (surface.md:93,
LINE_CAP). Recommend 80 for short only (it is the list label; one-line fields keep 500). Basis:
judgment; unblocks Phase 1.
P2 Restates-the-recommendation: which definition (whole-word containment either way, round.py:262-280; or equality after folding, round.py:585-595) and refuse or warn, in both
lanes. Recommend containment, refuse on write in both lanes, keep the page fold for old files via
the server flag. Basis: judgment (containment catches more; refusing matches the reply/revise
behavior the page lane already has). Unblocks Phase 2.
P3 Withheld shared question: keep 2-3 alternative cards (people vote among them, no
recommended card) or take none (asked fully open). Recommend keep the alternatives. Basis:
judgment (SKILL.md:189 bars a recommendation on a card, not alternatives). Unblocks Phase 2.
P4 Page alternative keys: restrict to a-d (old files with other keys fail) or keep any
string on the page. Recommend restrict, after checking existing page fixtures for non a-d keys.
Basis: judgment. Unblocks Phase 1.
P5 1:1 page keys under the shared composer: both already use Ctrl/Cmd+Enter
(surface/index.html:2267) and keep single keys out of text boxes (:522). Choice: the
composer's per-field send/escape replaces the page's document-level handler (:2263-2283) inside
text boxes, or the page keeps its handler and the composer runs with no send key there. Recommend
the composer inside text boxes, the page handler everywhere else. Basis: judgment. Unblocks Phase 3.
P6 One markdown dialect: union (headings, quotes, issue refs, mentions, bare links) or the
shared page's smaller set. Recommend the union. Basis: judgment. Unblocks Phase 4.
P7 Question-screen layout on the 1:1 page (C12): in this effort as its own phase, or a
separate issue. Recommend a separate phase after Phase 3, designed with the other lane. Basis:
judgment; it is layout, not question-shape knowledge.
Re-measure the Decide list clamp at 1280x720 and the sticky decide bar at 1440x900.
Out of scope, stay on planning: interview web view, deferred review findings #7303: DX-3 exit codes (its SKILL.md blocker is stale; the codes live in context/surface.md), DX-5 wake.json, DX-9 server start log, DX-11 watcher guard by basename. All four are in lib/session-bridge, which is cross-plugin.
Update: Phase 3 landed (2026-10-22, supersedes the cutoff note above)
Phase 3 shipped as #9242 (2f68361), which replaced #9197: one served question-shape file, so both pages use the same composer and visual-viewport fit. Two fixes landed with it:
the Notes key hint after a reconnect;
a macOS same-second race in test_tunnel.
Still open here, not started:
Phase 4: one markdown renderer for both pages, basis shown as written, REPO read from the server.
Phase 5: docs progressive disclosure.
The post-Phase 4 fixes from the triage comment above.
Released for re-triage under the owner's 2026-10-24 rule: an issue stops for a person only for an intent or vision question, a security call, a host or attended run, or spend outside prepaid credit. The hold label came off and intake triage runs again now; it re-applies a hold, with a numbered question, when one of those applies.
To hold this issue again, add needs-human (or status: needs-decision) and say why in a comment.
Refinement. A read-only run explored this repository, searched its issues and pull requests, and read the allowed docs to draft what follows. On an issue a listed human filed, it counts as the issue's spec once posted, while unedited; it is still model text, so check it.
Intended outcome: Finish the remaining one-on-one alignment as separate changes: Phase 4 (one safe markdown renderer in question-shape.js for both pages, basis shown as written, REPO read from the server), Phase 5 (docs point to question-shape.md), then the #7303 UI-9 label and re-measure fixes, with the page-specific differences written down.
Dependencies
Phase 3 (#9242) is landed: both pages already load question-shape.js (plugins/planning/surface/index.html:541).
#8089 repo-path links wait on Phase 4 and on a decision about where a pinned commit SHA comes from.
Should this issue be split into one issue per remaining phase (Phase 4, Phase 5, post-Phase 4 fixes), each with its own checkable criteria, since the acceptance lines are not checkable and the work spans several changes?
RECOMMENDED: Yes. Make Phase 4 the next lane issue, with Phase 5 and the #7303 UI-9 and re-measure fixes after it, and keep #8633 as the tracker. Basis: judgment
Is the markdown dialect for Phase 4 settled as the union (headings, quotes, issue refs, mentions, bare links)?
RECOMMENDED: Yes, the union. The 2026-10-22 state comment already calls it 'union dialect, P6'. Basis: #8633 comment 2026-10-22 (design record P6)
Where does the pinned commit SHA for #8089 repo-path links come from?
RECOMMENDED: Leave #8089 out of this issue until the owner picks a SHA source, because no pinned commit reaches the page today. Basis: #8633 comment 2026-10-22 (triage of #7303, #5795 and #5893)
Does the 'Acceptance' line about #7303, #5795 and #5893 require every finding fixed, or may the out-of-scope ones (DX-3, DX-5, DX-9, DX-11, eval-first, UX-3, DX-7, #5796 G4/G11) stay open on their own issues with a written reason?
RECOMMENDED: Close them with a reason and leave them on their own issues. Basis: #8633 comment 2026-10-22 (triage of #7303, #5795 and #5893)
Sources read
/home/runner/work/_temp/refine-data/context.json: the owner's comments from 2026-10-22 and 2026-10-23 give the design record P1-P7, say Phases 1-3 shipped (#9160, #9164, #9242), and list Phase 4, Phase 5 and the post-Phase 4 fixes as open.
plugins/planning/surface/index.html:622: the page still has its own inline() and md() renderers.
plugins/planning/surface/index.html:541: the page already loads /question-shape.js.
plugins/planning/shared/web/app.js:542: the shared page still has its own inline(), links() and rich() renderers (app.js:551 and :607).
plugins/planning/surface/server.py:85: the 1:1 server already serves /question-shape.js.
Automated refinement by the lanes App. Answer with a comment whose first line is **Decision recorded.**.
Decision recorded. The owner answered in the Refinement session (2026-10-25), deciding the open items below on the facts:
go with whatever the facts/data/metrics + /discovery:research says and ensure we're aligned with SOLID, DRY/SSOT principles
I should only ever be in the loop ONLY when necessary (intent/vision changed, something came up during implementation that impacts trajectory/alignment, OR a high security risk) - I've covered this all before
Question asked: Split the rest into Phase 4, Phase 5 and the post-Phase 4 fixes with the union markdown dialect, keeping #8633 as the tracker?
Is the issue split into one issue per remaining phase (Phase 4, Phase 5, post-Phase 4 fixes), each with checkable criteria?
Decided: Yes. Phase 4 is the next lane issue; Phase 5 and the planning: interview web view, deferred review findings #7303 UI-9 and re-measure fixes follow; feat(planning): bring the one-on-one interview page in line with the shared page #8633 stays the tracker. Basis: Phase 4 is not done on origin/main: plugins/planning/surface/index.html:633 and :655 keep their own inline and md, and plugins/planning/shared/web/app.js:542, :551 and :607 keep inline, links and rich; judgment for the split; kept.
Acceptance criteria (converted from the body's Acceptance, wording unchanged except the dialect and split from items 1 and 2):
Both pages use the same question-screen layout, composer and phone keyboard behavior, or the differences are written down with their reason.
Both pages render markdown through one renderer in plugins/planning/shared/web/question-shape.js using the union dialect, and the page-local md/inline and rich/inline/links are gone.
Automated split by the lanes App. Admission waits on this issue while a sub-issue is open; work-unblock closes it as completed once every sub-issue is closed by a merge or a listed human.
Activity
github-actions commentedon Oct 20, 2026
Proposed work class: work-class: structural
Basis: automated intake triage; the lanes App applied this class.
Size: Large
Complexity: Medium
Uncertainty: Known
Flags: none
Blocked by: none
#8633 (comment)
This decision settles the open questions: the issue is split per phase with #8633 as tracker, Phase 4 uses the union markdown dialect, the #8089 SHA source is out of scope, and out-of-scope findings close here with a written reason. The work is large, so it is tracked here and done in the split issues.
Automated triage, applied by the lanes App.
kyle-sexton commentedon Oct 21, 2026
Scope addition: move the shared validator the one-on-one page and the shared page both use into lib/, so the two pages enforce the same question shape from one copy.
feat(planning): take page question fields from the shared question sh…
kyle-sexton commentedon Oct 22, 2026
Design record for this effort, moved here from a local working file so the issue holds it. Line numbers cite main as of 2026-10-22 and may have drifted.
DESIGN.md
One source of truth for question-shape knowledge (#8633)
Base: origin/main
bd05b7b973. Everyfile:linebelow is against that commit and relative toplugins/planning/unless it starts withdocs/. Read-only design; nothing is built yet.Owner decision (2026-10-22, in session): "true dedupe, SSOT, progressive disclosure, whatever that
shakes out to". Goal: each piece of question-shape knowledge has one authoritative representation
that every lane and page consumes.
Grounding
within a system"; "DRY is about the duplication of knowledge, of intent", tested by whether one
change needs edits in several places. Hunt & Thomas, The Pragmatic Programmer, 20th anniversary
ed., DRY topic, publisher excerpt https://media.pragprog.com/titles/tpp20/dry.pdf (read in
.work/owner-questions-research/shared-ui-code/RESEARCH-findings.md:154-163, claim 4, HIGH).So the unit is knowledge, not code shape: two functions with the same name and different meaning
stay apart (section 1, "not the same knowledge").
(same findings file:180): a shared primitive that grows per-page flags is the wrong abstraction.
Page-specific behavior stays on its page.
(
docs/adr/0019-share-code-across-plugins-by-vendoring-with-a-sync-gate.md:75-86): one committedcopy, no sync script, no drift gate, a plugin version bump per change. The 2026-10-02 amendment
(same file:88-102, generated copies with
--check) is for cross-plugin copies and does not applyhere.
skills/interview/context/question-shape.md;lane docs point to it.
skills/interview/SKILL.mdstays byte-identical (standing items), so theStance rules it already holds (Withheld
SKILL.md:141,189; BasisSKILL.md:140) remain theirsource and question-shape.md points to them.
Python and the browser cannot share one file
A served JS file cannot dedupe a rule Python also encodes. Each row in section 2 picks one of:
field in the page's data, and the browser only reads it. One implementation. Precedent: the 1:1
server already derives
state,revising,upstreamChanged,answeredin memory beforesending (
surface/server.py:866-890); the shared server builds a per-viewer snapshot(
shared/session.py:6290).lives in one classic script exposing one global (as
shared/web/core.jsdoes), served by bothservers (Q2 decision:
STATICentryshared/server.py:75-85, dispatch:774-775; one route insurface/server.pyroute_get:1117-1155, whose CSP already allows'self'scripts and styles:76-77).shared/schema/question-shape.schema.json;other schemas
$refit (the shared state and ops schemas already do:state.schema.json:572-579,ops.schema.json:250-257).Python home for (S):
surface/question_shape.py, next tosurface/schema.py. Both servers alreadyimport from
surface/(shared/session.py:44-50puts it onsys.pathforschema.py;round.py:95imports it), so this adds no new path. Basis: fewest moving parts, judgment.
1. Inventory
Agree: Y = every representation says the same; N = they disagree; 1 = one representation only.
shared/schema/question-shape.schema.json:1-40;shared/schema/state.schema.json:565-588(refs K1);shared/schema/ops.schema.json:245-259(refs K1);surface/schema/questions.schema.json:254-293(own copy, no ref);question-shape.md:18-29title/facts/commitson the page;text/context/formatshared)question-shape.md:35-41"Per lane";surface/schema/questions.schema.json:269-274;surface.md:75-77op fields; consumerssurface/index.html(q.title,q.facts),shared/web/core.js/app.js(q.text,q.context)shortlength capquestion-shape.schema.jsonshort.maxLength,question-shape.md:23,shared/session.py:2400. 500:surface/server.py:49LINE_CAP,surface/round.py:133,surface.md:93tradeoffa-denum +tradeoff:question-shape.schema.jsonalternative,question-shape.md:28,shared/session.py:2413-2420. Any string key, notradeoff,additionalProperties: false:surface/schema/questions.schema.json:189-197shared/session.py:2409-2412,question-shape.md:41. Page: at least 2, exactly 1 ifbinary,surface/round.py:354-373,round.py:56,64,surface.md:217(R-I). SchemamaxItems: 4in K1question-shape.md:28. Three code definitions: refuse on whole-word containmentsurface/round.py:262-280(check_rec_alts, reply/revise); warn on equality after folding(recommended)surface/round.py:585-595+:626-630(norm_alt, add); fold into Accept on the pagesurface/index.html:1141-1145(normAlt,altsOf). Shared lane: none (shared/session.py:2404-2420accepts any distinct-key alternatives; each becomes a card:2520)Withheld:recommendation = asked open, never on a cardSKILL.md:141,189;question-shape.md:26. Page:surface/index.html:719(withheld), used:1146-1151(no Rec/Hedged choices) and:1199heading. Shared: none;shared/session.py:2516-2520makes areccard from any recommendation andshared/web/app.js:5722labels it "Claude recommends"basiscontent and how it readsquestion-shape.md:27(opensChecked against:, thenBasis:and reasoning),SKILL.md:140. Page: "Why" fold, markdown,surface/index.html:1200. Shared: prefixesBasis:to the whole field,shared/web/app.js:5730; fixtures hold"basis": "Checked against: …"(shared/tests/fixtures) so the page showsBasis: Checked against: …Checked against:line the rule puts first)shared/session.py:186-187(OTHER_OPTION, label "Something else…"),:434-467(rule check),:2454-2455(refuse if written);state.schema.jsonoptiondescription + id enum;question-shape.md:102-104; browser reads the server label and hard-codes the id"other"(shared/web/core.js:168,192,1222,1226). Page lane: no OTHER; "Own answer" choice instead (surface/index.html:1149)question-shape.md:95-98(choice 2-4, multi 2-8, rank 3-6);shared/session.py:185OPTION_COUNTS;state.schema.jsonformat(stored, OTHER included: choice 2-5, multi 2-9, rank 3-7;ops.schema.json:259reuses it for input)minItemsis slack by one (stored options always add OTHER)question-shape.md:80-99;shared/session.py:182-183;state.schema.jsonpurpose,format; shared page branches onq.format(app.jsisChoice,:490). Page lane: no formats (decisions only)composer()shared/web/app.js:667-700+,grow():854-860, CSSshared/web/app.css:538-541,795,1219-1232. Page:grow()surface/index.html:1284(40% of window, no counter or send-key hint); answer boxrenderAnswer:1231-1268fitViewportpins to the visual viewport,--kb,data-kbshared/web/core.js:1605-1630; reveal-on-focusshared/web/app.js:862,873. Page: nonerich/inline/linksshared/web/app.js:536-620(paragraphs,-lists,**bold**,`code`, bare links). Page: string buildermd/inlinesurface/index.html:578-617(headings, quotes, lists, links, issue refs:583)surface/index.html:536-537(LABEL,DEC),surface/schema/responses.schema.json,surface/exporters.pyanswerLine(q)"You answered …"surface/index.html:704; sharedanswerLine(a)card text or file countshared/web/app.js:510owner/repopatternshared/session.py:253(its comment names copies instate.schema.json:148andshared/web/app.js:562); page issue-link regexsurface/index.html:583Q<N>anddependsOnearlier-onlyquestion-shape.schema.jsonquestionId;shared/session.py:2425-2430;surface/round.py(dependsOn checks);question-shape.md:18,29Not the same knowledge (no SSOT; same name, different meaning):
answerLine,inline,grow(K12 merges the behavior, not these two helpers as written),
who,step,post,select,row,num(the 21-name intersection of the two pages' function names). Merging them would beMetz's wrong abstraction.
2. Source of truth per piece, and how the rest derive
shared/schema/question-shape.schema.jsonsurface/schema/questions.schema.jsonquestion properties$refits$defsfor types and limits; lane extras (facts,commits,binary, state fields) stay local. Blocker:surface/schema.py:46-51_by_idsearches only loaded registries, soround.pyandsurface/server.pymust loadshared/schemaonce (schema.registry(SHARED_SCHEMA_DIR)) before validating. Moving the file to a plugin-levelschema/instead would touchshared/tests/test_schema_shapes.py:27,shared/tests/tracer.test.sh:8and every$id; not recommendedquestion-shape.md"Per lane"title→texton the page would break files the page lane still reads (surface/test_schema.py:114, v2.1 files without a schema version). The mapping table is the single representationshort.maxLengthround.py:133drops its ownshortcap (schema enforces). Value is an owner choice (P1)alternativetradeoffbecomes allowed on the page, and the page shows it under Alternatives. Key enum on old page files is an owner choice (P4)surface.md:217R-I andlane-shared.mdkeep their counts;question-shape.md:41keeps the shared count once. No changerestates(alt_text, rec)insurface/question_shape.pyround.pycheck_rec_altsandrestates_recommendation/norm_altcall it;shared/session.pyinput parsing calls it; both servers shipalternatives[i].restates: truein page data;surface/index.htmlaltsOfreads the flag instead ofnormAlt. Which definition, and refuse vs warn, is an owner choice (P2)withheld(rec)insurface/question_shape.py; rule text staysSKILL.md:189withheld: true;surface/index.html:719reads it.shared/session.py:2516-2520makes noreccard for a withheld recommendation; the shared snapshot shipswithheld;app.jsshows theWithheld:text as the question's open note. Alternatives on a withheld shared question: owner choice (P3)question-shape.md:27Basis:prefix and renders the field as written (it already opensChecked against:and carries its ownBasis:label), through the K14 renderer, folded like the page's "Why". Follows from the written ruleshared/session.py:186OTHER_OPTION"other"id literal incore.jsbecomes one constant in the served file (B). Page lane: no changeshared/session.py:185OPTION_COUNTS+ the schemaquestion-shape.md:95-98keeps the model-facing numbers (D, the model needs them); a Python test asserts the doc table,OPTION_COUNTSand the stored schema bounds agree (stored = counts + 1). Tighten storedminItemsto counts + 1shared/session.py:182-183+ schemashared/web/question-shape.js(B) +shared/web/question-shape.csscomposer()andgrow()move out ofapp.jsinto the served file;app.jscalls the global;surface/index.htmlloads both files (new route +<link>) and wires its answer and note textareas to the same composer. Page-specific send actions stay as callbacksfitViewportmoves out ofcore.js:1605-1630into the served file; both pages call it. The 1:1 page's layout must consume--vv-h/--kbsurface/index.htmlmd/inlineandapp.jsrich/inline/linksare removed. Dialect is an owner choice (P6)shared/session.py:253REPOquestion-shape.mdDocs (progressive disclosure):
question-shape.mdholds every model-facing rule once and points toSKILL.mdStance for Withheld and Basis.lane-page.md,surface.md,lane-shared.mdpoint to itand keep only lane-specific rules (R-I count, op fields,
purpose/formatinference already inquestion-shape.md). Today they mostly point already (
lane-shared.md:153,328,334);surface.md:93restates a cap that K3 resolves.
3. Behavior changes unification forces
Withheld:text question shows no "Claude recommends" card; its text shows as an open noteSKILL.md:189("asked open, never on a card"; "on the page itsrecommendationfield carries theWithheld:text"). Fixes the bug atshared/session.py:2516-2520session.py:2409-2410requires them with any recommendation)question-shape.md:28"none restates the recommendation"); which definition and refuse vs warn is a new product choice (P2)basisas written (no extraBasis:prefix), rendered and foldedquestion-shape.md:27shortcap becomes one numberquestion-shape.md:23) vs 500 (surface.md:93); two written rules conflicttradeoffand show itquestion-shape.md:28({key, text, tradeoff})a-dsurface/index.html:2267) and runs single-key shortcuts only outside text boxes (:522), so the keys mostly agree; whether the composer's per-field send replaces the page's one document-level Ctrl+Enter handler (:2263-2283) is a new product choice (P5)4. Phased build plan (one PR each)
Common to every phase: worktree under
<worktrees>/ccp-shared-<slug>; planning versionbump per ADR 0019:75-86 only if the release flow needs it (check how
.changes/planningfragmentsdrive versions first); a
.changes/planningfragment;SKILL.mduntouched. Python suites:plugins/planning/shared/tests/test_*.pyandplugins/planning/surface/test_*.py. Browser suites oneat a time host-wide, only when
pgrep -x chrome-headlessprints nothing: shared asenv UI_JOBS=2 nice node plugins/planning/shared/tests/<suite>.js; 1:1 asbash plugins/planning/surface/surface-checks.sh --part main|ui-c|journey. Any interview doc editalso runs
tests/interview-defenses.test.shandtests/reattach-slice.test.sh. Every phase thatedits
surface/is the other lane's: checkListAgentsand message its owner first; say so in the PR.Phase 1: schema SSOT (K1, K3, K4, K10). Needs P1, P4.
questions.schema.jsonquestion properties$refquestion-shape$defs;round.pyand
surface/server.pyloadshared/schemabefore validating;round.py:133short cap gone;stored
minItems= counts + 1; a test asserts doc table =OPTION_COUNTS= schema bounds - 1.surface/test_schema.py,test_round.py,test_server.py,shared/tests/test_schema_shapes.py);shared/tests/tracer.test.sh(it scopes the schema).round.pyops) and reader (surface/server.py,exporters.py); shared ops parsing.surface/edited.Phase 2: question_shape.py (K6, K7, C1, C3, C4). Needs P2, P3.
surface/question_shape.pyholdsrestatesandwithheld;round.pyandshared/session.pycall them and keep no other definition (grep shows nonorm_alt/norm_textrestates logic outside it); both servers ship
withheldandalternatives[i].restates; a withheldshared text question has no
reccard;surface/index.htmlreads the flags.browser
ui_claude_proposals.js,ui_discussion.js; 1:1--part ui-c(ui_c.js covers folding),--part journey,--part main(ui_e.js covers withheld).round.pyadd/reply/revise,session.pyquestion input and_add_question,snapshot view,
surface/index.htmlchoicesOf/altsOf,exporters.pyif it reads alternatives.Phase 3: served file and route (K9 id constant, K12, K13). Needs P5.
shared/web/question-shape.jsand.cssexist, served by both servers (STATICentry; one
surface/server.pyroute);composer,grow,fitViewportexist only there;shared/web/index.htmlandsurface/index.htmlload them; the 1:1 answer and note boxes use thecomposer; the 1:1 page pins to the visual viewport.
shared/tests/test_server.pystatic list;surface/test_server.pynew route and CSP); shared browser
ui_composer.js,ui_keyboard.js,ui_phone.js,ui_askbox.js; 1:1 all three parts.composer(call inapp.js(:1080,1090,1347,1376,1389,1390,1448, morebelow),
core.jsviewport consumers (core-viewportevent), 1:1 answer box and keyboard handler.Phase 4: one renderer and basis (K8, K14, K17, C5). Needs P6.
surface/index.htmlmd/inlineand
app.jsrich/inline/linksremoved; shared page showsbasisas written, folded;REPOread from the server value.ui_formats.js,ui_reflinks.js,ui_claude_proposals.js;1:1 all three parts.
history, Claude replies). Highest visual risk; consider
/testing:check-visual-paritybaselines.Phase 5: docs (progressive disclosure, K2, K5). No owner choice beyond P1-P4.
question-shape.mdstates each rule once and points toSKILL.mdStance for Withheldand Basis;
surface.md,lane-page.md,lane-shared.mdpoint instead of restating(
surface.md:93short cap); written list of remaining page-specific differences (K15, K16, C12)with reasons, as feat(planning): bring the one-on-one interview page in line with the shared page #8633 acceptance asks.
tests/interview-defenses.test.sh,tests/reattach-slice.test.sh,tests/interview-surface-decision-mirror.test.sh.Not covered by this design: #7303, #5795, #5893 (the other half of #8633's acceptance) and C12.
Every PR here opens
Refs: #8633until those land.New product choices for the owner
shortcap: 80 (question-shape.md:23, schema, shared server) or 500 (surface.md:93,LINE_CAP). Recommend 80 for
shortonly (it is the list label; one-line fields keep 500). Basis:judgment; unblocks Phase 1.
round.py:262-280; or equality after folding,round.py:585-595) and refuse or warn, in bothlanes. Recommend containment, refuse on write in both lanes, keep the page fold for old files via
the server flag. Basis: judgment (containment catches more; refusing matches the reply/revise
behavior the page lane already has). Unblocks Phase 2.
recommended card) or take none (asked fully open). Recommend keep the alternatives. Basis:
judgment (SKILL.md:189 bars a recommendation on a card, not alternatives). Unblocks Phase 2.
a-d(old files with other keys fail) or keep anystring on the page. Recommend restrict, after checking existing page fixtures for non a-d keys.
Basis: judgment. Unblocks Phase 1.
(
surface/index.html:2267) and keep single keys out of text boxes (:522). Choice: thecomposer's per-field send/escape replaces the page's document-level handler (
:2263-2283) insidetext boxes, or the page keeps its handler and the composer runs with no send key there. Recommend
the composer inside text boxes, the page handler everywhere else. Basis: judgment. Unblocks Phase 3.
shared page's smaller set. Recommend the union. Basis: judgment. Unblocks Phase 4.
separate issue. Recommend a separate phase after Phase 3, designed with the other lane. Basis:
judgment; it is layout, not question-shape knowledge.
kyle-sexton commentedon Oct 22, 2026
State at the 2026-10-22 cutoff
Shipped:
question_shape.pyrestates/withheld; merging now.shortover 80 characters stays writable; merging now.Parked: Phase 3, #9197 (one served composer/viewport file for both pages); branch kept, resume steps on the PR.
Not started:
basisshown as written, REPO read from the server.question-shape.mdstates each rule once and the lane docs point to it.Triage of #7303, #5795, #5893 (read-only, against main at aafe82d):
surface/index.html.context/surface.md), DX-5wake.json, DX-9 server start log, DX-11 watcher guard by basename. All four are inlib/session-bridge, which is cross-plugin.The design record (P1-P7, phase acceptance criteria) is in the comment above.
kyle-sexton commentedon Oct 23, 2026
Update: Phase 3 landed (2026-10-22, supersedes the cutoff note above)
Phase 3 shipped as #9242 (2f68361), which replaced #9197: one served question-shape file, so both pages use the same composer and visual-viewport fit. Two fixes landed with it:
test_tunnel.Still open here, not started:
basisshown as written, REPO read from the server.#8089 stays held until Phase 4.
kyle-sexton commentedon Oct 25, 2026
Released for re-triage under the owner's 2026-10-24 rule: an issue stops for a person only for an intent or vision question, a security call, a host or attended run, or spend outside prepaid credit. The hold label came off and intake triage runs again now; it re-applies a hold, with a numbered question, when one of those applies.
To hold this issue again, add
needs-human(orstatus: needs-decision) and say why in a comment.3 remaining items
melodic-automation-lanes commentedon Oct 25, 2026
Not admitted to the issue lane yet: Size large: the split into sub-issues at intake is pending (owner Q93).
melodic-automation-lanes commentedon Oct 25, 2026
The latest triage verdict holds nothing: the issue is at
status: ready.melodic-automation-lanes commentedon Oct 25, 2026
Refinement. A read-only run explored this repository, searched its issues and pull requests, and read the allowed docs to draft what follows. On an issue a listed human filed, it counts as the issue's spec once posted, while unedited; it is still model text, so check it.
Intended outcome: Finish the remaining one-on-one alignment as separate changes: Phase 4 (one safe markdown renderer in question-shape.js for both pages, basis shown as written, REPO read from the server), Phase 5 (docs point to question-shape.md), then the #7303 UI-9 label and re-measure fixes, with the page-specific differences written down.
Dependencies
Related work
Open questions
RECOMMENDED: Yes. Make Phase 4 the next lane issue, with Phase 5 and the #7303 UI-9 and re-measure fixes after it, and keep #8633 as the tracker. Basis: judgment
RECOMMENDED: Yes, the union. The 2026-10-22 state comment already calls it 'union dialect, P6'. Basis: #8633 comment 2026-10-22 (design record P6)
RECOMMENDED: Leave #8089 out of this issue until the owner picks a SHA source, because no pinned commit reaches the page today. Basis: #8633 comment 2026-10-22 (triage of #7303, #5795 and #5893)
RECOMMENDED: Close them with a reason and leave them on their own issues. Basis: #8633 comment 2026-10-22 (triage of #7303, #5795 and #5893)
Sources read
Automated refinement by the lanes App. Answer with a comment whose first line is
**Decision recorded.**.kyle-sexton commentedon Oct 26, 2026
Decision recorded. The owner answered in the Refinement session (2026-10-25), deciding the open items below on the facts:
Question asked: Split the rest into Phase 4, Phase 5 and the post-Phase 4 fixes with the union markdown dialect, keeping #8633 as the tracker?
Decided: Yes. Phase 4 is the next lane issue; Phase 5 and the planning: interview web view, deferred review findings #7303 UI-9 and re-measure fixes follow; feat(planning): bring the one-on-one interview page in line with the shared page #8633 stays the tracker. Basis: Phase 4 is not done on origin/main: plugins/planning/surface/index.html:633 and :655 keep their own
inlineandmd, and plugins/planning/shared/web/app.js:542, :551 and :607 keepinline,linksandrich; judgment for the split; kept.Decided: Yes, the union. Basis: design record P6 in feat(planning): bring the one-on-one interview page in line with the shared page #8633 (comment) recommends it on judgment. No owner decision record answers P6, so this record decides it on the facts above.
Decided: Out of this issue's scope: fix(planning): link repo paths in interview recommendations to the pinned commit #8089's SHA source is settled on fix(planning): link repo paths in interview recommendations to the pinned commit #8089, where its links are built. Basis: the 2026-10-22 state comment says no pinned commit reaches the page, so the work this issue tracks does not need one.
Decided: Close each out-of-scope finding here with a written reason and leave it on its own issue. Basis: body Acceptance allows "closed with a reason"; kept.
Acceptance criteria (converted from the body's Acceptance, wording unchanged except the dialect and split from items 1 and 2):
md/inlineandrich/inline/linksare gone.melodic-automation-lanes commentedon Oct 26, 2026
Split at intake (owner Q93). This issue is Size Large, so its done-when is split into 4 sub-issues, each holding its task-list lines as written.
Automated split by the lanes App. Admission waits on this issue while a sub-issue is open; work-unblock closes it as completed once every sub-issue is closed by a merge or a listed human.