diff --git a/.github/workflows/proposal-shared.yml b/.github/workflows/proposal-shared.yml index a4ab710..fb5d03e 100644 --- a/.github/workflows/proposal-shared.yml +++ b/.github/workflows/proposal-shared.yml @@ -94,6 +94,10 @@ jobs: echo "::error::schedule/workflow_dispatch-triggered runs must receive capacity_query, queue_query, and review_limit" exit 1 fi + if [ -z "${{ secrets.model_credentials }}" ]; then + echo "::error::schedule/workflow_dispatch-triggered runs must receive the model_credentials secret -- Propose calls a model to decide field values, it no longer mocks them" + exit 1 + fi fi # Verifies the triggering comment is the proposal's actual first/ # control comment, not just any comment containing matching checkbox @@ -235,7 +239,33 @@ jobs: queue_query: ${{ inputs.queue_query }} review_limit: ${{ inputs.review_limit }} github_token: ${{ secrets.token }} - # Field values are mocked, not agent-decided yet — tracked in #11. + - name: Gather proposal context + id: context + if: steps.dequeue.outputs.result == 'dequeued' + uses: rubykatzen/starcast/actions/proposal@main + with: + mode: propose-context + parent_issue_id: ${{ steps.dequeue.outputs.issue_id }} + regulations_repo: ${{ inputs.regulations_repo }} + regulations_path: ${{ inputs.regulations_path }} + prefix: ${{ inputs.prefix }} + github_token: ${{ secrets.token }} + - name: Set up Node.js + if: steps.dequeue.outputs.result == 'dequeued' + uses: actions/setup-node@v4 + - name: Install Copilot CLI + if: steps.dequeue.outputs.result == 'dequeued' + run: npm install -g @github/copilot + - name: Decide proposal content + id: inference + if: steps.dequeue.outputs.result == 'dequeued' + uses: actions/ai-inference@v1 + with: + prompt: ${{ steps.context.outputs.prompt }} + system-prompt: 'You propose structured field values for a GitHub Issue, constrained by consumer-owned regulations text. Respond with exactly one JSON object and nothing else -- no prose, no Markdown code fence.' + model: claude-sonnet-4.5 + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.model_credentials }} - name: Create proposal id: propose if: steps.dequeue.outputs.result == 'dequeued' @@ -245,6 +275,7 @@ jobs: parent_issue_id: ${{ steps.dequeue.outputs.issue_id }} prefix: ${{ inputs.prefix }} proposal_type_name: ${{ inputs.proposal_type_name }} + model_response: ${{ steps.inference.outputs.response }} entity_query: ${{ inputs.entity_query }} propose_transition_mutation: ${{ inputs.propose_transition_mutation }} github_token: ${{ secrets.token }} diff --git a/.github/workflows/proposal.yml b/.github/workflows/proposal.yml index 5906775..436f759 100644 --- a/.github/workflows/proposal.yml +++ b/.github/workflows/proposal.yml @@ -18,7 +18,8 @@ jobs: # secrets.GITHUB_TOKEN cannot mutate Projects V2 (no such permission # scope exists for the auto-provisioned token) -- this caller needs a # real PAT with organization Projects read/write, stored as - # PROJECTS_TOKEN. + # PROJECTS_TOKEN. Propose's model call needs a separate PAT with an + # active GitHub Copilot seat, stored as COPILOT_PAT. handle: uses: ./.github/workflows/proposal-shared.yml with: @@ -39,3 +40,4 @@ jobs: mutation($entityId: ID!) { updateProjectV2ItemFieldValue(input: {projectId: "PVT_kwDOD5LpBM4Bfjz_", itemId: $entityId, fieldId: "PVTSSF_lADOD5LpBM4Bfjz_zhZ13FI", value: {singleSelectOptionId: "98236657"}}) { clientMutationId } } secrets: token: ${{ secrets.PROJECTS_TOKEN }} + model_credentials: ${{ secrets.COPILOT_PAT }} diff --git a/AGENTS.md b/AGENTS.md index ea683a6..6b95157 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,8 @@ commit SHA. Do not recommend `@main` for stable consumers. enforces that `issue_comment` runs carry `issue_number`/`comment_id` and that `schedule`/`workflow_dispatch` runs do not, and that `schedule`/`workflow_dispatch` runs carry `capacity_query`/`queue_query`/ - `review_limit`. On `issue_comment` runs, `validate` also verifies the + `review_limit`/the `model_credentials` secret. On `issue_comment` runs, + `validate` also verifies the triggering comment is actually the proposal's first/control comment (#63) via `mode: check-control-comment`, shared here rather than duplicated across `apply`/`reject`/`distill`/`rework` since all four @@ -95,12 +96,22 @@ commit SHA. Do not recommend `@main` for stable consumers. starts with `prefix`, stripped, plus the always-available fixed set (`title`, `body`, `type`, `parent`, `labels`) — the menu a caller (in the future, an agent) picks from. + - `propose-context` gathers everything a model needs to decide a + proposal's content -- regulations text (fetched from + `regulations_repo`/`regulations_path`'s default branch via + `repository.object(expression: "HEAD:")`), the candidate + issue's title/body, and the field catalog from `proposal_fields` -- + into one self-contained prompt, output as `prompt`. Produces text + only; agnostic to whichever inference mechanism a caller wires in. - `propose` creates a proposal issue against a parent: resolves the - Issue Type, creates the sub-issue, fills every discovered custom - field with a trivial type-appropriate mock value (`propose` does - not read regulations or call a model yet — real agent judgment is - tracked in #11), and posts the control comment - (`CONTROL_COMMENT_BODY`) as the first comment. + Issue Type, creates the sub-issue, writes whichever fields + `--model-response` (a JSON blob shaped `{"title", "body", + "fields": {...}}`, produced from `propose-context`'s prompt by a + real model call) included, and posts the control comment + (`CONTROL_COMMENT_BODY`) as the first comment. Does not decide + content itself -- an unknown field name in the response is a + warning and a skip, not an error, since the model's output isn't + trusted to match the catalog exactly. - `propose`/`apply` optionally transition whatever "controlling entity" tracks an issue's state (e.g. a `ProjectV2Item`) as it moves through the lifecycle (#69): `entity_query` resolves the diff --git a/CHANGELOG.md b/CHANGELOG.md index d369bd6..458fc3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,15 +22,25 @@ `content { ... on Issue { id } }`). - `list-fields`: discovers the prefixed proposal Issue Fields available in a repository. + - `propose-context`: gathers regulations text, the candidate issue's + title/body, and the field catalog into one self-contained prompt for + a model to decide proposal content from — output only, agnostic to + whichever inference mechanism a caller wires in. - `propose`: creates a proposal issue against a parent — Issue Type, - sub-issue relationship, field values, and the control comment. Field - *values* are mocked (a trivial type-appropriate placeholder) rather - than agent-decided; real regulation-constrained judgment is tracked - in #11. + sub-issue relationship, field values, and the control comment. Takes + the content to write as a `--model-response` JSON blob + (`{"title", "body", "fields": {...}}`) rather than deciding it + itself; an unknown field name in the response is a warning and a + skip, not an error. - `apply`/`reject`: unchanged behavior from the actions they replace (below), now reached via `mode: apply`/`mode: reject`. -- `proposal-shared.yml`'s `propose` job now dequeues a candidate and - creates a (mocked) proposal for it, instead of a placeholder notice. +- `proposal-shared.yml`'s `propose` job now dequeues a candidate, asks a + model (via `actions/ai-inference` wrapping GitHub Copilot CLI, + requiring a `model_credentials` secret backed by a PAT with an active + Copilot seat) what to propose, and creates the proposal from its + response — instead of a placeholder notice. `validate` hard-errors on + schedule/`workflow_dispatch` runs missing `model_credentials`, the + same way it already does for `capacity_query`/`queue_query`. - `entity_query`, `propose_transition_mutation`, and `apply_transition_mutation` inputs on `proposal-shared.yml`: optionally transition whatever "controlling entity" tracks an issue's state (e.g. diff --git a/README.md b/README.md index 48fb1c1..bdbc4bf 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,9 @@ history remains available in Git. The current reusable workflows cover centralized Project intake for issues and pull requests, plus explicit label-based issue routing. The proposal-as-issue lifecycle described above (`Propose`/`Apply`/`Reject`/`Distill`/`Rework`) has a -working contract, `proposal-shared.yml`: `Apply`/`Reject` are fully -implemented, `Propose` dequeues and creates a proposal with mocked field -values (real agent judgment not wired in yet), and `Distill`/`Rework` remain -placeholders (tracked in #11). +working contract, `proposal-shared.yml`: `Apply`/`Reject`/`Propose` are fully +implemented (`Propose` decides field values via a real model call, not a +mock), and `Distill`/`Rework` remain placeholders (tracked in #86/#87). ## Reusable workflows @@ -223,21 +222,32 @@ jobs: empty/unset proposal field (title/body included) leaves the parent's field untouched. `Reject` just closes the proposal. Both are idempotent: re-running against an already-closed proposal is a no-op. -- **`Propose` dequeues and creates, with mocked content** (#55, #11) — on - each scheduled run it runs the consumer-supplied `capacity_query`, and if - the result is below `review_limit`, runs `queue_query` and takes its first +- **`Propose` dequeues, asks a model, and creates** (#55, #11) — on each + scheduled run it runs the consumer-supplied `capacity_query`, and if the + result is below `review_limit`, runs `queue_query` and takes its first candidate; at capacity or an empty queue are both clean no-ops. Both queries are consumer-owned GraphQL text: `capacity_query` must alias exactly one scalar numeric field as `capacity`, `queue_query` exactly one array-valued field as `queue` (each element at least `{ id }`), found by a recursive walk matching alias name and value type — zero or more than one match is a hard configuration error. Only one candidate is ever dequeued - per run, so no pagination/cursor state is needed between runs. When a - candidate is found, `Propose` creates the proposal issue against it — - Issue Type, sub-issue relationship, the control comment — but **fills every - field with a trivial mock value rather than a real agent decision**; wiring - in regulation-constrained judgment is tracked in #11. `Distill`/`Rework` - remain placeholders. + per run, so no pagination/cursor state is needed between runs. + + Deciding what to propose and creating the proposal are two separate + steps, split the same way `Apply`/`Reject` are deterministic and + `Distill`/`Rework` are agent work: `propose-context` gathers the + regulations text, the candidate's title/body, and the catalog of + available fields into one self-contained prompt (schema-agnostic about + which inference mechanism reads it); the caller hands that prompt to a + model and gets back one JSON object (`{"title", "body", "fields": {...}}`); + `propose` takes that JSON as `--model-response` and does only the + mechanical part — Issue Type, sub-issue relationship, field writes, the + control comment. The dogfood caller (`.github/workflows/proposal.yml`) + wires this to GitHub Copilot CLI via `actions/ai-inference`, requiring a + PAT with an active Copilot seat (`model_credentials`/`COPILOT_PAT`) — + `validate` hard-errors on schedule/`workflow_dispatch` runs if it's + missing, the same way it does for `capacity_query`/`queue_query`. + `Distill`/`Rework` remain placeholders. - **The control comment has one fixed template**, posted by `Propose` immediately after creation: `- [ ] Apply`, `- [ ] Reject`, `- [ ] Distill`, `- [ ] Rework`, nothing else. Before `Apply`/`Reject`/ diff --git a/actions/proposal/action.yml b/actions/proposal/action.yml index 3859d5c..33de144 100644 --- a/actions/proposal/action.yml +++ b/actions/proposal/action.yml @@ -14,7 +14,16 @@ inputs: description: 'Id of the comment to verify is the proposal''s first/control comment (check-control-comment; see #63)' required: false parent_issue_id: - description: 'GraphQL node id of the issue to propose against (propose)' + description: 'GraphQL node id of the issue to propose against (propose, propose-context)' + required: false + regulations_repo: + description: '"owner/repo" that owns the regulations document (propose-context)' + required: false + regulations_path: + description: 'Path to the regulations document inside regulations_repo, read from its default branch (propose-context)' + required: false + model_response: + description: 'JSON {"title", "body", "fields": {...}} decided by a model from propose-context''s prompt (propose)' required: false capacity_query: description: 'GraphQL query text; must alias exactly one scalar numeric field as `capacity` (dequeue)' @@ -55,6 +64,9 @@ outputs: fields: description: 'JSON field catalog (list-fields only)' value: ${{ steps.run.outputs.fields }} + prompt: + description: 'Self-contained prompt for a model to decide proposal content (propose-context only)' + value: ${{ steps.run.outputs.prompt }} summary: description: 'Human-readable summary of the outcome' value: ${{ steps.run.outputs.summary }} @@ -70,6 +82,9 @@ runs: ISSUE_NUMBER: ${{ inputs.issue_number }} COMMENT_ID: ${{ inputs.comment_id }} PARENT_ISSUE_ID: ${{ inputs.parent_issue_id }} + REGULATIONS_REPO: ${{ inputs.regulations_repo }} + REGULATIONS_PATH: ${{ inputs.regulations_path }} + MODEL_RESPONSE: ${{ inputs.model_response }} CAPACITY_QUERY: ${{ inputs.capacity_query }} QUEUE_QUERY: ${{ inputs.queue_query }} REVIEW_LIMIT: ${{ inputs.review_limit }} @@ -87,8 +102,11 @@ runs: list-fields) args+=(--repository "$REPOSITORY" --prefix "$PREFIX") ;; + propose-context) + args+=(--parent-issue-id "$PARENT_ISSUE_ID" --regulations-repo "$REGULATIONS_REPO" --regulations-path "$REGULATIONS_PATH" --prefix "$PREFIX") + ;; propose) - args+=(--parent-issue-id "$PARENT_ISSUE_ID" --prefix "$PREFIX" --type "$PROPOSAL_TYPE_NAME" --entity-query "$ENTITY_QUERY" --transition-mutation "$PROPOSE_TRANSITION_MUTATION") + args+=(--parent-issue-id "$PARENT_ISSUE_ID" --prefix "$PREFIX" --type "$PROPOSAL_TYPE_NAME" --model-response "$MODEL_RESPONSE" --entity-query "$ENTITY_QUERY" --transition-mutation "$PROPOSE_TRANSITION_MUTATION") ;; apply) args+=(--repository "$REPOSITORY" --issue-number "$ISSUE_NUMBER" --prefix "$PREFIX" --type "$PROPOSAL_TYPE_NAME" --entity-query "$ENTITY_QUERY" --transition-mutation "$APPLY_TRANSITION_MUTATION") diff --git a/actions/proposal/proposal.py b/actions/proposal/proposal.py index 6c56643..81f8969 100644 --- a/actions/proposal/proposal.py +++ b/actions/proposal/proposal.py @@ -26,12 +26,18 @@ repository (so a caller -- in the future, an agent -- knows what it can fill in), and `propose` creates the proposal issue itself. -`propose` is mocked in this version: it does not read regulations or -call a model. It fills every discovered field with a trivial -type-appropriate placeholder value and creates the proposal exactly as -a real decision-maker eventually would, so the mechanical pipeline -(creation, field writes, control comment) is fully exercised ahead of -wiring in real agent judgment. +`propose` does not decide content itself -- that's real agent +judgment, out of scope for a deterministic script. It takes a +`--model-response` JSON blob (`{"title": ..., "body": ..., +"fields": {...}}`) and does only the mechanical part: creates the +issue, writes the given field values, posts the control comment. The +`propose-context` mode is the other half: it gathers everything a +model needs to produce that JSON -- regulations text, the candidate +issue's title/body, and the catalog of fields available to fill in -- +as one self-contained prompt, so a caller can hand it straight to a +model (e.g. via `actions/ai-inference` wrapping GitHub Copilot CLI) and +feed the raw response back into `propose --model-response` without +either mode needing to know about the other's transport. The control comment's exact template is defined once here (CONTROL_COMMENT_BODY) and posted by `propose` immediately after @@ -62,8 +68,7 @@ import re import subprocess import sys -from datetime import datetime as dt -from datetime import timezone +import uuid DEFAULT_PREFIX = "Proposal " DEFAULT_PROPOSAL_TYPE = "Proposal" @@ -104,11 +109,20 @@ def gh_graphql(query: str, **variables: object) -> dict: def write_output(**fields: str) -> None: - lines = "".join(f"{key}={value}\n" for key, value in fields.items()) + """Write step outputs, safe for multiline values (e.g. `prompt`). + + A plain `key=value\\n` line breaks if `value` itself contains a + newline -- GITHUB_OUTPUT would parse the continuation as new, + unrelated key=value pairs. Uses the delimiter form GitHub documents + for multiline values (`key< tuple[str | None, list[str] | None]: - """A trivial, type-appropriate placeholder -- stands in for a real - agent decision (#11's Propose is agent work; this tool mocks it).""" +def model_value_for_field(field: dict, raw: object) -> tuple[str | None, list[str] | None]: + """Convert one `--model-response` "fields" entry into build_field_input's + (value, names) shape, based on the field's actual data type -- the + model always writes a plain string (or, for MULTI_SELECT, a list of + strings); this is not a second copy of the model's judgment, just a + shape adapter. + """ data_type = field["dataType"] - if data_type == "TEXT": - return f"Test {field['name']}", None - if data_type == "NUMBER": - return "0", None - if data_type == "DATE": - return dt.now(tz=timezone.utc).date().isoformat(), None - if data_type == "SINGLE_SELECT": - options = field.get("options") or [] - return None, [options[0]["name"]] if options else None if data_type == "MULTI_SELECT": - options = field.get("options") or [] - return None, [options[0]["name"]] if options else None - return None, None + names = raw if isinstance(raw, list) else [raw] + return None, [str(n) for n in names] + if data_type == "SINGLE_SELECT": + return None, [str(raw)] + return str(raw), None # -------------------------------------------------------------- mutations @@ -536,14 +547,7 @@ def cmd_list_fields(args: argparse.Namespace) -> None: # -------------------------------------------------------------- propose mode -def cmd_propose(args: argparse.Namespace) -> None: - """Create a proposal against args.parent_issue_id. - - Mocked per #11/discussion: fills a trivial type-appropriate - placeholder for every discovered custom field instead of calling a - model. The mechanical pipeline (creation, field writes, control - comment) is real; only the *content* is a stand-in. - """ +def resolve_parent(parent_issue_id: str) -> dict: data = gh_graphql( """ query($id: ID!) { @@ -555,12 +559,156 @@ def cmd_propose(args: argparse.Namespace) -> None: } } """, - id=args.parent_issue_id, + id=parent_issue_id, ) parent = data["node"] if parent is None: - print(f"ERROR: parent issue '{args.parent_issue_id}' not found", file=sys.stderr) + print(f"ERROR: parent issue '{parent_issue_id}' not found", file=sys.stderr) + sys.exit(1) + return parent + + +def fetch_issue_title_body(owner: str, name: str, number: int) -> tuple[str, str]: + data = gh_graphql( + """ + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { title body } + } + } + """, + owner=owner, + name=name, + number=number, + ) + repository = data["repository"] + if repository is None: + print(f"ERROR: repository '{owner}/{name}' not found or inaccessible", file=sys.stderr) + sys.exit(1) + issue = repository["issue"] + if issue is None: + print(f"ERROR: issue #{number} not found in {owner}/{name}", file=sys.stderr) + sys.exit(1) + return issue["title"], issue["body"] or "" + + +def fetch_regulations_text(owner: str, name: str, path: str) -> str: + data = gh_graphql( + """ + query($owner: String!, $name: String!, $expr: String!) { + repository(owner: $owner, name: $name) { + object(expression: $expr) { + ... on Blob { text } + } + } + } + """, + owner=owner, + name=name, + expr=f"HEAD:{path}", + ) + repository = data["repository"] + if repository is None: + print(f"ERROR: regulations repository '{owner}/{name}' not found or inaccessible", file=sys.stderr) + sys.exit(1) + obj = repository["object"] + if obj is None: + print(f"ERROR: '{path}' not found on {owner}/{name}'s default branch", file=sys.stderr) sys.exit(1) + return obj["text"] + + +def cmd_propose_context(args: argparse.Namespace) -> None: + """Gather everything a model needs to decide a proposal's content. + + Outputs one self-contained prompt: the regulations text, the + candidate issue's title/body, and the catalog of fields available to + propose, plus an explicit instruction for the JSON response shape + `propose --model-response` expects back. Deliberately produces text + only -- it does not itself call a model, so it stays agnostic to + whichever inference mechanism a caller wires in. + """ + parent = resolve_parent(args.parent_issue_id) + owner = parent["repository"]["owner"]["login"] + name = parent["repository"]["name"] + + title, body = fetch_issue_title_body(owner, name, parent["number"]) + + regulations_owner, regulations_name = args.regulations_repo.split("/") + regulations_text = fetch_regulations_text(regulations_owner, regulations_name, args.regulations_path) + + fields = proposal_fields(owner, name, args.prefix) + if fields: + field_lines = [] + for field_name, field in fields.items(): + data_type = field["dataType"] + if data_type in ("SINGLE_SELECT", "MULTI_SELECT"): + options = ", ".join(o["name"] for o in field["options"]) + field_lines.append(f'- "{field_name}" ({data_type}, one of: {options})') + else: + field_lines.append(f'- "{field_name}" ({data_type})') + fields_block = "\n".join(field_lines) + else: + fields_block = "(no custom fields configured)" + + prompt = f"""# Regulations + +{regulations_text} + +# Issue to propose against ({owner}/{name}#{parent['number']}) + +## Title + +{title} + +## Body + +{body} + +# Available proposal fields + +Besides `title` and `body` (always proposable), these custom fields are +available: + +{fields_block} + +# Required response format + +Respond with a single JSON object and nothing else -- no prose, no +Markdown code fence. Shape: + +{{ + "title": "", + "body": "", + "fields": {{ + "": "" + }} +}} + +Only include a field in "fields" if you have a real value to propose +for it -- omit it entirely rather than guessing. For a field whose type +is listed as MULTI_SELECT, its value must be a JSON array of option +name strings, even if you are only selecting one option. For every +other type, its value is a single string (write dates as YYYY-MM-DD).""" + + write_output( + result="context", + prompt=prompt, + summary=f"context gathered for {owner}/{name}#{parent['number']} ({len(fields)} custom field(s))", + ) + + +def cmd_propose(args: argparse.Namespace) -> None: + """Create a proposal against args.parent_issue_id. + + Takes the model's decision as a `--model-response` JSON blob + (produced from `propose-context`'s prompt by whatever inference + mechanism the caller wires in) and does only the mechanical part: + create the issue, write the given field values, post the control + comment. Does not itself decide content -- that's `propose-context` + plus a real model call, not this mode's job. + """ + parent = resolve_parent(args.parent_issue_id) owner = parent["repository"]["owner"]["login"] name = parent["repository"]["name"] @@ -569,9 +717,20 @@ def cmd_propose(args: argparse.Namespace) -> None: print(f"ERROR: Issue Type '{args.type}' does not exist in {owner}/{name}", file=sys.stderr) sys.exit(1) + try: + model_response = json.loads(args.model_response) + except json.JSONDecodeError as error: + print(f"ERROR: --model-response is not valid JSON: {error}", file=sys.stderr) + sys.exit(1) + + title = model_response.get("title") + if not title: + print("ERROR: --model-response must include a non-empty 'title'", file=sys.stderr) + sys.exit(1) + body = model_response.get("body") or "" + model_fields = model_response.get("fields") or {} + fields = proposal_fields(owner, name, args.prefix) - title = f"Test proposal for {owner}/{name}#{parent['number']}" - body = "Test body (mocked -- see #11 for real Propose agent work)." create_data = gh_graphql( """ @@ -593,8 +752,12 @@ def cmd_propose(args: argparse.Namespace) -> None: field_inputs = [] filled = [] - for field_name, field in fields.items(): - value, names = mock_value_for(field) + for field_name, raw_value in model_fields.items(): + field = fields.get(field_name) + if field is None: + print(f"::warning::model proposed unknown field '{field_name}', skipping", file=sys.stderr) + continue + value, names = model_value_for_field(field, raw_value) field_input = build_field_input(field, value, names) if field_input is not None: field_inputs.append(field_input) @@ -614,7 +777,7 @@ def cmd_propose(args: argparse.Namespace) -> None: issue_id=proposal["id"], summary=( f"created {owner}/{name}#{proposal['number']} for parent #{parent['number']}; " - f"mocked fields={','.join(filled) if filled else '(none)'}; transitioned={transitioned}" + f"fields={','.join(filled) if filled else '(none)'}; transitioned={transitioned}" ), ) @@ -961,11 +1124,22 @@ def build_parser() -> argparse.ArgumentParser: list_fields.add_argument("--prefix", default=DEFAULT_PREFIX) list_fields.set_defaults(func=cmd_list_fields) - propose = subparsers.add_parser("propose", help="create a proposal against a parent issue (mocked field values)") + propose_context = subparsers.add_parser( + "propose-context", + help="gather regulations/issue/field-catalog into one prompt for a model to decide proposal content", + ) + propose_context.add_argument("--parent-issue-id", required=True) + propose_context.add_argument("--regulations-repo", required=True) + propose_context.add_argument("--regulations-path", required=True) + propose_context.add_argument("--prefix", default=DEFAULT_PREFIX) + propose_context.set_defaults(func=cmd_propose_context) + + propose = subparsers.add_parser("propose", help="create a proposal against a parent issue from a model's decided content") propose.add_argument("--parent-issue-id", required=True) propose.add_argument("--repository-id", default=None) propose.add_argument("--prefix", default=DEFAULT_PREFIX) propose.add_argument("--type", default=DEFAULT_PROPOSAL_TYPE) + propose.add_argument("--model-response", required=True, help='JSON {"title", "body", "fields": {...}} from propose-context + a model call') propose.add_argument("--entity-query", default=None, help="resolves the controlling entity's id (#69); optional") propose.add_argument("--transition-mutation", default=None, help="moves the controlling entity incoming -> review (#69); optional") propose.set_defaults(func=cmd_propose)