diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index 96fe2dc22..b11d4b996 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -8,7 +8,7 @@ for current product capability. |---|---|---| | [WS-DB-002](initiatives/WS-DB-002/OVERVIEW.md) | Complete | Shared UUIDv7 record generation, native-UUID relationships and fresh v0.1 baseline; natural-owner retry custody and aligned CI/local setup | | [WS-MCP-002](initiatives/WS-MCP-002/OVERVIEW.md) | Planned | Nine tools through WS-MCP-002-03: self-service and administrative reads; 18 tools remain and WS-MCP-002-04 administrative grant mutations are next | -| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, project inspection and manager task browsing delivered through WS-CLI-001-04; further public journeys and binary distribution remain | +| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI self-service, project inspection, manager browsing and contributor discovery delivered through WS-CLI-001-05; further public journeys and binary distribution remain | | [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Source storage, inert AUTH contracts, TASK request reservation, hidden exact AUTH preparation and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; TASK-before-CHECKERS reservation/current-read custody and ordered admission INSERTs are delivered by 04E1B-B1; hidden routing handlers are next. True admission remains independent of shared acceptance; false activation still requires exact AUTH receipt custody, database complete-set enforcement, currentness race proof and atomic routing activation. | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Exact checker input/output custody and packet foundations are delivered; routing integration, remediation and public intake remain. | | [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | AUTH-19A commitments, TASK request reservation, ARCH-04E2-A hidden strict PREP matching and the REV-04C hidden FinalAcceptance/TASK/CON participant are delivered; the action remains unavailable, with mandatory exact AUTH receipt input, database closure, audit/outbox and scoped activation still required for the automated path. | diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index e6044cda4..7dfbdade5 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -8,7 +8,8 @@ exact-project authorization context; [WS-CLI-001-02](WS-CLI-001-02.md), human self-profile editing through the public REST API; [WS-CLI-001-03](WS-CLI-001-03.md), exact-project inspection with server-owned full/minimal disclosure; - [WS-CLI-001-04](WS-CLI-001-04.md), public manager task pagination and detail. + [WS-CLI-001-04](WS-CLI-001-04.md), public manager task pagination and detail; + [WS-CLI-001-05](WS-CLI-001-05.md), contributor ready-task discovery and instructions. ## Current boundary @@ -24,6 +25,8 @@ package under `cli/` implements `whoami`, `project access PROJECT_ID`, and reads only the existing public project's server-selected disclosure shape. `project tasks PROJECT_ID` and `project task PROJECT_ID TASK_ID` add the public manager queue/detail journey with server-owned authority on each page. +`task ready PROJECT_ID` and `task show TASK_ID` add the contributor projection +with exact Submitter authority and server-owned assignment visibility. All have text/JSON output and built-binary integration proof. Mutations preserve omitted/null semantics and explicitly report uncertain outcomes without automatic retries. @@ -66,11 +69,13 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. boundaries; do not invent a public project-list contract. 4. **WS-CLI-001-04:** Browse a manager task page and open its exact project/task detail, passing opaque continuation unchanged without automatic pagination. -5. **Later governed-work commands:** Add project setup, contributor task, submission, +5. **WS-CLI-001-05:** Discover one ready-task page and inspect contributor + instructions through public reads, without management metadata or task writes. +6. **Later governed-work commands:** Add project setup, contributor task mutations, submission, review, revision, and contribution reads/writes only as their actual public contracts and authority boundaries become available. Split by user journey, not one PR per endpoint or one giant catalogue PR. -6. **Optional TUI:** Add a focused public queue/evidence view after its API +7. **Optional TUI:** Add a focused public queue/evidence view after its API workflow is complete. Never require a TUI for agents or scripts. ## Risks and proof diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-05.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-05.md new file mode 100644 index 000000000..6e17066f0 --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-05.md @@ -0,0 +1,123 @@ +# WS-CLI-001-05 — Discover contributor work + +- Initiative: `WS-CLI-001` +- Durable disposition: `Complete` +- Intended merge outcome: Browse one ready-task page and inspect contributor + instructions through existing public REST reads. + +## Intent + +The CLI has manager task browsing, not a contributor discovery journey. +`queue_router.py:ready_tasks` exposes the exact-project Submitter queue; +`router.py:get_task` exposes contributor instructions for unassigned ready work +or the caller's own active assignment. AUTH and TASK retain all decisions. + +## Bounded change + +Allowed: cohesive Go client/command code under `cli/internal/`, built-process +integration tests under `cli/tests/integration/`, CLI/root README, affected +roadmap claims, this record, CLI overview and index. Reuse the existing transport, +UUID identity, strict decoding, pagination bounds and terminal escaping. + +Not allowed: backend, MCP, workflows, dependencies, hidden routes, task writes, +claim/start commands, credential persistence, role preflight/filtering, automatic +pages/retries, generic dispatch, a TUI, or additional test/coverage gates. + +## Design + +`workstream task ready PROJECT_ID [--limit N] [--cursor CURSOR]` calls exactly one +`GET /api/v1/projects/{project_id}/tasks/ready`. `workstream task show TASK_ID` +calls exactly one `GET /api/v1/tasks/{task_id}`. Original supported UUID selectors +are individually escaped and sent unchanged; response identity compares UUID +values. Pagination defaults to 50, bounds 1–100 and passes a supplied 1–512 +character UTF-8 cursor unchanged. Workstream binds cursors to action, project +and limit. Do not infer authority or reserve work from a discovery response. + +Validate the closed queue envelope and its eight-field contributor summaries, +not management summaries. Validate contributor detail without allowing management +source, actor or assignment fields. Preserve nullable/omitted fields from the +API, exact raw JSON and complete safely escaped text. Reject malformed required +fields, null arrays/members, invalid identities/times, unknown/duplicate fields, +foreign queue items, duplicate task identities and substituted detail identities +before output. A task-only detail selector has no caller-supplied project to +compare; validate its returned project UUID without inventing a preflight. +Existing 12-second deadline and 64 KiB response bound remain. + +## Acceptance criteria + +- Exact fixed route, unchanged bearer, one request per invocation; invalid local + selectors/limits/cursors produce no network call. +- Full text/raw-JSON parity, queue nulls, detail null/omission, safe continuation + and terminal escaping. Management-only data cannot silently become contributor + output. Malformed/substituted/oversized replies fail with empty stdout. +- Real FastAPI/PostgreSQL proof stores two projects with ready tasks, discovers + two pages and exact detail, omits draft/claimed work, and preserves a caller's + own assigned-detail visibility while another active same-project Submitter + cannot read that assignment. That other actor first succeeds on unassigned + ready detail, so missing authority cannot masquerade as assignment isolation. +- Exact Submitter grants permit reads; absent, Reviewer-only, foreign-project, + revoked and suspended authority deny. Restore a fresh Submitter grant and prove + both reads work before suspension. Cursor project substitution uses a caller + with active Submitter grants on both active projects; action substitution uses + a caller with both Submitter and Manager authority on that project; limit + substitution retains the same valid authority. Each independently reaches + 422, separately from authority concealment. +- Reuse canonical guide-activation fixtures to arrange approved upstream inputs; + scripted guide inference/storage are fixture boundaries, not live guide or S3 + certification. Activate with real AUTH/PROJECT, create/screen/release/claim work + and issue/revoke grants through public HTTP. No runtime API dependency override + or direct task/assignment row creation. Both CLI reads must appear in OpenAPI. +- Existing tests are retained. Add focused process proof in + `cli/tests/integration/test_contributor_task_http.py`. Extend the existing + self-service API journey after its administrator bootstrap through + `cli/tests/integration/contributor_task_journey.py`; reuse its API process and + configure the local verifier for the activation fixture issuer + `https://identity.flowresearch.tech`. No second bootstrap or order-dependent + standalone API journey. Run Go verify/tidy/vet/build, + Ruff, links/stale scans, Commitrail, workflow guards and the complete hosted CI. + +## Evidence + +The built-process HTTP checks prove exact contributor fields, wire selectors, +terminal safety, malformed-response refusal and single-request behavior. +Duplicate task rejection covers both identical UUID strings and canonical/compact +spellings of the same identity, independently of the page-size guard; raw-string +duplicate tracking must fail this regression. +The public FastAPI/PostgreSQL journey proves persisted ready work, three pages, +own-assignment visibility, same-project non-owner concealment and independent +authority/cursor controls. It reuses one API/bootstrap and canonical upstream +fixtures; scripted guide inference/storage are not live-provider certification. +Hosted completeness and exact-target reviewer evidence belong in the PR, not +this durable record. Existing CLI proof and the 240-second deadline are retained. + +```sh +cd cli +go mod verify +go mod tidy -diff +go vet ./... +go build -trimpath -o /tmp/workstream-cli ./cmd/workstream +cd ../backend +WORKSTREAM_CLI_EXECUTABLE=/tmp/workstream-cli python scripts/run_isolated_tests.py --metadata-json /tmp/workstream-cli-isolation.json --timeout-seconds 240 -- python -m pytest -q ../cli/tests/integration +``` + +## Risk and review routing + +Risk: `L1`, bounded credential transport and tenant/actor disclosure. Plan review +precedes code. Required tracks: security; architecture/documentation; QA/test +delta, in three focused assignments. No CI track: CI is unchanged, and the lead +runs its guards. Human focus: contributor-only disclosure, one-page continuation, +assignment visibility and server-owned live authority. + +Reject manager-route reuse: it grants and discloses a different contract. Reject +role inference, automatic paging and hidden setup in the CLI. Separate read-only +discovery from future claim/intake mutations. No new human design decision is +needed; approval and merge remain human-owned. + +Remaining boundary: further role-specific public workflows and binary distribution. + +## Plan reconciliation + +`PLAN-SEC-CLI05-001/002` require positive same-project authority before testing +assignment concealment and independent authorized controls for cursor dimensions. +`PLAN-FIXTURE-CLI05-001` reuses one administrator bootstrap/API journey and the +canonical fixture issuer. Preserve the existing 240-second CI deadline. diff --git a/README.md b/README.md index 8e2fc5beb..4b9c2abc0 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,8 @@ caller-owned human display fields, through the currently public REST API. the caller's current authority, preserving contributor-minimal disclosure. `workstream project tasks PROJECT_ID` and `project task PROJECT_ID TASK_ID` provide a paginated manager list-to-detail journey through public reads. +`workstream task ready PROJECT_ID` and `task show TASK_ID` provide contributor +discovery and instructions, with live Submitter authority and no task claim. All support human-readable and JSON output, using the caller's Flow token. The first source package is buildable; further workflow commands and published binaries remain planned. diff --git a/cli/README.md b/cli/README.md index 5a2ba2a8c..2b095b04f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,7 +2,8 @@ An independent Go client for Workstream's public REST API, for humans and agents using the terminal. It provides human self-profile reads and editing, -plus exact-project inspection, authority reads and manager task browsing: +plus exact-project inspection, authority reads, manager task browsing and +contributor work discovery: | Command | Public API | |---|---| @@ -12,6 +13,8 @@ plus exact-project inspection, authority reads and manager task browsing: | `workstream project show PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID` | | `workstream project tasks PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/tasks` | | `workstream project task PROJECT_ID TASK_ID` | `GET /api/v1/projects/PROJECT_ID/tasks/TASK_ID` | +| `workstream task ready PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/tasks/ready` | +| `workstream task show TASK_ID` | `GET /api/v1/tasks/TASK_ID` | Workstream verifies the caller's Flow bearer and owns identity resolution, authorization and lifecycle decisions. Reading a profile can admit a first-time @@ -119,6 +122,38 @@ malformed replies fail with empty stdout. The existing 64 KiB response bound applies to a whole page: an oversized response fails without partial output; request a smaller `--limit` if needed. +## Discover work as a contributor + +```sh +workstream task ready PROJECT_ID --limit 10 --output json +workstream task ready PROJECT_ID --limit 10 --cursor PREVIOUS_NEXT_CURSOR --output json +workstream task show TASK_ID --output json +``` + +These are contributor routes, not aliases for manager browsing. The ready queue +requires an active project and its exact active Submitter grant; Manager or +Reviewer authority alone does not permit it. It lists only unassigned ready +tasks. Contributor detail shows unassigned ready work or the caller's own active +assignment under current Submitter authority. It does not expose management +source/actor/assignment metadata, and a different same-project Submitter cannot +read your claimed task. Workstream makes these decisions on every request. + +The same one-page limit/cursor bounds, UUID selector encoding, strict response +validation and safe text/raw-JSON output apply. Ready summaries contain task and +project IDs, title, nullable type/difficulty/estimated minutes, skills and creation +time. Detail adds instructions, criteria, status, deadline and update time; +nullable detail fields may be omitted by the API and display as `—`. +`task show` takes only a task selector; Workstream resolves its project and +authorizes that resource. No project preflight or locally inferred permission +is added. Management-only fields in a contributor reply are rejected rather +than silently displayed or ignored. + +Discovery is live, not a reservation or a claimability guarantee. A later claim +must revalidate current authority and state. Cursors are action/project/limit +bound and cannot be reused as manager cursors; the CLI never decodes them or +automatically fetches another page. These commands do not claim/start tasks, +upload submissions or complete unfinished acceptance integration. + ## Edit your profile ```sh @@ -169,6 +204,15 @@ It separates signed-cursor substitution (authorized caller receives 422) from foreign authority denial, and proves a restored grant permits both reads before suspension denies them. Hostile HTTP process tests validate complete field output, one-request pagination, malformed/substituted replies and redirect refusal. +Contributor proof reuses that API process and bootstrap, arranging approved +upstream guide inputs through canonical fixtures. Guide inference and storage +are scripted prerequisites, not live-provider proof; real AUTH activates the +projects. Public task create/screen/release/claim operations supply persisted +ready and assigned work. Reads prove pagination parity, draft/claimed exclusion, +same-project non-owner concealment, independent authorized cursor substitution, +exact Submitter grants, Reviewer-only/foreign/revoked denial and suspension after +restored positive authority. The process fixture verifies the separate contributor +shapes, complete field output and refusal of management-only data. Local Flow-compatible tokens are test fixtures, not deployed-provider proof. No coverage percentage or test-count target is used. diff --git a/cli/internal/api/contributor_tasks.go b/cli/internal/api/contributor_tasks.go new file mode 100644 index 000000000..95447d180 --- /dev/null +++ b/cli/internal/api/contributor_tasks.go @@ -0,0 +1,93 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/url" +) + +// ReadyTaskSummary is the public contributor projection, not management facts. +type ReadyTaskSummary struct { + TaskID string `json:"task_id"` + ProjectID string `json:"project_id"` + Title string `json:"title"` + TaskType *string `json:"task_type"` + Difficulty *string `json:"difficulty"` + SkillTags []string `json:"skill_tags"` + EstimatedTimeMinutes *int `json:"estimated_time_minutes"` + CreatedAt string `json:"created_at"` +} + +type ReadyTaskPage struct { + ProjectID string + Items []ReadyTaskSummary + NextCursor *string +} + +// ContributorTaskDetail excludes management-only source and actor metadata. +type ContributorTaskDetail struct { + TaskSummary + Description string `json:"description"` + AcceptanceCriteria *string `json:"acceptance_criteria"` + RejectionCriteria *string `json:"rejection_criteria"` +} + +func (c *Client) ReadyTasks(ctx context.Context, project string, limit int, cursor *string) (Result[ReadyTaskPage], error) { + var result Result[ReadyTaskPage] + path, err := taskProjectPath(project) + if err != nil { + return result, err + } + query, err := taskPageQuery(limit, cursor) + if err != nil { + return result, err + } + raw, err := c.request(ctx, http.MethodGet, path+"/ready", query, nil) + if err != nil { + return result, err + } + page, err := decodeTaskPage(raw, project, limit) + if err != nil { + return result, err + } + value := ReadyTaskPage{ProjectID: page.ProjectID, Items: make([]ReadyTaskSummary, 0, len(page.Items)), NextCursor: page.NextCursor} + seen := make(map[[16]byte]bool) + selected, _ := uuidIdentity(project) + for _, item := range page.Items { + var task ReadyTaskSummary + err := decodeTaskFields(item, &task, + []string{"task_id", "project_id", "title", "created_at"}, + []string{"task_type", "difficulty", "estimated_time_minutes", "skill_tags"}) + id, validID := uuidIdentity(task.TaskID) + returned, validProject := uuidIdentity(task.ProjectID) + if err != nil || !validID || !validProject || returned != selected || + !validTime(task.CreatedAt) || seen[id] { + return result, &Failure{Code: "invalid_api_response"} + } + seen[id] = true + value.Items = append(value.Items, task) + } + return Result[ReadyTaskPage]{Raw: raw, Value: value}, nil +} + +func (c *Client) ContributorTask(ctx context.Context, selector string) (Result[ContributorTaskDetail], error) { + var result Result[ContributorTaskDetail] + selected, valid := uuidIdentity(selector) + if !valid || len(selector) > 100 { + return result, errors.New("TASK_ID must be a UUID") + } + raw, err := c.request(ctx, http.MethodGet, "/api/v1/tasks/"+url.PathEscape(selector), "", nil) + if err != nil { + return result, err + } + var value ContributorTaskDetail + err = decodeTaskFields(raw, &value, + []string{"task_id", "project_id", "title", "description", "status", "created_at", "updated_at"}, + []string{"skill_tags"}) + returned, valid := uuidIdentity(value.TaskID) + if err != nil || !valid || returned != selected || !validTask(value.TaskSummary, value.ProjectID) { + return result, &Failure{Code: "invalid_api_response"} + } + return Result[ContributorTaskDetail]{Raw: raw, Value: value}, nil +} diff --git a/cli/internal/api/tasks.go b/cli/internal/api/tasks.go index 41a734038..dca736f1f 100644 --- a/cli/internal/api/tasks.go +++ b/cli/internal/api/tasks.go @@ -45,6 +45,12 @@ type TaskPage struct { NextCursor *string } +type taskPageResponse struct { + ProjectID string `json:"project_id"` + Items []json.RawMessage `json:"items"` + NextCursor *string `json:"next_cursor"` +} + func taskProjectPath(selector string) (string, error) { if _, ok := uuidIdentity(selector); !ok || len(selector) > 100 { return "", errors.New("PROJECT_ID must be a UUID") @@ -58,28 +64,17 @@ func (c *Client) Tasks(ctx context.Context, project string, limit int, cursor *s if err != nil { return result, err } - if limit < 1 || limit > 100 || (cursor != nil && !validTaskCursor(*cursor)) { - return result, errors.New("limit must be 1–100; cursor must contain 1–512 valid UTF-8 characters") - } - query := url.Values{"limit": {strconv.Itoa(limit)}} - if cursor != nil { - query.Set("cursor", *cursor) - } - raw, err := c.request(ctx, http.MethodGet, path, query.Encode(), nil) + query, err := taskPageQuery(limit, cursor) if err != nil { return result, err } - var page struct { - ProjectID string `json:"project_id"` - Items []json.RawMessage `json:"items"` - NextCursor *string `json:"next_cursor"` + raw, err := c.request(ctx, http.MethodGet, path, query, nil) + if err != nil { + return result, err } - err = decode(raw, &page, []string{"project_id", "items", "next_cursor"}, nil) - selected, _ := uuidIdentity(project) - returned, valid := uuidIdentity(page.ProjectID) - if err != nil || !valid || selected != returned || page.Items == nil || len(page.Items) > limit || - (page.NextCursor != nil && (!validTaskCursor(*page.NextCursor) || len(page.Items) == 0)) { - return result, &Failure{Code: "invalid_api_response"} + page, err := decodeTaskPage(raw, project, limit) + if err != nil { + return result, err } value := TaskPage{ProjectID: page.ProjectID, Items: make([]TaskSummary, 0, len(page.Items)), NextCursor: page.NextCursor} seen := make(map[[16]byte]bool) @@ -98,6 +93,29 @@ func (c *Client) Tasks(ctx context.Context, project string, limit int, cursor *s return Result[TaskPage]{Raw: raw, Value: value}, nil } +func taskPageQuery(limit int, cursor *string) (string, error) { + if limit < 1 || limit > 100 || (cursor != nil && !validTaskCursor(*cursor)) { + return "", errors.New("limit must be 1–100; cursor must contain 1–512 valid UTF-8 characters") + } + query := url.Values{"limit": {strconv.Itoa(limit)}} + if cursor != nil { + query.Set("cursor", *cursor) + } + return query.Encode(), nil +} + +func decodeTaskPage(raw json.RawMessage, project string, limit int) (taskPageResponse, error) { + var page taskPageResponse + err := decode(raw, &page, []string{"project_id", "items", "next_cursor"}, nil) + selected, _ := uuidIdentity(project) + returned, valid := uuidIdentity(page.ProjectID) + if err != nil || !valid || selected != returned || page.Items == nil || len(page.Items) > limit || + (page.NextCursor != nil && (!validTaskCursor(*page.NextCursor) || len(page.Items) == 0)) { + return page, &Failure{Code: "invalid_api_response"} + } + return page, nil +} + func (c *Client) Task(ctx context.Context, project, selector string) (Result[TaskDetail], error) { var result Result[TaskDetail] path, err := taskProjectPath(project) @@ -149,6 +167,10 @@ func decodeTask(raw json.RawMessage, value any, detail bool) error { } else { required = append(required, "task_type", "difficulty", "estimated_time_minutes", "deadline_at") } + return decodeTaskFields(raw, value, strings, required) +} + +func decodeTaskFields(raw json.RawMessage, value any, strings, required []string) error { var fields map[string]json.RawMessage if err := jsonv2.Unmarshal(raw, &fields); err != nil || fields == nil { return errors.New("invalid task object") @@ -159,5 +181,5 @@ func decodeTask(raw json.RawMessage, value any, detail bool) error { return errors.New("invalid required task string") } } - return decode(raw, value, required, []string{"skill_tags"}) + return decode(raw, value, append(append([]string{}, required...), strings...), []string{"skill_tags"}) } diff --git a/cli/internal/command/command.go b/cli/internal/command/command.go index 53757852f..5b98be02e 100644 --- a/cli/internal/command/command.go +++ b/cli/internal/command/command.go @@ -170,6 +170,7 @@ func Run(args []string, stdout, stderr io.Writer, getenv environment, version st }, }) root.AddCommand(project) + addContributorTaskReads(root, client, &output, stdout) err := root.ExecuteContext(context.Background()) if err == nil { diff --git a/cli/internal/command/contributor_tasks.go b/cli/internal/command/contributor_tasks.go new file mode 100644 index 000000000..d3ee026ae --- /dev/null +++ b/cli/internal/command/contributor_tasks.go @@ -0,0 +1,79 @@ +package command + +import ( + "fmt" + "io" + + "github.com/Flow-Research/workstream/cli/internal/api" + "github.com/spf13/cobra" +) + +func addContributorTaskReads(root *cobra.Command, client func() (*api.Client, error), output *string, stdout io.Writer) { + task := &cobra.Command{Use: "task", Short: "Discover and inspect contributor work"} + var limit int + var cursor string + ready := &cobra.Command{ + Use: "ready PROJECT_ID", Short: "List one page of ready work under your Submitter grant", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var continuation *string + if cmd.Flags().Changed("cursor") { + continuation = &cursor + } + apiClient, err := client() + if err != nil { + return err + } + result, err := apiClient.ReadyTasks(cmd.Context(), args[0], limit, continuation) + if err != nil { + return err + } + if *output == "json" { + return writeJSON(stdout, result.Raw) + } + if _, err := fmt.Fprintf(stdout, "Project: %s\nReady tasks: %d\n", safeText(result.Value.ProjectID), len(result.Value.Items)); err != nil { + return err + } + for _, t := range result.Value.Items { + minutes := "—" + if t.EstimatedTimeMinutes != nil { + minutes = fmt.Sprint(*t.EstimatedTimeMinutes) + } + if _, err := fmt.Fprintf(stdout, + "Task: %s\nProject: %s\nTitle: %s\nType: %s\nDifficulty: %s\nSkills: %s\nEstimated minutes: %s\nCreated: %s\n", + safeText(t.TaskID), safeText(t.ProjectID), safeText(t.Title), optional(t.TaskType), optional(t.Difficulty), + list(t.SkillTags), minutes, safeText(t.CreatedAt)); err != nil { + return err + } + } + _, err = fmt.Fprintf(stdout, "Next cursor: %s\n", optional(result.Value.NextCursor)) + return err + }, + } + ready.Flags().IntVar(&limit, "limit", 50, "Page size (1–100; keep unchanged when continuing)") + ready.Flags().StringVar(&cursor, "cursor", "", "Opaque next cursor from the previous page") + task.AddCommand(ready) + task.AddCommand(&cobra.Command{ + Use: "show TASK_ID", Short: "Inspect contributor instructions under current task authority", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := client() + if err != nil { + return err + } + result, err := apiClient.ContributorTask(cmd.Context(), args[0]) + if err != nil { + return err + } + if *output == "json" { + return writeJSON(stdout, result.Raw) + } + t := result.Value + if err := writeTaskSummary(stdout, t.TaskSummary); err != nil { + return err + } + _, err = fmt.Fprintf(stdout, "Description: %s\nAcceptance criteria: %s\nRejection criteria: %s\n", + safeText(t.Description), optional(t.AcceptanceCriteria), optional(t.RejectionCriteria)) + return err + }, + }) + root.AddCommand(task) +} diff --git a/cli/tests/integration/contributor_task_journey.py b/cli/tests/integration/contributor_task_journey.py new file mode 100644 index 000000000..3a230b681 --- /dev/null +++ b/cli/tests/integration/contributor_task_journey.py @@ -0,0 +1,249 @@ +"""Real public contributor reads over canonical approved-guide prerequisites. + +Only upstream guide inference/storage are scripted fixtures. Task, assignment, +grant and lifecycle operations exercise the real API with no route overrides. +""" + +from contextlib import AsyncExitStack +import json +from uuid import uuid4 + +from api_contract_e2e import flow_settings, issue_flow_token +from tests.projects.guide_activation.pg_support import activation_case +from tests.authorization.guide_activation.pg_support import activate + + +async def exercise_contributor_task_reads( + direct, cli, origin, tokens, profiles, env, qualification +): + manager = {"Authorization": f"Bearer {tokens['cli-manager']}"} + admin = {"Authorization": f"Bearer {tokens['cli-admin']}"} + token = tokens["cli-outsider"] + issuer, audience, secret = flow_settings(env) + + async def post(path, body, headers=manager): + response = await direct.post( + path, headers=headers | {"Idempotency-Key": str(uuid4())}, json=body + ) + assert response.status_code in (200, 201), response.text + return response.json() + + def read(command, presented=token, status=None): + result = cli(origin, presented, "task", *command, "-o", "json") + if status is not None: + assert result.returncode == 1 and result.stdout == "", result.stderr + assert json.loads(result.stderr)["error"]["status"] == status + return None + assert result.returncode == 0 and result.stderr == "", result.stderr + return json.loads(result.stdout) + + async def grant(project, actor, role="submitter"): + return await post( + f"/api/v1/projects/{project}/role-grants", + { + "target_actor_profile_id": actor, + "role": role, + "qualification": qualification, + "reason": "CLI contributor read proof", + }, + ) + + peer_profiles = {} + peer_tokens = {} + for name in ("cli-task-peer", "cli-task-reviewer"): + peer_tokens[name] = issue_flow_token( + name, [], issuer=issuer, audience=audience, secret=secret + ) + admitted = await direct.get( + "/api/v1/actors/me", + headers={"Authorization": f"Bearer {peer_tokens[name]}"}, + ) + assert admitted.status_code == 200, admitted.text + peer_profiles[name] = admitted.json()["actor_profile_id"] + + async with AsyncExitStack() as stack: + projects = [] + for _ in range(2): + factory, command, actor, *_rest = await stack.enter_async_context( + activation_case(env["WORKSTREAM_DATABASE_URL"]) + ) + await activate(factory, actor, command) + projects.append(str(command.target.proposal.project_id)) + project, foreign_project = projects + ready_ids = [] + for selected_project, count in ((project, 3), (foreign_project, 1)): + for index in range(count): + created = await post( + f"/api/v1/projects/{selected_project}/tasks", + { + "title": f"Contributor work {index}", + "description": "Inspect the supplied evidence.", + "task_type": "evaluation", + "difficulty": "medium", + "skill_tags": ["analysis"], + "estimated_time_minutes": 17, + "acceptance_criteria": "Evidence is complete.", + "rejection_criteria": "Evidence is missing.", + "source_ref": "manager-private-source", + }, + ) + task_id = created["id"] + for operation in ("screen", "release"): + transitioned = await post( + f"/api/v1/tasks/{task_id}/{operation}", + {"reason": "Prepare contributor discovery"}, + ) + assert transitioned["status"] == "ready" + if selected_project == project: + ready_ids.append(task_id) + else: + foreign_task_id = task_id + draft = await post( + f"/api/v1/projects/{project}/tasks", + {"title": "Not released", "description": "Draft control"}, + ) + actor_id = profiles["cli-outsider"]["actor_profile_id"] + + # Stored ready resources exist, but no matching grant permits either read. + read(("ready", project), status=404) + read(("show", ready_ids[0]), status=404) + first_grant = await grant(project, actor_id) + await grant(project, peer_profiles["cli-task-peer"]) + await grant(project, peer_profiles["cli-task-reviewer"], role="reviewer") + for command in (("ready", project), ("show", ready_ids[0])): + read(command, peer_tokens["cli-task-reviewer"], status=404) + + cursor = None + observed = [] + first_cursor = None + for index in range(3): + flags = () if cursor is None else ("--cursor", cursor) + page = read(("ready", project.replace("-", ""), "--limit", "1", *flags)) + params = {"limit": "1"} | ({} if cursor is None else {"cursor": cursor}) + expected = await direct.get( + f"/api/v1/projects/{project}/tasks/ready", + headers={"Authorization": f"Bearer {token}"}, + params=params, + ) + assert expected.status_code == 200 and page == expected.json() + assert len(page["items"]) == 1 + assert set(page["items"][0]) == { + "task_id", + "project_id", + "title", + "task_type", + "difficulty", + "skill_tags", + "estimated_time_minutes", + "created_at", + } + observed.append(page["items"][0]["task_id"]) + cursor = page["next_cursor"] + if index == 0: + first_cursor = cursor + assert first_cursor is not None + assert observed == ready_ids and cursor is None and draft["id"] not in observed + + async def detail(task_id, presented=token): + value = read(("show", task_id.replace("-", "")), presented) + expected = await direct.get( + f"/api/v1/tasks/{task_id}", + headers={"Authorization": f"Bearer {presented}"}, + ) + assert expected.status_code == 200 and value == expected.json() + assert value["task_id"] == task_id and value["project_id"] == project + assert ( + "source_ref" not in value + and "assigned_to" not in value + and "created_by" not in value + ) + return value + + await detail(ready_ids[0]) + await detail(ready_ids[0], peer_tokens["cli-task-peer"]) + read(("show", draft["id"]), status=404) + read(("ready", foreign_project), status=404) + read(("show", foreign_task_id), status=404) + # Each cursor substitution retains authority before testing the codec. + await grant(foreign_project, actor_id) + read(("ready", foreign_project)) + read(("show", foreign_task_id)) + read( + ("ready", foreign_project, "--limit", "1", "--cursor", first_cursor), + status=422, + ) + read(("ready", project, "--limit", "2", "--cursor", first_cursor), status=422) + await post( + "/api/v1/admin-role-grants", + { + "target_actor_profile_id": actor_id, + "role": "project_manager", + "scope_type": "project", + "scope_project_id": project, + "reason": "Independent authorized cursor action control", + }, + admin, + ) + management_control = cli( + origin, token, "project", "tasks", project, "--limit", "1", "-o", "json" + ) + assert management_control.returncode == 0 and management_control.stderr == "" + assert ( + json.loads(management_control.stdout)["items"][0]["task_id"] == ready_ids[0] + ) + management = cli( + origin, + token, + "project", + "tasks", + project, + "--limit", + "1", + "--cursor", + first_cursor, + "-o", + "json", + ) + assert management.returncode == 1 and management.stdout == "" + assert json.loads(management.stderr)["error"]["status"] == 422 + + claimed = await post( + f"/api/v1/tasks/{ready_ids[0]}/claim", + {"reason": "Assignment visibility control"}, + {"Authorization": f"Bearer {token}"}, + ) + assert claimed["assignment"]["contributor_id"] == actor_id + assert (await detail(ready_ids[0]))["status"] == "claimed" + # Same-project authority was demonstrated before and after this ownership change. + read(("show", ready_ids[0]), peer_tokens["cli-task-peer"], status=404) + await detail(ready_ids[1], peer_tokens["cli-task-peer"]) + for presented in (token, peer_tokens["cli-task-peer"]): + page = read(("ready", project), presented) + assert {item["task_id"] for item in page["items"]} == set(ready_ids[1:]) + + await post( + f"/api/v1/projects/{project}/role-grants/{first_grant['id']}/revoke", + {"reason": "Discovery must reauthorize"}, + ) + read(("ready", project, "--limit", "1", "--cursor", first_cursor), status=404) + read(("show", ready_ids[1]), status=404) + # Manager authority cannot substitute for a revoked Submitter grant. + await grant(project, actor_id) + read(("ready", project, "--limit", "1", "--cursor", first_cursor)) + await detail(ready_ids[1]) + # The same freshly authorized actor is suspended: stale revocation cannot + # masquerade as lifecycle denial. Restore it for the outer journey. + await post( + f"/api/v1/actors/{actor_id}/suspend", + {"reason": "CLI live contributor lifecycle control"}, + admin, + ) + read(("ready", project), status=404) + read(("show", ready_ids[1]), status=404) + await post( + f"/api/v1/actors/{actor_id}/reactivate", + {"reason": "Continue independent CLI proof"}, + admin, + ) + read(("ready", project)) + await detail(ready_ids[1]) diff --git a/cli/tests/integration/test_contributor_task_http.py b/cli/tests/integration/test_contributor_task_http.py new file mode 100644 index 000000000..1fa577800 --- /dev/null +++ b/cli/tests/integration/test_contributor_task_http.py @@ -0,0 +1,336 @@ +"""Built-process contributor projection, route and failure-boundary proof.""" + +import json +from urllib.parse import parse_qs, urlsplit + +from test_http_boundary import ACTOR, PROJECT, TOKEN, assert_failure, http_fixture +from test_task_http_boundary import DETAIL, SUMMARY, SUMMARY_TEXT, TASK + +READY = { + key: SUMMARY[key] + for key in ( + "task_id", + "project_id", + "title", + "task_type", + "difficulty", + "skill_tags", + "estimated_time_minutes", + "created_at", + ) +} +CONTRIBUTOR = { + key: value + for key, value in DETAIL.items() + if key + not in { + "source_type", + "source_ref", + "source_payload_hash", + "import_batch_id", + "external_task_id", + "created_by", + "assigned_to", + } +} | {"status": "ready"} +READY_TEXT = ( + f"Task: {TASK}\nProject: {PROJECT}\nTitle: Work é\\u000A\\u001B[31m\n" + "Type: evaluation\nDifficulty: medium\nSkills: analysis, tag\\u000A\n" + "Estimated minutes: 17\nCreated: 2026-10-01T00:00:00Z\n" +) +DETAIL_TEXT = SUMMARY_TEXT.replace("Status: draft", "Status: ready") + ( + "Description: Instructions\\u000A\\u001B[32m\n" + "Acceptance criteria: Accurate\nRejection criteria: Missing\n" +) + + +def test_contributor_reads_preserve_closed_fields_and_wire(cli): + cursor = "opaque+/=&?#\n" + with http_fixture() as (origin, response, requests): + page = {"project_id": PROJECT, "items": [READY], "next_cursor": cursor} + response["body"] = json.dumps(page).encode() + result = cli( + origin, TOKEN, "task", "ready", PROJECT, "--limit", "1", "-o", "json" + ) + assert result.returncode == 0 and result.stderr == "" + assert result.stdout.strip().encode() == response["body"] + assert requests[-1] == ( + "GET", + f"/api/v1/projects/{PROJECT}/tasks/ready?limit=1", + "Bearer " + TOKEN, + ) + result = cli( + origin, TOKEN, "task", "ready", PROJECT, "--limit", "1", "--cursor", cursor + ) + assert result.returncode == 0 and result.stderr == "" + assert ( + result.stdout + == f"Project: {PROJECT}\nReady tasks: 1\n" + + READY_TEXT + + "Next cursor: opaque+/=&?#\\u000A\n" + ) + assert parse_qs(urlsplit(requests[-1][1]).query) == { + "limit": ["1"], + "cursor": [cursor], + } + + for selector in ( + PROJECT.replace("-", ""), + "{" + PROJECT + "}", + "urn:uuid:" + PROJECT, + ): + result = cli(origin, TOKEN, "task", "ready", selector, "-o", "json") + assert result.returncode == 0 and result.stderr == "" + assert json.loads(result.stdout) == page + assert ( + requests[-1][1] + == f"/api/v1/projects/{selector.replace('{', '%7B').replace('}', '%7D')}/tasks/ready?limit=50" + ) + + nulls = READY | { + key: None for key in ("task_type", "difficulty", "estimated_time_minutes") + } + response["body"] = json.dumps( + page | {"items": [nulls], "next_cursor": None} + ).encode() + result = cli(origin, TOKEN, "task", "ready", PROJECT, "-o", "json") + assert ( + result.returncode == 0 + and result.stdout.strip().encode() == response["body"] + ) + result = cli(origin, TOKEN, "task", "ready", PROJECT) + assert result.returncode == 0 and result.stdout == ( + f"Project: {PROJECT}\nReady tasks: 1\n" + + READY_TEXT.replace("evaluation", "—") + .replace("medium", "—") + .replace("17", "—") + + "Next cursor: —\n" + ) + response["body"] = json.dumps( + {"project_id": PROJECT, "items": [], "next_cursor": None} + ).encode() + result = cli(origin, TOKEN, "task", "ready", PROJECT) + assert ( + result.returncode == 0 + and result.stdout == f"Project: {PROJECT}\nReady tasks: 0\nNext cursor: —\n" + ) + + response["body"] = json.dumps(CONTRIBUTOR).encode() + for selector in ( + TASK, + TASK.replace("-", ""), + "{" + TASK + "}", + "urn:uuid:" + TASK, + ): + result = cli(origin, TOKEN, "task", "show", selector, "-o", "json") + assert result.returncode == 0 and result.stderr == "" + assert result.stdout.strip().encode() == response["body"] + assert requests[-1] == ( + "GET", + "/api/v1/tasks/" + selector.replace("{", "%7B").replace("}", "%7D"), + "Bearer " + TOKEN, + ) + result = cli(origin, TOKEN, "task", "show", TASK) + assert ( + result.returncode == 0 + and result.stderr == "" + and result.stdout == DETAIL_TEXT + ) + nullable_keys = { + "task_type", + "difficulty", + "estimated_time_minutes", + "deadline_at", + "acceptance_criteria", + "rejection_criteria", + } + for detail in ( + { + key: value + for key, value in CONTRIBUTOR.items() + if key not in nullable_keys + }, + CONTRIBUTOR | {key: None for key in nullable_keys}, + ): + response["body"] = json.dumps(detail).encode() + result = cli(origin, TOKEN, "task", "show", TASK, "-o", "json") + assert ( + result.returncode == 0 + and result.stdout.strip().encode() == response["body"] + ) + result = cli(origin, TOKEN, "task", "show", TASK) + assert result.returncode == 0 and result.stdout == ( + DETAIL_TEXT.replace("evaluation", "—") + .replace("medium", "—") + .replace("17", "—") + .replace("2026-10-03T00:00:00Z", "—") + .replace("Accurate", "—") + .replace("Missing", "—") + ) + assert ( + len(requests) == 17 + ) # One request each; no authority preflight or automatic pages. + + +def test_contributor_reads_reject_malformed_and_management_disclosure(cli): + with http_fixture() as (origin, response, _requests): + page = {"project_id": PROJECT, "items": [READY], "next_cursor": None} + bad_pages = [ + None, + [], + {}, + page | {"project_id": ACTOR}, + page | {"items": None}, + page | {"items": [None]}, + page | {"unknown": True}, + page | {"next_cursor": ""}, + page | {"next_cursor": "x" * 513}, + page | {"next_cursor": 3}, + page | {"items": [], "next_cursor": "cursor"}, + ] + bad_pages.extend( + {key: value for key, value in page.items() if key != missing} + for missing in page + ) + bad_items = [ + READY | {"project_id": ACTOR}, + READY | {"task_id": "bad"}, + READY | {"created_at": "2026-10-01"}, + READY | {"skill_tags": [None]}, + ] + bad_items.extend( + {key: value for key, value in READY.items() if key != missing} + for missing in READY + ) + bad_items.extend( + READY | {key: None} + for key in ("task_id", "project_id", "title", "created_at", "skill_tags") + ) + bad_items.extend( + READY | {key: SUMMARY[key]} + for key in ("status", "deadline_at", "updated_at") + ) + bad_pages.extend(page | {"items": [bad]} for bad in bad_items) + for bad in bad_pages: + response["body"] = json.dumps(bad).encode() + assert_failure( + cli(origin, TOKEN, "task", "ready", PROJECT, "-o", "json"), + "invalid_api_response", + ) + # limit=2 lets the duplicate reach identity validation, not page-size rejection. + for duplicate_id in (TASK, TASK.replace("-", "")): + response["body"] = json.dumps( + page | {"items": [READY, READY | {"task_id": duplicate_id}]} + ).encode() + assert_failure( + cli( + origin, + TOKEN, + "task", + "ready", + PROJECT, + "--limit", + "2", + "-o", + "json", + ), + "invalid_api_response", + ) + response["body"] = json.dumps( + page | {"items": [READY, READY | {"task_id": ACTOR}]} + ).encode() + assert_failure( + cli(origin, TOKEN, "task", "ready", PROJECT, "--limit", "1", "-o", "json"), + "invalid_api_response", + ) + + bad_details = [ + None, + {}, + CONTRIBUTOR | {"task_id": ACTOR}, + CONTRIBUTOR | {"project_id": "bad"}, + CONTRIBUTOR | {"deadline_at": "bad"}, + CONTRIBUTOR | {"skill_tags": [None]}, + ] + required = ( + "task_id", + "project_id", + "title", + "description", + "status", + "skill_tags", + "created_at", + "updated_at", + ) + bad_details.extend( + {key: value for key, value in CONTRIBUTOR.items() if key != missing} + for missing in required + ) + bad_details.extend(CONTRIBUTOR | {key: None} for key in required) + bad_details.extend( + CONTRIBUTOR | {key: DETAIL[key]} for key in DETAIL if key not in CONTRIBUTOR + ) + for bad in bad_details: + response["body"] = json.dumps(bad).encode() + assert_failure( + cli(origin, TOKEN, "task", "show", TASK, "-o", "json"), + "invalid_api_response", + ) + for command, body in ( + (("ready", PROJECT), json.dumps(page).replace('"title":', '"Title":')), + ( + ("ready", PROJECT), + json.dumps(page).replace('"title":', '"title":"duplicate","title":'), + ), + ( + ("show", TASK), + json.dumps(CONTRIBUTOR).replace( + '"description":', '"description":"duplicate","description":' + ), + ), + ): + response["body"] = body.encode() + assert_failure( + cli(origin, TOKEN, "task", *command, "-o", "json"), + "invalid_api_response", + ) + response["body"] = json.dumps( + CONTRIBUTOR | {"description": "x" * 65537} + ).encode() + assert_failure( + cli(origin, TOKEN, "task", "show", TASK, "-o", "json"), + "invalid_api_response", + ) + + +def test_contributor_reads_bound_inputs_and_preserve_failure_transport(cli): + with http_fixture() as (origin, response, requests): + for command in ( + ("ready", "bad"), + ("show", "bad"), + ("show", TASK + "/extra"), + ("ready", PROJECT, "--limit", "0"), + ("ready", PROJECT, "--limit", "101"), + ("ready", PROJECT, "--cursor", ""), + ("ready", PROJECT, "--cursor", "x" * 513), + ): + result = cli(origin, TOKEN, "-o", "json", "task", *command) + assert result.returncode == 2 and result.stdout == "" + assert requests == [] + for command in (("ready", PROJECT), ("show", TASK)): + response.update( + status=404, + body=b'{"error":{"code":"project_authorization_resource_not_found"}}', + ) + denied = cli(origin, TOKEN, "task", *command, "-o", "json") + assert_failure(denied, "project_authorization_resource_not_found") + assert json.loads(denied.stderr)["error"]["status"] == 404 + response.update( + status=302, headers={"Location": origin + "/capture"}, body=b"" + ) + assert_failure( + cli(origin, TOKEN, "task", *command, "-o", "json"), "redirect_refused" + ) + assert len(requests) == 4 and all( + "/capture" not in entry[1] for entry in requests + ) diff --git a/cli/tests/integration/test_public_self_service.py b/cli/tests/integration/test_public_self_service.py index 022134a88..92f2c2f40 100644 --- a/cli/tests/integration/test_public_self_service.py +++ b/cli/tests/integration/test_public_self_service.py @@ -18,6 +18,8 @@ ROOT = Path(__file__).resolve().parents[3] BACKEND = ROOT / "backend" sys.path.insert(0, str(BACKEND / "scripts")) +# Canonical backend test helpers also use their pytest-root sibling imports. +sys.path.insert(0, str(BACKEND / "tests")) from api_contract_e2e import ( # noqa: E402 api_environment, assert_isolated_database_url, @@ -25,6 +27,7 @@ flow_settings, issue_flow_token, ) +from contributor_task_journey import exercise_contributor_task_reads # noqa: E402 async def _ready(url: str, process: subprocess.Popen[bytes]) -> None: @@ -65,6 +68,10 @@ async def test_installed_cli_uses_only_public_profile_and_project_context( tmp_path: Path, cli ) -> None: env = api_environment() + # Reused canonical activation fixtures arrange identities at this issuer. + # This remains a local HMAC verifier, not a deployed Flow certification. + env["WORKSTREAM_E2E_FLOW_ISSUER"] = "https://identity.flowresearch.tech" + env["WORKSTREAM_FLOW_AUTH_ISSUER"] = env["WORKSTREAM_E2E_FLOW_ISSUER"] assert_isolated_database_url(env["WORKSTREAM_DATABASE_URL"]) issuer, audience, secret = flow_settings(env) tokens = { @@ -117,6 +124,8 @@ async def test_installed_cli_uses_only_public_profile_and_project_context( for path in ( "/api/v1/projects/{project_id}/tasks", "/api/v1/projects/{project_id}/tasks/{task_id}", + "/api/v1/projects/{project_id}/tasks/ready", + "/api/v1/tasks/{task_id}", ): assert "get" in specification.json()["paths"][path] profiles: dict[str, dict] = {} @@ -381,6 +390,9 @@ async def test_installed_cli_uses_only_public_profile_and_project_context( ) assert issued.status_code == 201, issued.text assert issued.json()["status"] == "active" + await exercise_contributor_task_reads( + direct, cli, origin, tokens, profiles, env, qualification + ) for command in (("tasks", project_id), ("task", project_id, task_ids[0])): denied_manager_read = cli( origin, tokens["cli-outsider"], "project", *command, "-o", "json" diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index a7af2f853..611d651cf 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -137,7 +137,8 @@ The [independent MCP package](../mcp_server/README.md) implements nine self-serv and administrative read tools through WS-MCP-002-03. The [Go CLI](../cli/README.md) provides caller-profile and exact-project authorization reads plus human self-profile editing, exact-project inspection and manager -task queue/detail reads, with text/JSON output. Project inspection preserves +task queue/detail reads and contributor ready-work/instruction reads, with +text/JSON output. Project inspection preserves server-selected full/minimal fields; no public project-list route is invented. CLI write uncertainty is explicit and never automatically retried. These source packages do not claim @@ -302,6 +303,9 @@ cannot be reused as post-submission review-gate evidence. See the [CLI manager task browsing](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-04.md) adds one-page task discovery and exact task detail through existing manager reads, with server-owned live authority and opaque continuation. + [CLI contributor discovery](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-05.md) + adds ready-task pages and contributor instructions through public reads, + preserving Submitter scope and assignment visibility without claiming work. Built-binary HTTP integration and isolated real-API proof accompany the package. Further public commands, optional TUI and binary distribution