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

FileContents
README.mdThis index
cli-contracts.mdCLI 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.jsonJSON Schema for widen-pending.jsonl sidecar entries (WidenPendingEntry)
review-payload.schema.jsonJSON Schema for the candidate-review internal payload (CandidateReview) produced by the active LLM session

Contract Ownership Notes

  • [w]iden prompt text, [b/c] prompt text, [a/e/d] prompt text: owned here.
  • POST /api/v1/decision-points/{id}/widen request/response schema: owned by spec-kitty-saas #110.
  • GET /api/v1/missions/{id}/audience-default response schema: owned by spec-kitty-saas #110.
  • Discussion fetch response schema (DiscussionData): owned by spec-kitty-saas #111.
  • decision resolve call 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):
  • b or Enter → WidenAction.BLOCK (FR-008).
  • cWidenAction.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 (or VISUAL) 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 (or manual if 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 } } }