Contracts
README.md
Contracts Index — CLI Widen Mode & Decision Write-Back
Mission: cli-widen-mode-and-write-back-01KPXFGJ
This directory contains CLI-side contracts only. SaaS endpoint schemas are owned by spec-kitty-saas #110 (widen + audience-default) and #111 (Slack orchestration + discussion fetch). Do not duplicate them here.
Files in this directory
| File | Contents |
|---|---|
README.md | This index |
cli-contracts.md | CLI prompt format specs: [w]iden affordance, [b/c] pause prompt, [a/e/d] review prompt, blocked-prompt behavior, LLM summarization request/response format, LLM suggestion hint format |
widen-state.schema.json | JSON Schema for widen-pending.jsonl sidecar entries (WidenPendingEntry) |
review-payload.schema.json | JSON Schema for the candidate-review internal payload (CandidateReview) produced by the active LLM session |
Contract Ownership Notes
[w]idenprompt text,[b/c]prompt text,[a/e/d]prompt text: owned here.POST /api/v1/decision-points/{id}/widenrequest/response schema: owned by spec-kitty-saas #110.GET /api/v1/missions/{id}/audience-defaultresponse schema: owned by spec-kitty-saas #110.- Discussion fetch response schema (
DiscussionData): owned by spec-kitty-saas #111. decision resolvecall signature (existing): owned by spec-kitty #757.
Versioning
All schemas carry "schema_version": 1. Breaking changes require a version bump and a migration note.
cli-contracts.md
CLI Prompt Contracts — CLI Widen Mode & Decision Write-Back
Mission: cli-widen-mode-and-write-back-01KPXFGJ
These contracts define the exact prompt strings, option sets, and structured output formats for all new CLI surfaces introduced by this mission. Implementers must match these formats exactly; tests must assert against them.
§1. Per-Question Interview Prompt
1.1 Standard prompt (prereqs NOT satisfied or decision already widened/terminal)
Identical to mission #757 baseline:
<question text> [<default>]:
[enter]=accept default | [text]=type answer | [d]efer | [!cancel]
1.2 Widen-enabled prompt (prereqs satisfied, decision open, not already widened)
<question text> [<default>]:
[enter]=accept default | [text]=type answer | [w]iden | [d]efer | [!cancel]
[w]iden is appended between [text]=type answer and [d]efer. No other changes to the baseline prompt.
1.3 Already-widened question prompt (decision in widened state, pending-external-input)
<question text> [pending-external-input]
[f]etch & resolve | [local answer]=type answer | [d]efer | [!cancel]
[f]etch & resolve enters run_candidate_review(). Plain text answer triggers the local-answer-at-blocked-prompt path (FR-018).
1.4 LLM suggestion hint (FR-021, optional)
When the active LLM session detects a strong widen signal, it may prepend a dim hint line before the prompt. The CLI reads a special structured prefix from the LLM output:
[WIDEN-HINT] This looks like a good widen candidate — press w to consult the team.
The CLI detects lines starting with [WIDEN-HINT] in the current harness output context and renders them as [dim]<text>[/dim] above the prompt. The hint does not change the available options; [w] is always present regardless (FR-021, C-001).
Format invariant: [WIDEN-HINT] prefix (case-sensitive, with trailing space) followed by the hint text. Single line only. CLI strips the prefix before rendering.
§2. Widen Mode — Audience Review Prompt
Entered after user presses w. Fetches GET /api/v1/missions/{id}/audience-default.
2.1 Audience display
╭─ Widen: <question text (truncated to 60 chars)> ──────────────────╮
│ Default audience for this decision: │
│ Alice Johnson, Bob Smith, Carol Lee, Dana Park │
│ │
│ [Enter] to confirm, or type comma-separated names to trim. │
│ Type "cancel" or press Ctrl+C to abort. │
╰────────────────────────────────────────────────────────────────────╯
Audience >
2.2 Trim input parsing
- Empty input (Enter) = use full default list.
- Comma-separated input = trim to named subset. Names are matched case-insensitively against the default list. Unknown names produce a warning but are not blocked (owner may know team members not in the default).
cancel(case-insensitive) = abort Widen Mode; no widen call made (FR-006).- Ctrl+C = same as
cancel.
2.3 Confirmation display
Audience confirmed: Alice Johnson, Carol Lee (2 members)
Calling widen endpoint...
On error from SaaS:
[red]Widen failed:[/red] <error message>
Returning to interview prompt.
§3. Pause Semantics Prompt ([b/c])
Shown after successful POST /widen response (FR-007).
╭─ Widened ✓ ────────────────────────────────────────────────────────╮
│ Slack thread created. Alice Johnson and Carol Lee have been │
│ invited to discuss: "<question text (truncated)>" │
╰────────────────────────────────────────────────────────────────────╯
Block here or continue with other questions? [b/c] (default: b):
bor Enter →WidenAction.BLOCK(FR-008).c→WidenAction.CONTINUE(FR-009).
On [c]:
Question parked as pending. You'll be prompted to resolve it at end of interview.
§4. Blocked-Prompt Behavior
Entered when user chose [b]. The interview is paused at this question (FR-008).
╭─ Waiting for widened discussion ───────────────────────────────────╮
│ Question: <question text> │
│ Participants: Alice Johnson, Carol Lee │
│ Slack thread: https://... │
╰────────────────────────────────────────────────────────────────────╯
Options:
[f]etch & review — fetch current discussion and produce candidate
<type an answer> — resolve locally right now (closes Slack thread)
[d]efer — defer this question for later
Waiting >
4.1 Inactivity reminder (NFR-004)
If the blocked prompt is idle for 60 minutes (real-time), the CLI re-renders:
[yellow]Still waiting on widened discussion.[/yellow]
Check Slack, type a local answer, or press d to defer.
Waiting >
This is implemented via a background thread or non-blocking select/signal approach. The reminder fires once; subsequent inactivity periods fire again.
4.2 Local answer at blocked prompt (FR-018)
If the user types plain text (not f, d, !cancel) at Waiting >:
- The input is treated as the
final_answer. - CLI calls
decision.resolve(final_answer=<typed text>, summary_json={"source": "manual", "text": ""}). - CLI prints:
Resolved locally. SaaS will close the Slack thread shortly. - Interview resumes at next question.
4.3 [f]etch & review path
Fetches discussion from SaaS (#111 endpoint), then enters the LLM Summarization Request flow (§5), then enters [a/e/d] Review Prompt (§6).
§5. LLM Summarization Request / Response Contract
The CLI emits a structured instruction block to stdout that the active LLM session interprets as a task. The LLM responds with a structured JSON block.
5.1 CLI-emitted instruction block
╔══════════════════════════════════════════════════════════════════╗
║ WIDEN SUMMARIZATION REQUEST ║
║ decision_id: <ulid> ║
║ question: <full question text> ║
╚══════════════════════════════════════════════════════════════════╝
[DISCUSSION DATA]
Participants: Alice Johnson, Carol Lee
Message count: 7
Thread URL: https://slack.com/archives/...
--- Messages ---
[Alice Johnson] We should definitely go with PostgreSQL for this.
[Carol Lee] Agreed, but let's make sure we consider the migration path.
[Alice Johnson] Good point. I'd say: PostgreSQL, plan migration from day 1.
... (4 more messages)
---
Based on the discussion above, please produce a candidate summary and answer.
Respond with ONLY the following JSON block (no prose before or after):
{ "candidate_summary": "<concise summary of the discussion consensus>", "candidate_answer": "<proposed answer to the question above>", "source_hint": "slack_extraction" }
5.2 Expected LLM response format
The LLM must respond with exactly the JSON block — no prose, no markdown wrapping (the `json fence is part of the instruction, not the response). CLI parses the response by extracting content between { and } (JSON object extraction with error handling).
Valid response example:
{
"candidate_summary": "Team consensus is PostgreSQL with migration-from-day-1 planning. Alice and Carol both agree on the direction.",
"candidate_answer": "PostgreSQL, with migration path planned from day 1.",
"source_hint": "slack_extraction"
}
5.3 Fallback on parse failure or timeout
If no valid JSON is received within 30s (NFR-003), or if parsing fails:
[yellow]Summarization timed out or produced invalid output.[/yellow]
Showing raw discussion. Please write the answer manually.
[a]ccept empty | [e]dit (blank pre-fill) | [d]efer
The CLI enters the [a/e/d] prompt with empty candidate_summary and candidate_answer. If owner types an answer via [e], source=manual.
§6. [a/e/d] Candidate Review Prompt (FR-013, FR-014, FR-015, FR-017)
Shown after a successful LLM summarization response.
╭─ Candidate Review ─────────────────────────────────────────────────╮
│ Question: <question text> │
│ │
│ Summary: │
│ <candidate_summary> │
│ │
│ Proposed answer: │
│ <candidate_answer> │
╰────────────────────────────────────────────────────────────────────╯
[a]ccept | [e]dit | [d]efer:
6.1 [a]ccept path (FR-014)
- Calls
decision.resolve(final_answer=candidate_answer, summary_json={"text": candidate_summary, "source": "slack_extraction"}). - No rationale prompt (C-006: rationale stays owner-authored).
- Prints:
[green]Decision resolved.[/green]
6.2 [e]dit path (FR-015, FR-016)
- Opens
$EDITOR(orVISUAL) pre-filled with the candidate answer text. - On save, CLI computes normalized edit distance between saved text and candidate answer.
- If distance > 30% of candidate length OR saved text is empty →
source=mission_owner_override(ormanualif empty). - If minor/no change →
source=slack_extraction. - If owner changed the answer materially, CLI prompts:
Optional rationale (press Enter to skip):. - Calls
decision.resolve(final_answer=edited_answer, summary_json={"text": summary_text, "source": <determined above>}, rationale=<owner-supplied or None>).
6.3 [d]efer path (FR-017)
- Prompts:
Rationale for deferral (required):. - Calls
decision.defer(rationale=<owner-supplied>). - Prints:
[yellow]Decision deferred.[/yellow] - The widened state is preserved in SaaS; the Slack thread remains open.
§7. End-of-Interview Pending Pass (FR-010)
Shown at interview completion when WidenPendingStore.list_pending() returns non-empty.
╭─ Pending Widened Questions ────────────────────────────────────────╮
│ 3 widened questions are still pending. Resolve them before │
│ finalizing the interview. │
╰────────────────────────────────────────────────────────────────────╯
(1/3) Question: <question text>
Widened at: 2026-04-23 16:00 UTC
Participants: Alice Johnson, Carol Lee
Fetching discussion...
Each pending question proceeds through §5 (LLM summarization) and §6 ([a/e/d] review). After all are resolved or deferred, the interview finalizes and writes answers.yaml.
If the owner defers all pending questions without resolving: the interview completes normally; the deferred decisions carry status=deferred in the decisions index.
review-payload.schema.json
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://spec-kitty.local/schemas/candidate-review/v1", "title": "CandidateReview", "description": "Internal payload for the [a/e/d] candidate review step. Produced by the active LLM session via the structured summarization request (contracts/cli-contracts.md §5), then parsed by specify_cli.widen.review. Not persisted; in-memory only during review.", "type": "object", "required": [ "decision_id", "discussion_fetch", "candidate_summary", "candidate_answer", "source_hint" ], "additionalProperties": false, "properties": { "decision_id": { "type": "string", "minLength": 1, "description": "ULID of the DecisionPoint being reviewed." }, "discussion_fetch": { "type": "object", "description": "Compact representation of the fetched Slack discussion.", "required": ["participants", "message_count", "messages", "truncated"], "additionalProperties": false, "properties": { "participants": { "type": "array", "items": { "type": "string" }, "description": "Display names of discussion participants." }, "message_count": { "type": "integer", "minimum": 0, "description": "Total message count in the Slack thread (may exceed messages array length if truncated)." }, "thread_url": { "type": ["string", "null"], "format": "uri", "description": "Slack thread URL for owner reference. Null if unavailable." }, "messages": { "type": "array", "items": { "type": "string" }, "maxItems": 50, "description": "Compact message list. Capped at 50 for V1 (NFR-003 / context-window limit). Format: '[DisplayName] message text'." }, "truncated": { "type": "boolean", "description": "True if message_count > 50 and messages array is a subset." } } }, "candidate_summary": { "type": "string", "description": "Concise summary of the discussion consensus, produced by the local LLM session. Empty string if LLM timed out or failed (llm_timed_out=true)." }, "candidate_answer": { "type": "string", "description": "Proposed answer to the interview question, produced by the local LLM session. Empty string if LLM timed out or failed." }, "source_hint": { "type": "string", "enum": ["slack_extraction", "manual"], "description": "Initial provenance hint from LLM. 'slack_extraction' = LLM produced from discussion; 'manual' = fallback (no LLM output). Final summary_json.source may differ based on owner editing." }, "llm_timed_out": { "type": "boolean", "default": false, "description": "True if the 30s LLM summarization timeout (NFR-003) fired before a valid response was received." } },
"$defs": { "SummarySource": { "type": "string", "enum": ["slack_extraction", "mission_owner_override", "manual"], "description": "Valid values for summary_json.source written to decision resolve. Defined in C-005. 'slack_extraction': candidate accepted or minor edit. 'mission_owner_override': owner materially changed candidate. 'manual': owner wrote from scratch or fallback path." } } }
widen-state.schema.json
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://spec-kitty.local/schemas/widen-pending-entry/v1", "title": "WidenPendingEntry", "description": "One line in widen-pending.jsonl. Represents a widened DecisionPoint that the interview has moved past (user chose [c]ontinue). Owned by specify_cli.widen.state.WidenPendingStore.", "type": "object", "required": [ "schema_version", "decision_id", "mission_slug", "question_id", "question_text", "entered_pending_at", "widen_endpoint_response" ], "additionalProperties": false, "properties": { "schema_version": { "type": "integer", "const": 1, "description": "Schema version. Must be 1 for this version of the sidecar format." }, "decision_id": { "type": "string", "minLength": 1, "description": "ULID of the DecisionPoint in the decisions index." }, "mission_slug": { "type": "string", "minLength": 1, "description": "Mission slug, e.g. 'cli-widen-mode-and-write-back-01KPXFGJ'." }, "question_id": { "type": "string", "minLength": 1, "description": "Dotted step_id of the interview question, e.g. 'charter.technical_constraints'." }, "question_text": { "type": "string", "minLength": 1, "description": "Full human-readable question text as shown in the interview prompt." }, "entered_pending_at": { "type": "string", "format": "date-time", "description": "ISO 8601 UTC timestamp when the user pressed [c] and the question was parked." }, "widen_endpoint_response": { "type": "object", "description": "Raw JSON response from POST /api/v1/decision-points/{id}/widen (spec-kitty-saas #110). Stored for debug/audit. Exact shape owned by #110.", "required": ["decision_id", "widened_at"], "properties": { "decision_id": { "type": "string" }, "widened_at": { "type": "string", "format": "date-time" }, "slack_thread_url": { "type": ["string", "null"] }, "invited_count": { "type": ["integer", "null"] } }, "additionalProperties": true } } }