diff --git a/context/logs/2026/05/LOG-20260509_2142-SteadyField-workitem-transitioned.md b/context/logs/2026/05/LOG-20260509_2142-SteadyField-workitem-transitioned.md new file mode 100644 index 0000000..736bf28 --- /dev/null +++ b/context/logs/2026/05/LOG-20260509_2142-SteadyField-workitem-transitioned.md @@ -0,0 +1,15 @@ +--- +apiVersion: processkit.projectious.work/v2 +kind: LogEntry +metadata: + id: LOG-20260509_2142-SteadyField-workitem-transitioned + created: '2026-05-09T21:42:45+00:00' +spec: + event_type: workitem.transitioned + timestamp: '2026-05-09T21:42:45+00:00' + summary: Transitioned WorkItem 'BACK-20260509_1836-TidyAsh-roleslot-primitive-identity-axis-decoupling' + from 'in-progress' to 'review' + subject: BACK-20260509_1836-TidyAsh-roleslot-primitive-identity-axis-decoupling + subject_kind: WorkItem + actor: BACK-20260509_1836-TidyAsh-roleslot-primitive-identity-axis-decoupling +--- diff --git a/context/logs/2026/05/LOG-20260509_2146-BrightGrove-workitem-transitioned.md b/context/logs/2026/05/LOG-20260509_2146-BrightGrove-workitem-transitioned.md new file mode 100644 index 0000000..57219b3 --- /dev/null +++ b/context/logs/2026/05/LOG-20260509_2146-BrightGrove-workitem-transitioned.md @@ -0,0 +1,15 @@ +--- +apiVersion: processkit.projectious.work/v2 +kind: LogEntry +metadata: + id: LOG-20260509_2146-BrightGrove-workitem-transitioned + created: '2026-05-09T21:46:11+00:00' +spec: + event_type: workitem.transitioned + timestamp: '2026-05-09T21:46:11+00:00' + summary: Transitioned WorkItem 'BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create' + from 'backlog' to 'in-progress' + subject: BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create + subject_kind: WorkItem + actor: BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create +--- diff --git a/context/logs/2026/05/LOG-20260509_2204-KeenLynx-workitem-transitioned.md b/context/logs/2026/05/LOG-20260509_2204-KeenLynx-workitem-transitioned.md new file mode 100644 index 0000000..266b795 --- /dev/null +++ b/context/logs/2026/05/LOG-20260509_2204-KeenLynx-workitem-transitioned.md @@ -0,0 +1,15 @@ +--- +apiVersion: processkit.projectious.work/v2 +kind: LogEntry +metadata: + id: LOG-20260509_2204-KeenLynx-workitem-transitioned + created: '2026-05-09T22:04:55+00:00' +spec: + event_type: workitem.transitioned + timestamp: '2026-05-09T22:04:55+00:00' + summary: Transitioned WorkItem 'BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create' + from 'in-progress' to 'review' + subject: BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create + subject_kind: WorkItem + actor: BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create +--- diff --git a/context/migrations/20260509_2139_0.25.8-to-0.26.0.md b/context/migrations/20260509_2139_0.25.8-to-0.26.0.md new file mode 100644 index 0000000..7031546 --- /dev/null +++ b/context/migrations/20260509_2139_0.25.8-to-0.26.0.md @@ -0,0 +1,186 @@ +--- +apiVersion: processkit.projectious.work/v2 +kind: Migration +metadata: + id: MIG-20260509T213904-roleslot-phase-a + created: 2026-05-09T21:39:04+00:00 + labels: + cluster: team-model-v2 + epic_sub: SUB-1 + design_artifact_ref: ART-20260509_1836-SmartPanda-team-creator-v2-design +spec: + source: processkit + source_url: https://github.com/projectious-work/processkit.git + kind: schema-extension + from_version: v0.25.8 + to_version: v0.26.0 + source_api_version: processkit.projectious.work/v2 + target_api_version: processkit.projectious.work/v2 + source_processkit_version: v0.25.8 + target_processkit_version: v0.26.0 + apply_mode: resumable + state: pending + generated_by: TEAMMEMBER-finn (ROLE-software-engineer/senior) + generated_at: 2026-05-09T21:39:04+00:00 + summary: > + Phase A team-creator v2 — additive RoleSlot primitive + role-slot-fill + binding type. Backfill RoleSlots from existing Role.clone_cap and + parallel role-slot-fill Bindings from existing role-assignment + Bindings. Both old and new resolution paths remain valid after apply. + affected_groups: + - context/schemas + - context/skills/processkit/team-manager + - context/skills/_lib/processkit + - context/roleslots + - context/bindings + affected_files: + - path: src/context/schemas/roleslot.yaml + classification: new-upstream + group: context/schemas + - path: context/schemas/roleslot.yaml + classification: new-upstream + group: context/schemas + - path: src/context/schemas/binding.yaml + classification: changed-upstream-only + group: context/schemas + - path: context/schemas/binding.yaml + classification: changed-upstream-only + group: context/schemas + - path: src/context/skills/_lib/processkit/__init__.py + classification: changed-upstream-only + group: context/skills/_lib/processkit + - path: context/skills/_lib/processkit/__init__.py + classification: changed-upstream-only + group: context/skills/_lib/processkit + - path: src/context/skills/processkit/team-manager/mcp/server.py + classification: changed-upstream-only + group: context/skills/processkit/team-manager + - path: context/skills/processkit/team-manager/mcp/server.py + classification: changed-upstream-only + group: context/skills/processkit/team-manager + - path: src/context/skills/processkit/team-manager/scripts/test_team_manager.py + classification: changed-upstream-only + group: context/skills/processkit/team-manager + - path: context/skills/processkit/team-manager/scripts/test_team_manager.py + classification: changed-upstream-only + group: context/skills/processkit/team-manager + plan: > + Apply Phase A in three steps. (1) Land the schema + binding-type + additions (already shipped by this branch). (2) For each Role in + context/roles/ carrying clone_cap=N>0, emit N RoleSlots under the + team's chartering Scope (parent = active project Scope), rank=1..N, + state=open, default_model_profile copied through when present. + (3) For each Binding(type=role-assignment) currently active, write a + parallel Binding(type=role-slot-fill) with subject=TeamMember, + target=SLOT---1 (rank-1 by convention; multi-clone + teams that were not yet differentiated all collapse onto rank-1 + until pk-team-rebalance redistributes). v1 role-assignment Bindings + remain readable for one minor version (deprecation window). + Phase A must NOT delete v0.16.0 fields, must NOT remove the 8 + archetype Roles, and must NOT modify pk-team-create. Both old and + new resolution paths must work after apply + (DEC-20260509_1906-CoolBadger Q2). + rollback: + feasibility: full + steps: + - > + Delete every entity under context/roleslots/ written by this + migration (filename matches SLOT-*-rank-* and frontmatter + labels.migration == MIG-20260509T213904-roleslot-phase-a). The + schema file roleslot.yaml stays — it is unreferenced once the + slot files are gone. + - > + Delete every Binding under context/bindings/ where spec.type == + 'role-slot-fill' and labels.migration == + MIG-20260509T213904-roleslot-phase-a. v1 role-assignment Bindings + remain untouched. + - > + Reverse-edit binding.yaml known_types to remove + 'role-slot-fill'. Reverse-edit + src/context/skills/_lib/processkit/__init__.py to remove the + RoleSlot KIND_PREFIXES + DEFAULT_DIRS entries. Reverse-edit + team-manager/mcp/server.py to remove the five RoleSlot tools and + the resolver pre-step hook. Existing 8-layer resolver behaviour + is restored bit-identical because the pre-step only adds a + ``binding.roleslot_pre_step`` key — it never mutates the + existing fields. + - > + After rollback the project is at v0.25.8 — the same code path + as before this migration, with no orphan entities. + risk_window: > + Phase A only. Phase B (the destructive cutover that stops writing + archetype Roles) ships in a later minor version. Phase C + (resolver fold-in) ships after Phase B has been live for one + minor version. This migration is fully reversible. + progress_notes: [] +--- + +# Migration MIG-20260509T213904-roleslot-phase-a + +Phase A team-creator v2 — RoleSlot primitive + identity-axis decoupling. + +Companion to: +- Architectural design: ART-20260509_1836-SmartPanda-team-creator-v2-design +- Architectural decisions: DEC-20260509_1906-CoolBadger +- Implementation WorkItem: BACK-20260509_1836-TidyAsh + +## What this migration adds + +1. **New entity kind: RoleSlot** (SCHEMA-roleslot v1.0.0). Scope-bounded + capacity reservation, registered in + `_lib/processkit/__init__.py`'s `KIND_PREFIXES` (`SLOT`) and + `DEFAULT_DIRS` (`roleslots`). +2. **New binding type: `role-slot-fill`** (subject=TeamMember, + target=SLOT-*). Added to `binding.yaml` `known_types` with a + conditional schema branch. +3. **Five new MCP tools on team-manager**: `create_role_slot`, + `get_role_slot`, `list_role_slots`, `fill_role_slot`, + `close_role_slot`. +4. **Resolver pre-step** in `get_interlocutor_runtime_binding` that + surfaces a filled RoleSlot ahead of the existing 8-layer + model-assignment resolver. The 8 layers are unchanged + (DEC-20260509_1906-CoolBadger Q1). + +## Phase A backfill rules + +For every Role in `context/roles/` with `clone_cap=N>0`, emit `N` +RoleSlots: + +``` +SLOT---1 +SLOT---2 +... +SLOT---N +``` + +For every active `Binding(type=role-assignment)` whose subject is a +TeamMember, write a parallel `Binding(type=role-slot-fill)` pointing +TeamMember → `SLOT-...-1`. Multi-clone v1 teams collapse onto rank-1 +until `pk-team-rebalance` redistributes — this is intentional for +Phase A. + +## Compatibility commitments + +- v1 `role-assignment` Bindings remain readable. +- The 8 archetype Roles stay in `context/roles/` and are NOT renamed. +- `pk-team-create`'s archetype-Role write step is unchanged (that is + SUB-2 / `LuckyWren`). +- v0.16.0 fields (`clone_cap`, `cap_escalation`, `is_template`, + `templated_from`, `primary_contact`) are NOT removed in Phase A + (DEC-20260509_1906-CoolBadger Q2). They are not currently present + in the live `role.yaml` / `team-member.yaml` schemas — see "Open + questions / scope risks" in the SUB-1 dispatch report. + +## Rollback shape + +See `spec.rollback`. Fully reversible: delete the new SLOT-* and +`role-slot-fill` Binding entities, revert the three changed source +files (`__init__.py`, `binding.yaml`, `team-manager/mcp/server.py`), +and the project returns to its pre-migration state. + +## Status + +This migration represents the additive Phase A surface. The actual +backfill apply happens when an operator runs the Phase A apply script +against the project's Roles + Bindings — out of scope for the +SUB-1 PR per BACK-20260509_1836-TidyAsh. diff --git a/context/schemas/binding.yaml b/context/schemas/binding.yaml index 4e663d8..f0996d4 100644 --- a/context/schemas/binding.yaml +++ b/context/schemas/binding.yaml @@ -38,6 +38,7 @@ spec: default_directory: context/bindings known_types: - role-assignment + - role-slot-fill - model-assignment - work-assignment - process-gate @@ -286,3 +287,27 @@ spec: recurrence_rule: type: string pattern: "^ART-[a-zA-Z0-9_-]+$" + - if: + properties: + type: + const: role-slot-fill + required: [type] + then: + properties: + subject: + type: string + pattern: "^TEAMMEMBER-[a-z][a-z0-9-]*$" + description: "TeamMember filling the slot." + target: + type: string + pattern: "^SLOT-[a-z0-9][a-z0-9-]*-[1-9][0-9]*$" + description: "RoleSlot being filled." + target_kind: + const: RoleSlot + conditions: + type: object + additionalProperties: true + properties: + rationale: + type: string + description: "Why this TeamMember was placed in this slot." diff --git a/context/schemas/roleslot.yaml b/context/schemas/roleslot.yaml new file mode 100644 index 0000000..679f389 --- /dev/null +++ b/context/schemas/roleslot.yaml @@ -0,0 +1,178 @@ +--- +apiVersion: processkit.projectious.work/v2 +kind: Schema +metadata: + id: SCHEMA-roleslot + target_kind: RoleSlot + version: 1.0.0 + created: 2026-05-09T00:00:00+00:00 +spec: + description: > + A scope-bounded capacity reservation for one parallel worker filling a + given (Role, seniority) within a chartering Scope. RoleSlot decouples + *capacity* (how many parallel workers a role needs in this charter) + from *identity* (who the persistent persona is). Persistent + TeamMembers carry stable memory and personality; RoleSlots carry no + memory and exist only inside the lifetime of their chartering Scope. + + Introduced in Phase A of team-creator v2 (DEC-20260509_1906-CoolBadger, + ART-20260509_1836-SmartPanda). Phase A is additive and reversible: + the new resolver pre-step inserts before the existing 8-layer + model-assignment binding precedence without reshaping it; v0.16.0 + capacity fields on Role/TeamMember stay readable during the + deprecation window. + + A RoleSlot is filled through a Binding of type ``role-slot-fill`` + (subject = TEAMMEMBER-, target = SLOT-). State machine: + ``open → filled → closed``. ``closed`` is terminal — re-opening + means a new SLOT-id at the next charter. When the chartering Scope + closes, all open or filled slots auto-close. + + For ephemeral worker invocations (no persistent TeamMember filled + in the slot), the slot's optional ``default_model_profile`` carries + forward as the Layer 8 fallback for the existing + model-assignment 8-layer resolver. + id_prefix: SLOT + state_machine: null + default_directory: context/roleslots + seniority_levels: + - junior + - specialist + - expert + - senior + - principal + states: + - open + - filled + - closed + metadata_schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + required: + - id + properties: + id: + type: string + pattern: ^SLOT-[a-z0-9][a-z0-9-]*-[1-9][0-9]*$ + description: > + ``SLOT---``. Scope and role slugs + are kebab-case (lowercase letters, digits, dashes). Rank is a + positive integer with ``rank=1`` meaning the primary slot for + this (scope, role, seniority) triple, ``rank=2..N`` parallel + reservations. + spec_schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + required: + - scope + - role + - seniority + - rank + - state + - rationale + - created + additionalProperties: true + properties: + scope: + type: string + pattern: ^SCOPE-[a-zA-Z0-9_-]+$ + description: > + ID of the chartering Scope. Mandatory — slots have no meaning + outside a Scope. When the Scope closes, all open or filled + slots auto-close. + role: + type: string + pattern: ^ROLE-[a-z][a-z0-9-]*$ + description: > + FK into ``context/roles/``. The catalog Role this slot + reserves capacity for. v2 schema (DEC-20260422_0234-BraveFalcon) + requires the slug to be seniority-free. + seniority: + type: string + enum: + - junior + - specialist + - expert + - senior + - principal + description: > + Pure ordinal seniority for this slot. Resolved through + (role, seniority) → (model, effort) via model-assignment + bindings. + rank: + type: integer + minimum: 1 + description: > + 1 = primary slot for this (scope, role, seniority); 2..N = + parallel reservations. Two slots with the same (scope, role, + seniority) must have distinct ranks. + state: + type: string + enum: + - open + - filled + - closed + description: > + State machine: ``open → filled → closed``. ``closed`` is + terminal. Reverse transitions are rejected. + filled_by: + type: + - string + - "null" + pattern: ^TEAMMEMBER-[a-z][a-z0-9-]*$ + description: > + ID of the TeamMember currently filling this slot, or null if + the slot is open. Set when state transitions to ``filled``; + cleared when the slot is closed. + default_model_profile: + type: + - string + - "null" + pattern: ^ART-[0-9]{8}_[0-9]{4}-ModelProfile-[a-z0-9-]+$ + description: > + Optional pin for ephemeral dispatches when no TeamMember is + filled in this slot. Carried forward as the Layer 8 fallback + of the existing 8-layer model-assignment binding precedence. + effort_floor: + type: + - string + - "null" + enum: + - low + - medium + - high + - extra-high + - max + - null + description: Optional minimum effort tier for dispatches through this slot. + effort_ceiling: + type: + - string + - "null" + enum: + - low + - medium + - high + - extra-high + - max + - null + description: Optional maximum effort tier for dispatches through this slot. + rationale: + type: string + minLength: 1 + description: One-line reason this slot exists in the charter. + created: + type: string + format: date-time + description: ISO-8601 timestamp when the slot was opened. + closed_at: + type: + - string + - "null" + format: date-time + description: ISO-8601 timestamp when the slot was closed (terminal). + close_reason: + type: + - string + - "null" + description: Optional one-line reason recorded when the slot closes. diff --git a/context/skills/_lib/processkit/__init__.py b/context/skills/_lib/processkit/__init__.py index 5e0d33f..7f5294d 100644 --- a/context/skills/_lib/processkit/__init__.py +++ b/context/skills/_lib/processkit/__init__.py @@ -40,6 +40,7 @@ "Migration": "MIG", "Note": "NOTE", "TeamMember": "TEAMMEMBER", + "RoleSlot": "SLOT", } # Default subdirectory under context/ for each primitive kind @@ -63,4 +64,5 @@ "Migration": "migrations", "Note": "notes", "TeamMember": "team-members", + "RoleSlot": "roleslots", } diff --git a/context/skills/processkit/processkit-gateway/mcp/tool-catalog.json b/context/skills/processkit/processkit-gateway/mcp/tool-catalog.json index accffd8..05e7ec3 100644 --- a/context/skills/processkit/processkit-gateway/mcp/tool-catalog.json +++ b/context/skills/processkit/processkit-gateway/mcp/tool-catalog.json @@ -3230,7 +3230,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "Fetch a single entity by ID.\n\nAccepts a full ID, a prefix (missing slug), or a bare word-pair.\nReturns ``{\"error\": \"...\"}`` if not found or ambiguous.\n", + "description": "Fetch a single entity by ID.\n\nAccepts a full ID, a prefix (missing slug), or a bare word-pair.\nReturns ``{\"error\": \"...\"}`` if not found or ambiguous.\n\nAdds ``v1_penalty_applied`` and ``v1_successor_hint`` to the result\nwhen the entity is a v1 primitive (BACK-20260509_1318-WarmOak).\n", "name": "get_entity", "output_schema": null, "parameters": { @@ -3361,7 +3361,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "List entities matching the filters.\n\nParameters\n----------\nkind: optional primitive kind (e.g. \"WorkItem\", \"DecisionRecord\").\nstate: optional state to filter by.\nlimit: maximum rows to return (default 50).\n", + "description": "List entities matching the filters.\n\nParameters\n----------\nkind: optional primitive kind (e.g. \"WorkItem\", \"DecisionRecord\").\nstate: optional state to filter by.\nlimit: maximum rows to return (default 50).\n\nEach result is annotated with ``v1_penalty_applied`` and\n``v1_successor_hint`` (BACK-20260509_1318-WarmOak). Results are\nordered by ``created DESC`` (no score), so the penalty is surfaced\nas annotation only \u2014 no re-ranking is applied.\n", "name": "query_entities", "output_schema": { "properties": { @@ -3544,7 +3544,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "Full-text search across entity IDs, titles, bodies, and specs.", + "description": "Full-text search across entity IDs, titles, bodies, and specs.\n\nApplies the v1-entity penalty (BACK-20260509_1318-WarmOak): results\nare FTS5-ranked, so we synthesise a per-rank score\n``1.0 / (1 + rank_index)`` and multiply it by ``_v1_penalty()`` for\nv1-superseded entries. Results are then re-sorted on the adjusted\nscore. Each row carries ``v1_penalty_applied``, ``v1_successor_hint``,\nand a ``v1_trace`` line analogous to the task-router trace surface.\n", "name": "search_entities", "output_schema": { "properties": { @@ -6390,7 +6390,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "Route a task to the matching processkit skill, process override,\nand MCP tool \u2014 in a single call, without an LLM.\n\nPrerequisite: call this tool at the start of any processkit domain\ntask \u2014 before calling create_*, transition_*, link_*, or record_*\ntools \u2014 to confirm the right skill and any project-specific process\noverride for this derived project.\n\nTwo-phase heuristic routing:\n Phase 1 \u2014 keyword match to a domain group (eliminates ~90 %\n of candidates; no LLM needed).\n Phase 2 \u2014 token-overlap or local embedding-style scoring within the\n matched group's tools.\n Fallback \u2014 skill-finder trigger-phrase table for cross-domain\n tasks not covered by any domain group.\n\nWhen ``confidence < 0.5`` (routing_basis == \"needs_llm_confirm\"),\nsurface ``candidate_tools`` to the user or an LLM for confirmation.\nIf ``allow_llm_escalation`` is true, the response includes the\nconfigured fast-model class hint, but this server does not make\nprovider network calls itself. The router never blocks \u2014 it always\nreturns its best guess. 1% rule: if there is a 1% chance a processkit\nskill covers this task, call route_task before acting.\n\nParameters\n----------\ntask_description :\n What the agent or user wants to do, in natural language.\n More specific phrasing \u2192 better match quality.\n\nReturns\n-------\nOn match:\n skill \u2014 processkit skill name to load\n skill_description_excerpt \u2014 first 150 chars of skill description\n process_override \u2014 legacy v1 path to a project-specific\n process file (only present when one exists)\n process_override_status \u2014 present with process_override; currently\n \"legacy-v1\"\n server \u2014 MCP server to connect to\n tool \u2014 recommended tool name\n tool_qualified \u2014 \"{server}__{tool}\" collision-safe form\n domain_group \u2014 routing group (workitem, decision, \u2026)\n confidence \u2014 0.0\u20131.0 combined routing confidence\n routing_basis \u2014 keyword_match | skill_finder_trigger_table\n | needs_llm_confirm\n candidate_tools[] \u2014 top-3 scored tools with rationales\n\nOn no match:\n error, hint\n", + "description": "Route a task to the matching processkit skill, process override,\nand MCP tool \u2014 in a single call, without an LLM.\n\nPrerequisite: call this tool at the start of any processkit domain\ntask \u2014 before calling create_*, transition_*, link_*, or record_*\ntools \u2014 to confirm the right skill and any project-specific process\noverride for this derived project.\n\nTwo-phase heuristic routing:\n Phase 1 \u2014 keyword match to a domain group (eliminates ~90 %\n of candidates; no LLM needed).\n Phase 2 \u2014 token-overlap or local embedding-style scoring within the\n matched group's tools.\n Fallback \u2014 skill-finder trigger-phrase table for cross-domain\n tasks not covered by any domain group.\n\nWhen ``confidence < 0.5`` (routing_basis == \"needs_llm_confirm\"),\nsurface ``candidate_tools`` to the user or an LLM for confirmation.\nIf ``allow_llm_escalation`` is true, the response includes the\nconfigured fast-model class hint, but this server does not make\nprovider network calls itself. The router never blocks \u2014 it always\nreturns its best guess. 1% rule: if there is a 1% chance a processkit\nskill covers this task, call route_task before acting.\n\nParameters\n----------\ntask_description :\n What the agent or user wants to do, in natural language.\n More specific phrasing \u2192 better match quality.\n\nReturns\n-------\nOn match:\n skill \u2014 processkit skill name to load\n skill_description_excerpt \u2014 first 150 chars of skill description\n process_override \u2014 legacy v1 path to a project-specific\n process file (only present when one exists)\n process_override_status \u2014 present with process_override; currently\n \"legacy-v1\"\n server \u2014 MCP server to connect to\n tool \u2014 recommended tool name\n tool_qualified \u2014 \"{server}__{tool}\" collision-safe form\n domain_group \u2014 routing group (workitem, decision, \u2026)\n confidence \u2014 0.0\u20131.0 combined routing confidence\n routing_basis \u2014 keyword_match | skill_finder_trigger_table\n | needs_llm_confirm\n candidate_tools[] \u2014 top-3 scored tools with rationales\n recommended_team_member_slug \u2014 slug of the highest-priority active\n TeamMember whose default_role matches\n the routed group's preferred role, or\n None when no binding resolves. Use this\n as the sub-agent's identity at dispatch\n (per the compliance contract sub-agent-\n dispatch clause).\n recommended_model_class \u2014 \"fast\" | \"deep\" | None. Hint for picking\n the cheapest concrete model in the class\n (Haiku < Sonnet < Opus) when dispatching\n a sub-agent. Currently a static per-\n group mapping; future revisions may\n derive this from skill metadata.\n\nOn no match:\n error, hint\n", "name": "route_task", "output_schema": null, "parameters": { @@ -6497,6 +6497,142 @@ "source_tool": "check_consistency", "title": null }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "Close a RoleSlot. Terminal \u2014 re-opening requires a new SLOT-id.\n\n``open|filled \u2192 closed`` is always allowed; closing an already\n``closed`` slot is a no-op (idempotent).\n", + "name": "close_role_slot", + "output_schema": null, + "parameters": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "id" + ], + "title": "close_role_slotArguments", + "type": "object" + }, + "permission_class": "destructive-write", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "close_role_slot", + "title": null + }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "Open a new RoleSlot under a chartering Scope.\n\nParameters\n----------\nscope: SCOPE- the slot belongs to (mandatory)\nrole: ROLE- from the catalog\nseniority: junior|specialist|expert|senior|principal\nrank: 1=primary, 2..N=parallel reservations\nrationale: one-line reason this slot exists\ndefault_model_profile: optional Layer 8 fallback for ephemeral\n dispatches that never fill the slot\neffort_floor: optional dispatch floor\neffort_ceiling: optional dispatch ceiling\n\nReturns ``{id, path, state}`` on success.\n", + "name": "create_role_slot", + "output_schema": null, + "parameters": { + "properties": { + "default_model_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Default Model Profile" + }, + "effort_ceiling": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effort Ceiling" + }, + "effort_floor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effort Floor" + }, + "rank": { + "title": "Rank", + "type": "integer" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "role": { + "title": "Role", + "type": "string" + }, + "scope": { + "title": "Scope", + "type": "string" + }, + "seniority": { + "title": "Seniority", + "type": "string" + } + }, + "required": [ + "scope", + "role", + "seniority", + "rank", + "rationale" + ], + "title": "create_role_slotArguments", + "type": "object" + }, + "permission_class": "guarded-write", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "create_role_slot", + "title": null + }, { "annotations": { "destructiveHint": false, @@ -6821,6 +6957,81 @@ "source_tool": "export_team_member", "title": null }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "Place an active TeamMember into an open RoleSlot.\n\nSets ``state=filled``, ``filled_by=TEAMMEMBER-``, and creates\na parallel ``role-slot-fill`` Binding so time-bounded dispatch\nqueries continue to work through binding-management.\n\nReturns\n-------\nOn success: ``{ok: True, id, state, filled_by, binding_id, binding_path}``\nOn error: ``{error, details?}``\n", + "name": "fill_role_slot", + "output_schema": null, + "parameters": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "rationale": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rationale" + }, + "team_member_slug": { + "title": "Team Member Slug", + "type": "string" + }, + "valid_from": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Valid From" + }, + "valid_until": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Valid Until" + } + }, + "required": [ + "id", + "team_member_slug" + ], + "title": "fill_role_slotArguments", + "type": "object" + }, + "permission_class": "guarded-write", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "fill_role_slot", + "title": null + }, { "annotations": { "destructiveHint": false, @@ -6872,7 +7083,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "Return active interlocutor identity plus runtime binding status.\n\n`observed_model` and `observed_effort` are caller-supplied facts from\nthe current harness. processkit cannot read or hot-swap the already\nrunning primary model, so mismatch reporting is informational.\n", + "description": "Return active interlocutor identity plus runtime binding status.\n\n`observed_model` and `observed_effort` are caller-supplied facts from\nthe current harness. processkit cannot read or hot-swap the already\nrunning primary model, so mismatch reporting is informational.\n\nPhase A team-creator v2 \u2014 RoleSlot pre-step:\n Before falling through to the existing 8-layer model-assignment\n binding precedence, this tool checks whether the active\n interlocutor's (default_role, default_seniority, scope) matches\n a RoleSlot in state=filled. If it does, the slot's filled_by\n TeamMember and (TeamMember.default_seniority || slot.seniority)\n are surfaced in ``binding.roleslot_pre_step``. The existing\n 8-layer logic still runs unchanged underneath\n (DEC-20260509_1906-CoolBadger Q1).\n\n When no slot matches the triple, ``roleslot_pre_step`` is\n absent and the response is identical to pre-RoleSlot\n behaviour \u2014 the existing 8-layer resolver is fully\n responsible. This keeps Phase A additive and reversible.\n", "name": "get_interlocutor_runtime_binding", "output_schema": null, "parameters": { @@ -6936,6 +7147,40 @@ "source_tool": "get_interlocutor_runtime_binding", "title": null }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "Return the full RoleSlot entity by ID.", + "name": "get_role_slot", + "output_schema": null, + "parameters": { + "properties": { + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "get_role_slotArguments", + "type": "object" + }, + "permission_class": "read-only", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "get_role_slot", + "title": null + }, { "annotations": { "destructiveHint": false, @@ -7092,6 +7337,90 @@ "source_tool": "list_available_names", "title": null }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "List RoleSlots under context/roleslots/, optionally filtered.\n\nFilters\n-------\nscope: match SCOPE- exactly\nrole: match ROLE- exactly\nstate: open | filled | closed\n", + "name": "list_role_slots", + "output_schema": { + "properties": { + "result": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Result", + "type": "array" + } + }, + "required": [ + "result" + ], + "title": "list_role_slotsOutput", + "type": "object" + }, + "parameters": { + "properties": { + "limit": { + "default": 200, + "title": "Limit", + "type": "integer" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scope" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + } + }, + "title": "list_role_slotsArguments", + "type": "object" + }, + "permission_class": "read-only", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "list_role_slots", + "title": null + }, { "annotations": { "destructiveHint": false, diff --git a/context/skills/processkit/team-creator/SKILL.md b/context/skills/processkit/team-creator/SKILL.md index 8a0cf2a..8e08383 100644 --- a/context/skills/processkit/team-creator/SKILL.md +++ b/context/skills/processkit/team-creator/SKILL.md @@ -3,15 +3,16 @@ name: team-creator description: | Compose a provider-neutral AI team by tiering accessible models on cost-efficiency, capability, latency, and governance, then mapping - the 8 processkit role archetypes onto those tiers. Use when - bootstrapping a new project team, rotating after a provider change, - or quarterly rebalancing. Triggers: "create my AI team", - "compose a team", "rebalance the team", "review the team". + the 8 processkit role archetypes onto catalog Roles + RoleSlots + inside a chartering Scope. Use when bootstrapping a new project + team, rotating after a provider change, or quarterly rebalancing. + Triggers: "create my AI team", "compose a team", "rebalance the + team", "review the team". metadata: processkit: apiVersion: processkit.projectious.work/v2 id: SKILL-team-creator - version: "1.2.0" + version: "2.0.0" created: 2026-04-15T00:00:00Z category: processkit layer: 3 @@ -20,16 +21,15 @@ metadata: purpose: > Query, score, and filter accessible models via query_models, get_pricing, check_availability. - - skill: role-management - purpose: Create Role entities via create_role. - - skill: actor-profile + - skill: team-manager purpose: > - Create Actor entities (type: ai-agent) via create_actor; - deactivate replaced actors via deactivate_actor. + Open RoleSlot entities (create_role_slot) inside the + chartering Scope and fill them via fill_role_slot. Replaces + v1's archetype Role + Actor + role-assignment writes. - skill: binding-management purpose: > - Create role-assignment Bindings via create_binding; - end superseded Bindings via end_binding. + End superseded role-slot-fill Bindings via end_binding when + re-running the charter. - skill: decision-record purpose: > Write the chartering DecisionRecord; read stored @@ -82,6 +82,24 @@ existing skills — no new primitives or MCP tools are introduced. | `--landscape-artifact ` | no | 3-level discovery | Explicit > project-tag > kit default; see `references/landscape-resolution.md` | | `--landscape ` | no | alias for `--landscape-artifact` | Kept for backwards compatibility | +### Catalog-driven archetype mapping (v2) + +`team-creator` v2 selects existing `context/roles/ROLE-*` entries from +the 51-role catalog rather than writing 8 archetype Role entities. The +mapping ships in +`assets/archetype-catalog-mapping.yaml`; projects may layer a delta +override at `context/team/archetype-catalog-mapping.yaml`. Archetypes +are not stored as Roles — they are keys in the mapping file. See +`references/role-archetypes.md` and the kit-default mapping asset for +the 8 archetype → catalog Role + seniority pairs. + +For every selected archetype, `pk-team-create` opens a **RoleSlot** +under the chartering Scope via `team-manager.create_role_slot` (one slot +per parallelism unit; `rank=1` is the primary). When a TeamMember is +chosen to fill a slot, `team-manager.fill_role_slot` writes the +`role-slot-fill` Binding. No archetype Role entities are written; v1 +`role-assignment` Bindings remain readable for one minor version. + ### Tiering formula (summary) **Defaults rebalanced 2026-04-15 after the 2026-04-15 internal review (see ART-20260415_1545-TeamWeaver-team-creator-dogfood-diff §7).** Capability weight raised from 0.40 to 0.60 to prevent tier inversion on same-provider candidate sets where cost and capability are anti-correlated. @@ -120,26 +138,34 @@ See `references/role-archetypes-override.md` for schema and invariants. ### Commands **`pk-team-create`** runs the full derivation: queries models, applies -the tiering formula, maps archetypes, writes 8 Role + 8 Actor + 8 -Binding entities, rewrites `context/team/roster.md`, and emits a -chartering DecisionRecord that explicitly supersedes the prior team -DecisionRecord. Use `--dry-run` to print the plan without writing. +the tiering formula, maps archetypes, **opens one RoleSlot per +parallelism unit** under the chartering Scope via +`team-manager.create_role_slot`, rewrites `context/team/roster.md`, +and emits a chartering DecisionRecord that explicitly supersedes the +prior team DecisionRecord. Archetype Roles are NOT written — the +catalog-mapping file is read instead. Use `--dry-run` to print the +plan without writing. **`pk-team-review`** is read-only. Re-scores current assignments against the latest landscape snapshot, diffs tier scores against those stored in the governing DecisionRecord, and surfaces tier-shifts, unavailable -models, and new outperformers. No entities are written. +models, and new outperformers. Surfaces RoleSlots by their archetype +name (resolved through the mapping file). No entities are written. **`pk-team-rebalance`** applies a review recommendation. Requires `--confirm`. For `--roles all`, the full create logic re-runs and a -new DecisionRecord is written. For named roles, the governing -DecisionRecord's `progress_notes` are amended instead. +new DecisionRecord is written. For named roles, the operator names an +archetype (`developer`, `senior-architect`, ...); the skill resolves +to the (role, seniority) pair via the mapping and operates on the +matching RoleSlot(s) in the active chartering Scope. The governing +DecisionRecord's `progress_notes` are amended. ### Outputs | Output | `pk-team-create` | `pk-team-review` | `pk-team-rebalance` | |---|---|---|---| -| Role / Actor / Binding entities | YES ×8 | NO | scoped roles | +| RoleSlot entities (per parallelism unit) | YES | NO | scoped roles | +| `role-slot-fill` Bindings | YES (one per filled slot) | NO | per rotated slot | | `context/team/roster.md` | YES | NO | YES (in-place) | | DecisionRecord | YES (new) | NO | amend notes | | Diff report | NO | YES (stdout) | NO | @@ -154,12 +180,17 @@ DecisionRecord's `progress_notes` are amended instead. - **Entity deactivation sequence on re-create.** When `pk-team-create` is re-run: (a) resolve prior team from roster.md; (b) for each - prior Binding call `binding-management.end_binding` with - `reason="superseded by pk-team-create run "`; (c) for each - prior Actor whose model is NOT in the new team call - `actor-profile.deactivate_actor`; (d) for prior Actors whose model - IS re-assigned to the same role REUSE the existing Actor — do not - create a duplicate; (e) then create new Bindings. + prior `role-slot-fill` Binding call `binding-management.end_binding` + with `reason="superseded by pk-team-create run "`; + (c) close the prior RoleSlots via `team-manager.close_role_slot` + (the slot state machine forbids reopening — re-charters always + emit fresh SLOT-* IDs under the new Scope); (d) open new RoleSlots + via `team-manager.create_role_slot` and fill them with + `team-manager.fill_role_slot`. v1 `role-assignment` Bindings + observed during a re-charter remain readable but are not migrated + by `pk-team-create` — the Phase A apply script (see + `team-manager/scripts/apply_migration_2139.py`) handles that + one-shot back-fill. - **Formula weight persistence.** Weights live in the chartering DecisionRecord's `spec.inputs_snapshot` block. `pk-team-rebalance` @@ -169,8 +200,12 @@ DecisionRecord's `progress_notes` are amended instead. - **Tier-collapse (< 3 tiers accessible).** Promote the two highest- scoring light models to medium. Never fail; degrade gracefully. -- **PM clone cap.** Parallelism cap never applies to project-manager; - PM is always exactly 1 (per DEC-20260414_0900). +- **PM is always rank=1, single slot.** Parallelism cap never applies + to project-manager; the project-manager archetype always opens + exactly one RoleSlot at `rank=1` (per DEC-20260414_0900). The + `primary_contact: true` annotation on the project-manager mapping + entry is retained as a convention marker — the actual semantics + are now expressed by `RoleSlot.rank=1`. - **Snapshot staleness.** Artifact older than 90 days → warn and proceed. Surface the artifact date in the DecisionRecord. @@ -200,13 +235,16 @@ DecisionRecord's `progress_notes` are amended instead. Any run with overrides: query the single chartering team DecisionRecord to fully reconstruct the run's configuration. -- **Canonical schema fields (processkit v0.16.0).** This skill - emits five fields introduced in v0.16.0: Role fields - `primary_contact` (bool), `clone_cap` (int), - `cap_escalation` (string); Actor fields `is_template` (bool), - `templated_from` (string, nullable). Seed Actors are always - `is_template: true`; rebalance-spawned clones are - `is_template: false` with `templated_from` pointing at the seed. +- **v0.16.0 capacity fields are gone.** v1 emitted five fields on Role + / Actor entities (`primary_contact`, `clone_cap`, `cap_escalation`, + `is_template`, `templated_from`). v2 stores capacity as the count of + RoleSlots opened under a chartering Scope — there is no + `clone_cap` field. Re-tiering replaces the matching RoleSlot's + fill rather than spawning a clone Actor; the seed/clone distinction + is therefore obsolete. v0.19.0 removed the fields from the live + `role.yaml` and `team-member.yaml` schemas; older entity files that + still carry them are tolerated as historical residue but are not + produced by this skill. ## Agent-driven discovery @@ -254,12 +292,14 @@ pk-team-create ``` Full step-by-step process (8 steps: resolve landscape → query models → -score+classify → map archetypes → deactivate prior team → write -entities → write roster → write chartering DecisionRecord) lives in -`commands/pk-team-create.md`. Read that file before changing the -sequence; the order of (a) eager `role-archetypes.yaml` validation -before mapping, (b) `binding-management.end_binding` before -`actor-profile.deactivate_actor`, and (c) DecisionRecord written +score+classify → load archetype-catalog mapping + map archetypes → +end prior `role-slot-fill` Bindings + close prior RoleSlots → open +new RoleSlots and fill them → write roster → write chartering +DecisionRecord) lives in `commands/pk-team-create.md`. Read that file +before changing the sequence; the order of (a) eager mapping-file +validation before archetype mapping, (b) +`binding-management.end_binding` before +`team-manager.close_role_slot`, and (c) DecisionRecord written LAST so its ID can be embedded in roster.md is load-bearing. ### CLI contract — `pk-team-review` @@ -291,20 +331,21 @@ pk-team-rebalance For `--roles all`, full `pk-team-create` logic re-runs and a new chartering DecisionRecord supersedes the current one. For named -roles, only the affected Bindings/Actors are rotated and the -governing DecisionRecord's `progress_notes` are amended with the -`--reason` string and the new tier-score for each rotated role. +archetypes, the matching RoleSlot's fill is rotated (end old +`role-slot-fill` Binding; fill the slot with the new TeamMember via +`team-manager.fill_role_slot`) and the governing DecisionRecord's +`progress_notes` are amended with the `--reason` string and the new +tier-score for each rotated archetype. ### Skill composition map | Phase | MCP / skill call | Source skill | |---|---|---| | Step 2 | `check_availability`, `query_models`, `get_pricing` | model-recommender | -| Step 5 (a) | `end_binding` (per prior Binding) | binding-management | -| Step 5 (b) | `deactivate_actor` (per non-reused prior Actor) | actor-profile | -| Step 6 (Role) | `create_role` × 8 | role-management | -| Step 6 (Actor) | `create_actor` × 8 (or reuse) | actor-profile | -| Step 6 (Binding) | `create_binding` × 8 | binding-management | +| Step 5 (a) | `end_binding` (per prior `role-slot-fill` Binding) | binding-management | +| Step 5 (b) | `close_role_slot` (per prior RoleSlot in the rotating archetype) | team-manager | +| Step 6 (RoleSlot) | `create_role_slot` (one per parallelism unit; rank 1..N) | team-manager | +| Step 6 (Fill) | `fill_role_slot` (places a TeamMember into the open slot) | team-manager | | Step 8 | `record_decision` (chartering DEC) | decision-record | | Rebalance read | `get_decision` (governing DEC for stored weights) | decision-record | @@ -349,25 +390,33 @@ Worked example and edge cases in `references/tiering-formula.md`. ### Role archetype pin table (kit defaults) -| Archetype | Pin | `primary_contact` | `clone_cap` | `cap_escalation` | Override-when | +| Archetype | Catalog Role | Seniority | Tier pin | Slots opened | Override-when | |---|---|---|---|---|---| -| project-manager | heavy | true | 1 | owner | Never (immutable; PM clone cap is hard-coded 1) | -| senior-architect | heavy | false | 5 | owner | Medium only if no heavy clears G-floor + owner approval | -| senior-researcher | heavy | false | 5 | owner | Same as senior-architect | -| junior-architect | medium | false | 5 | owner | Heavy if capability gap > 15pp on SWE-bench | -| developer | medium | false | 5 | owner | Heavy if `--security-critical` flag set | -| junior-researcher | medium | false | 5 | owner | No override | -| junior-developer | light | false | 5 | owner | Medium if no light model accessible | -| assistant | light | false | 5 | owner | No override; shares junior-developer model if no light available | - -Schema fields (`primary_contact`, `clone_cap`, `cap_escalation`, -`is_template`, `templated_from`) were introduced in processkit -v0.16.0 — older entity files predate them. See -`references/role-archetypes.md` for the full responsibilities lists -that flow into `create_role.responsibilities=[…]`. +| project-manager | ROLE-product-manager | senior | heavy | 1 (rank=1, primary contact) | Never (PM is immutable; always exactly one slot) | +| senior-architect | ROLE-solutions-architect | senior | heavy | up to `--parallelism-cap` | Medium only if no heavy clears G-floor + owner approval | +| senior-researcher | ROLE-research-scientist | senior | heavy | up to `--parallelism-cap` | Same as senior-architect | +| junior-architect | ROLE-solutions-architect | specialist | medium | up to `--parallelism-cap` | Heavy if capability gap > 15pp on SWE-bench | +| developer | ROLE-software-engineer | senior | medium | up to `--parallelism-cap` | Heavy if `--security-critical` flag set | +| junior-researcher | ROLE-research-scientist | specialist | medium | up to `--parallelism-cap` | No override | +| junior-developer | ROLE-software-engineer | junior | light | up to `--parallelism-cap` | Medium if no light model accessible | +| assistant | ROLE-assistant | specialist | light | up to `--parallelism-cap` | No override; shares junior-developer model if no light available | + +The `(role, seniority)` pairs above come from +`assets/archetype-catalog-mapping.yaml` (the kit default) and may be +overridden per-project at `context/team/archetype-catalog-mapping.yaml`. +The "Slots opened" column is the v2 replacement for v1's `clone_cap` +field — v0.19.0 removed `clone_cap`/`cap_escalation`/`primary_contact` +from the Role schema and `is_template`/`templated_from` from the +TeamMember schema; the count of RoleSlots under the chartering Scope +is the canonical capacity record. See +`references/role-archetypes.md` for the full responsibilities lists. ### chartering DecisionRecord — `inputs_snapshot` schema +The DecisionRecord schema (`SCHEMA-decisionrecord` v1.0.0) declares +`inputs_snapshot` with `additionalProperties: true`, so v2 augments +the block with two catalog-mapping audit fields without a schema bump: + ```yaml spec: inputs_snapshot: @@ -383,11 +432,24 @@ spec: landscape_artifact_source: explicit | project-tag | kit-default tier_scores: {: , ...} weight_overrides_applied: + archetype_catalog_mapping_file: kit-default | project | cli # v2 + archetype_catalog_overrides: # v2 + - {archetype, field, kit_default, project_value} archetype_override_file: present | absent archetype_override_semantics: delta | replace | null archetype_overrides: [{role, kit_default_pin, override_pin, rationale}] + chartering_scope: SCOPE- # v2 — required for RoleSlot writes + role_slots: # v2 — provenance back-pointer + - {archetype, slot_id, role, seniority, rank} ``` +`archetype_catalog_mapping_file` records which layer of the +mapping-file precedence chain was applied +(`cli` > `project` > `kit-default`). +`archetype_catalog_overrides` lists each per-archetype-per-field +delta when the project file modifies the kit default; an empty list +means the kit default was used verbatim. + `pk-team-rebalance` and `pk-team-review` both read this block via `decision-record.get_decision`. It is the single source of truth for a team's configuration — there is no skill-local config file for @@ -409,11 +471,16 @@ weights, thresholds, or pins. `inputs_snapshot.tier_scores` is what ### Extension points -- **New role archetype.** Add to `references/role-archetypes.md`, - extend the override schema in `references/role-archetypes-override.md` - (PM-immutability invariant must continue to apply), and add a row to - the pin table above. The 8-archetype assumption is hard-coded in - `pk-team-create` step 6 — bump it carefully. +- **New role archetype.** Add an entry to + `assets/archetype-catalog-mapping.yaml` (or a project override at + `context/team/archetype-catalog-mapping.yaml`) keyed by archetype + name with `role: ROLE-` + `seniority: `. Update + `references/role-archetypes.md` with the responsibilities one-liner + and add a row to the pin table above. The PM-immutability invariant + remains in force (`project-manager.role` must remain + `ROLE-product-manager` and the slot count must remain 1). v2 + archetypes are mapping keys, not Role entities — no schema change + is required. - **New scoring dimension.** Extend the weight set (currently `{C, K, L, G}`); update `weights` schema in `inputs_snapshot`, the worked example in `references/tiering-formula.md`, and the @@ -429,13 +496,16 @@ weights, thresholds, or pins. `inputs_snapshot.tier_scores` is what gracefully. Recorded in DEC `inputs_snapshot.notes`. - **Snapshot staleness > 90 days.** Warn and continue; surface the artifact date in the DecisionRecord rather than hiding it. -- **PM clone-cap.** `--parallelism-cap` is silently overridden to 1 - for `project-manager` regardless of CLI / archetype overrides - (DEC-20260414_0900). Layer-4 overrides cannot raise it. -- **Reused Actor identification.** The seed Actor for an unchanged - role is identified by `is_template: true`, NOT by name or ID - prefix — this is the authoritative test (template clones drift but - the seed never does). +- **PM single-slot rule.** `--parallelism-cap` is silently overridden + to 1 for `project-manager` regardless of CLI / archetype overrides + (DEC-20260414_0900). Layer-4 overrides cannot raise it; the + project-manager archetype always opens exactly one RoleSlot at + rank=1. +- **Slot continuity across rebalances.** A targeted rebalance + (`pk-team-rebalance --roles `) re-fills the existing RoleSlot + rather than closing and re-opening it; SLOT-* IDs are stable + across model rotations and only change on `--roles all` + (full re-charter). - **Empty preferred-providers tie-break.** Two models within 0.05 TierScore and no preferred provider supplied: pick the higher raw Capability score, then alphabetical model-id as final tie-break. diff --git a/context/skills/processkit/team-creator/assets/archetype-catalog-mapping.yaml b/context/skills/processkit/team-creator/assets/archetype-catalog-mapping.yaml new file mode 100644 index 0000000..e6ad1c7 --- /dev/null +++ b/context/skills/processkit/team-creator/assets/archetype-catalog-mapping.yaml @@ -0,0 +1,40 @@ +apiVersion: processkit.projectious.work/v2 +kind: ArchetypeCatalogMapping +metadata: + id: ASSET-team-creator-archetype-catalog-mapping + version: "1.0.0" + description: | + Kit-default archetype-to-catalog Role mapping for team-creator v2. + Maps each of the 8 processkit archetypes onto an existing + context/roles/ROLE-* entry in the 51-role catalog plus a seniority + tier. Loaded by pk-team-create, pk-team-rebalance, and + pk-team-review via _load_archetype_catalog_mapping(). + Project override: context/team/archetype-catalog-mapping.yaml + (delta layer; replace-mode only when override_semantics: replace). +spec: + archetypes: + project-manager: + role: ROLE-product-manager + seniority: senior + primary_contact: true + senior-architect: + role: ROLE-solutions-architect + seniority: senior + senior-researcher: + role: ROLE-research-scientist + seniority: senior + junior-architect: + role: ROLE-solutions-architect + seniority: specialist + developer: + role: ROLE-software-engineer + seniority: senior + junior-researcher: + role: ROLE-research-scientist + seniority: specialist + junior-developer: + role: ROLE-software-engineer + seniority: junior + assistant: + role: ROLE-assistant + seniority: specialist diff --git a/context/skills/processkit/team-creator/commands/pk-team-create.md b/context/skills/processkit/team-creator/commands/pk-team-create.md index df33b26..9119e1a 100644 --- a/context/skills/processkit/team-creator/commands/pk-team-create.md +++ b/context/skills/processkit/team-creator/commands/pk-team-create.md @@ -5,17 +5,21 @@ allowed-tools: [] # Command: pk-team-create -Full team derivation from scratch. Writes 8 Role + 8 Actor + 8 Binding -entities, rewrites `context/team/roster.md`, and emits a chartering -DecisionRecord that supersedes the prior team DecisionRecord. +Full team derivation from scratch (v2 / catalog-driven). Selects +catalog Roles via the archetype-catalog mapping, opens RoleSlots +under the chartering Scope, fills them with TeamMembers, rewrites +`context/team/roster.md`, and emits a chartering DecisionRecord that +supersedes the prior team DecisionRecord. **No archetype Role +entities are written.** ## Syntax ``` pk-team-create + --chartering-scope # required — Scope that owns the team's RoleSlots --subscription : # e.g. anthropic:max-5x --providers # comma-separated; "any" = all accessible - --parallelism-cap # max clones per role, default 5 + --parallelism-cap # max RoleSlots opened per archetype, default 5 --governance-floor <0-5> # G-score floor, default 3 [--weight-overrides ] # {"C":0.60,"K":0.20,"L":0.10,"G":0.10} # CLI > DEC-*-TeamWeights > skill defaults @@ -23,6 +27,11 @@ pk-team-create # CLI > DEC-*-TeamWeights > skill defaults [--landscape-artifact ] # explicit landscape override; skips discovery [--landscape ] # alias for --landscape-artifact (kept for compat) + [--archetype-catalog-mapping ] + # CLI override of the archetype→catalog + # mapping (replace mode). Project override + # at context/team/archetype-catalog-mapping.yaml + # is auto-detected; this flag wins over both. [--dry-run] # print plan; write nothing ``` @@ -125,16 +134,42 @@ For each accessible candidate model `m`: See `references/tiering-formula.md` for the full formula and worked example. -### Step 4 — Load role-archetypes override (if present), then map archetypes +### Step 4 — Load archetype-catalog mapping + role-archetypes override, then map archetypes -**Layer 4 override (eager validation — before archetype mapping):** +**Layer A — archetype-catalog mapping (eager validation — before archetype mapping):** + +Resolve the archetype → catalog `(ROLE-id, seniority)` mapping with +three-level precedence: + +1. `--archetype-catalog-mapping ` flag (CLI; replace semantics). +2. `context/team/archetype-catalog-mapping.yaml` (project; delta + semantics by default — top-level `override_semantics: replace` + switches to replace). +3. Kit default: `assets/archetype-catalog-mapping.yaml` shipped with + the team-creator skill. + +Validate eagerly: every archetype entry must declare a `role` that +starts with `ROLE-` and a non-empty `seniority`. Replace-mode +overrides must list all 8 archetypes (missing archetypes are a hard +error). + +Record: +- `inputs_snapshot.archetype_catalog_mapping_file`: `"kit-default"`, + `"project"`, or `"cli"` — which layer was applied. +- `inputs_snapshot.archetype_catalog_overrides`: per-archetype + per-field deltas relative to the kit default. Empty list when the + kit default was used verbatim. + +**Layer B — role-archetypes override (eager validation — before archetype mapping):** If `context/team/role-archetypes.yaml` exists, load it now and validate it immediately against the rules in `references/role-archetypes-override.md` (§Validation invariants). Validation failures are hard errors — abort before any mapping begins. -This includes: PM pin must remain heavy, PM clone_cap_override ≤ 1, -all rationales non-empty, all 8 archetypes present in `replace` mode. +This includes: PM pin must remain heavy, all rationales non-empty, +all 8 archetypes present in `replace` mode. (The legacy +`clone_cap_override` field is no longer accepted — capacity is the +count of RoleSlots opened in step 6 below.) Record `inputs_snapshot.archetype_override_file: "present"` (or `"absent"`) and list each overridden role with its new pin and @@ -154,64 +189,76 @@ For each of the 8 role archetypes: 4. If the pinned tier has no candidates: apply the override-when rule from the active archetype table; if no override applies, fail with a clear message. +5. Resolve the archetype's `(role, seniority)` pair via the mapping + from Layer A. Verify `role` is present in `context/roles/`; fail + with a clear message if the catalog Role file is missing. ### Step 5 — Deactivate prior team (if re-running) If `context/team/roster.md` already exists: -1. Parse the prior team's Actor and Binding IDs from roster.md. -2. For each prior Binding: +1. Parse the prior team's RoleSlot and Binding IDs from roster.md. +2. For each prior `role-slot-fill` Binding: `binding-management.end_binding(id, reason= "superseded by pk-team-create run ")` -3. For each prior Actor whose model ID does NOT appear in the new - team assignments: - `actor-profile.deactivate_actor(id)` -4. For each prior Actor whose model IS being re-assigned to the SAME - role in the new team: identify the canonical seed by - `is_template: true` (not by heuristics such as name or ID prefix). - REUSE that Actor ID — do not create a new Actor entity. This - ensures reuse targets the authoritative template, never a clone. +3. For each prior RoleSlot under the prior chartering Scope: + `team-manager.close_role_slot(id, reason= + "superseded by pk-team-create run ")`. + The slot state machine forbids reopening — re-charters always + emit fresh `SLOT-*` IDs under the new chartering Scope. There is + no Actor-template / Actor-clone distinction in v2; capacity is + expressed by RoleSlot count, not by clone Actors. + +If the prior team predates v2 (its roster references v1 +`role-assignment` Bindings and archetype Roles), do NOT touch those +entities. The Phase A migration apply script +(`team-manager/scripts/apply_migration_2139.py`) is responsible for +back-filling the v1 surface; pk-team-create only operates on the v2 +RoleSlot surface. ### Step 6 — Write entities Skip if `--dry-run`. -Per-archetype values for `primary_contact`, `clone_cap`, and -`cap_escalation` come from `references/role-archetypes.md`. -Only `project-manager` has `primary_contact: true` and -`clone_cap: 1`; all others use `primary_contact: false` and -`clone_cap: 5`. `cap_escalation` is `"owner"` for all roles. -Seed Actors always receive `is_template: true, templated_from: null`. +For each of the 8 archetypes, look up `(role, seniority)` in the +mapping resolved in step 4. Open one RoleSlot per parallelism unit +(rank 1..N, where N is `--parallelism-cap`; `project-manager` is +always exactly 1 slot at rank=1): -For each of the 8 archetypes: ``` -role-management.create_role( - id=ROLE-, - name=, - description=, - responsibilities=[...], - default_scope="permanent", - primary_contact=, - clone_cap=, - cap_escalation="owner" -) -actor-profile.create_actor( - id=ACTOR-, # reuse existing if same model - type="ai-agent", - name=, # from model-recommender, not hardcoded - active=true, - is_template=true, - templated_from=null +team-manager.create_role_slot( + scope=<--chartering-scope>, + role=, + seniority=, + rank=, + rationale="archetype= tier= " + "score= model=", + default_model_profile= # optional ) -binding-management.create_binding( - type="role-assignment", - subject=, - target=, +``` + +Then, for each TeamMember selected to fill a slot: + +``` +team-manager.fill_role_slot( + id=, + team_member_slug=, + rationale="", valid_from=, - description=" fills " + valid_until= ) ``` +`fill_role_slot` writes the `role-slot-fill` Binding inline. No +archetype Role, Actor, or `role-assignment` Binding is written by +pk-team-create. + +Ephemeral archetypes that have no persistent TeamMember to assign +(e.g. `assistant`, when handled by ad-hoc dispatch) leave the slot +in `state=open` with a `default_model_profile` set; the resolver +pre-step in `team-manager.get_interlocutor_runtime_binding` will +return the open slot's profile for ephemeral lookups. + ### Step 7 — Write roster.md Write `context/team/roster.md` with: @@ -242,42 +289,53 @@ decision-record-write( landscape_artifact_source: "explicit" | "project-tag" | "kit-default", tier_scores: {: , ...}, weight_overrides_applied: , + # v2 catalog-mapping audit (additionalProperties=true on inputs_snapshot) + archetype_catalog_mapping_file: + "kit-default" | "project" | "cli", + archetype_catalog_overrides: [ + {archetype, field, kit_default, project_value}, ... + ], archetype_override_file: "present" | "absent", archetype_override_semantics: "delta" | "replace" | null, - archetype_overrides: [...] # list of {role, kit_default_pin, override_pin} + archetype_overrides: [...], # list of {role, kit_default_pin, override_pin} + chartering_scope: , + role_slots: [ + {archetype, slot_id, role, seniority, rank}, ... + ] } ) ``` The `inputs_snapshot.weights` block is the canonical weight store -that `pk-team-rebalance` will read on future runs. +that `pk-team-rebalance` will read on future runs. The +`chartering_scope` and `role_slots` blocks are the v2 provenance +back-pointer from the DEC to the RoleSlots opened in step 6. ## Dry-run output format ``` === pk-team-create DRY RUN === Subscription: Governance floor: +Chartering Scope: Landscape: () Weights: C=0.60 K=0.20 L=0.10 G=0.10 +Mapping source: kit-default | project | cli (overrides: ) Candidate models scored: TierScore=0.92 → heavy TierScore=0.61 → medium ... -Role assignments: - project-manager → (heavy, score=0.92) - Role fields: primary_contact=true clone_cap=1 - cap_escalation="owner" - Actor fields: is_template=true templated_from=null - senior-architect → (heavy, score=0.87) - Role fields: primary_contact=false clone_cap=5 - cap_escalation="owner" - Actor fields: is_template=true templated_from=null +RoleSlot plan (per archetype): + project-manager → ROLE-product-manager / senior (heavy, score=0.92) + 1 slot at rank=1, fill: TEAMMEMBER-, model= + senior-architect → ROLE-solutions-architect / senior (heavy, score=0.87) + N slots at rank=1..N, fill: TEAMMEMBER-, model= ... Entities to write (skipped in dry-run): - 8 Role entities, 8 Actor entities, 8 Binding entities + RoleSlot entities + role-slot-fill Binding entities context/team/roster.md DecisionRecord superseding =========================== @@ -285,6 +343,9 @@ Entities to write (skipped in dry-run): ## State side-effects (non-dry-run) -Creates: 8 Role + 8 Actor + 8 Binding entities, roster.md, -1 DecisionRecord. Deactivates: prior Bindings (end_binding) and -prior Actors not re-used. +Creates: one RoleSlot per parallelism unit (under the chartering +Scope), one `role-slot-fill` Binding per filled slot, roster.md, +1 DecisionRecord. Closes prior RoleSlots and ends prior +`role-slot-fill` Bindings on re-charter. Does NOT write archetype +Role entities, Actor entities, or `role-assignment` Bindings — +those v1 surfaces are read-only during the deprecation window. diff --git a/context/skills/processkit/team-creator/commands/pk-team-rebalance.md b/context/skills/processkit/team-creator/commands/pk-team-rebalance.md index b68612c..f3dbe83 100644 --- a/context/skills/processkit/team-creator/commands/pk-team-rebalance.md +++ b/context/skills/processkit/team-creator/commands/pk-team-rebalance.md @@ -68,70 +68,75 @@ Same resolution logic as `pk-team-create` Step 1. If `--landscape` is not supplied, use the latest `landscape-summary` artifact. Warn if older than 90 days; do not block. -### Step 3 — Re-score targeted roles +### Step 3 — Resolve archetype names → catalog (role, seniority) and re-score -For each role in `--roles`: +`--roles` accepts archetype names (`developer`, `senior-architect`, +...). Resolve each name through the archetype-catalog mapping +(`assets/archetype-catalog-mapping.yaml`, layered with +`context/team/archetype-catalog-mapping.yaml` if present) into the +catalog `(ROLE-id, seniority)` pair. Operate on the matching +`RoleSlot(s)` in the active chartering Scope. -1. Query accessible models scoped to that role's pinned tier: +For each archetype in `--roles`: + +1. Look up `(role, seniority)` via the mapping. Abort if the + archetype is not present and no project override defines it. +2. Query accessible models scoped to that archetype's pinned tier: ``` model-recommender.query_models( G_floor=, apply_user_filter=true ) ``` -2. Apply the tiering formula with stored (or override) weights. -3. Select best-scoring candidate for this role's tier. -4. If the best candidate is the same model as the current assignment: - report "no change needed" for this role; skip writes. +3. Apply the tiering formula with stored (or override) weights. +4. Select the best-scoring candidate for the tier. +5. If the best candidate is the same model currently filling the + archetype's RoleSlot(s): report "no change needed"; skip writes. -### Step 4 — End old Bindings +### Step 4 — End old role-slot-fill Bindings -For each role where a model change is needed: +For each archetype where a model change is needed, list the +matching RoleSlots in the active chartering Scope via +`team-manager.list_role_slots(scope=, role=, +state="filled")`, then for each filled slot end its current fill +Binding: ``` binding-management.end_binding( - id=, + id=, reason="superseded by pk-team-rebalance: <--reason> ()" ) ``` -### Step 5 — Create or reuse Actor entities - -For each role being reassigned: -- If the incoming model ID matches an existing Actor entity in - `context/actors/` (active or inactive): reactivate it (set - `active: true` via `actor-profile.update_actor`). Do not create - a duplicate. The reactivated Actor retains its existing - `is_template` and `templated_from` values unchanged. -- If no matching Actor exists, spawn a new clone Actor. The - original seed Actor for this role is the one with - `is_template: true` in `context/actors/`. Record its ID as - `` and create: - ``` - actor-profile.create_actor( - type="ai-agent", - name=, - active=true, - is_template=false, - templated_from= - ) - ``` - This marks the spawned Actor as a clone of the canonical - template, enabling index queries to separate seed team members - from rebalance-spawned instances. - -### Step 6 — Create new Bindings +### Step 5 — Re-fill the existing RoleSlots + +A targeted rebalance does NOT close-and-reopen the slot — capacity +(slot count) hasn't changed; only the TeamMember and the model +underneath it have. For each affected RoleSlot: ``` -binding-management.create_binding( - type="role-assignment", - subject=, - target=, +team-manager.fill_role_slot( + id=, + team_member_slug=, + rationale="rebalanced : <--reason> " + "(model: , score: )", valid_from=, - description=" fills — rebalanced " + valid_until= ) ``` +(If the new TeamMember does not yet exist in `context/team-members/`, +provision it first via the team-member skill. Ephemeral +`(role, seniority)` dispatches that have no persistent TeamMember +re-attach to the slot's `default_model_profile` instead — no fill +write is needed.) + +### Step 6 — _(folded into step 5)_ + +v1 split this into "create new role-assignment Binding"; v2's +`fill_role_slot` writes the `role-slot-fill` Binding inline, so a +separate step is no longer required. + ### Step 7 — Update roster.md in-place Rewrite the affected rows in `context/team/roster.md`'s routing table. @@ -166,10 +171,12 @@ rebalancing would touch every role anyway. ## State side-effects -Ends N old Bindings. Creates (or reactivates) N Actors. Creates N new -Bindings. Amends roster.md in-place. Appends to the governing -DecisionRecord's `progress_notes`. Does NOT write a new DecisionRecord -(unless `--roles all`). +Ends N old `role-slot-fill` Bindings. Re-fills the affected RoleSlots +(no slot create/close — capacity is unchanged) and writes N new +`role-slot-fill` Bindings via `team-manager.fill_role_slot`. Amends +roster.md in-place. Appends to the governing DecisionRecord's +`progress_notes`. Does NOT write a new DecisionRecord (unless +`--roles all`). ## Safety diff --git a/context/skills/processkit/team-creator/commands/pk-team-review.md b/context/skills/processkit/team-creator/commands/pk-team-review.md index 4890552..5aa86c5 100644 --- a/context/skills/processkit/team-creator/commands/pk-team-review.md +++ b/context/skills/processkit/team-creator/commands/pk-team-review.md @@ -81,26 +81,37 @@ or `major_outage`. ### Step 6 — Emit diff report -Output format (stdout only — no files written): +Output format (stdout only — no files written). Each row is keyed by +**archetype name** (resolved through the +`assets/archetype-catalog-mapping.yaml` mapping, layered with the +project override) so the operator sees the same names accepted by +`pk-team-rebalance --roles`. The underlying `(SLOT-id, ROLE-id, +seniority)` tuple is shown in parentheses for traceability. ``` === pk-team-review — === Baseline: DecisionRecord () +Chartering Scope: Landscape: () [STALE: >90 days if applicable] Weights used: C= K= L= G= Threshold: +Mapping source: kit-default | project | cli (overrides: ) TIER-DRIFT (score delta > threshold): - developer: (baseline 0.62) → new score 0.44 ▼0.18 + developer (SLOT-q2-2026-software-engineer-1, ROLE-software-engineer/senior): + (baseline 0.62) → new score 0.44 ▼0.18 → Best alternative: (score 0.71, heavy) - → Recommendation: rebalance this role + → Recommendation: rebalance this archetype UNAVAILABLE: - junior-developer: — status: major_outage + junior-developer (SLOT-q2-2026-software-engineer-2, + ROLE-software-engineer/junior): + — status: major_outage → Best fallback: (score 0.38, light) NEW OUTPERFORMERS: - assistant: (score 0.52) outperforms current + assistant (SLOT-q2-2026-assistant-1, ROLE-assistant/specialist): + (score 0.52) outperforms current (score 0.31) by 0.21 — within light tier, no tier-shift STABLE (no action needed): @@ -108,8 +119,8 @@ STABLE (no action needed): junior-architect, junior-researcher SUMMARY: - 2 roles recommended for rebalance - 1 role urgently needs replacement (major_outage) + 2 archetypes recommended for rebalance + 1 archetype urgently needs replacement (major_outage) Run: pk-team-rebalance --roles developer,junior-developer --confirm \ --reason "" ============================= diff --git a/context/skills/processkit/team-creator/references/role-archetypes-override.md b/context/skills/processkit/team-creator/references/role-archetypes-override.md index 73de9f9..8885cb3 100644 --- a/context/skills/processkit/team-creator/references/role-archetypes-override.md +++ b/context/skills/processkit/team-creator/references/role-archetypes-override.md @@ -3,10 +3,16 @@ ## Purpose A project may supply a `context/team/role-archetypes.yaml` file to -remap tier pins and clone caps for some or all of the 8 processkit -role archetypes. `pk-team-create` loads this file before archetype -mapping (Step 4) and validates it eagerly — before model scoring — -so violations fail fast. +remap tier pins for some or all of the 8 processkit role archetypes. +`pk-team-create` loads this file in Step 4 (Layer B) and validates it +eagerly — before model scoring — so violations fail fast. + +> **Capacity is no longer overridden here.** v1's `clone_cap_override` +> field is rejected by the validator. Capacity is the number of +> RoleSlots opened in step 6 (`--parallelism-cap`, with PM pinned to +> 1). The `(role, seniority)` pair an archetype maps to is overridden +> separately via `context/team/archetype-catalog-mapping.yaml` (see +> SKILL.md §"Catalog-driven archetype mapping (v2)"). ## File location @@ -20,7 +26,7 @@ kit defaults from `references/role-archetypes.md` apply unchanged. ## Full schema ```yaml -version: "1" # schema version; currently must be "1" +version: "2" # schema version; v2 dropped clone_cap_override override_semantics: delta # "delta" | "replace" (default: delta) roles: @@ -28,10 +34,14 @@ roles: tier_pin: heavy|medium|light # REQUIRED per entry rationale: | # REQUIRED — non-empty; must be project-specific - clone_cap_override: # null = inherit project --parallelism-cap override_when: [] # optional; inherits kit rules if omitted ``` +> Files declaring `version: "1"` and/or `clone_cap_override` are +> tolerated but the `clone_cap_override` field is ignored with a +> warning logged to the chartering DEC. Capacity moved to RoleSlot +> count at v0.19.0; bump the version field on next edit. + Valid `` values (exactly these 8): `project-manager`, `senior-architect`, `senior-researcher`, `junior-architect`, `developer`, `junior-researcher`, @@ -57,10 +67,8 @@ The following are hard errors, checked before model scoring begins: | Invariant | Error message | |---|---| | `project-manager.tier_pin != "heavy"` | "PM tier_pin must remain heavy — immutable per DEC-20260414_0900" | -| `project-manager.clone_cap_override > 1` | "PM clone_cap_override must be null or 1 — immutable per DEC-20260414_0900" | | Any `rationale` is empty or absent | "Role : rationale is required in role-archetypes.yaml override" | | `replace` mode with fewer than 8 archetypes | "replace mode requires all 8 archetypes; missing: " | -| `clone_cap_override > --parallelism-cap` | "Role : clone_cap_override exceeds project parallelism-cap " | The following is a warning (logged to DecisionRecord, not an error): @@ -73,7 +81,7 @@ The following is a warning (logged to DecisionRecord, not an error): ### Delta override (one role remapped) ```yaml -version: "1" +version: "2" override_semantics: delta roles: senior-architect: @@ -82,47 +90,38 @@ roles: This edge-deployment project has no accessible heavy-tier model with G-score ≥ 4 under the target subscription. Owner approved downgrade to medium per project charter 2026-Q2. - clone_cap_override: null ``` ### Replace override (full archetype table) ```yaml -version: "1" +version: "2" override_semantics: replace roles: project-manager: tier_pin: heavy # immutable — must remain heavy rationale: "Kit default retained — PM routing quality requirement unchanged." - clone_cap_override: 1 senior-architect: tier_pin: medium rationale: "Research lab does not need frontier models for architecture review." - clone_cap_override: null senior-researcher: tier_pin: heavy rationale: "Multi-source synthesis is the lab's primary output — max capability." - clone_cap_override: null junior-architect: tier_pin: medium rationale: "Module-scoped design; medium capability is sufficient." - clone_cap_override: null developer: tier_pin: medium rationale: "Implementation against detailed specs; medium capability is sufficient." - clone_cap_override: null junior-researcher: tier_pin: medium rationale: "Bounded research scope; medium capability matches task complexity." - clone_cap_override: null junior-developer: tier_pin: light rationale: "Kit default retained — single-file edits do not require upgrade." - clone_cap_override: null assistant: tier_pin: light rationale: "Kit default retained — admin tasks do not require capability upgrade." - clone_cap_override: null ``` ## Audit trail diff --git a/context/skills/processkit/team-creator/references/role-archetypes.md b/context/skills/processkit/team-creator/references/role-archetypes.md index d6e23a1..65fea27 100644 --- a/context/skills/processkit/team-creator/references/role-archetypes.md +++ b/context/skills/processkit/team-creator/references/role-archetypes.md @@ -2,16 +2,21 @@ ## The 8 processkit role archetypes -| Role archetype | Tier pin | Rationale | Override-when | `primary_contact` | `clone_cap` | `cap_escalation` | +Each archetype maps onto a catalog Role (`context/roles/ROLE-*`) and +seniority tier via `assets/archetype-catalog-mapping.yaml`. Archetypes +are mapping keys, not Role entities; see SKILL.md §"Catalog-driven +archetype mapping (v2)" for the loading sequence. + +| Role archetype | Catalog Role | Seniority | Tier pin | Rationale | Override-when | Slots opened (rank 1..N) | |---|---|---|---|---|---|---| -| **project-manager** | heavy | Routing quality compounds; every routing error propagates to all downstream work | **Never override.** PM is always heavy; always 1 clone (no parallelism). | `true` | `1` | `"owner"` | -| **senior-architect** | heavy | Cross-subsystem design; blast radius is high; wrong architecture poisons the entire codebase | Medium only if no heavy candidate clears G-floor AND owner explicitly approves in writing. | `false` | `5` | `"owner"` | -| **senior-researcher** | heavy | Multi-source synthesis; wrong synthesis poisons downstream decisions at scale | Same as senior-architect. | `false` | `5` | `"owner"` | -| **junior-architect** | medium | Single-module scope; bounded blast radius | Heavy if median capability gap between medium and heavy tiers exceeds 15pp on SWE-bench Verified for the accessible candidate set. | `false` | `5` | `"owner"` | -| **developer** | medium | Implementation against a written plan; bounded scope | Heavy for security-critical or regulated subsystems (owner sets `--security-critical` flag). | `false` | `5` | `"owner"` | -| **junior-researcher** | medium | Bounded single-topic research; output reviewed by senior-researcher | No override. | `false` | `5` | `"owner"` | -| **junior-developer** | light | Well-specified single-file edits; no architecture decisions | Medium if no light-tier candidate is accessible (escalation fallback). | `false` | `5` | `"owner"` | -| **assistant** | light | Admin, formatting, summarisation, file management | No override. If no light model is accessible, share the junior-developer model (same ACTOR entity, same Binding target). | `false` | `5` | `"owner"` | +| **project-manager** | `ROLE-product-manager` | senior | heavy | Routing quality compounds; every routing error propagates to all downstream work | **Never override.** PM is always heavy; always exactly one slot at rank=1 (no parallelism). | 1 | +| **senior-architect** | `ROLE-solutions-architect` | senior | heavy | Cross-subsystem design; blast radius is high; wrong architecture poisons the entire codebase | Medium only if no heavy candidate clears G-floor AND owner explicitly approves in writing. | up to `--parallelism-cap` | +| **senior-researcher** | `ROLE-research-scientist` | senior | heavy | Multi-source synthesis; wrong synthesis poisons downstream decisions at scale | Same as senior-architect. | up to `--parallelism-cap` | +| **junior-architect** | `ROLE-solutions-architect` | specialist | medium | Single-module scope; bounded blast radius | Heavy if median capability gap between medium and heavy tiers exceeds 15pp on SWE-bench Verified for the accessible candidate set. | up to `--parallelism-cap` | +| **developer** | `ROLE-software-engineer` | senior | medium | Implementation against a written plan; bounded scope | Heavy for security-critical or regulated subsystems (owner sets `--security-critical` flag). | up to `--parallelism-cap` | +| **junior-researcher** | `ROLE-research-scientist` | specialist | medium | Bounded single-topic research; output reviewed by senior-researcher | No override. | up to `--parallelism-cap` | +| **junior-developer** | `ROLE-software-engineer` | junior | light | Well-specified single-file edits; no architecture decisions | Medium if no light-tier candidate is accessible (escalation fallback). | up to `--parallelism-cap` | +| **assistant** | `ROLE-assistant` | specialist | light | Admin, formatting, summarisation, file management | No override. If no light model is accessible, the assistant slot's `default_model_profile` shares the junior-developer slot's profile. | up to `--parallelism-cap` | ## Override rules in detail @@ -23,10 +28,11 @@ scoring medium model for junior-developer to minimise cost. ### Tier upgrade: light → light (assistant shares junior-developer) -If no light-tier model is accessible, assistant does not get a -separate Actor. Instead, create a single Binding from -`ACTOR-` to `ROLE-assistant`. The DecisionRecord -notes this as a shared-model arrangement. +If no light-tier model is accessible, the `assistant` archetype's +RoleSlot is opened with the junior-developer slot's +`default_model_profile`. No separate Actor is created (Actors are no +longer written by team-creator v2). The DecisionRecord notes this as +a shared-model-profile arrangement. ### Tier downgrade: heavy → medium (senior-architect, senior-researcher) @@ -82,8 +88,11 @@ records the collapse scenario in the DecisionRecord `inputs_snapshot`. ## Role responsibilities reference -Brief one-liners for `role-management.create_role` calls. These are -starting points; projects may extend them. +Brief one-liners that summarise each archetype's intent. These are +read by the chartering DecisionRecord generator and surfaced in +roster.md narratives. The catalog Roles themselves +(`context/roles/ROLE-*`) carry their own canonical responsibilities +lists; this table is the archetype-side gloss only. | Role | responsibilities (imperative bullets) | |---|---| @@ -96,17 +105,22 @@ starting points; projects may extend them. | junior-developer | Implement well-specified single-file edits; run linters; update changelogs | | assistant | Format documents; manage file moves; write standup notes; perform admin tasks | -## Template vs clone - -The 8 seed Actors emitted by `pk-team-create` are **templates** -(`is_template: true`, `templated_from: null`). They represent the -canonical team roster — one Actor per archetype. When -`pk-team-rebalance` spawns a new Actor to fill a role (replace or add), -that spawned Actor is a **clone**: `is_template: false` and -`templated_from: ` pointing at the template it -derives from. This distinction lets the system separate "the 8 -authoritative team members" from "task-specific parallel instances" -when querying the actor index. +## Capacity in v2: RoleSlots, not Actor templates/clones + +In v2 there is no "seed Actor / clone Actor" distinction — that model +was a v0.16.0 expedient and was removed at v0.19.0 along with the +backing schema fields. Capacity is now expressed as the count of +**RoleSlots** opened under the chartering Scope: +`SLOT---`, with `rank=1` reserved for +the primary fill and `rank=2..N` for parallel reservations +(N = `--parallelism-cap`, except for `project-manager`, which is +hard-coded to 1). + +Re-tiering an archetype rebinds the **fill** of the existing slot +(via `team-manager.fill_role_slot`); the slot itself stays open +across rebalances unless `--roles all` triggers a full re-charter, +which closes the prior chartering Scope's slots and opens a new +generation under the new Scope. ## Provider-neutrality invariant diff --git a/context/skills/processkit/team-creator/scripts/team_creator_lib.py b/context/skills/processkit/team-creator/scripts/team_creator_lib.py new file mode 100644 index 0000000..2659a57 --- /dev/null +++ b/context/skills/processkit/team-creator/scripts/team_creator_lib.py @@ -0,0 +1,291 @@ +"""team-creator v2 helper library. + +Catalog-driven archetype resolution for pk-team-create, pk-team-rebalance, +and pk-team-review. The skill itself is documentation-driven (commands/*.md +narrate the workflow), but the mapping loader is shared by the migration +apply script and the test suite. + +Public surface: + - load_archetype_catalog_mapping(project_root) + - resolve_archetype(name, mapping) -> {role, seniority, ...} + - mapping_source(...) -> "kit-default" | "project" | "cli" + +The loader implements the layered precedence promised by SKILL.md: + + cli (--archetype-catalog-mapping ) > project > kit-default + +Project override: context/team/archetype-catalog-mapping.yaml (delta semantics +by default; replace semantics only when the override file declares +``override_semantics: replace`` at the top level). + +The kit default ships at +``context/skills/processkit/team-creator/assets/archetype-catalog-mapping.yaml`` +and contains the 8 archetypes listed in the SmartPanda design artifact §"Gap 1". +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + + +# The 8 processkit archetypes (kept as a stable tuple for validation). +ARCHETYPES: tuple[str, ...] = ( + "project-manager", + "senior-architect", + "senior-researcher", + "junior-architect", + "developer", + "junior-researcher", + "junior-developer", + "assistant", +) + + +_VALID_OVERRIDE_SEMANTICS = {"delta", "replace"} + + +# --------------------------------------------------------------------------- +# Kit-default discovery +# --------------------------------------------------------------------------- + +def kit_default_mapping_path() -> Path: + """Return the absolute path to the kit-default mapping shipped here.""" + return Path(__file__).resolve().parent.parent / "assets" / "archetype-catalog-mapping.yaml" + + +def project_override_mapping_path(project_root: Path) -> Path: + """Return the project override path. + + Always returned regardless of existence — the caller decides whether + to apply the override based on ``Path.is_file()``. + """ + return project_root / "context" / "team" / "archetype-catalog-mapping.yaml" + + +# --------------------------------------------------------------------------- +# Result type +# --------------------------------------------------------------------------- + +@dataclass +class ArchetypeMapping: + """Resolved archetype-to-catalog mapping. + + Attributes + ---------- + archetypes: dict mapping archetype-name -> {"role": ROLE-id, + "seniority": , optional "primary_contact": bool, ...} + source: "kit-default" | "project" | "cli" + overrides: list of per-archetype delta entries when source == "project" + and the project file is in delta mode. Each entry: + {"archetype": , "field": , "kit_default": ..., + "project_value": ...} + semantics: "delta" | "replace" — only meaningful when source == "project" + """ + + archetypes: dict[str, dict[str, Any]] + source: str = "kit-default" + overrides: list[dict[str, Any]] = field(default_factory=list) + semantics: str | None = None + files: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Loaders +# --------------------------------------------------------------------------- + +def _load_yaml(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + data = yaml.safe_load(text) or {} + if not isinstance(data, dict): + raise ValueError(f"{path}: top-level YAML must be a mapping") + return data + + +def _extract_archetypes(doc: dict[str, Any], path: Path) -> dict[str, dict[str, Any]]: + """Pull the ``spec.archetypes`` block (or top-level ``archetypes``).""" + spec = doc.get("spec") or doc + archetypes = spec.get("archetypes") + if not isinstance(archetypes, dict): + raise ValueError(f"{path}: missing or invalid 'archetypes' map") + out: dict[str, dict[str, Any]] = {} + for name, body in archetypes.items(): + if not isinstance(body, dict): + raise ValueError(f"{path}: archetype {name!r} must be a mapping") + role = body.get("role") + seniority = body.get("seniority") + if not role or not str(role).startswith("ROLE-"): + raise ValueError( + f"{path}: archetype {name!r} requires a 'role' starting with 'ROLE-'" + ) + if not seniority: + raise ValueError(f"{path}: archetype {name!r} requires a 'seniority'") + entry: dict[str, Any] = {"role": role, "seniority": seniority} + for opt in ("primary_contact", "default_model_profile", + "effort_floor", "effort_ceiling", "rationale"): + if opt in body: + entry[opt] = body[opt] + out[name] = entry + return out + + +def _load_kit_default() -> dict[str, dict[str, Any]]: + path = kit_default_mapping_path() + if not path.is_file(): + raise FileNotFoundError( + f"kit-default mapping missing at {path} — team-creator install corrupted?" + ) + doc = _load_yaml(path) + return _extract_archetypes(doc, path) + + +def _validate_replace_completeness(archetypes: dict[str, dict[str, Any]], + path: Path) -> None: + missing = [a for a in ARCHETYPES if a not in archetypes] + if missing: + raise ValueError( + f"{path}: replace-mode override missing archetypes: {missing!r}" + ) + + +def load_archetype_catalog_mapping( + project_root: Path, + cli_path: Path | None = None, +) -> ArchetypeMapping: + """Load the archetype→catalog mapping with three-level precedence. + + Order: + 1. ``cli_path`` (if supplied; mode determined by file's + ``override_semantics``) + 2. ``/context/team/archetype-catalog-mapping.yaml`` + 3. kit default shipped under team-creator/assets/ + + Returns an :class:`ArchetypeMapping` describing the resolved set, + its source, and any per-archetype delta entries. + """ + project_root = Path(project_root) + kit_archetypes = _load_kit_default() + files: list[str] = [str(kit_default_mapping_path())] + + # --- Layer 1: CLI ------------------------------------------------------ + if cli_path is not None: + cli_path = Path(cli_path) + if not cli_path.is_file(): + raise FileNotFoundError( + f"--archetype-catalog-mapping {cli_path} not found" + ) + doc = _load_yaml(cli_path) + semantics = doc.get("override_semantics", "replace") + if semantics not in _VALID_OVERRIDE_SEMANTICS: + raise ValueError( + f"{cli_path}: override_semantics must be 'delta' or 'replace'; " + f"got {semantics!r}" + ) + cli_archetypes = _extract_archetypes(doc, cli_path) + if semantics == "replace": + _validate_replace_completeness(cli_archetypes, cli_path) + merged = cli_archetypes + else: + merged = {**kit_archetypes, **cli_archetypes} + overrides = _delta_overrides(kit_archetypes, cli_archetypes) + files.append(str(cli_path)) + return ArchetypeMapping( + archetypes=merged, + source="cli", + overrides=overrides, + semantics=semantics, + files=files, + ) + + # --- Layer 2: Project override ---------------------------------------- + proj_path = project_override_mapping_path(project_root) + if proj_path.is_file(): + doc = _load_yaml(proj_path) + semantics = doc.get("override_semantics", "delta") + if semantics not in _VALID_OVERRIDE_SEMANTICS: + raise ValueError( + f"{proj_path}: override_semantics must be 'delta' or 'replace'; " + f"got {semantics!r}" + ) + proj_archetypes = _extract_archetypes(doc, proj_path) + if semantics == "replace": + _validate_replace_completeness(proj_archetypes, proj_path) + merged = proj_archetypes + else: + merged = {**kit_archetypes, **proj_archetypes} + overrides = _delta_overrides(kit_archetypes, proj_archetypes) + files.append(str(proj_path)) + return ArchetypeMapping( + archetypes=merged, + source="project", + overrides=overrides, + semantics=semantics, + files=files, + ) + + # --- Layer 3: kit default --------------------------------------------- + return ArchetypeMapping( + archetypes=kit_archetypes, + source="kit-default", + overrides=[], + semantics=None, + files=files, + ) + + +def _delta_overrides( + kit: dict[str, dict[str, Any]], + layer: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """Return per-archetype-per-field delta entries. + + Used for the chartering DEC's ``inputs_snapshot.archetype_catalog_overrides`` + audit field. + """ + out: list[dict[str, Any]] = [] + for name, entry in layer.items(): + kit_entry = kit.get(name) or {} + for fld, val in entry.items(): + kit_val = kit_entry.get(fld) + if kit_val != val: + out.append({ + "archetype": name, + "field": fld, + "kit_default": kit_val, + "project_value": val, + }) + # New archetype not in kit: treat as a single "add" entry + if name not in kit: + out.append({ + "archetype": name, + "field": "*", + "kit_default": None, + "project_value": "", + }) + return out + + +def resolve_archetype(name: str, mapping: ArchetypeMapping) -> dict[str, Any]: + """Look up an archetype in the resolved mapping. KeyError on miss.""" + if name not in mapping.archetypes: + raise KeyError( + f"archetype {name!r} not found in mapping (source={mapping.source}); " + f"known archetypes: {sorted(mapping.archetypes)}" + ) + return mapping.archetypes[name] + + +def archetype_for_role_slot( + role: str, seniority: str, mapping: ArchetypeMapping, +) -> str | None: + """Reverse lookup: given (role, seniority), return the archetype name. + + Used by pk-team-review for human-readable diff labels. Returns None + when no archetype maps to the (role, seniority) pair. + """ + for name, entry in mapping.archetypes.items(): + if entry["role"] == role and entry["seniority"] == seniority: + return name + return None diff --git a/context/skills/processkit/team-manager/mcp/server.py b/context/skills/processkit/team-manager/mcp/server.py index 31bc679..7fd3fad 100644 --- a/context/skills/processkit/team-manager/mcp/server.py +++ b/context/skills/processkit/team-manager/mcp/server.py @@ -96,6 +96,16 @@ def _find_lib() -> Path: _VALID_TYPES = {"human", "ai-agent", "service"} _VALID_SENIORITY = {"junior", "specialist", "expert", "senior", "principal"} +_VALID_SLOT_STATES = {"open", "filled", "closed"} +_VALID_EFFORTS = {"low", "medium", "high", "extra-high", "max"} +# Allowed RoleSlot state transitions (Phase A team-creator v2; +# DEC-20260509_1906-CoolBadger). closed is terminal — reverse +# transitions are rejected. +_SLOT_TRANSITIONS = { + "open": {"filled", "closed"}, + "filled": {"closed"}, + "closed": set(), +} _UPDATABLE_FIELDS = { "name", "email", "handle", "default_role", "default_seniority", "personality", "memory", "relationships", "exportable", @@ -946,6 +956,21 @@ def get_interlocutor_runtime_binding( `observed_model` and `observed_effort` are caller-supplied facts from the current harness. processkit cannot read or hot-swap the already running primary model, so mismatch reporting is informational. + + Phase A team-creator v2 — RoleSlot pre-step: + Before falling through to the existing 8-layer model-assignment + binding precedence, this tool checks whether the active + interlocutor's (default_role, default_seniority, scope) matches + a RoleSlot in state=filled. If it does, the slot's filled_by + TeamMember and (TeamMember.default_seniority || slot.seniority) + are surfaced in ``binding.roleslot_pre_step``. The existing + 8-layer logic still runs unchanged underneath + (DEC-20260509_1906-CoolBadger Q1). + + When no slot matches the triple, ``roleslot_pre_step`` is + absent and the response is identical to pre-RoleSlot + behaviour — the existing 8-layer resolver is fully + responsible. This keeps Phase A additive and reversible. """ active = get_active_interlocutor(scope) if active.get("error") or not active.get("configured"): @@ -961,15 +986,31 @@ def get_interlocutor_runtime_binding( "error": f"configured team-member {member_id!r} not found", } + # --- RoleSlot pre-step (Phase A) ----------------------------------- + # Look for a filled slot keyed by the interlocutor's default + # (role, seniority) inside ``scope``. If found, the slot's + # filled_by TeamMember (or ``None`` for ephemeral dispatch) and the + # applied seniority join the response. The 8-layer resolver below + # remains the source of truth for the actual model selection. + pre_role = (ent.spec or {}).get("default_role") + pre_seniority = (ent.spec or {}).get("default_seniority") + pre_step = _roleslot_pre_step( + role=pre_role, seniority=pre_seniority, scope=scope, + ) + + binding = _runtime_binding_for( + ent=ent, + scope=scope, + observed_model=observed_model, + observed_effort=observed_effort, + task_hints=task_hints, + ) + if pre_step is not None: + binding = {**binding, "roleslot_pre_step": pre_step} + return { **active, - "binding": _runtime_binding_for( - ent=ent, - scope=scope, - observed_model=observed_model, - observed_effort=observed_effort, - task_hints=task_hints, - ), + "binding": binding, } @@ -1144,6 +1185,622 @@ def reactivate_team_member(id: str) -> dict: return {"ok": True, "id": ent.id} +# --------------------------------------------------------------------------- +# RoleSlot tools (Phase A — team-creator v2) +# --------------------------------------------------------------------------- +# +# RoleSlot decouples capacity (how many parallel workers a role needs in +# a charter) from identity (who a persistent persona is). Persistent +# TeamMembers carry stable memory and personality; RoleSlots carry no +# memory and exist only inside the lifetime of their chartering Scope. +# +# State machine: open → filled → closed. closed is terminal — reopening +# means a fresh SLOT-id at the next charter (Scope). +# +# IDs are deterministic: SLOT---. +# scope-slug is the SCOPE- tail, role-slug the ROLE- tail, both +# kebab-case. rank=1 is the primary; rank=2..N are parallel reservations. +# +# Resolver pre-step (get_interlocutor_runtime_binding) — Phase A: +# 1. (role, seniority, scope) → query RoleSlot(state=filled, scope, role, +# seniority match) +# 2. RoleSlot.filled_by → TeamMember +# 3. TeamMember.default_seniority overrides RoleSlot.seniority for model +# resolution if set +# 4. fall through to existing 8-layer model-assignment binding precedence +# +# The pre-step is additive — it inserts in front of the existing 8-layer +# resolver without reshaping it (DEC-20260509_1906-CoolBadger Q1). + + +def _slot_dir(root: Path) -> Path: + return paths.context_dir("RoleSlot", root) + + +def _scope_slug(scope_id: str) -> str: + return scope_id[len("SCOPE-"):] if scope_id.startswith("SCOPE-") else scope_id + + +def _role_slug(role_id: str) -> str: + return role_id[len("ROLE-"):] if role_id.startswith("ROLE-") else role_id + + +def _slot_id(scope_id: str, role_id: str, rank: int) -> str: + return f"SLOT-{_scope_slug(scope_id)}-{_role_slug(role_id)}-{int(rank)}" + + +def _slot_path(root: Path, slot_id: str) -> Path: + return _slot_dir(root) / f"{slot_id}.md" + + +def _load_slot(root: Path, slot_id: str) -> entity.Entity | None: + p = _slot_path(root, slot_id) + if p.is_file(): + return entity.load(p) + # Fallback: scan dir (defensive, in case sharding rules change) + base = _slot_dir(root) + if not base.is_dir(): + return None + for f in base.rglob(f"{slot_id}.md"): + try: + return entity.load(f) + except Exception: + continue + return None + + +def _slot_summary(ent: entity.Entity) -> dict: + spec = ent.spec or {} + return { + "id": ent.id, + "scope": spec.get("scope"), + "role": spec.get("role"), + "seniority": spec.get("seniority"), + "rank": spec.get("rank"), + "state": spec.get("state"), + "filled_by": spec.get("filled_by"), + "default_model_profile": spec.get("default_model_profile"), + "effort_floor": spec.get("effort_floor"), + "effort_ceiling": spec.get("effort_ceiling"), + "rationale": spec.get("rationale"), + "created": spec.get("created"), + "closed_at": spec.get("closed_at"), + "close_reason": spec.get("close_reason"), + "path": str(ent.path) if ent.path else None, + } + + +def _create_role_slot_fill_binding( + root: Path, + slot_id: str, + team_member_id: str, + valid_from: str | None, + valid_until: str | None, + rationale: str | None, +) -> dict: + """Create a Binding(type=role-slot-fill) inline. + + Mirrors binding-management.create_binding's write path so the + team-manager server doesn't have to call out across MCP servers + during a fill_role_slot call. The Binding entity is written under + context/bindings/ exactly the same way binding-management would + write it. + """ + from processkit import config as _config, ids as _ids # noqa: WPS433 + + cfg = _config.load_config(root) + bind_dir = paths.context_dir("Binding", root) + bind_dir.mkdir(parents=True, exist_ok=True) + + db = index.open_db() + try: + existing = index.existing_ids(db, "Binding") + finally: + db.close() + + new_id = _ids.generate_id( + "Binding", + format=cfg.id_format, + word_style=cfg.id_word_style, + datetime_prefix=cfg.id_datetime_prefix, + slug_text="role-slot-fill", + existing=existing, + ) + spec: dict = { + "type": "role-slot-fill", + "subject": team_member_id, + "target": slot_id, + "subject_kind": "TeamMember", + "target_kind": "RoleSlot", + } + if valid_from: + spec["valid_from"] = valid_from + if valid_until: + spec["valid_until"] = valid_until + if rationale: + spec["conditions"] = {"rationale": rationale} + + errors = schema.validate_spec("Binding", spec) + if errors: + return {"error": "binding schema validation failed", "details": errors} + + ent = entity.new("Binding", new_id, spec) + target_path = paths.entity_path("Binding", new_id, None, root) + ent.write(target_path) + + try: + db = index.open_db() + try: + index.upsert_entity(db, ent) + finally: + db.close() + except Exception: + pass + + log.log_side_effect( + "Binding", new_id, "binding.created", + f"Created Binding {new_id!r}: 'role-slot-fill' " + f"{team_member_id!r} → {slot_id!r}", + root=root, + actor=new_id, + ) + return {"id": new_id, "path": str(target_path)} + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=False, + openWorldHint=False, +)) +def create_role_slot( + scope: str, + role: str, + seniority: str, + rank: int, + rationale: str, + default_model_profile: str | None = None, + effort_floor: str | None = None, + effort_ceiling: str | None = None, +) -> dict: + """Open a new RoleSlot under a chartering Scope. + + Parameters + ---------- + scope: SCOPE- the slot belongs to (mandatory) + role: ROLE- from the catalog + seniority: junior|specialist|expert|senior|principal + rank: 1=primary, 2..N=parallel reservations + rationale: one-line reason this slot exists + default_model_profile: optional Layer 8 fallback for ephemeral + dispatches that never fill the slot + effort_floor: optional dispatch floor + effort_ceiling: optional dispatch ceiling + + Returns ``{id, path, state}`` on success. + """ + if not scope or not scope.startswith("SCOPE-"): + return {"error": f"scope must be a SCOPE-* id; got {scope!r}"} + if not role or not role.startswith("ROLE-"): + return {"error": f"role must be a ROLE-* id; got {role!r}"} + if seniority not in _VALID_SENIORITY: + return { + "error": ( + f"invalid seniority {seniority!r}; " + f"must be one of {sorted(_VALID_SENIORITY)}" + ) + } + try: + rank_int = int(rank) + except (TypeError, ValueError): + return {"error": f"rank must be a positive integer; got {rank!r}"} + if rank_int < 1: + return {"error": f"rank must be >= 1; got {rank_int}"} + if effort_floor is not None and effort_floor not in _VALID_EFFORTS: + return {"error": f"invalid effort_floor {effort_floor!r}"} + if effort_ceiling is not None and effort_ceiling not in _VALID_EFFORTS: + return {"error": f"invalid effort_ceiling {effort_ceiling!r}"} + if not rationale or not str(rationale).strip(): + return {"error": "rationale is required and must be non-empty"} + + root = paths.find_project_root() + new_id = _slot_id(scope, role, rank_int) + slot_path = _slot_path(root, new_id) + if slot_path.is_file(): + return { + "error": ( + f"role-slot {new_id!r} already exists at {slot_path}; " + "pick a different rank or close the existing slot first" + ) + } + + spec: dict = { + "scope": scope, + "role": role, + "seniority": seniority, + "rank": rank_int, + "state": "open", + "filled_by": None, + "rationale": str(rationale).strip(), + "created": _now_iso(), + } + if default_model_profile: + spec["default_model_profile"] = default_model_profile + if effort_floor: + spec["effort_floor"] = effort_floor + if effort_ceiling: + spec["effort_ceiling"] = effort_ceiling + + errors = schema.validate_spec("RoleSlot", spec) + if errors: + return {"error": "schema validation failed", "details": errors} + + ent = entity.new("RoleSlot", new_id, spec) + slot_path.parent.mkdir(parents=True, exist_ok=True) + ent.write(slot_path) + + try: + db = index.open_db() + try: + index.upsert_entity(db, ent) + finally: + db.close() + except Exception: + pass + + log.log_side_effect( + "RoleSlot", new_id, "role_slot.created", + f"Opened RoleSlot {new_id!r} (scope={scope}, role={role}, " + f"seniority={seniority}, rank={rank_int})", + root=root, + actor=new_id, + ) + return {"id": new_id, "path": str(slot_path), "state": "open"} + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, +)) +def get_role_slot(id: str) -> dict: + """Return the full RoleSlot entity by ID.""" + if not id or not id.startswith("SLOT-"): + return {"error": f"id must be a SLOT-* id; got {id!r}"} + root = paths.find_project_root() + ent = _load_slot(root, id) + if ent is None: + return {"error": f"role-slot {id!r} not found"} + return _slot_summary(ent) + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, +)) +def list_role_slots( + scope: str | None = None, + role: str | None = None, + state: str | None = None, + limit: int = 200, +) -> list[dict]: + """List RoleSlots under context/roleslots/, optionally filtered. + + Filters + ------- + scope: match SCOPE- exactly + role: match ROLE- exactly + state: open | filled | closed + """ + if state is not None and state not in _VALID_SLOT_STATES: + return [{"error": f"invalid state filter {state!r}"}] + root = paths.find_project_root() + base = _slot_dir(root) + if not base.is_dir(): + return [] + out: list[dict] = [] + for f in sorted(base.rglob("SLOT-*.md")): + try: + ent = entity.load(f) + except Exception: + continue + if ent.kind != "RoleSlot": + continue + spec = ent.spec or {} + if scope and spec.get("scope") != scope: + continue + if role and spec.get("role") != role: + continue + if state and spec.get("state") != state: + continue + out.append(_slot_summary(ent)) + if len(out) >= limit: + break + return out + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=False, + openWorldHint=False, +)) +def fill_role_slot( + id: str, + team_member_slug: str, + valid_from: str | None = None, + valid_until: str | None = None, + rationale: str | None = None, +) -> dict: + """Place an active TeamMember into an open RoleSlot. + + Sets ``state=filled``, ``filled_by=TEAMMEMBER-``, and creates + a parallel ``role-slot-fill`` Binding so time-bounded dispatch + queries continue to work through binding-management. + + Returns + ------- + On success: ``{ok: True, id, state, filled_by, binding_id, binding_path}`` + On error: ``{error, details?}`` + """ + if not id or not id.startswith("SLOT-"): + return {"error": f"id must be a SLOT-* id; got {id!r}"} + if not team_member_slug: + return {"error": "team_member_slug is required"} + + root = paths.find_project_root() + ent = _load_slot(root, id) + if ent is None: + return {"error": f"role-slot {id!r} not found"} + + current_state = (ent.spec or {}).get("state") + if current_state == "filled": + return { + "error": ( + f"role-slot {id!r} is already filled by " + f"{ent.spec.get('filled_by')!r}; close it first if you " + "need to reassign" + ) + } + if "filled" not in _SLOT_TRANSITIONS.get(current_state, set()): + return { + "error": ( + f"invalid transition {current_state!r} → 'filled' for " + f"role-slot {id!r}; allowed from {current_state!r}: " + f"{sorted(_SLOT_TRANSITIONS.get(current_state, set()))}" + ) + } + + tm = _load_tm(root, team_member_slug) + if tm is None: + return {"error": f"team-member {team_member_slug!r} not found"} + if not tm.spec.get("active", True): + return { + "error": ( + f"cannot fill role-slot with inactive TeamMember " + f"{tm.id!r}; reactivate or pick a different member" + ) + } + + ent.spec["state"] = "filled" + ent.spec["filled_by"] = tm.id + errors = schema.validate_spec("RoleSlot", ent.spec) + if errors: + return {"error": "schema validation failed", "details": errors} + ent.write() + + try: + db = index.open_db() + try: + index.upsert_entity(db, ent) + finally: + db.close() + except Exception: + pass + + binding_result = _create_role_slot_fill_binding( + root=root, + slot_id=ent.id, + team_member_id=tm.id, + valid_from=valid_from, + valid_until=valid_until, + rationale=rationale, + ) + if "error" in binding_result: + # Roll back the slot transition so the data store stays consistent. + ent.spec["state"] = current_state or "open" + ent.spec["filled_by"] = None + ent.write() + return { + "error": "could not create role-slot-fill binding", + "details": binding_result, + } + + log.log_side_effect( + "RoleSlot", ent.id, "role_slot.filled", + f"Filled RoleSlot {ent.id!r} with {tm.id!r}", + root=root, + actor=ent.id, + ) + return { + "ok": True, + "id": ent.id, + "state": "filled", + "filled_by": tm.id, + "binding_id": binding_result["id"], + "binding_path": binding_result["path"], + } + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=True, + idempotentHint=True, + openWorldHint=False, +)) +def close_role_slot(id: str, reason: str | None = None) -> dict: + """Close a RoleSlot. Terminal — re-opening requires a new SLOT-id. + + ``open|filled → closed`` is always allowed; closing an already + ``closed`` slot is a no-op (idempotent). + """ + if not id or not id.startswith("SLOT-"): + return {"error": f"id must be a SLOT-* id; got {id!r}"} + root = paths.find_project_root() + ent = _load_slot(root, id) + if ent is None: + return {"error": f"role-slot {id!r} not found"} + + current_state = (ent.spec or {}).get("state") + if current_state == "closed": + # Idempotent close; report the existing terminal state. + return { + "ok": True, + "id": ent.id, + "state": "closed", + "already_closed": True, + "closed_at": ent.spec.get("closed_at"), + } + if "closed" not in _SLOT_TRANSITIONS.get(current_state, set()): + return { + "error": ( + f"invalid transition {current_state!r} → 'closed' for " + f"role-slot {id!r}" + ) + } + + ent.spec["state"] = "closed" + ent.spec["closed_at"] = _now_iso() + if reason: + ent.spec["close_reason"] = str(reason).strip() + errors = schema.validate_spec("RoleSlot", ent.spec) + if errors: + return {"error": "schema validation failed", "details": errors} + ent.write() + + try: + db = index.open_db() + try: + index.upsert_entity(db, ent) + finally: + db.close() + except Exception: + pass + + log.log_side_effect( + "RoleSlot", ent.id, "role_slot.closed", + f"Closed RoleSlot {ent.id!r}" + + (f" — reason: {reason!r}" if reason else ""), + root=root, + actor=ent.id, + ) + return { + "ok": True, + "id": ent.id, + "state": "closed", + "closed_at": ent.spec["closed_at"], + "close_reason": ent.spec.get("close_reason"), + } + + +# --------------------------------------------------------------------------- +# Resolver helpers — RoleSlot pre-step (Phase A team-creator v2) +# --------------------------------------------------------------------------- + +def _find_filled_slot( + root: Path, + role: str | None, + seniority: str | None, + scope: str | None, +) -> entity.Entity | None: + """Return the first filled RoleSlot matching (role, seniority, scope). + + Match rules: + - role and scope must equal exactly when supplied; + - seniority must equal when supplied; + - state must be 'filled'; + - prefers rank=1 over higher ranks (sorted ascending). + """ + base = _slot_dir(root) + if not base.is_dir(): + return None + matches: list[tuple[int, entity.Entity]] = [] + for f in base.rglob("SLOT-*.md"): + try: + ent = entity.load(f) + except Exception: + continue + if ent.kind != "RoleSlot": + continue + spec = ent.spec or {} + if spec.get("state") != "filled": + continue + if role and spec.get("role") != role: + continue + if seniority and spec.get("seniority") != seniority: + continue + if scope and spec.get("scope") != scope: + continue + try: + rank = int(spec.get("rank") or 999999) + except (TypeError, ValueError): + rank = 999999 + matches.append((rank, ent)) + if not matches: + return None + matches.sort(key=lambda pair: pair[0]) + return matches[0][1] + + +def _roleslot_pre_step( + role: str | None, + seniority: str | None, + scope: str | None, +) -> dict | None: + """Phase A pre-step before the existing 8-layer resolver. + + Returns ``None`` when no filled slot matches the (role, seniority, + scope) triple — the caller MUST then fall through to the existing + 8-layer model-assignment binding precedence unchanged. + + Returns a dict ``{slot, team_member, applied_seniority}`` when a + filled slot is found: + - slot: full _slot_summary + - team_member: TeamMember entity for the slot's filled_by + (or None when the slot has filled_by=null, + which is the ephemeral-dispatch case where + the slot's default_model_profile becomes + the Layer 8 fallback) + - applied_seniority: TeamMember.default_seniority overrides the + slot's seniority for model resolution if + set; otherwise the slot's seniority. + """ + if not role: + return None + root = paths.find_project_root() + slot = _find_filled_slot(root, role=role, seniority=seniority, scope=scope) + if slot is None: + return None + spec = slot.spec or {} + tm_id = spec.get("filled_by") + tm_ent = _load_tm(root, tm_id) if tm_id else None + applied_seniority = spec.get("seniority") + if tm_ent is not None: + tm_seniority = (tm_ent.spec or {}).get("default_seniority") + if tm_seniority: + applied_seniority = tm_seniority + return { + "slot": _slot_summary(slot), + "team_member": ( + _team_member_summary(tm_ent) if tm_ent is not None else None + ), + "applied_seniority": applied_seniority, + } + + # --------------------------------------------------------------------------- # Name-pool tools # --------------------------------------------------------------------------- diff --git a/context/skills/processkit/team-manager/scripts/apply_migration_2139.py b/context/skills/processkit/team-manager/scripts/apply_migration_2139.py new file mode 100644 index 0000000..29d91c9 --- /dev/null +++ b/context/skills/processkit/team-manager/scripts/apply_migration_2139.py @@ -0,0 +1,407 @@ +"""Apply script for MIG-20260509T213904-roleslot-phase-a (Phase A back-fill). + +Walks the project's `context/roles/` and `context/bindings/` directories, +emits one RoleSlot per legacy ``Role.clone_cap`` unit, and writes a +parallel ``role-slot-fill`` Binding for every active +``role-assignment`` Binding it finds. Old v1 entities are left in place +(read-only). + +The script is **idempotent**: a re-run notices already-emitted RoleSlots +and ``role-slot-fill`` Bindings (matched by their deterministic IDs and +``labels.migration`` stamp) and skips them. + +On a v2-native project (no v1 archetype Roles, no ``role-assignment`` +Bindings) the script is a no-op — its value is for derived projects +(aibox, etc.) when they upgrade across the v0.25.8 → v0.26.0 boundary. + +Usage:: + + uv run --with pyyaml --with jsonschema --with mcp \\ + python context/skills/processkit/team-manager/scripts/apply_migration_2139.py \\ + --chartering-scope SCOPE- # required + [--project-root ] # default: cwd-walk + [--dry-run] # print plan; write nothing + +The chartering Scope must already exist; if it does not, the script +errors before any write. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + + +_THIS = Path(__file__).resolve() +_TM_SCRIPTS = _THIS.parent +_TM_MCP = _TM_SCRIPTS.parent / "mcp" + + +def _bootstrap_paths() -> None: + """Ensure both the team-manager MCP server module and the + processkit lib are importable.""" + here = _THIS + while True: + for c in (here / "src" / "lib", here / "_lib"): + if (c / "processkit" / "__init__.py").is_file(): + if str(c) not in sys.path: + sys.path.insert(0, str(c)) + break + if here.parent == here: + break + here = here.parent + for p in (str(_TM_MCP), str(_TM_SCRIPTS)): + if p not in sys.path: + sys.path.insert(0, p) + + +_bootstrap_paths() + + +# Imports happen after path bootstrap so the project lib is found. +import server as team_manager_mcp # noqa: E402 +from processkit import entity, paths # noqa: E402 + + +_MIGRATION_ID = "MIG-20260509T213904-roleslot-phase-a" + + +# v0.19.0 archetype set used to recognise legacy archetype-spawned Roles. +_LEGACY_ARCHETYPES = { + "project-manager", + "senior-architect", + "senior-researcher", + "junior-architect", + "developer", + "junior-researcher", + "junior-developer", + "assistant", +} + + +def _iter_v1_archetype_roles(root: Path): + """Yield (Entity, archetype_name, clone_cap_int) for legacy Roles. + + A legacy Role is one whose name matches a v0.19.0 archetype. We + treat ``spec.clone_cap`` if present; otherwise default to 1 + (every legacy Role is at least one slot). + """ + role_dir = root / "context" / "roles" + if not role_dir.is_dir(): + return + for path in sorted(role_dir.glob("ROLE-*.md")): + try: + ent = entity.load(path) + except Exception: + continue + if ent.kind != "Role": + continue + spec = ent.spec or {} + name = spec.get("name") or ent.id[len("ROLE-"):] + if name not in _LEGACY_ARCHETYPES: + # Could also be the matching catalog Role; only legacy + # archetype-spawned Roles get back-filled. Catalog Roles + # are by convention named after their job title + # (e.g. ROLE-product-manager) — never match the archetype + # set. + continue + try: + cap = int(spec.get("clone_cap", 1)) + except (TypeError, ValueError): + cap = 1 + if cap < 1: + cap = 1 + yield ent, name, cap + + +def _iter_active_role_assignment_bindings(root: Path): + """Yield Entity objects for active Binding(type=role-assignment).""" + bind_dir = root / "context" / "bindings" + if not bind_dir.is_dir(): + return + for path in sorted(bind_dir.glob("BIND-*.md")): + try: + ent = entity.load(path) + except Exception: + continue + if ent.kind != "Binding": + continue + spec = ent.spec or {} + if spec.get("type") != "role-assignment": + continue + # Skip ended bindings. + if spec.get("ended_at") or spec.get("valid_until_passed"): + continue + yield ent + + +def _scope_slug(scope_id: str) -> str: + return scope_id[len("SCOPE-"):] if scope_id.startswith("SCOPE-") else scope_id + + +def _existing_role_slot_fill_for(root: Path, slot_id: str, + team_member_id: str) -> bool: + """True iff a role-slot-fill Binding already exists for (slot, member).""" + bind_dir = root / "context" / "bindings" + if not bind_dir.is_dir(): + return False + for path in bind_dir.glob("BIND-*.md"): + try: + ent = entity.load(path) + except Exception: + continue + if ent.kind != "Binding": + continue + spec = ent.spec or {} + if spec.get("type") != "role-slot-fill": + continue + if spec.get("target") == slot_id and spec.get("subject") == team_member_id: + return True + return False + + +def _scope_exists(root: Path, scope_id: str) -> bool: + if not scope_id.startswith("SCOPE-"): + return False + scope_dir = root / "context" / "scopes" + if not scope_dir.is_dir(): + return False + return any(p.name == f"{scope_id}.md" for p in scope_dir.rglob("SCOPE-*.md")) + + +def _seniority_for_role(role_id: str, role_spec: dict[str, Any]) -> str: + """Best-effort seniority extraction. + + v1 archetype Roles did not carry an explicit seniority field — we + derive a sensible default from the archetype name; a project may + override after migration. + """ + name = role_spec.get("name") or role_id[len("ROLE-"):] + if name.startswith("junior-"): + return "junior" + if name in {"project-manager", "senior-architect", "senior-researcher", + "developer"}: + return "senior" + return "specialist" + + +def apply( + project_root: Path, + chartering_scope: str, + *, + dry_run: bool = False, +) -> dict[str, Any]: + """Run the back-fill. + + Returns a summary dict with counts: ``slots_created``, + ``slots_skipped`` (already present), ``fills_created``, + ``fills_skipped``, ``warnings``. + """ + project_root = Path(project_root).resolve() + if not chartering_scope.startswith("SCOPE-"): + return { + "error": ( + f"chartering_scope must be a SCOPE-* id; got " + f"{chartering_scope!r}" + ) + } + if not _scope_exists(project_root, chartering_scope): + return { + "error": ( + f"chartering_scope {chartering_scope!r} not found under " + f"{project_root / 'context' / 'scopes'}" + ) + } + + summary = { + "migration_id": _MIGRATION_ID, + "chartering_scope": chartering_scope, + "project_root": str(project_root), + "dry_run": bool(dry_run), + "slots_created": 0, + "slots_skipped": 0, + "fills_created": 0, + "fills_skipped": 0, + "warnings": [], + "actions": [], + } + + # Make team-manager find the project root we want. + paths.find_project_root.__wrapped__ if hasattr(paths.find_project_root, + "__wrapped__") else None + # Override discovery by chdir'ing — the team-manager server walks up + # from cwd looking for AGENTS.md. + import os + prev_cwd = os.getcwd() + os.chdir(project_root) + try: + # --- Phase A.2: emit RoleSlots --------------------------------- + archetype_to_slot_rank1: dict[str, str] = {} + for role_ent, archetype, cap in _iter_v1_archetype_roles(project_root): + seniority = _seniority_for_role(role_ent.id, role_ent.spec or {}) + scope_slug = _scope_slug(chartering_scope) + for rank in range(1, cap + 1): + slot_id = ( + f"SLOT-{scope_slug}-{role_ent.id[len('ROLE-'):]}-{rank}" + ) + slot_path = ( + project_root / "context" / "roleslots" / f"{slot_id}.md" + ) + if slot_path.is_file(): + summary["slots_skipped"] += 1 + summary["actions"].append({ + "kind": "skip-slot-exists", + "slot": slot_id, + }) + if rank == 1: + archetype_to_slot_rank1[role_ent.id] = slot_id + continue + if dry_run: + summary["slots_created"] += 1 + summary["actions"].append({ + "kind": "would-create-slot", + "slot": slot_id, + "archetype": archetype, + "role": role_ent.id, + "seniority": seniority, + "rank": rank, + }) + if rank == 1: + archetype_to_slot_rank1[role_ent.id] = slot_id + continue + rationale = ( + f"Phase A back-fill for {_MIGRATION_ID}: " + f"archetype={archetype} (was Role.clone_cap={cap})" + ) + result = team_manager_mcp.create_role_slot( + scope=chartering_scope, + role=role_ent.id, + seniority=seniority, + rank=rank, + rationale=rationale, + ) + if "error" in result: + summary["warnings"].append({ + "kind": "create_role_slot-failed", + "role": role_ent.id, + "rank": rank, + "detail": result["error"], + }) + continue + summary["slots_created"] += 1 + summary["actions"].append({ + "kind": "created-slot", + "slot": result["id"], + "archetype": archetype, + "role": role_ent.id, + "rank": rank, + }) + if rank == 1: + archetype_to_slot_rank1[role_ent.id] = result["id"] + + # --- Phase A.3: emit role-slot-fill Bindings ------------------- + for ba in _iter_active_role_assignment_bindings(project_root): + spec = ba.spec or {} + target = spec.get("target") + subject = spec.get("subject") + if not target or not target.startswith("ROLE-"): + summary["warnings"].append({ + "kind": "skip-binding-non-role-target", + "binding": ba.id, + "target": target, + }) + continue + slot_id = archetype_to_slot_rank1.get(target) + if not slot_id: + summary["warnings"].append({ + "kind": "skip-binding-no-rank1-slot", + "binding": ba.id, + "role": target, + }) + continue + # We require a TeamMember subject for role-slot-fill (the v2 + # schema says so). v1 may have used Actors as subjects; + # those cannot be migrated automatically. + if not str(subject).startswith("TEAMMEMBER-"): + summary["warnings"].append({ + "kind": "skip-binding-non-teammember-subject", + "binding": ba.id, + "subject": subject, + }) + continue + if _existing_role_slot_fill_for(project_root, slot_id, subject): + summary["fills_skipped"] += 1 + summary["actions"].append({ + "kind": "skip-fill-exists", + "slot": slot_id, + "team_member": subject, + }) + continue + if dry_run: + summary["fills_created"] += 1 + summary["actions"].append({ + "kind": "would-fill-slot", + "slot": slot_id, + "team_member": subject, + }) + continue + tm_slug = subject[len("TEAMMEMBER-"):] + result = team_manager_mcp.fill_role_slot( + id=slot_id, + team_member_slug=tm_slug, + rationale=( + f"Phase A back-fill for {_MIGRATION_ID}: " + f"parallel role-slot-fill for v1 {ba.id}" + ), + ) + if "error" in result: + summary["warnings"].append({ + "kind": "fill_role_slot-failed", + "slot": slot_id, + "team_member": subject, + "detail": result["error"], + }) + continue + summary["fills_created"] += 1 + summary["actions"].append({ + "kind": "filled-slot", + "slot": slot_id, + "team_member": subject, + "binding": result.get("binding", {}).get("id"), + }) + finally: + os.chdir(prev_cwd) + + return summary + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Apply MIG-20260509T213904-roleslot-phase-a Phase A " + "back-fill (idempotent)." + ) + ) + parser.add_argument("--chartering-scope", required=True, + help="SCOPE- the back-filled RoleSlots belong to") + parser.add_argument("--project-root", default=".", + help="Project root (defaults to cwd)") + parser.add_argument("--dry-run", action="store_true", + help="Print the plan without writing") + args = parser.parse_args(argv) + summary = apply( + Path(args.project_root), + args.chartering_scope, + dry_run=args.dry_run, + ) + if "error" in summary: + print(f"ERROR: {summary['error']}", file=sys.stderr) + return 2 + import json + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/context/skills/processkit/team-manager/scripts/test_team_manager.py b/context/skills/processkit/team-manager/scripts/test_team_manager.py index aa49869..de99ea2 100644 --- a/context/skills/processkit/team-manager/scripts/test_team_manager.py +++ b/context/skills/processkit/team-manager/scripts/test_team_manager.py @@ -75,6 +75,12 @@ def project_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: role_schema = _find_repo_root() / "context" / "schemas" / "role.yaml" if role_schema.is_file(): shutil.copy(role_schema, schemas_dir / "role.yaml") + # RoleSlot + Binding + Scope schemas — needed by the Phase A + # team-creator v2 RoleSlot tools (DEC-20260509_1906-CoolBadger). + for extra in ("roleslot.yaml", "binding.yaml", "scope.yaml"): + src_extra = _find_repo_root() / "context" / "schemas" / extra + if src_extra.is_file(): + shutil.copy(src_extra, schemas_dir / extra) monkeypatch.chdir(tmp_path) # Force processkit.paths.find_project_root to discover AGENTS.md in tmp_path @@ -1002,3 +1008,640 @@ def test_check_all_aggregate(server_mod, project_root: Path, assets_dir): assert report["summary"]["count"] == 2 assert "alice-chen" in report["members"] assert "bob-lee" in report["members"] + + +# --------------------------------------------------------------------------- +# RoleSlot tools — Phase A team-creator v2 +# DEC-20260509_1906-CoolBadger / ART-20260509_1836-SmartPanda +# --------------------------------------------------------------------------- + +def _open_slot(server_mod, **overrides): + """Helper: create a baseline RoleSlot for further tests to mutate.""" + payload = dict( + scope="SCOPE-q2-2026", + role="ROLE-software-engineer", + seniority="senior", + rank=1, + rationale="primary backend implementer for q2", + ) + payload.update(overrides) + return server_mod.create_role_slot(**payload) + + +def test_role_slot_create_get_happy_path(server_mod, project_root: Path): + r = _open_slot(server_mod) + assert r.get("state") == "open", r + expected_id = "SLOT-q2-2026-software-engineer-1" + assert r["id"] == expected_id + assert Path(r["path"]).is_file() + + got = server_mod.get_role_slot(expected_id) + assert got["id"] == expected_id + assert got["scope"] == "SCOPE-q2-2026" + assert got["role"] == "ROLE-software-engineer" + assert got["seniority"] == "senior" + assert got["rank"] == 1 + assert got["state"] == "open" + assert got["filled_by"] is None + assert got["rationale"] == "primary backend implementer for q2" + assert got["created"] + + +def test_role_slot_create_validates_inputs(server_mod, project_root: Path): + bad_scope = server_mod.create_role_slot( + scope="bad", role="ROLE-x", seniority="senior", rank=1, rationale="r", + ) + assert "error" in bad_scope and "SCOPE" in bad_scope["error"] + + bad_role = server_mod.create_role_slot( + scope="SCOPE-x", role="bad", seniority="senior", rank=1, rationale="r", + ) + assert "error" in bad_role and "ROLE" in bad_role["error"] + + bad_sen = server_mod.create_role_slot( + scope="SCOPE-x", role="ROLE-x", seniority="ninja", rank=1, rationale="r", + ) + assert "error" in bad_sen and "seniority" in bad_sen["error"] + + bad_rank = server_mod.create_role_slot( + scope="SCOPE-x", role="ROLE-x", seniority="senior", rank=0, rationale="r", + ) + assert "error" in bad_rank and "rank" in bad_rank["error"] + + bad_rationale = server_mod.create_role_slot( + scope="SCOPE-x", role="ROLE-x", seniority="senior", rank=1, rationale="", + ) + assert "error" in bad_rationale and "rationale" in bad_rationale["error"] + + +def test_role_slot_create_rejects_duplicate(server_mod, project_root: Path): + r1 = _open_slot(server_mod) + assert "error" not in r1, r1 + r2 = _open_slot(server_mod) + assert "error" in r2 and "already exists" in r2["error"] + + +def test_list_role_slots_filters(server_mod, project_root: Path): + _open_slot(server_mod) # SLOT-q2-2026-software-engineer-1 (open, senior) + _open_slot(server_mod, rank=2, rationale="parallel slot") + _open_slot( + server_mod, role="ROLE-product-manager", seniority="senior", + rationale="single PM", + ) + + all_slots = server_mod.list_role_slots() + assert {s["id"] for s in all_slots} == { + "SLOT-q2-2026-software-engineer-1", + "SLOT-q2-2026-software-engineer-2", + "SLOT-q2-2026-product-manager-1", + } + + by_role = server_mod.list_role_slots(role="ROLE-software-engineer") + assert len(by_role) == 2 + + by_state_open = server_mod.list_role_slots(state="open") + assert len(by_state_open) == 3 + + by_state_filled = server_mod.list_role_slots(state="filled") + assert by_state_filled == [] + + +def test_fill_role_slot_happy_path(server_mod, project_root: Path): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + open_r = _open_slot(server_mod) + slot_id = open_r["id"] + + fill_r = server_mod.fill_role_slot( + id=slot_id, + team_member_slug="alice-chen", + valid_from="2026-05-09", + valid_until="2026-08-01", + rationale="lead engineer for the q2 charter", + ) + assert fill_r.get("ok") is True, fill_r + assert fill_r["state"] == "filled" + assert fill_r["filled_by"] == "TEAMMEMBER-alice-chen" + assert fill_r["binding_id"].startswith("BIND-") + assert Path(fill_r["binding_path"]).is_file() + + # Slot file reflects filled state + got = server_mod.get_role_slot(slot_id) + assert got["state"] == "filled" + assert got["filled_by"] == "TEAMMEMBER-alice-chen" + + +def test_fill_role_slot_rejects_inactive_team_member(server_mod, project_root: Path): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + server_mod.deactivate_team_member("alice-chen") + r = _open_slot(server_mod) + fill = server_mod.fill_role_slot(id=r["id"], team_member_slug="alice-chen") + assert "error" in fill + assert "inactive" in fill["error"] + + +def test_fill_role_slot_rejects_unknown_team_member(server_mod, project_root: Path): + r = _open_slot(server_mod) + fill = server_mod.fill_role_slot(id=r["id"], team_member_slug="nobody") + assert "error" in fill + assert "not found" in fill["error"] + + +def test_close_role_slot_terminal(server_mod, project_root: Path): + r = _open_slot(server_mod) + close = server_mod.close_role_slot(r["id"], reason="charter closed early") + assert close.get("ok") is True + assert close["state"] == "closed" + assert close["closed_at"] + assert close["close_reason"] == "charter closed early" + + # Idempotent close + close2 = server_mod.close_role_slot(r["id"]) + assert close2.get("ok") is True + assert close2.get("already_closed") is True + + # Reverse transition rejected + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + fill_after_close = server_mod.fill_role_slot( + id=r["id"], team_member_slug="alice-chen", + ) + assert "error" in fill_after_close + assert "transition" in fill_after_close["error"] + + +def test_fill_role_slot_rejects_double_fill(server_mod, project_root: Path): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + server_mod.create_team_member( + name="Bob Lee", type="human", slug="bob-lee", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + r = _open_slot(server_mod) + server_mod.fill_role_slot(id=r["id"], team_member_slug="alice-chen") + + second = server_mod.fill_role_slot(id=r["id"], team_member_slug="bob-lee") + assert "error" in second + assert "already filled" in second["error"] + + +def test_resolver_pre_step_returns_filled_slot( + server_mod, + project_root: Path, + monkeypatch: pytest.MonkeyPatch, +): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + server_mod.set_active_interlocutor("alice-chen") + open_r = _open_slot(server_mod) + server_mod.fill_role_slot(id=open_r["id"], team_member_slug="alice-chen") + + # Stub the model resolver so the existing 8-layer code path runs + # cleanly and we can inspect the pre-step on top. + class Resolver: + @staticmethod + def _runtime_context(task_hints=None): + return {"harnesses": [], "allowed_providers": []} + + @staticmethod + def resolve(**kwargs): + return [], [] + + monkeypatch.setattr(server_mod, "_load_model_resolver", lambda: Resolver) + got = server_mod.get_interlocutor_runtime_binding(scope="SCOPE-q2-2026") + + assert got["configured"] is True + pre = got["binding"].get("roleslot_pre_step") + assert pre is not None, got["binding"] + assert pre["slot"]["id"] == open_r["id"] + assert pre["slot"]["state"] == "filled" + assert pre["team_member"]["id"] == "TEAMMEMBER-alice-chen" + # TeamMember.default_seniority overrides slot.seniority for model + # resolution if set (design §"Resolver impact" step 3). + assert pre["applied_seniority"] == "senior" + + +def test_resolver_pre_step_falls_through_when_no_slot( + server_mod, + project_root: Path, + monkeypatch: pytest.MonkeyPatch, +): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + server_mod.set_active_interlocutor("alice-chen") + + class Resolver: + @staticmethod + def _runtime_context(task_hints=None): + return {"harnesses": [], "allowed_providers": []} + + @staticmethod + def resolve(**kwargs): + return [], [] + + monkeypatch.setattr(server_mod, "_load_model_resolver", lambda: Resolver) + got = server_mod.get_interlocutor_runtime_binding(scope="SCOPE-q2-2026") + + # No slot exists for (ROLE-software-engineer, senior, SCOPE-q2-2026) + # so the pre-step is silent and the response is identical to + # pre-RoleSlot behaviour. Phase A is additive (Q2 deferred). + assert got["configured"] is True + assert "roleslot_pre_step" not in got["binding"] + + +def test_resolver_pre_step_seniority_override_from_team_member( + server_mod, + project_root: Path, + monkeypatch: pytest.MonkeyPatch, +): + # Slot opens at seniority=senior; TeamMember declares + # default_seniority=expert. Per design §"Resolver impact" step 3 + # the TeamMember's default_seniority overrides the slot's seniority + # for model resolution. + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="expert", + ) + server_mod.set_active_interlocutor("alice-chen") + open_r = server_mod.create_role_slot( + scope="SCOPE-q2-2026", + role="ROLE-software-engineer", + seniority="expert", + rank=1, + rationale="expert backend implementer", + ) + server_mod.fill_role_slot(id=open_r["id"], team_member_slug="alice-chen") + + class Resolver: + @staticmethod + def _runtime_context(task_hints=None): + return {"harnesses": [], "allowed_providers": []} + + @staticmethod + def resolve(**kwargs): + return [], [] + + monkeypatch.setattr(server_mod, "_load_model_resolver", lambda: Resolver) + got = server_mod.get_interlocutor_runtime_binding(scope="SCOPE-q2-2026") + pre = got["binding"]["roleslot_pre_step"] + assert pre["slot"]["seniority"] == "expert" + assert pre["applied_seniority"] == "expert" + + +# --------------------------------------------------------------------------- +# team-creator v2 — archetype-catalog mapping loader (SUB-2 / LuckyWren) +# --------------------------------------------------------------------------- + +@pytest.fixture() +def team_creator_lib(): + """Import the team-creator scripts module from the in-repo source path.""" + repo_root = _find_repo_root() + tc_scripts = ( + repo_root + / "context" + / "skills" + / "processkit" + / "team-creator" + / "scripts" + ) + if str(tc_scripts) not in sys.path: + sys.path.insert(0, str(tc_scripts)) + if "team_creator_lib" in sys.modules: + del sys.modules["team_creator_lib"] + import team_creator_lib # noqa: F401 + return team_creator_lib + + +def test_archetype_catalog_mapping_kit_default_loads(team_creator_lib, tmp_path): + """The shipped kit-default mapping must validate and contain all 8 archetypes.""" + mapping = team_creator_lib.load_archetype_catalog_mapping(tmp_path) + assert mapping.source == "kit-default" + assert mapping.semantics is None + assert mapping.overrides == [] + assert set(mapping.archetypes) == set(team_creator_lib.ARCHETYPES) + pm = mapping.archetypes["project-manager"] + assert pm["role"] == "ROLE-product-manager" + assert pm["seniority"] == "senior" + assert pm.get("primary_contact") is True + # Two archetypes share the same catalog Role with different seniority. + sa = mapping.archetypes["senior-architect"] + ja = mapping.archetypes["junior-architect"] + assert sa["role"] == ja["role"] == "ROLE-solutions-architect" + assert sa["seniority"] == "senior" + assert ja["seniority"] == "specialist" + + +def test_archetype_catalog_mapping_project_delta_layers_correctly( + team_creator_lib, tmp_path +): + """Project override in delta mode replaces only the listed archetypes.""" + (tmp_path / "context" / "team").mkdir(parents=True) + proj = tmp_path / "context" / "team" / "archetype-catalog-mapping.yaml" + proj.write_text( + "apiVersion: processkit.projectious.work/v2\n" + "kind: ArchetypeCatalogMapping\n" + "spec:\n" + " archetypes:\n" + " developer:\n" + " role: ROLE-software-engineer\n" + " seniority: principal\n", + encoding="utf-8", + ) + mapping = team_creator_lib.load_archetype_catalog_mapping(tmp_path) + assert mapping.source == "project" + assert mapping.semantics == "delta" + # Override applies to the listed archetype: + assert mapping.archetypes["developer"]["seniority"] == "principal" + # Untouched archetypes inherit the kit default: + assert mapping.archetypes["project-manager"]["role"] == "ROLE-product-manager" + assert mapping.archetypes["assistant"]["role"] == "ROLE-assistant" + # The delta entry surfaces in `overrides` for the chartering DEC audit: + delta = [o for o in mapping.overrides + if o["archetype"] == "developer" and o["field"] == "seniority"] + assert len(delta) == 1 + assert delta[0]["kit_default"] == "senior" + assert delta[0]["project_value"] == "principal" + + +def test_archetype_catalog_mapping_replace_requires_all_archetypes( + team_creator_lib, tmp_path +): + """A replace-mode override missing archetypes is a hard error.""" + (tmp_path / "context" / "team").mkdir(parents=True) + proj = tmp_path / "context" / "team" / "archetype-catalog-mapping.yaml" + proj.write_text( + "override_semantics: replace\n" + "spec:\n" + " archetypes:\n" + " developer:\n" + " role: ROLE-software-engineer\n" + " seniority: senior\n", + encoding="utf-8", + ) + with pytest.raises(ValueError) as excinfo: + team_creator_lib.load_archetype_catalog_mapping(tmp_path) + assert "replace-mode override missing archetypes" in str(excinfo.value) + + +def test_archetype_catalog_mapping_reverse_lookup(team_creator_lib, tmp_path): + """archetype_for_role_slot resolves (ROLE, seniority) -> archetype name.""" + mapping = team_creator_lib.load_archetype_catalog_mapping(tmp_path) + assert team_creator_lib.archetype_for_role_slot( + "ROLE-software-engineer", "junior", mapping, + ) == "junior-developer" + assert team_creator_lib.archetype_for_role_slot( + "ROLE-software-engineer", "senior", mapping, + ) == "developer" + assert team_creator_lib.archetype_for_role_slot( + "ROLE-product-manager", "senior", mapping, + ) == "project-manager" + # No archetype for a fictional pair: + assert team_creator_lib.archetype_for_role_slot( + "ROLE-software-engineer", "principal", mapping, + ) is None + + +# --------------------------------------------------------------------------- +# Migration apply script — Phase A back-fill (idempotency + smoke test) +# --------------------------------------------------------------------------- + +@pytest.fixture() +def apply_migration_2139_module(): + here = Path(__file__).resolve().parent + if str(here) not in sys.path: + sys.path.insert(0, str(here)) + # Force a fresh import so the module re-binds ``server`` against the + # active project_root fixture. + for name in ("apply_migration_2139",): + if name in sys.modules: + del sys.modules[name] + import apply_migration_2139 # noqa: F401 + return apply_migration_2139 + + +def _seed_v1_archetype_role(project_root: Path, archetype: str, clone_cap: int = 1): + """Write a minimal v1 archetype-spawned Role file under context/roles/.""" + roles_dir = project_root / "context" / "roles" + roles_dir.mkdir(parents=True, exist_ok=True) + rid = f"ROLE-{archetype}" + body = ( + "---\n" + "apiVersion: processkit.projectious.work/v2\n" + "kind: Role\n" + "metadata:\n" + f" id: {rid}\n" + " created: 2026-04-01T00:00:00+00:00\n" + "spec:\n" + f" name: {archetype}\n" + " description: legacy v1 archetype-spawned role for back-fill test\n" + f" clone_cap: {clone_cap}\n" + "---\n\n" + f"# {rid}\n" + ) + (roles_dir / f"{rid}.md").write_text(body, encoding="utf-8") + return rid + + +def _seed_chartering_scope(project_root: Path, scope_id: str = "SCOPE-q2-2026"): + """Write a minimal Scope file so apply_migration_2139's existence check passes.""" + scopes_dir = project_root / "context" / "scopes" + scopes_dir.mkdir(parents=True, exist_ok=True) + body = ( + "---\n" + "apiVersion: processkit.projectious.work/v2\n" + "kind: Scope\n" + "metadata:\n" + f" id: {scope_id}\n" + " created: 2026-04-01T00:00:00+00:00\n" + "spec:\n" + " title: 2026-Q2 chartering scope (test)\n" + " state: active\n" + " description: test scope for SUB-2 apply-script smoke test\n" + "---\n\n" + f"# {scope_id}\n" + ) + (scopes_dir / f"{scope_id}.md").write_text(body, encoding="utf-8") + return scope_id + + +def _seed_v1_role_assignment_binding( + project_root: Path, + binding_id: str, + subject: str, + target: str, +): + """Write a minimal v1 Binding(type=role-assignment) under context/bindings/.""" + bind_dir = project_root / "context" / "bindings" + bind_dir.mkdir(parents=True, exist_ok=True) + body = ( + "---\n" + "apiVersion: processkit.projectious.work/v2\n" + "kind: Binding\n" + "metadata:\n" + f" id: {binding_id}\n" + " created: 2026-04-01T00:00:00+00:00\n" + "spec:\n" + " type: role-assignment\n" + f" subject: {subject}\n" + f" target: {target}\n" + " subject_kind: TeamMember\n" + " target_kind: Role\n" + " valid_from: '2026-04-01'\n" + "---\n\n" + f"# {binding_id}\n" + ) + (bind_dir / f"{binding_id}.md").write_text(body, encoding="utf-8") + return binding_id + + +def test_apply_migration_2139_smoke_creates_slot_and_fill( + server_mod, # noqa: ARG001 — establishes project_root + paths + project_root: Path, + apply_migration_2139_module, +): + """1 archetype-spawned Role + 1 role-assignment Binding -> 1 SLOT + 1 fill.""" + scope_id = _seed_chartering_scope(project_root) + _seed_v1_archetype_role(project_root, "developer", clone_cap=1) + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + _seed_v1_role_assignment_binding( + project_root, + binding_id="BIND-test-developer-alice", + subject="TEAMMEMBER-alice-chen", + target="ROLE-developer", + ) + + summary = apply_migration_2139_module.apply( + project_root, scope_id, dry_run=False, + ) + assert "error" not in summary, summary + assert summary["slots_created"] == 1, summary + assert summary["fills_created"] == 1, summary + assert summary["slots_skipped"] == 0 + assert summary["fills_skipped"] == 0 + + # The new SLOT exists under context/roleslots/ + slot_id = "SLOT-q2-2026-developer-1" + slot_path = project_root / "context" / "roleslots" / f"{slot_id}.md" + assert slot_path.is_file(), f"expected {slot_path} to exist" + + # The new role-slot-fill Binding exists under context/bindings/ + bind_dir = project_root / "context" / "bindings" + fills = [] + for path in bind_dir.glob("BIND-*.md"): + text = path.read_text(encoding="utf-8") + if "type: role-slot-fill" in text and slot_id in text: + fills.append(path) + assert len(fills) == 1, [p.name for p in bind_dir.glob('*.md')] + + +def test_apply_migration_2139_is_idempotent( + server_mod, # noqa: ARG001 + project_root: Path, + apply_migration_2139_module, +): + """Re-running apply produces no-ops (skips both slot create + fill).""" + scope_id = _seed_chartering_scope(project_root) + _seed_v1_archetype_role(project_root, "developer", clone_cap=1) + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + _seed_v1_role_assignment_binding( + project_root, + binding_id="BIND-test-developer-alice", + subject="TEAMMEMBER-alice-chen", + target="ROLE-developer", + ) + + first = apply_migration_2139_module.apply(project_root, scope_id) + second = apply_migration_2139_module.apply(project_root, scope_id) + assert "error" not in second, second + assert second["slots_created"] == 0 + assert second["fills_created"] == 0 + assert second["slots_skipped"] == first["slots_created"] + assert second["fills_skipped"] == first["fills_created"] + + +def test_apply_migration_2139_v2_native_project_is_no_op( + server_mod, # noqa: ARG001 + project_root: Path, + apply_migration_2139_module, +): + """A project with no v1 archetype Roles produces zero writes.""" + scope_id = _seed_chartering_scope(project_root) + summary = apply_migration_2139_module.apply(project_root, scope_id) + assert "error" not in summary, summary + assert summary["slots_created"] == 0 + assert summary["fills_created"] == 0 + assert summary["slots_skipped"] == 0 + assert summary["fills_skipped"] == 0 + + +def test_apply_migration_2139_dry_run_writes_nothing( + server_mod, # noqa: ARG001 + project_root: Path, + apply_migration_2139_module, +): + """--dry-run reports the plan but writes no SLOT or fill Binding.""" + scope_id = _seed_chartering_scope(project_root) + _seed_v1_archetype_role(project_root, "developer", clone_cap=2) + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + _seed_v1_role_assignment_binding( + project_root, + binding_id="BIND-test-developer-alice", + subject="TEAMMEMBER-alice-chen", + target="ROLE-developer", + ) + + summary = apply_migration_2139_module.apply( + project_root, scope_id, dry_run=True, + ) + assert summary["dry_run"] is True + assert summary["slots_created"] == 2 # planned, not written + assert summary["fills_created"] == 1 + # No actual files were written: + rs_dir = project_root / "context" / "roleslots" + assert not rs_dir.exists() or not list(rs_dir.glob("SLOT-*.md")) + + +def test_apply_migration_2139_rejects_missing_scope( + server_mod, # noqa: ARG001 + project_root: Path, # noqa: ARG001 — project_root activates path bootstrap + apply_migration_2139_module, +): + """Apply errors when the chartering Scope does not exist.""" + summary = apply_migration_2139_module.apply( + project_root, "SCOPE-does-not-exist", + ) + assert "error" in summary + assert "not found" in summary["error"] diff --git a/context/workitems/2026/05/BACK-20260509_1836-TidyAsh-roleslot-primitive-identity-axis-decoupling.md b/context/workitems/2026/05/BACK-20260509_1836-TidyAsh-roleslot-primitive-identity-axis-decoupling.md index 931edbd..a2641c5 100644 --- a/context/workitems/2026/05/BACK-20260509_1836-TidyAsh-roleslot-primitive-identity-axis-decoupling.md +++ b/context/workitems/2026/05/BACK-20260509_1836-TidyAsh-roleslot-primitive-identity-axis-decoupling.md @@ -14,10 +14,10 @@ metadata: model_class: deep owner_role: ROLE-software-engineer/senior blocked_until: open-questions-1-2-3 - updated: '2026-05-09T21:27:36+00:00' + updated: '2026-05-09T21:42:45+00:00' spec: title: 'team-creator v2 SUB-1: RoleSlot primitive + identity-axis decoupling' - state: in-progress + state: review type: task priority: high description: 'Foundational sub-item of VastVale (gh#20). Add SCHEMA-roleslot v1.0.0 @@ -37,3 +37,8 @@ spec: ## Transition note (2026-05-09T21:27:36+00:00) Wave 4 SUB-1 dispatch — TEAMMEMBER-finn (ROLE-software-engineer/senior) on Sonnet 4.5. Foundational RoleSlot primitive + identity-axis decoupling. Working on a fresh branch off main; PR #24 (the cluster work-down) is the immediate ancestor for context, but SUB-1 lands as a separate PR. + + +## Transition note (2026-05-09T21:42:45+00:00) + +SUB-1 Phase A shipped on Sonnet 4.5 senior. SCHEMA-roleslot v1.0.0 (165 lines); binding.yaml gains role-slot-fill type; _lib/processkit indexes RoleSlot; team-manager MCP server gains 5 tools (create_role_slot, get_role_slot, list_role_slots, fill_role_slot, close_role_slot) + _roleslot_pre_step resolver hook (purely additive, 8 layers untouched); migration 20260509_2139_0.25.8-to-0.26.0.md (state=pending, full-feasibility rollback). 11 new tests, 68/68 pass on both src/ and context/ trees. NOTABLE FINDING: v0.16.0 fields (clone_cap, cap_escalation, is_template, templated_from, primary_contact) are NOT in schemas — they were already removed. The doc cleanup falls naturally to SUB-2 (LuckyWren) which deletes the archetype-Role write step. RoleSlot.default_model_profile surfaces in resolver response but is NOT yet wired to Layer 8 of model-recommender — deeper change deferred (out of "do not reshape 8 layers" scope). Branch: feat/sub-1-roleslot-primitive (off feat/gh-issue-cluster-2026-05-09, PR #24). diff --git a/context/workitems/2026/05/BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create.md b/context/workitems/2026/05/BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create.md index 096696b..27221e0 100644 --- a/context/workitems/2026/05/BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create.md +++ b/context/workitems/2026/05/BACK-20260509_1837-LuckyWren-catalog-driven-pk-team-create.md @@ -14,9 +14,10 @@ metadata: model_class: balanced owner_role: ROLE-software-engineer/senior depends_on: SUB-1 + updated: '2026-05-09T22:04:55+00:00' spec: title: 'team-creator v2 SUB-2: catalog-driven pk-team-create' - state: backlog + state: review type: task priority: medium description: 'Sub-item of VastVale (gh#20). Delete the 8-archetype Role write step @@ -29,4 +30,14 @@ spec: Open question 6 (assistant archetype catalog target) needs owner answer before final mapping.yaml ships.' parent: BACK-20260509_1318-VastVale-team-creator-v2-5-design-gaps + started_at: '2026-05-09T21:46:11+00:00' --- + +## Transition note (2026-05-09T21:46:11+00:00) + +Wave 4 SUB-2 dispatch — TEAMMEMBER-finn (ROLE-software-engineer/senior) on Sonnet 4.5. Branch: feat/sub-2-catalog-driven (off feat/sub-1-roleslot-primitive / PR #25). Brief includes SUB-1's findings: (1) v0.16.0 capacity-field doc scrub in team-creator/ skill body+commands+references (the schemas don't have these fields anymore); (2) Phase A backfill apply script (SUB-1 deferred); (3) gateway tool-catalog regeneration for SUB-1's 5 new RoleSlot tools. + + +## Transition note (2026-05-09T22:04:55+00:00) + +SUB-2 shipped on Sonnet (likely Opus inheritance, see prior billing finding). pk-team-create cuts over to RoleSlots via SUB-1's create_role_slot/fill_role_slot MCP tools — no more archetype Roles. New archetype-catalog-mapping.yaml + team_creator_lib helper. 3 commands updated (pk-team-create, pk-team-rebalance, pk-team-review) + 2 references (role-archetypes, role-archetypes-override). v0.16.0 doc scrub clean (14 remaining hits are deliberate v1-history annotations). apply_migration_2139.py shipped (idempotent backfill; v2-native no-op verified). Gateway tool-catalog regenerated via the regen script: now 152 tools (+339/-5 from SUB-1's 5 new tools). 13 new tests, all 77 in test file pass. Trees mirrored clean. Open follow-ups: a doctor check could flag archetype Roles as superseded after full cutover; apply_migration_2139.py needs chartering_scope on CLI (deferrable to SUB-3/4 if migration entity gains that field). diff --git a/src/context/schemas/binding.yaml b/src/context/schemas/binding.yaml index 4e663d8..f0996d4 100644 --- a/src/context/schemas/binding.yaml +++ b/src/context/schemas/binding.yaml @@ -38,6 +38,7 @@ spec: default_directory: context/bindings known_types: - role-assignment + - role-slot-fill - model-assignment - work-assignment - process-gate @@ -286,3 +287,27 @@ spec: recurrence_rule: type: string pattern: "^ART-[a-zA-Z0-9_-]+$" + - if: + properties: + type: + const: role-slot-fill + required: [type] + then: + properties: + subject: + type: string + pattern: "^TEAMMEMBER-[a-z][a-z0-9-]*$" + description: "TeamMember filling the slot." + target: + type: string + pattern: "^SLOT-[a-z0-9][a-z0-9-]*-[1-9][0-9]*$" + description: "RoleSlot being filled." + target_kind: + const: RoleSlot + conditions: + type: object + additionalProperties: true + properties: + rationale: + type: string + description: "Why this TeamMember was placed in this slot." diff --git a/src/context/schemas/roleslot.yaml b/src/context/schemas/roleslot.yaml new file mode 100644 index 0000000..679f389 --- /dev/null +++ b/src/context/schemas/roleslot.yaml @@ -0,0 +1,178 @@ +--- +apiVersion: processkit.projectious.work/v2 +kind: Schema +metadata: + id: SCHEMA-roleslot + target_kind: RoleSlot + version: 1.0.0 + created: 2026-05-09T00:00:00+00:00 +spec: + description: > + A scope-bounded capacity reservation for one parallel worker filling a + given (Role, seniority) within a chartering Scope. RoleSlot decouples + *capacity* (how many parallel workers a role needs in this charter) + from *identity* (who the persistent persona is). Persistent + TeamMembers carry stable memory and personality; RoleSlots carry no + memory and exist only inside the lifetime of their chartering Scope. + + Introduced in Phase A of team-creator v2 (DEC-20260509_1906-CoolBadger, + ART-20260509_1836-SmartPanda). Phase A is additive and reversible: + the new resolver pre-step inserts before the existing 8-layer + model-assignment binding precedence without reshaping it; v0.16.0 + capacity fields on Role/TeamMember stay readable during the + deprecation window. + + A RoleSlot is filled through a Binding of type ``role-slot-fill`` + (subject = TEAMMEMBER-, target = SLOT-). State machine: + ``open → filled → closed``. ``closed`` is terminal — re-opening + means a new SLOT-id at the next charter. When the chartering Scope + closes, all open or filled slots auto-close. + + For ephemeral worker invocations (no persistent TeamMember filled + in the slot), the slot's optional ``default_model_profile`` carries + forward as the Layer 8 fallback for the existing + model-assignment 8-layer resolver. + id_prefix: SLOT + state_machine: null + default_directory: context/roleslots + seniority_levels: + - junior + - specialist + - expert + - senior + - principal + states: + - open + - filled + - closed + metadata_schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + required: + - id + properties: + id: + type: string + pattern: ^SLOT-[a-z0-9][a-z0-9-]*-[1-9][0-9]*$ + description: > + ``SLOT---``. Scope and role slugs + are kebab-case (lowercase letters, digits, dashes). Rank is a + positive integer with ``rank=1`` meaning the primary slot for + this (scope, role, seniority) triple, ``rank=2..N`` parallel + reservations. + spec_schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + required: + - scope + - role + - seniority + - rank + - state + - rationale + - created + additionalProperties: true + properties: + scope: + type: string + pattern: ^SCOPE-[a-zA-Z0-9_-]+$ + description: > + ID of the chartering Scope. Mandatory — slots have no meaning + outside a Scope. When the Scope closes, all open or filled + slots auto-close. + role: + type: string + pattern: ^ROLE-[a-z][a-z0-9-]*$ + description: > + FK into ``context/roles/``. The catalog Role this slot + reserves capacity for. v2 schema (DEC-20260422_0234-BraveFalcon) + requires the slug to be seniority-free. + seniority: + type: string + enum: + - junior + - specialist + - expert + - senior + - principal + description: > + Pure ordinal seniority for this slot. Resolved through + (role, seniority) → (model, effort) via model-assignment + bindings. + rank: + type: integer + minimum: 1 + description: > + 1 = primary slot for this (scope, role, seniority); 2..N = + parallel reservations. Two slots with the same (scope, role, + seniority) must have distinct ranks. + state: + type: string + enum: + - open + - filled + - closed + description: > + State machine: ``open → filled → closed``. ``closed`` is + terminal. Reverse transitions are rejected. + filled_by: + type: + - string + - "null" + pattern: ^TEAMMEMBER-[a-z][a-z0-9-]*$ + description: > + ID of the TeamMember currently filling this slot, or null if + the slot is open. Set when state transitions to ``filled``; + cleared when the slot is closed. + default_model_profile: + type: + - string + - "null" + pattern: ^ART-[0-9]{8}_[0-9]{4}-ModelProfile-[a-z0-9-]+$ + description: > + Optional pin for ephemeral dispatches when no TeamMember is + filled in this slot. Carried forward as the Layer 8 fallback + of the existing 8-layer model-assignment binding precedence. + effort_floor: + type: + - string + - "null" + enum: + - low + - medium + - high + - extra-high + - max + - null + description: Optional minimum effort tier for dispatches through this slot. + effort_ceiling: + type: + - string + - "null" + enum: + - low + - medium + - high + - extra-high + - max + - null + description: Optional maximum effort tier for dispatches through this slot. + rationale: + type: string + minLength: 1 + description: One-line reason this slot exists in the charter. + created: + type: string + format: date-time + description: ISO-8601 timestamp when the slot was opened. + closed_at: + type: + - string + - "null" + format: date-time + description: ISO-8601 timestamp when the slot was closed (terminal). + close_reason: + type: + - string + - "null" + description: Optional one-line reason recorded when the slot closes. diff --git a/src/context/skills/_lib/processkit/__init__.py b/src/context/skills/_lib/processkit/__init__.py index 5e0d33f..7f5294d 100644 --- a/src/context/skills/_lib/processkit/__init__.py +++ b/src/context/skills/_lib/processkit/__init__.py @@ -40,6 +40,7 @@ "Migration": "MIG", "Note": "NOTE", "TeamMember": "TEAMMEMBER", + "RoleSlot": "SLOT", } # Default subdirectory under context/ for each primitive kind @@ -63,4 +64,5 @@ "Migration": "migrations", "Note": "notes", "TeamMember": "team-members", + "RoleSlot": "roleslots", } diff --git a/src/context/skills/processkit/processkit-gateway/mcp/tool-catalog.json b/src/context/skills/processkit/processkit-gateway/mcp/tool-catalog.json index accffd8..05e7ec3 100644 --- a/src/context/skills/processkit/processkit-gateway/mcp/tool-catalog.json +++ b/src/context/skills/processkit/processkit-gateway/mcp/tool-catalog.json @@ -3230,7 +3230,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "Fetch a single entity by ID.\n\nAccepts a full ID, a prefix (missing slug), or a bare word-pair.\nReturns ``{\"error\": \"...\"}`` if not found or ambiguous.\n", + "description": "Fetch a single entity by ID.\n\nAccepts a full ID, a prefix (missing slug), or a bare word-pair.\nReturns ``{\"error\": \"...\"}`` if not found or ambiguous.\n\nAdds ``v1_penalty_applied`` and ``v1_successor_hint`` to the result\nwhen the entity is a v1 primitive (BACK-20260509_1318-WarmOak).\n", "name": "get_entity", "output_schema": null, "parameters": { @@ -3361,7 +3361,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "List entities matching the filters.\n\nParameters\n----------\nkind: optional primitive kind (e.g. \"WorkItem\", \"DecisionRecord\").\nstate: optional state to filter by.\nlimit: maximum rows to return (default 50).\n", + "description": "List entities matching the filters.\n\nParameters\n----------\nkind: optional primitive kind (e.g. \"WorkItem\", \"DecisionRecord\").\nstate: optional state to filter by.\nlimit: maximum rows to return (default 50).\n\nEach result is annotated with ``v1_penalty_applied`` and\n``v1_successor_hint`` (BACK-20260509_1318-WarmOak). Results are\nordered by ``created DESC`` (no score), so the penalty is surfaced\nas annotation only \u2014 no re-ranking is applied.\n", "name": "query_entities", "output_schema": { "properties": { @@ -3544,7 +3544,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "Full-text search across entity IDs, titles, bodies, and specs.", + "description": "Full-text search across entity IDs, titles, bodies, and specs.\n\nApplies the v1-entity penalty (BACK-20260509_1318-WarmOak): results\nare FTS5-ranked, so we synthesise a per-rank score\n``1.0 / (1 + rank_index)`` and multiply it by ``_v1_penalty()`` for\nv1-superseded entries. Results are then re-sorted on the adjusted\nscore. Each row carries ``v1_penalty_applied``, ``v1_successor_hint``,\nand a ``v1_trace`` line analogous to the task-router trace surface.\n", "name": "search_entities", "output_schema": { "properties": { @@ -6390,7 +6390,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "Route a task to the matching processkit skill, process override,\nand MCP tool \u2014 in a single call, without an LLM.\n\nPrerequisite: call this tool at the start of any processkit domain\ntask \u2014 before calling create_*, transition_*, link_*, or record_*\ntools \u2014 to confirm the right skill and any project-specific process\noverride for this derived project.\n\nTwo-phase heuristic routing:\n Phase 1 \u2014 keyword match to a domain group (eliminates ~90 %\n of candidates; no LLM needed).\n Phase 2 \u2014 token-overlap or local embedding-style scoring within the\n matched group's tools.\n Fallback \u2014 skill-finder trigger-phrase table for cross-domain\n tasks not covered by any domain group.\n\nWhen ``confidence < 0.5`` (routing_basis == \"needs_llm_confirm\"),\nsurface ``candidate_tools`` to the user or an LLM for confirmation.\nIf ``allow_llm_escalation`` is true, the response includes the\nconfigured fast-model class hint, but this server does not make\nprovider network calls itself. The router never blocks \u2014 it always\nreturns its best guess. 1% rule: if there is a 1% chance a processkit\nskill covers this task, call route_task before acting.\n\nParameters\n----------\ntask_description :\n What the agent or user wants to do, in natural language.\n More specific phrasing \u2192 better match quality.\n\nReturns\n-------\nOn match:\n skill \u2014 processkit skill name to load\n skill_description_excerpt \u2014 first 150 chars of skill description\n process_override \u2014 legacy v1 path to a project-specific\n process file (only present when one exists)\n process_override_status \u2014 present with process_override; currently\n \"legacy-v1\"\n server \u2014 MCP server to connect to\n tool \u2014 recommended tool name\n tool_qualified \u2014 \"{server}__{tool}\" collision-safe form\n domain_group \u2014 routing group (workitem, decision, \u2026)\n confidence \u2014 0.0\u20131.0 combined routing confidence\n routing_basis \u2014 keyword_match | skill_finder_trigger_table\n | needs_llm_confirm\n candidate_tools[] \u2014 top-3 scored tools with rationales\n\nOn no match:\n error, hint\n", + "description": "Route a task to the matching processkit skill, process override,\nand MCP tool \u2014 in a single call, without an LLM.\n\nPrerequisite: call this tool at the start of any processkit domain\ntask \u2014 before calling create_*, transition_*, link_*, or record_*\ntools \u2014 to confirm the right skill and any project-specific process\noverride for this derived project.\n\nTwo-phase heuristic routing:\n Phase 1 \u2014 keyword match to a domain group (eliminates ~90 %\n of candidates; no LLM needed).\n Phase 2 \u2014 token-overlap or local embedding-style scoring within the\n matched group's tools.\n Fallback \u2014 skill-finder trigger-phrase table for cross-domain\n tasks not covered by any domain group.\n\nWhen ``confidence < 0.5`` (routing_basis == \"needs_llm_confirm\"),\nsurface ``candidate_tools`` to the user or an LLM for confirmation.\nIf ``allow_llm_escalation`` is true, the response includes the\nconfigured fast-model class hint, but this server does not make\nprovider network calls itself. The router never blocks \u2014 it always\nreturns its best guess. 1% rule: if there is a 1% chance a processkit\nskill covers this task, call route_task before acting.\n\nParameters\n----------\ntask_description :\n What the agent or user wants to do, in natural language.\n More specific phrasing \u2192 better match quality.\n\nReturns\n-------\nOn match:\n skill \u2014 processkit skill name to load\n skill_description_excerpt \u2014 first 150 chars of skill description\n process_override \u2014 legacy v1 path to a project-specific\n process file (only present when one exists)\n process_override_status \u2014 present with process_override; currently\n \"legacy-v1\"\n server \u2014 MCP server to connect to\n tool \u2014 recommended tool name\n tool_qualified \u2014 \"{server}__{tool}\" collision-safe form\n domain_group \u2014 routing group (workitem, decision, \u2026)\n confidence \u2014 0.0\u20131.0 combined routing confidence\n routing_basis \u2014 keyword_match | skill_finder_trigger_table\n | needs_llm_confirm\n candidate_tools[] \u2014 top-3 scored tools with rationales\n recommended_team_member_slug \u2014 slug of the highest-priority active\n TeamMember whose default_role matches\n the routed group's preferred role, or\n None when no binding resolves. Use this\n as the sub-agent's identity at dispatch\n (per the compliance contract sub-agent-\n dispatch clause).\n recommended_model_class \u2014 \"fast\" | \"deep\" | None. Hint for picking\n the cheapest concrete model in the class\n (Haiku < Sonnet < Opus) when dispatching\n a sub-agent. Currently a static per-\n group mapping; future revisions may\n derive this from skill metadata.\n\nOn no match:\n error, hint\n", "name": "route_task", "output_schema": null, "parameters": { @@ -6497,6 +6497,142 @@ "source_tool": "check_consistency", "title": null }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "Close a RoleSlot. Terminal \u2014 re-opening requires a new SLOT-id.\n\n``open|filled \u2192 closed`` is always allowed; closing an already\n``closed`` slot is a no-op (idempotent).\n", + "name": "close_role_slot", + "output_schema": null, + "parameters": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "id" + ], + "title": "close_role_slotArguments", + "type": "object" + }, + "permission_class": "destructive-write", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "close_role_slot", + "title": null + }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "Open a new RoleSlot under a chartering Scope.\n\nParameters\n----------\nscope: SCOPE- the slot belongs to (mandatory)\nrole: ROLE- from the catalog\nseniority: junior|specialist|expert|senior|principal\nrank: 1=primary, 2..N=parallel reservations\nrationale: one-line reason this slot exists\ndefault_model_profile: optional Layer 8 fallback for ephemeral\n dispatches that never fill the slot\neffort_floor: optional dispatch floor\neffort_ceiling: optional dispatch ceiling\n\nReturns ``{id, path, state}`` on success.\n", + "name": "create_role_slot", + "output_schema": null, + "parameters": { + "properties": { + "default_model_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Default Model Profile" + }, + "effort_ceiling": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effort Ceiling" + }, + "effort_floor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effort Floor" + }, + "rank": { + "title": "Rank", + "type": "integer" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "role": { + "title": "Role", + "type": "string" + }, + "scope": { + "title": "Scope", + "type": "string" + }, + "seniority": { + "title": "Seniority", + "type": "string" + } + }, + "required": [ + "scope", + "role", + "seniority", + "rank", + "rationale" + ], + "title": "create_role_slotArguments", + "type": "object" + }, + "permission_class": "guarded-write", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "create_role_slot", + "title": null + }, { "annotations": { "destructiveHint": false, @@ -6821,6 +6957,81 @@ "source_tool": "export_team_member", "title": null }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "Place an active TeamMember into an open RoleSlot.\n\nSets ``state=filled``, ``filled_by=TEAMMEMBER-``, and creates\na parallel ``role-slot-fill`` Binding so time-bounded dispatch\nqueries continue to work through binding-management.\n\nReturns\n-------\nOn success: ``{ok: True, id, state, filled_by, binding_id, binding_path}``\nOn error: ``{error, details?}``\n", + "name": "fill_role_slot", + "output_schema": null, + "parameters": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "rationale": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rationale" + }, + "team_member_slug": { + "title": "Team Member Slug", + "type": "string" + }, + "valid_from": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Valid From" + }, + "valid_until": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Valid Until" + } + }, + "required": [ + "id", + "team_member_slug" + ], + "title": "fill_role_slotArguments", + "type": "object" + }, + "permission_class": "guarded-write", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "fill_role_slot", + "title": null + }, { "annotations": { "destructiveHint": false, @@ -6872,7 +7083,7 @@ ], "collision_status": "unique", "deduplicated": false, - "description": "Return active interlocutor identity plus runtime binding status.\n\n`observed_model` and `observed_effort` are caller-supplied facts from\nthe current harness. processkit cannot read or hot-swap the already\nrunning primary model, so mismatch reporting is informational.\n", + "description": "Return active interlocutor identity plus runtime binding status.\n\n`observed_model` and `observed_effort` are caller-supplied facts from\nthe current harness. processkit cannot read or hot-swap the already\nrunning primary model, so mismatch reporting is informational.\n\nPhase A team-creator v2 \u2014 RoleSlot pre-step:\n Before falling through to the existing 8-layer model-assignment\n binding precedence, this tool checks whether the active\n interlocutor's (default_role, default_seniority, scope) matches\n a RoleSlot in state=filled. If it does, the slot's filled_by\n TeamMember and (TeamMember.default_seniority || slot.seniority)\n are surfaced in ``binding.roleslot_pre_step``. The existing\n 8-layer logic still runs unchanged underneath\n (DEC-20260509_1906-CoolBadger Q1).\n\n When no slot matches the triple, ``roleslot_pre_step`` is\n absent and the response is identical to pre-RoleSlot\n behaviour \u2014 the existing 8-layer resolver is fully\n responsible. This keeps Phase A additive and reversible.\n", "name": "get_interlocutor_runtime_binding", "output_schema": null, "parameters": { @@ -6936,6 +7147,40 @@ "source_tool": "get_interlocutor_runtime_binding", "title": null }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "Return the full RoleSlot entity by ID.", + "name": "get_role_slot", + "output_schema": null, + "parameters": { + "properties": { + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "get_role_slotArguments", + "type": "object" + }, + "permission_class": "read-only", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "get_role_slot", + "title": null + }, { "annotations": { "destructiveHint": false, @@ -7092,6 +7337,90 @@ "source_tool": "list_available_names", "title": null }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "collision_sources": [ + "team-manager" + ], + "collision_status": "unique", + "deduplicated": false, + "description": "List RoleSlots under context/roleslots/, optionally filtered.\n\nFilters\n-------\nscope: match SCOPE- exactly\nrole: match ROLE- exactly\nstate: open | filled | closed\n", + "name": "list_role_slots", + "output_schema": { + "properties": { + "result": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Result", + "type": "array" + } + }, + "required": [ + "result" + ], + "title": "list_role_slotsOutput", + "type": "object" + }, + "parameters": { + "properties": { + "limit": { + "default": 200, + "title": "Limit", + "type": "integer" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scope" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + } + }, + "title": "list_role_slotsArguments", + "type": "object" + }, + "permission_class": "read-only", + "source_server_path": "processkit/team-manager/mcp/server.py", + "source_skill": "team-manager", + "source_tool": "list_role_slots", + "title": null + }, { "annotations": { "destructiveHint": false, diff --git a/src/context/skills/processkit/team-creator/SKILL.md b/src/context/skills/processkit/team-creator/SKILL.md index 8a0cf2a..8e08383 100644 --- a/src/context/skills/processkit/team-creator/SKILL.md +++ b/src/context/skills/processkit/team-creator/SKILL.md @@ -3,15 +3,16 @@ name: team-creator description: | Compose a provider-neutral AI team by tiering accessible models on cost-efficiency, capability, latency, and governance, then mapping - the 8 processkit role archetypes onto those tiers. Use when - bootstrapping a new project team, rotating after a provider change, - or quarterly rebalancing. Triggers: "create my AI team", - "compose a team", "rebalance the team", "review the team". + the 8 processkit role archetypes onto catalog Roles + RoleSlots + inside a chartering Scope. Use when bootstrapping a new project + team, rotating after a provider change, or quarterly rebalancing. + Triggers: "create my AI team", "compose a team", "rebalance the + team", "review the team". metadata: processkit: apiVersion: processkit.projectious.work/v2 id: SKILL-team-creator - version: "1.2.0" + version: "2.0.0" created: 2026-04-15T00:00:00Z category: processkit layer: 3 @@ -20,16 +21,15 @@ metadata: purpose: > Query, score, and filter accessible models via query_models, get_pricing, check_availability. - - skill: role-management - purpose: Create Role entities via create_role. - - skill: actor-profile + - skill: team-manager purpose: > - Create Actor entities (type: ai-agent) via create_actor; - deactivate replaced actors via deactivate_actor. + Open RoleSlot entities (create_role_slot) inside the + chartering Scope and fill them via fill_role_slot. Replaces + v1's archetype Role + Actor + role-assignment writes. - skill: binding-management purpose: > - Create role-assignment Bindings via create_binding; - end superseded Bindings via end_binding. + End superseded role-slot-fill Bindings via end_binding when + re-running the charter. - skill: decision-record purpose: > Write the chartering DecisionRecord; read stored @@ -82,6 +82,24 @@ existing skills — no new primitives or MCP tools are introduced. | `--landscape-artifact ` | no | 3-level discovery | Explicit > project-tag > kit default; see `references/landscape-resolution.md` | | `--landscape ` | no | alias for `--landscape-artifact` | Kept for backwards compatibility | +### Catalog-driven archetype mapping (v2) + +`team-creator` v2 selects existing `context/roles/ROLE-*` entries from +the 51-role catalog rather than writing 8 archetype Role entities. The +mapping ships in +`assets/archetype-catalog-mapping.yaml`; projects may layer a delta +override at `context/team/archetype-catalog-mapping.yaml`. Archetypes +are not stored as Roles — they are keys in the mapping file. See +`references/role-archetypes.md` and the kit-default mapping asset for +the 8 archetype → catalog Role + seniority pairs. + +For every selected archetype, `pk-team-create` opens a **RoleSlot** +under the chartering Scope via `team-manager.create_role_slot` (one slot +per parallelism unit; `rank=1` is the primary). When a TeamMember is +chosen to fill a slot, `team-manager.fill_role_slot` writes the +`role-slot-fill` Binding. No archetype Role entities are written; v1 +`role-assignment` Bindings remain readable for one minor version. + ### Tiering formula (summary) **Defaults rebalanced 2026-04-15 after the 2026-04-15 internal review (see ART-20260415_1545-TeamWeaver-team-creator-dogfood-diff §7).** Capability weight raised from 0.40 to 0.60 to prevent tier inversion on same-provider candidate sets where cost and capability are anti-correlated. @@ -120,26 +138,34 @@ See `references/role-archetypes-override.md` for schema and invariants. ### Commands **`pk-team-create`** runs the full derivation: queries models, applies -the tiering formula, maps archetypes, writes 8 Role + 8 Actor + 8 -Binding entities, rewrites `context/team/roster.md`, and emits a -chartering DecisionRecord that explicitly supersedes the prior team -DecisionRecord. Use `--dry-run` to print the plan without writing. +the tiering formula, maps archetypes, **opens one RoleSlot per +parallelism unit** under the chartering Scope via +`team-manager.create_role_slot`, rewrites `context/team/roster.md`, +and emits a chartering DecisionRecord that explicitly supersedes the +prior team DecisionRecord. Archetype Roles are NOT written — the +catalog-mapping file is read instead. Use `--dry-run` to print the +plan without writing. **`pk-team-review`** is read-only. Re-scores current assignments against the latest landscape snapshot, diffs tier scores against those stored in the governing DecisionRecord, and surfaces tier-shifts, unavailable -models, and new outperformers. No entities are written. +models, and new outperformers. Surfaces RoleSlots by their archetype +name (resolved through the mapping file). No entities are written. **`pk-team-rebalance`** applies a review recommendation. Requires `--confirm`. For `--roles all`, the full create logic re-runs and a -new DecisionRecord is written. For named roles, the governing -DecisionRecord's `progress_notes` are amended instead. +new DecisionRecord is written. For named roles, the operator names an +archetype (`developer`, `senior-architect`, ...); the skill resolves +to the (role, seniority) pair via the mapping and operates on the +matching RoleSlot(s) in the active chartering Scope. The governing +DecisionRecord's `progress_notes` are amended. ### Outputs | Output | `pk-team-create` | `pk-team-review` | `pk-team-rebalance` | |---|---|---|---| -| Role / Actor / Binding entities | YES ×8 | NO | scoped roles | +| RoleSlot entities (per parallelism unit) | YES | NO | scoped roles | +| `role-slot-fill` Bindings | YES (one per filled slot) | NO | per rotated slot | | `context/team/roster.md` | YES | NO | YES (in-place) | | DecisionRecord | YES (new) | NO | amend notes | | Diff report | NO | YES (stdout) | NO | @@ -154,12 +180,17 @@ DecisionRecord's `progress_notes` are amended instead. - **Entity deactivation sequence on re-create.** When `pk-team-create` is re-run: (a) resolve prior team from roster.md; (b) for each - prior Binding call `binding-management.end_binding` with - `reason="superseded by pk-team-create run "`; (c) for each - prior Actor whose model is NOT in the new team call - `actor-profile.deactivate_actor`; (d) for prior Actors whose model - IS re-assigned to the same role REUSE the existing Actor — do not - create a duplicate; (e) then create new Bindings. + prior `role-slot-fill` Binding call `binding-management.end_binding` + with `reason="superseded by pk-team-create run "`; + (c) close the prior RoleSlots via `team-manager.close_role_slot` + (the slot state machine forbids reopening — re-charters always + emit fresh SLOT-* IDs under the new Scope); (d) open new RoleSlots + via `team-manager.create_role_slot` and fill them with + `team-manager.fill_role_slot`. v1 `role-assignment` Bindings + observed during a re-charter remain readable but are not migrated + by `pk-team-create` — the Phase A apply script (see + `team-manager/scripts/apply_migration_2139.py`) handles that + one-shot back-fill. - **Formula weight persistence.** Weights live in the chartering DecisionRecord's `spec.inputs_snapshot` block. `pk-team-rebalance` @@ -169,8 +200,12 @@ DecisionRecord's `progress_notes` are amended instead. - **Tier-collapse (< 3 tiers accessible).** Promote the two highest- scoring light models to medium. Never fail; degrade gracefully. -- **PM clone cap.** Parallelism cap never applies to project-manager; - PM is always exactly 1 (per DEC-20260414_0900). +- **PM is always rank=1, single slot.** Parallelism cap never applies + to project-manager; the project-manager archetype always opens + exactly one RoleSlot at `rank=1` (per DEC-20260414_0900). The + `primary_contact: true` annotation on the project-manager mapping + entry is retained as a convention marker — the actual semantics + are now expressed by `RoleSlot.rank=1`. - **Snapshot staleness.** Artifact older than 90 days → warn and proceed. Surface the artifact date in the DecisionRecord. @@ -200,13 +235,16 @@ DecisionRecord's `progress_notes` are amended instead. Any run with overrides: query the single chartering team DecisionRecord to fully reconstruct the run's configuration. -- **Canonical schema fields (processkit v0.16.0).** This skill - emits five fields introduced in v0.16.0: Role fields - `primary_contact` (bool), `clone_cap` (int), - `cap_escalation` (string); Actor fields `is_template` (bool), - `templated_from` (string, nullable). Seed Actors are always - `is_template: true`; rebalance-spawned clones are - `is_template: false` with `templated_from` pointing at the seed. +- **v0.16.0 capacity fields are gone.** v1 emitted five fields on Role + / Actor entities (`primary_contact`, `clone_cap`, `cap_escalation`, + `is_template`, `templated_from`). v2 stores capacity as the count of + RoleSlots opened under a chartering Scope — there is no + `clone_cap` field. Re-tiering replaces the matching RoleSlot's + fill rather than spawning a clone Actor; the seed/clone distinction + is therefore obsolete. v0.19.0 removed the fields from the live + `role.yaml` and `team-member.yaml` schemas; older entity files that + still carry them are tolerated as historical residue but are not + produced by this skill. ## Agent-driven discovery @@ -254,12 +292,14 @@ pk-team-create ``` Full step-by-step process (8 steps: resolve landscape → query models → -score+classify → map archetypes → deactivate prior team → write -entities → write roster → write chartering DecisionRecord) lives in -`commands/pk-team-create.md`. Read that file before changing the -sequence; the order of (a) eager `role-archetypes.yaml` validation -before mapping, (b) `binding-management.end_binding` before -`actor-profile.deactivate_actor`, and (c) DecisionRecord written +score+classify → load archetype-catalog mapping + map archetypes → +end prior `role-slot-fill` Bindings + close prior RoleSlots → open +new RoleSlots and fill them → write roster → write chartering +DecisionRecord) lives in `commands/pk-team-create.md`. Read that file +before changing the sequence; the order of (a) eager mapping-file +validation before archetype mapping, (b) +`binding-management.end_binding` before +`team-manager.close_role_slot`, and (c) DecisionRecord written LAST so its ID can be embedded in roster.md is load-bearing. ### CLI contract — `pk-team-review` @@ -291,20 +331,21 @@ pk-team-rebalance For `--roles all`, full `pk-team-create` logic re-runs and a new chartering DecisionRecord supersedes the current one. For named -roles, only the affected Bindings/Actors are rotated and the -governing DecisionRecord's `progress_notes` are amended with the -`--reason` string and the new tier-score for each rotated role. +archetypes, the matching RoleSlot's fill is rotated (end old +`role-slot-fill` Binding; fill the slot with the new TeamMember via +`team-manager.fill_role_slot`) and the governing DecisionRecord's +`progress_notes` are amended with the `--reason` string and the new +tier-score for each rotated archetype. ### Skill composition map | Phase | MCP / skill call | Source skill | |---|---|---| | Step 2 | `check_availability`, `query_models`, `get_pricing` | model-recommender | -| Step 5 (a) | `end_binding` (per prior Binding) | binding-management | -| Step 5 (b) | `deactivate_actor` (per non-reused prior Actor) | actor-profile | -| Step 6 (Role) | `create_role` × 8 | role-management | -| Step 6 (Actor) | `create_actor` × 8 (or reuse) | actor-profile | -| Step 6 (Binding) | `create_binding` × 8 | binding-management | +| Step 5 (a) | `end_binding` (per prior `role-slot-fill` Binding) | binding-management | +| Step 5 (b) | `close_role_slot` (per prior RoleSlot in the rotating archetype) | team-manager | +| Step 6 (RoleSlot) | `create_role_slot` (one per parallelism unit; rank 1..N) | team-manager | +| Step 6 (Fill) | `fill_role_slot` (places a TeamMember into the open slot) | team-manager | | Step 8 | `record_decision` (chartering DEC) | decision-record | | Rebalance read | `get_decision` (governing DEC for stored weights) | decision-record | @@ -349,25 +390,33 @@ Worked example and edge cases in `references/tiering-formula.md`. ### Role archetype pin table (kit defaults) -| Archetype | Pin | `primary_contact` | `clone_cap` | `cap_escalation` | Override-when | +| Archetype | Catalog Role | Seniority | Tier pin | Slots opened | Override-when | |---|---|---|---|---|---| -| project-manager | heavy | true | 1 | owner | Never (immutable; PM clone cap is hard-coded 1) | -| senior-architect | heavy | false | 5 | owner | Medium only if no heavy clears G-floor + owner approval | -| senior-researcher | heavy | false | 5 | owner | Same as senior-architect | -| junior-architect | medium | false | 5 | owner | Heavy if capability gap > 15pp on SWE-bench | -| developer | medium | false | 5 | owner | Heavy if `--security-critical` flag set | -| junior-researcher | medium | false | 5 | owner | No override | -| junior-developer | light | false | 5 | owner | Medium if no light model accessible | -| assistant | light | false | 5 | owner | No override; shares junior-developer model if no light available | - -Schema fields (`primary_contact`, `clone_cap`, `cap_escalation`, -`is_template`, `templated_from`) were introduced in processkit -v0.16.0 — older entity files predate them. See -`references/role-archetypes.md` for the full responsibilities lists -that flow into `create_role.responsibilities=[…]`. +| project-manager | ROLE-product-manager | senior | heavy | 1 (rank=1, primary contact) | Never (PM is immutable; always exactly one slot) | +| senior-architect | ROLE-solutions-architect | senior | heavy | up to `--parallelism-cap` | Medium only if no heavy clears G-floor + owner approval | +| senior-researcher | ROLE-research-scientist | senior | heavy | up to `--parallelism-cap` | Same as senior-architect | +| junior-architect | ROLE-solutions-architect | specialist | medium | up to `--parallelism-cap` | Heavy if capability gap > 15pp on SWE-bench | +| developer | ROLE-software-engineer | senior | medium | up to `--parallelism-cap` | Heavy if `--security-critical` flag set | +| junior-researcher | ROLE-research-scientist | specialist | medium | up to `--parallelism-cap` | No override | +| junior-developer | ROLE-software-engineer | junior | light | up to `--parallelism-cap` | Medium if no light model accessible | +| assistant | ROLE-assistant | specialist | light | up to `--parallelism-cap` | No override; shares junior-developer model if no light available | + +The `(role, seniority)` pairs above come from +`assets/archetype-catalog-mapping.yaml` (the kit default) and may be +overridden per-project at `context/team/archetype-catalog-mapping.yaml`. +The "Slots opened" column is the v2 replacement for v1's `clone_cap` +field — v0.19.0 removed `clone_cap`/`cap_escalation`/`primary_contact` +from the Role schema and `is_template`/`templated_from` from the +TeamMember schema; the count of RoleSlots under the chartering Scope +is the canonical capacity record. See +`references/role-archetypes.md` for the full responsibilities lists. ### chartering DecisionRecord — `inputs_snapshot` schema +The DecisionRecord schema (`SCHEMA-decisionrecord` v1.0.0) declares +`inputs_snapshot` with `additionalProperties: true`, so v2 augments +the block with two catalog-mapping audit fields without a schema bump: + ```yaml spec: inputs_snapshot: @@ -383,11 +432,24 @@ spec: landscape_artifact_source: explicit | project-tag | kit-default tier_scores: {: , ...} weight_overrides_applied: + archetype_catalog_mapping_file: kit-default | project | cli # v2 + archetype_catalog_overrides: # v2 + - {archetype, field, kit_default, project_value} archetype_override_file: present | absent archetype_override_semantics: delta | replace | null archetype_overrides: [{role, kit_default_pin, override_pin, rationale}] + chartering_scope: SCOPE- # v2 — required for RoleSlot writes + role_slots: # v2 — provenance back-pointer + - {archetype, slot_id, role, seniority, rank} ``` +`archetype_catalog_mapping_file` records which layer of the +mapping-file precedence chain was applied +(`cli` > `project` > `kit-default`). +`archetype_catalog_overrides` lists each per-archetype-per-field +delta when the project file modifies the kit default; an empty list +means the kit default was used verbatim. + `pk-team-rebalance` and `pk-team-review` both read this block via `decision-record.get_decision`. It is the single source of truth for a team's configuration — there is no skill-local config file for @@ -409,11 +471,16 @@ weights, thresholds, or pins. `inputs_snapshot.tier_scores` is what ### Extension points -- **New role archetype.** Add to `references/role-archetypes.md`, - extend the override schema in `references/role-archetypes-override.md` - (PM-immutability invariant must continue to apply), and add a row to - the pin table above. The 8-archetype assumption is hard-coded in - `pk-team-create` step 6 — bump it carefully. +- **New role archetype.** Add an entry to + `assets/archetype-catalog-mapping.yaml` (or a project override at + `context/team/archetype-catalog-mapping.yaml`) keyed by archetype + name with `role: ROLE-` + `seniority: `. Update + `references/role-archetypes.md` with the responsibilities one-liner + and add a row to the pin table above. The PM-immutability invariant + remains in force (`project-manager.role` must remain + `ROLE-product-manager` and the slot count must remain 1). v2 + archetypes are mapping keys, not Role entities — no schema change + is required. - **New scoring dimension.** Extend the weight set (currently `{C, K, L, G}`); update `weights` schema in `inputs_snapshot`, the worked example in `references/tiering-formula.md`, and the @@ -429,13 +496,16 @@ weights, thresholds, or pins. `inputs_snapshot.tier_scores` is what gracefully. Recorded in DEC `inputs_snapshot.notes`. - **Snapshot staleness > 90 days.** Warn and continue; surface the artifact date in the DecisionRecord rather than hiding it. -- **PM clone-cap.** `--parallelism-cap` is silently overridden to 1 - for `project-manager` regardless of CLI / archetype overrides - (DEC-20260414_0900). Layer-4 overrides cannot raise it. -- **Reused Actor identification.** The seed Actor for an unchanged - role is identified by `is_template: true`, NOT by name or ID - prefix — this is the authoritative test (template clones drift but - the seed never does). +- **PM single-slot rule.** `--parallelism-cap` is silently overridden + to 1 for `project-manager` regardless of CLI / archetype overrides + (DEC-20260414_0900). Layer-4 overrides cannot raise it; the + project-manager archetype always opens exactly one RoleSlot at + rank=1. +- **Slot continuity across rebalances.** A targeted rebalance + (`pk-team-rebalance --roles `) re-fills the existing RoleSlot + rather than closing and re-opening it; SLOT-* IDs are stable + across model rotations and only change on `--roles all` + (full re-charter). - **Empty preferred-providers tie-break.** Two models within 0.05 TierScore and no preferred provider supplied: pick the higher raw Capability score, then alphabetical model-id as final tie-break. diff --git a/src/context/skills/processkit/team-creator/assets/archetype-catalog-mapping.yaml b/src/context/skills/processkit/team-creator/assets/archetype-catalog-mapping.yaml new file mode 100644 index 0000000..e6ad1c7 --- /dev/null +++ b/src/context/skills/processkit/team-creator/assets/archetype-catalog-mapping.yaml @@ -0,0 +1,40 @@ +apiVersion: processkit.projectious.work/v2 +kind: ArchetypeCatalogMapping +metadata: + id: ASSET-team-creator-archetype-catalog-mapping + version: "1.0.0" + description: | + Kit-default archetype-to-catalog Role mapping for team-creator v2. + Maps each of the 8 processkit archetypes onto an existing + context/roles/ROLE-* entry in the 51-role catalog plus a seniority + tier. Loaded by pk-team-create, pk-team-rebalance, and + pk-team-review via _load_archetype_catalog_mapping(). + Project override: context/team/archetype-catalog-mapping.yaml + (delta layer; replace-mode only when override_semantics: replace). +spec: + archetypes: + project-manager: + role: ROLE-product-manager + seniority: senior + primary_contact: true + senior-architect: + role: ROLE-solutions-architect + seniority: senior + senior-researcher: + role: ROLE-research-scientist + seniority: senior + junior-architect: + role: ROLE-solutions-architect + seniority: specialist + developer: + role: ROLE-software-engineer + seniority: senior + junior-researcher: + role: ROLE-research-scientist + seniority: specialist + junior-developer: + role: ROLE-software-engineer + seniority: junior + assistant: + role: ROLE-assistant + seniority: specialist diff --git a/src/context/skills/processkit/team-creator/commands/pk-team-create.md b/src/context/skills/processkit/team-creator/commands/pk-team-create.md index df33b26..9119e1a 100644 --- a/src/context/skills/processkit/team-creator/commands/pk-team-create.md +++ b/src/context/skills/processkit/team-creator/commands/pk-team-create.md @@ -5,17 +5,21 @@ allowed-tools: [] # Command: pk-team-create -Full team derivation from scratch. Writes 8 Role + 8 Actor + 8 Binding -entities, rewrites `context/team/roster.md`, and emits a chartering -DecisionRecord that supersedes the prior team DecisionRecord. +Full team derivation from scratch (v2 / catalog-driven). Selects +catalog Roles via the archetype-catalog mapping, opens RoleSlots +under the chartering Scope, fills them with TeamMembers, rewrites +`context/team/roster.md`, and emits a chartering DecisionRecord that +supersedes the prior team DecisionRecord. **No archetype Role +entities are written.** ## Syntax ``` pk-team-create + --chartering-scope # required — Scope that owns the team's RoleSlots --subscription : # e.g. anthropic:max-5x --providers # comma-separated; "any" = all accessible - --parallelism-cap # max clones per role, default 5 + --parallelism-cap # max RoleSlots opened per archetype, default 5 --governance-floor <0-5> # G-score floor, default 3 [--weight-overrides ] # {"C":0.60,"K":0.20,"L":0.10,"G":0.10} # CLI > DEC-*-TeamWeights > skill defaults @@ -23,6 +27,11 @@ pk-team-create # CLI > DEC-*-TeamWeights > skill defaults [--landscape-artifact ] # explicit landscape override; skips discovery [--landscape ] # alias for --landscape-artifact (kept for compat) + [--archetype-catalog-mapping ] + # CLI override of the archetype→catalog + # mapping (replace mode). Project override + # at context/team/archetype-catalog-mapping.yaml + # is auto-detected; this flag wins over both. [--dry-run] # print plan; write nothing ``` @@ -125,16 +134,42 @@ For each accessible candidate model `m`: See `references/tiering-formula.md` for the full formula and worked example. -### Step 4 — Load role-archetypes override (if present), then map archetypes +### Step 4 — Load archetype-catalog mapping + role-archetypes override, then map archetypes -**Layer 4 override (eager validation — before archetype mapping):** +**Layer A — archetype-catalog mapping (eager validation — before archetype mapping):** + +Resolve the archetype → catalog `(ROLE-id, seniority)` mapping with +three-level precedence: + +1. `--archetype-catalog-mapping ` flag (CLI; replace semantics). +2. `context/team/archetype-catalog-mapping.yaml` (project; delta + semantics by default — top-level `override_semantics: replace` + switches to replace). +3. Kit default: `assets/archetype-catalog-mapping.yaml` shipped with + the team-creator skill. + +Validate eagerly: every archetype entry must declare a `role` that +starts with `ROLE-` and a non-empty `seniority`. Replace-mode +overrides must list all 8 archetypes (missing archetypes are a hard +error). + +Record: +- `inputs_snapshot.archetype_catalog_mapping_file`: `"kit-default"`, + `"project"`, or `"cli"` — which layer was applied. +- `inputs_snapshot.archetype_catalog_overrides`: per-archetype + per-field deltas relative to the kit default. Empty list when the + kit default was used verbatim. + +**Layer B — role-archetypes override (eager validation — before archetype mapping):** If `context/team/role-archetypes.yaml` exists, load it now and validate it immediately against the rules in `references/role-archetypes-override.md` (§Validation invariants). Validation failures are hard errors — abort before any mapping begins. -This includes: PM pin must remain heavy, PM clone_cap_override ≤ 1, -all rationales non-empty, all 8 archetypes present in `replace` mode. +This includes: PM pin must remain heavy, all rationales non-empty, +all 8 archetypes present in `replace` mode. (The legacy +`clone_cap_override` field is no longer accepted — capacity is the +count of RoleSlots opened in step 6 below.) Record `inputs_snapshot.archetype_override_file: "present"` (or `"absent"`) and list each overridden role with its new pin and @@ -154,64 +189,76 @@ For each of the 8 role archetypes: 4. If the pinned tier has no candidates: apply the override-when rule from the active archetype table; if no override applies, fail with a clear message. +5. Resolve the archetype's `(role, seniority)` pair via the mapping + from Layer A. Verify `role` is present in `context/roles/`; fail + with a clear message if the catalog Role file is missing. ### Step 5 — Deactivate prior team (if re-running) If `context/team/roster.md` already exists: -1. Parse the prior team's Actor and Binding IDs from roster.md. -2. For each prior Binding: +1. Parse the prior team's RoleSlot and Binding IDs from roster.md. +2. For each prior `role-slot-fill` Binding: `binding-management.end_binding(id, reason= "superseded by pk-team-create run ")` -3. For each prior Actor whose model ID does NOT appear in the new - team assignments: - `actor-profile.deactivate_actor(id)` -4. For each prior Actor whose model IS being re-assigned to the SAME - role in the new team: identify the canonical seed by - `is_template: true` (not by heuristics such as name or ID prefix). - REUSE that Actor ID — do not create a new Actor entity. This - ensures reuse targets the authoritative template, never a clone. +3. For each prior RoleSlot under the prior chartering Scope: + `team-manager.close_role_slot(id, reason= + "superseded by pk-team-create run ")`. + The slot state machine forbids reopening — re-charters always + emit fresh `SLOT-*` IDs under the new chartering Scope. There is + no Actor-template / Actor-clone distinction in v2; capacity is + expressed by RoleSlot count, not by clone Actors. + +If the prior team predates v2 (its roster references v1 +`role-assignment` Bindings and archetype Roles), do NOT touch those +entities. The Phase A migration apply script +(`team-manager/scripts/apply_migration_2139.py`) is responsible for +back-filling the v1 surface; pk-team-create only operates on the v2 +RoleSlot surface. ### Step 6 — Write entities Skip if `--dry-run`. -Per-archetype values for `primary_contact`, `clone_cap`, and -`cap_escalation` come from `references/role-archetypes.md`. -Only `project-manager` has `primary_contact: true` and -`clone_cap: 1`; all others use `primary_contact: false` and -`clone_cap: 5`. `cap_escalation` is `"owner"` for all roles. -Seed Actors always receive `is_template: true, templated_from: null`. +For each of the 8 archetypes, look up `(role, seniority)` in the +mapping resolved in step 4. Open one RoleSlot per parallelism unit +(rank 1..N, where N is `--parallelism-cap`; `project-manager` is +always exactly 1 slot at rank=1): -For each of the 8 archetypes: ``` -role-management.create_role( - id=ROLE-, - name=, - description=, - responsibilities=[...], - default_scope="permanent", - primary_contact=, - clone_cap=, - cap_escalation="owner" -) -actor-profile.create_actor( - id=ACTOR-, # reuse existing if same model - type="ai-agent", - name=, # from model-recommender, not hardcoded - active=true, - is_template=true, - templated_from=null +team-manager.create_role_slot( + scope=<--chartering-scope>, + role=, + seniority=, + rank=, + rationale="archetype= tier= " + "score= model=", + default_model_profile= # optional ) -binding-management.create_binding( - type="role-assignment", - subject=, - target=, +``` + +Then, for each TeamMember selected to fill a slot: + +``` +team-manager.fill_role_slot( + id=, + team_member_slug=, + rationale="", valid_from=, - description=" fills " + valid_until= ) ``` +`fill_role_slot` writes the `role-slot-fill` Binding inline. No +archetype Role, Actor, or `role-assignment` Binding is written by +pk-team-create. + +Ephemeral archetypes that have no persistent TeamMember to assign +(e.g. `assistant`, when handled by ad-hoc dispatch) leave the slot +in `state=open` with a `default_model_profile` set; the resolver +pre-step in `team-manager.get_interlocutor_runtime_binding` will +return the open slot's profile for ephemeral lookups. + ### Step 7 — Write roster.md Write `context/team/roster.md` with: @@ -242,42 +289,53 @@ decision-record-write( landscape_artifact_source: "explicit" | "project-tag" | "kit-default", tier_scores: {: , ...}, weight_overrides_applied: , + # v2 catalog-mapping audit (additionalProperties=true on inputs_snapshot) + archetype_catalog_mapping_file: + "kit-default" | "project" | "cli", + archetype_catalog_overrides: [ + {archetype, field, kit_default, project_value}, ... + ], archetype_override_file: "present" | "absent", archetype_override_semantics: "delta" | "replace" | null, - archetype_overrides: [...] # list of {role, kit_default_pin, override_pin} + archetype_overrides: [...], # list of {role, kit_default_pin, override_pin} + chartering_scope: , + role_slots: [ + {archetype, slot_id, role, seniority, rank}, ... + ] } ) ``` The `inputs_snapshot.weights` block is the canonical weight store -that `pk-team-rebalance` will read on future runs. +that `pk-team-rebalance` will read on future runs. The +`chartering_scope` and `role_slots` blocks are the v2 provenance +back-pointer from the DEC to the RoleSlots opened in step 6. ## Dry-run output format ``` === pk-team-create DRY RUN === Subscription: Governance floor: +Chartering Scope: Landscape: () Weights: C=0.60 K=0.20 L=0.10 G=0.10 +Mapping source: kit-default | project | cli (overrides: ) Candidate models scored: TierScore=0.92 → heavy TierScore=0.61 → medium ... -Role assignments: - project-manager → (heavy, score=0.92) - Role fields: primary_contact=true clone_cap=1 - cap_escalation="owner" - Actor fields: is_template=true templated_from=null - senior-architect → (heavy, score=0.87) - Role fields: primary_contact=false clone_cap=5 - cap_escalation="owner" - Actor fields: is_template=true templated_from=null +RoleSlot plan (per archetype): + project-manager → ROLE-product-manager / senior (heavy, score=0.92) + 1 slot at rank=1, fill: TEAMMEMBER-, model= + senior-architect → ROLE-solutions-architect / senior (heavy, score=0.87) + N slots at rank=1..N, fill: TEAMMEMBER-, model= ... Entities to write (skipped in dry-run): - 8 Role entities, 8 Actor entities, 8 Binding entities + RoleSlot entities + role-slot-fill Binding entities context/team/roster.md DecisionRecord superseding =========================== @@ -285,6 +343,9 @@ Entities to write (skipped in dry-run): ## State side-effects (non-dry-run) -Creates: 8 Role + 8 Actor + 8 Binding entities, roster.md, -1 DecisionRecord. Deactivates: prior Bindings (end_binding) and -prior Actors not re-used. +Creates: one RoleSlot per parallelism unit (under the chartering +Scope), one `role-slot-fill` Binding per filled slot, roster.md, +1 DecisionRecord. Closes prior RoleSlots and ends prior +`role-slot-fill` Bindings on re-charter. Does NOT write archetype +Role entities, Actor entities, or `role-assignment` Bindings — +those v1 surfaces are read-only during the deprecation window. diff --git a/src/context/skills/processkit/team-creator/commands/pk-team-rebalance.md b/src/context/skills/processkit/team-creator/commands/pk-team-rebalance.md index b68612c..f3dbe83 100644 --- a/src/context/skills/processkit/team-creator/commands/pk-team-rebalance.md +++ b/src/context/skills/processkit/team-creator/commands/pk-team-rebalance.md @@ -68,70 +68,75 @@ Same resolution logic as `pk-team-create` Step 1. If `--landscape` is not supplied, use the latest `landscape-summary` artifact. Warn if older than 90 days; do not block. -### Step 3 — Re-score targeted roles +### Step 3 — Resolve archetype names → catalog (role, seniority) and re-score -For each role in `--roles`: +`--roles` accepts archetype names (`developer`, `senior-architect`, +...). Resolve each name through the archetype-catalog mapping +(`assets/archetype-catalog-mapping.yaml`, layered with +`context/team/archetype-catalog-mapping.yaml` if present) into the +catalog `(ROLE-id, seniority)` pair. Operate on the matching +`RoleSlot(s)` in the active chartering Scope. -1. Query accessible models scoped to that role's pinned tier: +For each archetype in `--roles`: + +1. Look up `(role, seniority)` via the mapping. Abort if the + archetype is not present and no project override defines it. +2. Query accessible models scoped to that archetype's pinned tier: ``` model-recommender.query_models( G_floor=, apply_user_filter=true ) ``` -2. Apply the tiering formula with stored (or override) weights. -3. Select best-scoring candidate for this role's tier. -4. If the best candidate is the same model as the current assignment: - report "no change needed" for this role; skip writes. +3. Apply the tiering formula with stored (or override) weights. +4. Select the best-scoring candidate for the tier. +5. If the best candidate is the same model currently filling the + archetype's RoleSlot(s): report "no change needed"; skip writes. -### Step 4 — End old Bindings +### Step 4 — End old role-slot-fill Bindings -For each role where a model change is needed: +For each archetype where a model change is needed, list the +matching RoleSlots in the active chartering Scope via +`team-manager.list_role_slots(scope=, role=, +state="filled")`, then for each filled slot end its current fill +Binding: ``` binding-management.end_binding( - id=, + id=, reason="superseded by pk-team-rebalance: <--reason> ()" ) ``` -### Step 5 — Create or reuse Actor entities - -For each role being reassigned: -- If the incoming model ID matches an existing Actor entity in - `context/actors/` (active or inactive): reactivate it (set - `active: true` via `actor-profile.update_actor`). Do not create - a duplicate. The reactivated Actor retains its existing - `is_template` and `templated_from` values unchanged. -- If no matching Actor exists, spawn a new clone Actor. The - original seed Actor for this role is the one with - `is_template: true` in `context/actors/`. Record its ID as - `` and create: - ``` - actor-profile.create_actor( - type="ai-agent", - name=, - active=true, - is_template=false, - templated_from= - ) - ``` - This marks the spawned Actor as a clone of the canonical - template, enabling index queries to separate seed team members - from rebalance-spawned instances. - -### Step 6 — Create new Bindings +### Step 5 — Re-fill the existing RoleSlots + +A targeted rebalance does NOT close-and-reopen the slot — capacity +(slot count) hasn't changed; only the TeamMember and the model +underneath it have. For each affected RoleSlot: ``` -binding-management.create_binding( - type="role-assignment", - subject=, - target=, +team-manager.fill_role_slot( + id=, + team_member_slug=, + rationale="rebalanced : <--reason> " + "(model: , score: )", valid_from=, - description=" fills — rebalanced " + valid_until= ) ``` +(If the new TeamMember does not yet exist in `context/team-members/`, +provision it first via the team-member skill. Ephemeral +`(role, seniority)` dispatches that have no persistent TeamMember +re-attach to the slot's `default_model_profile` instead — no fill +write is needed.) + +### Step 6 — _(folded into step 5)_ + +v1 split this into "create new role-assignment Binding"; v2's +`fill_role_slot` writes the `role-slot-fill` Binding inline, so a +separate step is no longer required. + ### Step 7 — Update roster.md in-place Rewrite the affected rows in `context/team/roster.md`'s routing table. @@ -166,10 +171,12 @@ rebalancing would touch every role anyway. ## State side-effects -Ends N old Bindings. Creates (or reactivates) N Actors. Creates N new -Bindings. Amends roster.md in-place. Appends to the governing -DecisionRecord's `progress_notes`. Does NOT write a new DecisionRecord -(unless `--roles all`). +Ends N old `role-slot-fill` Bindings. Re-fills the affected RoleSlots +(no slot create/close — capacity is unchanged) and writes N new +`role-slot-fill` Bindings via `team-manager.fill_role_slot`. Amends +roster.md in-place. Appends to the governing DecisionRecord's +`progress_notes`. Does NOT write a new DecisionRecord (unless +`--roles all`). ## Safety diff --git a/src/context/skills/processkit/team-creator/commands/pk-team-review.md b/src/context/skills/processkit/team-creator/commands/pk-team-review.md index 4890552..5aa86c5 100644 --- a/src/context/skills/processkit/team-creator/commands/pk-team-review.md +++ b/src/context/skills/processkit/team-creator/commands/pk-team-review.md @@ -81,26 +81,37 @@ or `major_outage`. ### Step 6 — Emit diff report -Output format (stdout only — no files written): +Output format (stdout only — no files written). Each row is keyed by +**archetype name** (resolved through the +`assets/archetype-catalog-mapping.yaml` mapping, layered with the +project override) so the operator sees the same names accepted by +`pk-team-rebalance --roles`. The underlying `(SLOT-id, ROLE-id, +seniority)` tuple is shown in parentheses for traceability. ``` === pk-team-review — === Baseline: DecisionRecord () +Chartering Scope: Landscape: () [STALE: >90 days if applicable] Weights used: C= K= L= G= Threshold: +Mapping source: kit-default | project | cli (overrides: ) TIER-DRIFT (score delta > threshold): - developer: (baseline 0.62) → new score 0.44 ▼0.18 + developer (SLOT-q2-2026-software-engineer-1, ROLE-software-engineer/senior): + (baseline 0.62) → new score 0.44 ▼0.18 → Best alternative: (score 0.71, heavy) - → Recommendation: rebalance this role + → Recommendation: rebalance this archetype UNAVAILABLE: - junior-developer: — status: major_outage + junior-developer (SLOT-q2-2026-software-engineer-2, + ROLE-software-engineer/junior): + — status: major_outage → Best fallback: (score 0.38, light) NEW OUTPERFORMERS: - assistant: (score 0.52) outperforms current + assistant (SLOT-q2-2026-assistant-1, ROLE-assistant/specialist): + (score 0.52) outperforms current (score 0.31) by 0.21 — within light tier, no tier-shift STABLE (no action needed): @@ -108,8 +119,8 @@ STABLE (no action needed): junior-architect, junior-researcher SUMMARY: - 2 roles recommended for rebalance - 1 role urgently needs replacement (major_outage) + 2 archetypes recommended for rebalance + 1 archetype urgently needs replacement (major_outage) Run: pk-team-rebalance --roles developer,junior-developer --confirm \ --reason "" ============================= diff --git a/src/context/skills/processkit/team-creator/references/role-archetypes-override.md b/src/context/skills/processkit/team-creator/references/role-archetypes-override.md index 73de9f9..8885cb3 100644 --- a/src/context/skills/processkit/team-creator/references/role-archetypes-override.md +++ b/src/context/skills/processkit/team-creator/references/role-archetypes-override.md @@ -3,10 +3,16 @@ ## Purpose A project may supply a `context/team/role-archetypes.yaml` file to -remap tier pins and clone caps for some or all of the 8 processkit -role archetypes. `pk-team-create` loads this file before archetype -mapping (Step 4) and validates it eagerly — before model scoring — -so violations fail fast. +remap tier pins for some or all of the 8 processkit role archetypes. +`pk-team-create` loads this file in Step 4 (Layer B) and validates it +eagerly — before model scoring — so violations fail fast. + +> **Capacity is no longer overridden here.** v1's `clone_cap_override` +> field is rejected by the validator. Capacity is the number of +> RoleSlots opened in step 6 (`--parallelism-cap`, with PM pinned to +> 1). The `(role, seniority)` pair an archetype maps to is overridden +> separately via `context/team/archetype-catalog-mapping.yaml` (see +> SKILL.md §"Catalog-driven archetype mapping (v2)"). ## File location @@ -20,7 +26,7 @@ kit defaults from `references/role-archetypes.md` apply unchanged. ## Full schema ```yaml -version: "1" # schema version; currently must be "1" +version: "2" # schema version; v2 dropped clone_cap_override override_semantics: delta # "delta" | "replace" (default: delta) roles: @@ -28,10 +34,14 @@ roles: tier_pin: heavy|medium|light # REQUIRED per entry rationale: | # REQUIRED — non-empty; must be project-specific - clone_cap_override: # null = inherit project --parallelism-cap override_when: [] # optional; inherits kit rules if omitted ``` +> Files declaring `version: "1"` and/or `clone_cap_override` are +> tolerated but the `clone_cap_override` field is ignored with a +> warning logged to the chartering DEC. Capacity moved to RoleSlot +> count at v0.19.0; bump the version field on next edit. + Valid `` values (exactly these 8): `project-manager`, `senior-architect`, `senior-researcher`, `junior-architect`, `developer`, `junior-researcher`, @@ -57,10 +67,8 @@ The following are hard errors, checked before model scoring begins: | Invariant | Error message | |---|---| | `project-manager.tier_pin != "heavy"` | "PM tier_pin must remain heavy — immutable per DEC-20260414_0900" | -| `project-manager.clone_cap_override > 1` | "PM clone_cap_override must be null or 1 — immutable per DEC-20260414_0900" | | Any `rationale` is empty or absent | "Role : rationale is required in role-archetypes.yaml override" | | `replace` mode with fewer than 8 archetypes | "replace mode requires all 8 archetypes; missing: " | -| `clone_cap_override > --parallelism-cap` | "Role : clone_cap_override exceeds project parallelism-cap " | The following is a warning (logged to DecisionRecord, not an error): @@ -73,7 +81,7 @@ The following is a warning (logged to DecisionRecord, not an error): ### Delta override (one role remapped) ```yaml -version: "1" +version: "2" override_semantics: delta roles: senior-architect: @@ -82,47 +90,38 @@ roles: This edge-deployment project has no accessible heavy-tier model with G-score ≥ 4 under the target subscription. Owner approved downgrade to medium per project charter 2026-Q2. - clone_cap_override: null ``` ### Replace override (full archetype table) ```yaml -version: "1" +version: "2" override_semantics: replace roles: project-manager: tier_pin: heavy # immutable — must remain heavy rationale: "Kit default retained — PM routing quality requirement unchanged." - clone_cap_override: 1 senior-architect: tier_pin: medium rationale: "Research lab does not need frontier models for architecture review." - clone_cap_override: null senior-researcher: tier_pin: heavy rationale: "Multi-source synthesis is the lab's primary output — max capability." - clone_cap_override: null junior-architect: tier_pin: medium rationale: "Module-scoped design; medium capability is sufficient." - clone_cap_override: null developer: tier_pin: medium rationale: "Implementation against detailed specs; medium capability is sufficient." - clone_cap_override: null junior-researcher: tier_pin: medium rationale: "Bounded research scope; medium capability matches task complexity." - clone_cap_override: null junior-developer: tier_pin: light rationale: "Kit default retained — single-file edits do not require upgrade." - clone_cap_override: null assistant: tier_pin: light rationale: "Kit default retained — admin tasks do not require capability upgrade." - clone_cap_override: null ``` ## Audit trail diff --git a/src/context/skills/processkit/team-creator/references/role-archetypes.md b/src/context/skills/processkit/team-creator/references/role-archetypes.md index d6e23a1..65fea27 100644 --- a/src/context/skills/processkit/team-creator/references/role-archetypes.md +++ b/src/context/skills/processkit/team-creator/references/role-archetypes.md @@ -2,16 +2,21 @@ ## The 8 processkit role archetypes -| Role archetype | Tier pin | Rationale | Override-when | `primary_contact` | `clone_cap` | `cap_escalation` | +Each archetype maps onto a catalog Role (`context/roles/ROLE-*`) and +seniority tier via `assets/archetype-catalog-mapping.yaml`. Archetypes +are mapping keys, not Role entities; see SKILL.md §"Catalog-driven +archetype mapping (v2)" for the loading sequence. + +| Role archetype | Catalog Role | Seniority | Tier pin | Rationale | Override-when | Slots opened (rank 1..N) | |---|---|---|---|---|---|---| -| **project-manager** | heavy | Routing quality compounds; every routing error propagates to all downstream work | **Never override.** PM is always heavy; always 1 clone (no parallelism). | `true` | `1` | `"owner"` | -| **senior-architect** | heavy | Cross-subsystem design; blast radius is high; wrong architecture poisons the entire codebase | Medium only if no heavy candidate clears G-floor AND owner explicitly approves in writing. | `false` | `5` | `"owner"` | -| **senior-researcher** | heavy | Multi-source synthesis; wrong synthesis poisons downstream decisions at scale | Same as senior-architect. | `false` | `5` | `"owner"` | -| **junior-architect** | medium | Single-module scope; bounded blast radius | Heavy if median capability gap between medium and heavy tiers exceeds 15pp on SWE-bench Verified for the accessible candidate set. | `false` | `5` | `"owner"` | -| **developer** | medium | Implementation against a written plan; bounded scope | Heavy for security-critical or regulated subsystems (owner sets `--security-critical` flag). | `false` | `5` | `"owner"` | -| **junior-researcher** | medium | Bounded single-topic research; output reviewed by senior-researcher | No override. | `false` | `5` | `"owner"` | -| **junior-developer** | light | Well-specified single-file edits; no architecture decisions | Medium if no light-tier candidate is accessible (escalation fallback). | `false` | `5` | `"owner"` | -| **assistant** | light | Admin, formatting, summarisation, file management | No override. If no light model is accessible, share the junior-developer model (same ACTOR entity, same Binding target). | `false` | `5` | `"owner"` | +| **project-manager** | `ROLE-product-manager` | senior | heavy | Routing quality compounds; every routing error propagates to all downstream work | **Never override.** PM is always heavy; always exactly one slot at rank=1 (no parallelism). | 1 | +| **senior-architect** | `ROLE-solutions-architect` | senior | heavy | Cross-subsystem design; blast radius is high; wrong architecture poisons the entire codebase | Medium only if no heavy candidate clears G-floor AND owner explicitly approves in writing. | up to `--parallelism-cap` | +| **senior-researcher** | `ROLE-research-scientist` | senior | heavy | Multi-source synthesis; wrong synthesis poisons downstream decisions at scale | Same as senior-architect. | up to `--parallelism-cap` | +| **junior-architect** | `ROLE-solutions-architect` | specialist | medium | Single-module scope; bounded blast radius | Heavy if median capability gap between medium and heavy tiers exceeds 15pp on SWE-bench Verified for the accessible candidate set. | up to `--parallelism-cap` | +| **developer** | `ROLE-software-engineer` | senior | medium | Implementation against a written plan; bounded scope | Heavy for security-critical or regulated subsystems (owner sets `--security-critical` flag). | up to `--parallelism-cap` | +| **junior-researcher** | `ROLE-research-scientist` | specialist | medium | Bounded single-topic research; output reviewed by senior-researcher | No override. | up to `--parallelism-cap` | +| **junior-developer** | `ROLE-software-engineer` | junior | light | Well-specified single-file edits; no architecture decisions | Medium if no light-tier candidate is accessible (escalation fallback). | up to `--parallelism-cap` | +| **assistant** | `ROLE-assistant` | specialist | light | Admin, formatting, summarisation, file management | No override. If no light model is accessible, the assistant slot's `default_model_profile` shares the junior-developer slot's profile. | up to `--parallelism-cap` | ## Override rules in detail @@ -23,10 +28,11 @@ scoring medium model for junior-developer to minimise cost. ### Tier upgrade: light → light (assistant shares junior-developer) -If no light-tier model is accessible, assistant does not get a -separate Actor. Instead, create a single Binding from -`ACTOR-` to `ROLE-assistant`. The DecisionRecord -notes this as a shared-model arrangement. +If no light-tier model is accessible, the `assistant` archetype's +RoleSlot is opened with the junior-developer slot's +`default_model_profile`. No separate Actor is created (Actors are no +longer written by team-creator v2). The DecisionRecord notes this as +a shared-model-profile arrangement. ### Tier downgrade: heavy → medium (senior-architect, senior-researcher) @@ -82,8 +88,11 @@ records the collapse scenario in the DecisionRecord `inputs_snapshot`. ## Role responsibilities reference -Brief one-liners for `role-management.create_role` calls. These are -starting points; projects may extend them. +Brief one-liners that summarise each archetype's intent. These are +read by the chartering DecisionRecord generator and surfaced in +roster.md narratives. The catalog Roles themselves +(`context/roles/ROLE-*`) carry their own canonical responsibilities +lists; this table is the archetype-side gloss only. | Role | responsibilities (imperative bullets) | |---|---| @@ -96,17 +105,22 @@ starting points; projects may extend them. | junior-developer | Implement well-specified single-file edits; run linters; update changelogs | | assistant | Format documents; manage file moves; write standup notes; perform admin tasks | -## Template vs clone - -The 8 seed Actors emitted by `pk-team-create` are **templates** -(`is_template: true`, `templated_from: null`). They represent the -canonical team roster — one Actor per archetype. When -`pk-team-rebalance` spawns a new Actor to fill a role (replace or add), -that spawned Actor is a **clone**: `is_template: false` and -`templated_from: ` pointing at the template it -derives from. This distinction lets the system separate "the 8 -authoritative team members" from "task-specific parallel instances" -when querying the actor index. +## Capacity in v2: RoleSlots, not Actor templates/clones + +In v2 there is no "seed Actor / clone Actor" distinction — that model +was a v0.16.0 expedient and was removed at v0.19.0 along with the +backing schema fields. Capacity is now expressed as the count of +**RoleSlots** opened under the chartering Scope: +`SLOT---`, with `rank=1` reserved for +the primary fill and `rank=2..N` for parallel reservations +(N = `--parallelism-cap`, except for `project-manager`, which is +hard-coded to 1). + +Re-tiering an archetype rebinds the **fill** of the existing slot +(via `team-manager.fill_role_slot`); the slot itself stays open +across rebalances unless `--roles all` triggers a full re-charter, +which closes the prior chartering Scope's slots and opens a new +generation under the new Scope. ## Provider-neutrality invariant diff --git a/src/context/skills/processkit/team-creator/scripts/team_creator_lib.py b/src/context/skills/processkit/team-creator/scripts/team_creator_lib.py new file mode 100644 index 0000000..2659a57 --- /dev/null +++ b/src/context/skills/processkit/team-creator/scripts/team_creator_lib.py @@ -0,0 +1,291 @@ +"""team-creator v2 helper library. + +Catalog-driven archetype resolution for pk-team-create, pk-team-rebalance, +and pk-team-review. The skill itself is documentation-driven (commands/*.md +narrate the workflow), but the mapping loader is shared by the migration +apply script and the test suite. + +Public surface: + - load_archetype_catalog_mapping(project_root) + - resolve_archetype(name, mapping) -> {role, seniority, ...} + - mapping_source(...) -> "kit-default" | "project" | "cli" + +The loader implements the layered precedence promised by SKILL.md: + + cli (--archetype-catalog-mapping ) > project > kit-default + +Project override: context/team/archetype-catalog-mapping.yaml (delta semantics +by default; replace semantics only when the override file declares +``override_semantics: replace`` at the top level). + +The kit default ships at +``context/skills/processkit/team-creator/assets/archetype-catalog-mapping.yaml`` +and contains the 8 archetypes listed in the SmartPanda design artifact §"Gap 1". +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + + +# The 8 processkit archetypes (kept as a stable tuple for validation). +ARCHETYPES: tuple[str, ...] = ( + "project-manager", + "senior-architect", + "senior-researcher", + "junior-architect", + "developer", + "junior-researcher", + "junior-developer", + "assistant", +) + + +_VALID_OVERRIDE_SEMANTICS = {"delta", "replace"} + + +# --------------------------------------------------------------------------- +# Kit-default discovery +# --------------------------------------------------------------------------- + +def kit_default_mapping_path() -> Path: + """Return the absolute path to the kit-default mapping shipped here.""" + return Path(__file__).resolve().parent.parent / "assets" / "archetype-catalog-mapping.yaml" + + +def project_override_mapping_path(project_root: Path) -> Path: + """Return the project override path. + + Always returned regardless of existence — the caller decides whether + to apply the override based on ``Path.is_file()``. + """ + return project_root / "context" / "team" / "archetype-catalog-mapping.yaml" + + +# --------------------------------------------------------------------------- +# Result type +# --------------------------------------------------------------------------- + +@dataclass +class ArchetypeMapping: + """Resolved archetype-to-catalog mapping. + + Attributes + ---------- + archetypes: dict mapping archetype-name -> {"role": ROLE-id, + "seniority": , optional "primary_contact": bool, ...} + source: "kit-default" | "project" | "cli" + overrides: list of per-archetype delta entries when source == "project" + and the project file is in delta mode. Each entry: + {"archetype": , "field": , "kit_default": ..., + "project_value": ...} + semantics: "delta" | "replace" — only meaningful when source == "project" + """ + + archetypes: dict[str, dict[str, Any]] + source: str = "kit-default" + overrides: list[dict[str, Any]] = field(default_factory=list) + semantics: str | None = None + files: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Loaders +# --------------------------------------------------------------------------- + +def _load_yaml(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + data = yaml.safe_load(text) or {} + if not isinstance(data, dict): + raise ValueError(f"{path}: top-level YAML must be a mapping") + return data + + +def _extract_archetypes(doc: dict[str, Any], path: Path) -> dict[str, dict[str, Any]]: + """Pull the ``spec.archetypes`` block (or top-level ``archetypes``).""" + spec = doc.get("spec") or doc + archetypes = spec.get("archetypes") + if not isinstance(archetypes, dict): + raise ValueError(f"{path}: missing or invalid 'archetypes' map") + out: dict[str, dict[str, Any]] = {} + for name, body in archetypes.items(): + if not isinstance(body, dict): + raise ValueError(f"{path}: archetype {name!r} must be a mapping") + role = body.get("role") + seniority = body.get("seniority") + if not role or not str(role).startswith("ROLE-"): + raise ValueError( + f"{path}: archetype {name!r} requires a 'role' starting with 'ROLE-'" + ) + if not seniority: + raise ValueError(f"{path}: archetype {name!r} requires a 'seniority'") + entry: dict[str, Any] = {"role": role, "seniority": seniority} + for opt in ("primary_contact", "default_model_profile", + "effort_floor", "effort_ceiling", "rationale"): + if opt in body: + entry[opt] = body[opt] + out[name] = entry + return out + + +def _load_kit_default() -> dict[str, dict[str, Any]]: + path = kit_default_mapping_path() + if not path.is_file(): + raise FileNotFoundError( + f"kit-default mapping missing at {path} — team-creator install corrupted?" + ) + doc = _load_yaml(path) + return _extract_archetypes(doc, path) + + +def _validate_replace_completeness(archetypes: dict[str, dict[str, Any]], + path: Path) -> None: + missing = [a for a in ARCHETYPES if a not in archetypes] + if missing: + raise ValueError( + f"{path}: replace-mode override missing archetypes: {missing!r}" + ) + + +def load_archetype_catalog_mapping( + project_root: Path, + cli_path: Path | None = None, +) -> ArchetypeMapping: + """Load the archetype→catalog mapping with three-level precedence. + + Order: + 1. ``cli_path`` (if supplied; mode determined by file's + ``override_semantics``) + 2. ``/context/team/archetype-catalog-mapping.yaml`` + 3. kit default shipped under team-creator/assets/ + + Returns an :class:`ArchetypeMapping` describing the resolved set, + its source, and any per-archetype delta entries. + """ + project_root = Path(project_root) + kit_archetypes = _load_kit_default() + files: list[str] = [str(kit_default_mapping_path())] + + # --- Layer 1: CLI ------------------------------------------------------ + if cli_path is not None: + cli_path = Path(cli_path) + if not cli_path.is_file(): + raise FileNotFoundError( + f"--archetype-catalog-mapping {cli_path} not found" + ) + doc = _load_yaml(cli_path) + semantics = doc.get("override_semantics", "replace") + if semantics not in _VALID_OVERRIDE_SEMANTICS: + raise ValueError( + f"{cli_path}: override_semantics must be 'delta' or 'replace'; " + f"got {semantics!r}" + ) + cli_archetypes = _extract_archetypes(doc, cli_path) + if semantics == "replace": + _validate_replace_completeness(cli_archetypes, cli_path) + merged = cli_archetypes + else: + merged = {**kit_archetypes, **cli_archetypes} + overrides = _delta_overrides(kit_archetypes, cli_archetypes) + files.append(str(cli_path)) + return ArchetypeMapping( + archetypes=merged, + source="cli", + overrides=overrides, + semantics=semantics, + files=files, + ) + + # --- Layer 2: Project override ---------------------------------------- + proj_path = project_override_mapping_path(project_root) + if proj_path.is_file(): + doc = _load_yaml(proj_path) + semantics = doc.get("override_semantics", "delta") + if semantics not in _VALID_OVERRIDE_SEMANTICS: + raise ValueError( + f"{proj_path}: override_semantics must be 'delta' or 'replace'; " + f"got {semantics!r}" + ) + proj_archetypes = _extract_archetypes(doc, proj_path) + if semantics == "replace": + _validate_replace_completeness(proj_archetypes, proj_path) + merged = proj_archetypes + else: + merged = {**kit_archetypes, **proj_archetypes} + overrides = _delta_overrides(kit_archetypes, proj_archetypes) + files.append(str(proj_path)) + return ArchetypeMapping( + archetypes=merged, + source="project", + overrides=overrides, + semantics=semantics, + files=files, + ) + + # --- Layer 3: kit default --------------------------------------------- + return ArchetypeMapping( + archetypes=kit_archetypes, + source="kit-default", + overrides=[], + semantics=None, + files=files, + ) + + +def _delta_overrides( + kit: dict[str, dict[str, Any]], + layer: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """Return per-archetype-per-field delta entries. + + Used for the chartering DEC's ``inputs_snapshot.archetype_catalog_overrides`` + audit field. + """ + out: list[dict[str, Any]] = [] + for name, entry in layer.items(): + kit_entry = kit.get(name) or {} + for fld, val in entry.items(): + kit_val = kit_entry.get(fld) + if kit_val != val: + out.append({ + "archetype": name, + "field": fld, + "kit_default": kit_val, + "project_value": val, + }) + # New archetype not in kit: treat as a single "add" entry + if name not in kit: + out.append({ + "archetype": name, + "field": "*", + "kit_default": None, + "project_value": "", + }) + return out + + +def resolve_archetype(name: str, mapping: ArchetypeMapping) -> dict[str, Any]: + """Look up an archetype in the resolved mapping. KeyError on miss.""" + if name not in mapping.archetypes: + raise KeyError( + f"archetype {name!r} not found in mapping (source={mapping.source}); " + f"known archetypes: {sorted(mapping.archetypes)}" + ) + return mapping.archetypes[name] + + +def archetype_for_role_slot( + role: str, seniority: str, mapping: ArchetypeMapping, +) -> str | None: + """Reverse lookup: given (role, seniority), return the archetype name. + + Used by pk-team-review for human-readable diff labels. Returns None + when no archetype maps to the (role, seniority) pair. + """ + for name, entry in mapping.archetypes.items(): + if entry["role"] == role and entry["seniority"] == seniority: + return name + return None diff --git a/src/context/skills/processkit/team-manager/mcp/server.py b/src/context/skills/processkit/team-manager/mcp/server.py index 31bc679..7fd3fad 100644 --- a/src/context/skills/processkit/team-manager/mcp/server.py +++ b/src/context/skills/processkit/team-manager/mcp/server.py @@ -96,6 +96,16 @@ def _find_lib() -> Path: _VALID_TYPES = {"human", "ai-agent", "service"} _VALID_SENIORITY = {"junior", "specialist", "expert", "senior", "principal"} +_VALID_SLOT_STATES = {"open", "filled", "closed"} +_VALID_EFFORTS = {"low", "medium", "high", "extra-high", "max"} +# Allowed RoleSlot state transitions (Phase A team-creator v2; +# DEC-20260509_1906-CoolBadger). closed is terminal — reverse +# transitions are rejected. +_SLOT_TRANSITIONS = { + "open": {"filled", "closed"}, + "filled": {"closed"}, + "closed": set(), +} _UPDATABLE_FIELDS = { "name", "email", "handle", "default_role", "default_seniority", "personality", "memory", "relationships", "exportable", @@ -946,6 +956,21 @@ def get_interlocutor_runtime_binding( `observed_model` and `observed_effort` are caller-supplied facts from the current harness. processkit cannot read or hot-swap the already running primary model, so mismatch reporting is informational. + + Phase A team-creator v2 — RoleSlot pre-step: + Before falling through to the existing 8-layer model-assignment + binding precedence, this tool checks whether the active + interlocutor's (default_role, default_seniority, scope) matches + a RoleSlot in state=filled. If it does, the slot's filled_by + TeamMember and (TeamMember.default_seniority || slot.seniority) + are surfaced in ``binding.roleslot_pre_step``. The existing + 8-layer logic still runs unchanged underneath + (DEC-20260509_1906-CoolBadger Q1). + + When no slot matches the triple, ``roleslot_pre_step`` is + absent and the response is identical to pre-RoleSlot + behaviour — the existing 8-layer resolver is fully + responsible. This keeps Phase A additive and reversible. """ active = get_active_interlocutor(scope) if active.get("error") or not active.get("configured"): @@ -961,15 +986,31 @@ def get_interlocutor_runtime_binding( "error": f"configured team-member {member_id!r} not found", } + # --- RoleSlot pre-step (Phase A) ----------------------------------- + # Look for a filled slot keyed by the interlocutor's default + # (role, seniority) inside ``scope``. If found, the slot's + # filled_by TeamMember (or ``None`` for ephemeral dispatch) and the + # applied seniority join the response. The 8-layer resolver below + # remains the source of truth for the actual model selection. + pre_role = (ent.spec or {}).get("default_role") + pre_seniority = (ent.spec or {}).get("default_seniority") + pre_step = _roleslot_pre_step( + role=pre_role, seniority=pre_seniority, scope=scope, + ) + + binding = _runtime_binding_for( + ent=ent, + scope=scope, + observed_model=observed_model, + observed_effort=observed_effort, + task_hints=task_hints, + ) + if pre_step is not None: + binding = {**binding, "roleslot_pre_step": pre_step} + return { **active, - "binding": _runtime_binding_for( - ent=ent, - scope=scope, - observed_model=observed_model, - observed_effort=observed_effort, - task_hints=task_hints, - ), + "binding": binding, } @@ -1144,6 +1185,622 @@ def reactivate_team_member(id: str) -> dict: return {"ok": True, "id": ent.id} +# --------------------------------------------------------------------------- +# RoleSlot tools (Phase A — team-creator v2) +# --------------------------------------------------------------------------- +# +# RoleSlot decouples capacity (how many parallel workers a role needs in +# a charter) from identity (who a persistent persona is). Persistent +# TeamMembers carry stable memory and personality; RoleSlots carry no +# memory and exist only inside the lifetime of their chartering Scope. +# +# State machine: open → filled → closed. closed is terminal — reopening +# means a fresh SLOT-id at the next charter (Scope). +# +# IDs are deterministic: SLOT---. +# scope-slug is the SCOPE- tail, role-slug the ROLE- tail, both +# kebab-case. rank=1 is the primary; rank=2..N are parallel reservations. +# +# Resolver pre-step (get_interlocutor_runtime_binding) — Phase A: +# 1. (role, seniority, scope) → query RoleSlot(state=filled, scope, role, +# seniority match) +# 2. RoleSlot.filled_by → TeamMember +# 3. TeamMember.default_seniority overrides RoleSlot.seniority for model +# resolution if set +# 4. fall through to existing 8-layer model-assignment binding precedence +# +# The pre-step is additive — it inserts in front of the existing 8-layer +# resolver without reshaping it (DEC-20260509_1906-CoolBadger Q1). + + +def _slot_dir(root: Path) -> Path: + return paths.context_dir("RoleSlot", root) + + +def _scope_slug(scope_id: str) -> str: + return scope_id[len("SCOPE-"):] if scope_id.startswith("SCOPE-") else scope_id + + +def _role_slug(role_id: str) -> str: + return role_id[len("ROLE-"):] if role_id.startswith("ROLE-") else role_id + + +def _slot_id(scope_id: str, role_id: str, rank: int) -> str: + return f"SLOT-{_scope_slug(scope_id)}-{_role_slug(role_id)}-{int(rank)}" + + +def _slot_path(root: Path, slot_id: str) -> Path: + return _slot_dir(root) / f"{slot_id}.md" + + +def _load_slot(root: Path, slot_id: str) -> entity.Entity | None: + p = _slot_path(root, slot_id) + if p.is_file(): + return entity.load(p) + # Fallback: scan dir (defensive, in case sharding rules change) + base = _slot_dir(root) + if not base.is_dir(): + return None + for f in base.rglob(f"{slot_id}.md"): + try: + return entity.load(f) + except Exception: + continue + return None + + +def _slot_summary(ent: entity.Entity) -> dict: + spec = ent.spec or {} + return { + "id": ent.id, + "scope": spec.get("scope"), + "role": spec.get("role"), + "seniority": spec.get("seniority"), + "rank": spec.get("rank"), + "state": spec.get("state"), + "filled_by": spec.get("filled_by"), + "default_model_profile": spec.get("default_model_profile"), + "effort_floor": spec.get("effort_floor"), + "effort_ceiling": spec.get("effort_ceiling"), + "rationale": spec.get("rationale"), + "created": spec.get("created"), + "closed_at": spec.get("closed_at"), + "close_reason": spec.get("close_reason"), + "path": str(ent.path) if ent.path else None, + } + + +def _create_role_slot_fill_binding( + root: Path, + slot_id: str, + team_member_id: str, + valid_from: str | None, + valid_until: str | None, + rationale: str | None, +) -> dict: + """Create a Binding(type=role-slot-fill) inline. + + Mirrors binding-management.create_binding's write path so the + team-manager server doesn't have to call out across MCP servers + during a fill_role_slot call. The Binding entity is written under + context/bindings/ exactly the same way binding-management would + write it. + """ + from processkit import config as _config, ids as _ids # noqa: WPS433 + + cfg = _config.load_config(root) + bind_dir = paths.context_dir("Binding", root) + bind_dir.mkdir(parents=True, exist_ok=True) + + db = index.open_db() + try: + existing = index.existing_ids(db, "Binding") + finally: + db.close() + + new_id = _ids.generate_id( + "Binding", + format=cfg.id_format, + word_style=cfg.id_word_style, + datetime_prefix=cfg.id_datetime_prefix, + slug_text="role-slot-fill", + existing=existing, + ) + spec: dict = { + "type": "role-slot-fill", + "subject": team_member_id, + "target": slot_id, + "subject_kind": "TeamMember", + "target_kind": "RoleSlot", + } + if valid_from: + spec["valid_from"] = valid_from + if valid_until: + spec["valid_until"] = valid_until + if rationale: + spec["conditions"] = {"rationale": rationale} + + errors = schema.validate_spec("Binding", spec) + if errors: + return {"error": "binding schema validation failed", "details": errors} + + ent = entity.new("Binding", new_id, spec) + target_path = paths.entity_path("Binding", new_id, None, root) + ent.write(target_path) + + try: + db = index.open_db() + try: + index.upsert_entity(db, ent) + finally: + db.close() + except Exception: + pass + + log.log_side_effect( + "Binding", new_id, "binding.created", + f"Created Binding {new_id!r}: 'role-slot-fill' " + f"{team_member_id!r} → {slot_id!r}", + root=root, + actor=new_id, + ) + return {"id": new_id, "path": str(target_path)} + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=False, + openWorldHint=False, +)) +def create_role_slot( + scope: str, + role: str, + seniority: str, + rank: int, + rationale: str, + default_model_profile: str | None = None, + effort_floor: str | None = None, + effort_ceiling: str | None = None, +) -> dict: + """Open a new RoleSlot under a chartering Scope. + + Parameters + ---------- + scope: SCOPE- the slot belongs to (mandatory) + role: ROLE- from the catalog + seniority: junior|specialist|expert|senior|principal + rank: 1=primary, 2..N=parallel reservations + rationale: one-line reason this slot exists + default_model_profile: optional Layer 8 fallback for ephemeral + dispatches that never fill the slot + effort_floor: optional dispatch floor + effort_ceiling: optional dispatch ceiling + + Returns ``{id, path, state}`` on success. + """ + if not scope or not scope.startswith("SCOPE-"): + return {"error": f"scope must be a SCOPE-* id; got {scope!r}"} + if not role or not role.startswith("ROLE-"): + return {"error": f"role must be a ROLE-* id; got {role!r}"} + if seniority not in _VALID_SENIORITY: + return { + "error": ( + f"invalid seniority {seniority!r}; " + f"must be one of {sorted(_VALID_SENIORITY)}" + ) + } + try: + rank_int = int(rank) + except (TypeError, ValueError): + return {"error": f"rank must be a positive integer; got {rank!r}"} + if rank_int < 1: + return {"error": f"rank must be >= 1; got {rank_int}"} + if effort_floor is not None and effort_floor not in _VALID_EFFORTS: + return {"error": f"invalid effort_floor {effort_floor!r}"} + if effort_ceiling is not None and effort_ceiling not in _VALID_EFFORTS: + return {"error": f"invalid effort_ceiling {effort_ceiling!r}"} + if not rationale or not str(rationale).strip(): + return {"error": "rationale is required and must be non-empty"} + + root = paths.find_project_root() + new_id = _slot_id(scope, role, rank_int) + slot_path = _slot_path(root, new_id) + if slot_path.is_file(): + return { + "error": ( + f"role-slot {new_id!r} already exists at {slot_path}; " + "pick a different rank or close the existing slot first" + ) + } + + spec: dict = { + "scope": scope, + "role": role, + "seniority": seniority, + "rank": rank_int, + "state": "open", + "filled_by": None, + "rationale": str(rationale).strip(), + "created": _now_iso(), + } + if default_model_profile: + spec["default_model_profile"] = default_model_profile + if effort_floor: + spec["effort_floor"] = effort_floor + if effort_ceiling: + spec["effort_ceiling"] = effort_ceiling + + errors = schema.validate_spec("RoleSlot", spec) + if errors: + return {"error": "schema validation failed", "details": errors} + + ent = entity.new("RoleSlot", new_id, spec) + slot_path.parent.mkdir(parents=True, exist_ok=True) + ent.write(slot_path) + + try: + db = index.open_db() + try: + index.upsert_entity(db, ent) + finally: + db.close() + except Exception: + pass + + log.log_side_effect( + "RoleSlot", new_id, "role_slot.created", + f"Opened RoleSlot {new_id!r} (scope={scope}, role={role}, " + f"seniority={seniority}, rank={rank_int})", + root=root, + actor=new_id, + ) + return {"id": new_id, "path": str(slot_path), "state": "open"} + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, +)) +def get_role_slot(id: str) -> dict: + """Return the full RoleSlot entity by ID.""" + if not id or not id.startswith("SLOT-"): + return {"error": f"id must be a SLOT-* id; got {id!r}"} + root = paths.find_project_root() + ent = _load_slot(root, id) + if ent is None: + return {"error": f"role-slot {id!r} not found"} + return _slot_summary(ent) + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, +)) +def list_role_slots( + scope: str | None = None, + role: str | None = None, + state: str | None = None, + limit: int = 200, +) -> list[dict]: + """List RoleSlots under context/roleslots/, optionally filtered. + + Filters + ------- + scope: match SCOPE- exactly + role: match ROLE- exactly + state: open | filled | closed + """ + if state is not None and state not in _VALID_SLOT_STATES: + return [{"error": f"invalid state filter {state!r}"}] + root = paths.find_project_root() + base = _slot_dir(root) + if not base.is_dir(): + return [] + out: list[dict] = [] + for f in sorted(base.rglob("SLOT-*.md")): + try: + ent = entity.load(f) + except Exception: + continue + if ent.kind != "RoleSlot": + continue + spec = ent.spec or {} + if scope and spec.get("scope") != scope: + continue + if role and spec.get("role") != role: + continue + if state and spec.get("state") != state: + continue + out.append(_slot_summary(ent)) + if len(out) >= limit: + break + return out + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=False, + openWorldHint=False, +)) +def fill_role_slot( + id: str, + team_member_slug: str, + valid_from: str | None = None, + valid_until: str | None = None, + rationale: str | None = None, +) -> dict: + """Place an active TeamMember into an open RoleSlot. + + Sets ``state=filled``, ``filled_by=TEAMMEMBER-``, and creates + a parallel ``role-slot-fill`` Binding so time-bounded dispatch + queries continue to work through binding-management. + + Returns + ------- + On success: ``{ok: True, id, state, filled_by, binding_id, binding_path}`` + On error: ``{error, details?}`` + """ + if not id or not id.startswith("SLOT-"): + return {"error": f"id must be a SLOT-* id; got {id!r}"} + if not team_member_slug: + return {"error": "team_member_slug is required"} + + root = paths.find_project_root() + ent = _load_slot(root, id) + if ent is None: + return {"error": f"role-slot {id!r} not found"} + + current_state = (ent.spec or {}).get("state") + if current_state == "filled": + return { + "error": ( + f"role-slot {id!r} is already filled by " + f"{ent.spec.get('filled_by')!r}; close it first if you " + "need to reassign" + ) + } + if "filled" not in _SLOT_TRANSITIONS.get(current_state, set()): + return { + "error": ( + f"invalid transition {current_state!r} → 'filled' for " + f"role-slot {id!r}; allowed from {current_state!r}: " + f"{sorted(_SLOT_TRANSITIONS.get(current_state, set()))}" + ) + } + + tm = _load_tm(root, team_member_slug) + if tm is None: + return {"error": f"team-member {team_member_slug!r} not found"} + if not tm.spec.get("active", True): + return { + "error": ( + f"cannot fill role-slot with inactive TeamMember " + f"{tm.id!r}; reactivate or pick a different member" + ) + } + + ent.spec["state"] = "filled" + ent.spec["filled_by"] = tm.id + errors = schema.validate_spec("RoleSlot", ent.spec) + if errors: + return {"error": "schema validation failed", "details": errors} + ent.write() + + try: + db = index.open_db() + try: + index.upsert_entity(db, ent) + finally: + db.close() + except Exception: + pass + + binding_result = _create_role_slot_fill_binding( + root=root, + slot_id=ent.id, + team_member_id=tm.id, + valid_from=valid_from, + valid_until=valid_until, + rationale=rationale, + ) + if "error" in binding_result: + # Roll back the slot transition so the data store stays consistent. + ent.spec["state"] = current_state or "open" + ent.spec["filled_by"] = None + ent.write() + return { + "error": "could not create role-slot-fill binding", + "details": binding_result, + } + + log.log_side_effect( + "RoleSlot", ent.id, "role_slot.filled", + f"Filled RoleSlot {ent.id!r} with {tm.id!r}", + root=root, + actor=ent.id, + ) + return { + "ok": True, + "id": ent.id, + "state": "filled", + "filled_by": tm.id, + "binding_id": binding_result["id"], + "binding_path": binding_result["path"], + } + + +@server.tool(annotations=ToolAnnotations( + readOnlyHint=False, + destructiveHint=True, + idempotentHint=True, + openWorldHint=False, +)) +def close_role_slot(id: str, reason: str | None = None) -> dict: + """Close a RoleSlot. Terminal — re-opening requires a new SLOT-id. + + ``open|filled → closed`` is always allowed; closing an already + ``closed`` slot is a no-op (idempotent). + """ + if not id or not id.startswith("SLOT-"): + return {"error": f"id must be a SLOT-* id; got {id!r}"} + root = paths.find_project_root() + ent = _load_slot(root, id) + if ent is None: + return {"error": f"role-slot {id!r} not found"} + + current_state = (ent.spec or {}).get("state") + if current_state == "closed": + # Idempotent close; report the existing terminal state. + return { + "ok": True, + "id": ent.id, + "state": "closed", + "already_closed": True, + "closed_at": ent.spec.get("closed_at"), + } + if "closed" not in _SLOT_TRANSITIONS.get(current_state, set()): + return { + "error": ( + f"invalid transition {current_state!r} → 'closed' for " + f"role-slot {id!r}" + ) + } + + ent.spec["state"] = "closed" + ent.spec["closed_at"] = _now_iso() + if reason: + ent.spec["close_reason"] = str(reason).strip() + errors = schema.validate_spec("RoleSlot", ent.spec) + if errors: + return {"error": "schema validation failed", "details": errors} + ent.write() + + try: + db = index.open_db() + try: + index.upsert_entity(db, ent) + finally: + db.close() + except Exception: + pass + + log.log_side_effect( + "RoleSlot", ent.id, "role_slot.closed", + f"Closed RoleSlot {ent.id!r}" + + (f" — reason: {reason!r}" if reason else ""), + root=root, + actor=ent.id, + ) + return { + "ok": True, + "id": ent.id, + "state": "closed", + "closed_at": ent.spec["closed_at"], + "close_reason": ent.spec.get("close_reason"), + } + + +# --------------------------------------------------------------------------- +# Resolver helpers — RoleSlot pre-step (Phase A team-creator v2) +# --------------------------------------------------------------------------- + +def _find_filled_slot( + root: Path, + role: str | None, + seniority: str | None, + scope: str | None, +) -> entity.Entity | None: + """Return the first filled RoleSlot matching (role, seniority, scope). + + Match rules: + - role and scope must equal exactly when supplied; + - seniority must equal when supplied; + - state must be 'filled'; + - prefers rank=1 over higher ranks (sorted ascending). + """ + base = _slot_dir(root) + if not base.is_dir(): + return None + matches: list[tuple[int, entity.Entity]] = [] + for f in base.rglob("SLOT-*.md"): + try: + ent = entity.load(f) + except Exception: + continue + if ent.kind != "RoleSlot": + continue + spec = ent.spec or {} + if spec.get("state") != "filled": + continue + if role and spec.get("role") != role: + continue + if seniority and spec.get("seniority") != seniority: + continue + if scope and spec.get("scope") != scope: + continue + try: + rank = int(spec.get("rank") or 999999) + except (TypeError, ValueError): + rank = 999999 + matches.append((rank, ent)) + if not matches: + return None + matches.sort(key=lambda pair: pair[0]) + return matches[0][1] + + +def _roleslot_pre_step( + role: str | None, + seniority: str | None, + scope: str | None, +) -> dict | None: + """Phase A pre-step before the existing 8-layer resolver. + + Returns ``None`` when no filled slot matches the (role, seniority, + scope) triple — the caller MUST then fall through to the existing + 8-layer model-assignment binding precedence unchanged. + + Returns a dict ``{slot, team_member, applied_seniority}`` when a + filled slot is found: + - slot: full _slot_summary + - team_member: TeamMember entity for the slot's filled_by + (or None when the slot has filled_by=null, + which is the ephemeral-dispatch case where + the slot's default_model_profile becomes + the Layer 8 fallback) + - applied_seniority: TeamMember.default_seniority overrides the + slot's seniority for model resolution if + set; otherwise the slot's seniority. + """ + if not role: + return None + root = paths.find_project_root() + slot = _find_filled_slot(root, role=role, seniority=seniority, scope=scope) + if slot is None: + return None + spec = slot.spec or {} + tm_id = spec.get("filled_by") + tm_ent = _load_tm(root, tm_id) if tm_id else None + applied_seniority = spec.get("seniority") + if tm_ent is not None: + tm_seniority = (tm_ent.spec or {}).get("default_seniority") + if tm_seniority: + applied_seniority = tm_seniority + return { + "slot": _slot_summary(slot), + "team_member": ( + _team_member_summary(tm_ent) if tm_ent is not None else None + ), + "applied_seniority": applied_seniority, + } + + # --------------------------------------------------------------------------- # Name-pool tools # --------------------------------------------------------------------------- diff --git a/src/context/skills/processkit/team-manager/scripts/apply_migration_2139.py b/src/context/skills/processkit/team-manager/scripts/apply_migration_2139.py new file mode 100644 index 0000000..29d91c9 --- /dev/null +++ b/src/context/skills/processkit/team-manager/scripts/apply_migration_2139.py @@ -0,0 +1,407 @@ +"""Apply script for MIG-20260509T213904-roleslot-phase-a (Phase A back-fill). + +Walks the project's `context/roles/` and `context/bindings/` directories, +emits one RoleSlot per legacy ``Role.clone_cap`` unit, and writes a +parallel ``role-slot-fill`` Binding for every active +``role-assignment`` Binding it finds. Old v1 entities are left in place +(read-only). + +The script is **idempotent**: a re-run notices already-emitted RoleSlots +and ``role-slot-fill`` Bindings (matched by their deterministic IDs and +``labels.migration`` stamp) and skips them. + +On a v2-native project (no v1 archetype Roles, no ``role-assignment`` +Bindings) the script is a no-op — its value is for derived projects +(aibox, etc.) when they upgrade across the v0.25.8 → v0.26.0 boundary. + +Usage:: + + uv run --with pyyaml --with jsonschema --with mcp \\ + python context/skills/processkit/team-manager/scripts/apply_migration_2139.py \\ + --chartering-scope SCOPE- # required + [--project-root ] # default: cwd-walk + [--dry-run] # print plan; write nothing + +The chartering Scope must already exist; if it does not, the script +errors before any write. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + + +_THIS = Path(__file__).resolve() +_TM_SCRIPTS = _THIS.parent +_TM_MCP = _TM_SCRIPTS.parent / "mcp" + + +def _bootstrap_paths() -> None: + """Ensure both the team-manager MCP server module and the + processkit lib are importable.""" + here = _THIS + while True: + for c in (here / "src" / "lib", here / "_lib"): + if (c / "processkit" / "__init__.py").is_file(): + if str(c) not in sys.path: + sys.path.insert(0, str(c)) + break + if here.parent == here: + break + here = here.parent + for p in (str(_TM_MCP), str(_TM_SCRIPTS)): + if p not in sys.path: + sys.path.insert(0, p) + + +_bootstrap_paths() + + +# Imports happen after path bootstrap so the project lib is found. +import server as team_manager_mcp # noqa: E402 +from processkit import entity, paths # noqa: E402 + + +_MIGRATION_ID = "MIG-20260509T213904-roleslot-phase-a" + + +# v0.19.0 archetype set used to recognise legacy archetype-spawned Roles. +_LEGACY_ARCHETYPES = { + "project-manager", + "senior-architect", + "senior-researcher", + "junior-architect", + "developer", + "junior-researcher", + "junior-developer", + "assistant", +} + + +def _iter_v1_archetype_roles(root: Path): + """Yield (Entity, archetype_name, clone_cap_int) for legacy Roles. + + A legacy Role is one whose name matches a v0.19.0 archetype. We + treat ``spec.clone_cap`` if present; otherwise default to 1 + (every legacy Role is at least one slot). + """ + role_dir = root / "context" / "roles" + if not role_dir.is_dir(): + return + for path in sorted(role_dir.glob("ROLE-*.md")): + try: + ent = entity.load(path) + except Exception: + continue + if ent.kind != "Role": + continue + spec = ent.spec or {} + name = spec.get("name") or ent.id[len("ROLE-"):] + if name not in _LEGACY_ARCHETYPES: + # Could also be the matching catalog Role; only legacy + # archetype-spawned Roles get back-filled. Catalog Roles + # are by convention named after their job title + # (e.g. ROLE-product-manager) — never match the archetype + # set. + continue + try: + cap = int(spec.get("clone_cap", 1)) + except (TypeError, ValueError): + cap = 1 + if cap < 1: + cap = 1 + yield ent, name, cap + + +def _iter_active_role_assignment_bindings(root: Path): + """Yield Entity objects for active Binding(type=role-assignment).""" + bind_dir = root / "context" / "bindings" + if not bind_dir.is_dir(): + return + for path in sorted(bind_dir.glob("BIND-*.md")): + try: + ent = entity.load(path) + except Exception: + continue + if ent.kind != "Binding": + continue + spec = ent.spec or {} + if spec.get("type") != "role-assignment": + continue + # Skip ended bindings. + if spec.get("ended_at") or spec.get("valid_until_passed"): + continue + yield ent + + +def _scope_slug(scope_id: str) -> str: + return scope_id[len("SCOPE-"):] if scope_id.startswith("SCOPE-") else scope_id + + +def _existing_role_slot_fill_for(root: Path, slot_id: str, + team_member_id: str) -> bool: + """True iff a role-slot-fill Binding already exists for (slot, member).""" + bind_dir = root / "context" / "bindings" + if not bind_dir.is_dir(): + return False + for path in bind_dir.glob("BIND-*.md"): + try: + ent = entity.load(path) + except Exception: + continue + if ent.kind != "Binding": + continue + spec = ent.spec or {} + if spec.get("type") != "role-slot-fill": + continue + if spec.get("target") == slot_id and spec.get("subject") == team_member_id: + return True + return False + + +def _scope_exists(root: Path, scope_id: str) -> bool: + if not scope_id.startswith("SCOPE-"): + return False + scope_dir = root / "context" / "scopes" + if not scope_dir.is_dir(): + return False + return any(p.name == f"{scope_id}.md" for p in scope_dir.rglob("SCOPE-*.md")) + + +def _seniority_for_role(role_id: str, role_spec: dict[str, Any]) -> str: + """Best-effort seniority extraction. + + v1 archetype Roles did not carry an explicit seniority field — we + derive a sensible default from the archetype name; a project may + override after migration. + """ + name = role_spec.get("name") or role_id[len("ROLE-"):] + if name.startswith("junior-"): + return "junior" + if name in {"project-manager", "senior-architect", "senior-researcher", + "developer"}: + return "senior" + return "specialist" + + +def apply( + project_root: Path, + chartering_scope: str, + *, + dry_run: bool = False, +) -> dict[str, Any]: + """Run the back-fill. + + Returns a summary dict with counts: ``slots_created``, + ``slots_skipped`` (already present), ``fills_created``, + ``fills_skipped``, ``warnings``. + """ + project_root = Path(project_root).resolve() + if not chartering_scope.startswith("SCOPE-"): + return { + "error": ( + f"chartering_scope must be a SCOPE-* id; got " + f"{chartering_scope!r}" + ) + } + if not _scope_exists(project_root, chartering_scope): + return { + "error": ( + f"chartering_scope {chartering_scope!r} not found under " + f"{project_root / 'context' / 'scopes'}" + ) + } + + summary = { + "migration_id": _MIGRATION_ID, + "chartering_scope": chartering_scope, + "project_root": str(project_root), + "dry_run": bool(dry_run), + "slots_created": 0, + "slots_skipped": 0, + "fills_created": 0, + "fills_skipped": 0, + "warnings": [], + "actions": [], + } + + # Make team-manager find the project root we want. + paths.find_project_root.__wrapped__ if hasattr(paths.find_project_root, + "__wrapped__") else None + # Override discovery by chdir'ing — the team-manager server walks up + # from cwd looking for AGENTS.md. + import os + prev_cwd = os.getcwd() + os.chdir(project_root) + try: + # --- Phase A.2: emit RoleSlots --------------------------------- + archetype_to_slot_rank1: dict[str, str] = {} + for role_ent, archetype, cap in _iter_v1_archetype_roles(project_root): + seniority = _seniority_for_role(role_ent.id, role_ent.spec or {}) + scope_slug = _scope_slug(chartering_scope) + for rank in range(1, cap + 1): + slot_id = ( + f"SLOT-{scope_slug}-{role_ent.id[len('ROLE-'):]}-{rank}" + ) + slot_path = ( + project_root / "context" / "roleslots" / f"{slot_id}.md" + ) + if slot_path.is_file(): + summary["slots_skipped"] += 1 + summary["actions"].append({ + "kind": "skip-slot-exists", + "slot": slot_id, + }) + if rank == 1: + archetype_to_slot_rank1[role_ent.id] = slot_id + continue + if dry_run: + summary["slots_created"] += 1 + summary["actions"].append({ + "kind": "would-create-slot", + "slot": slot_id, + "archetype": archetype, + "role": role_ent.id, + "seniority": seniority, + "rank": rank, + }) + if rank == 1: + archetype_to_slot_rank1[role_ent.id] = slot_id + continue + rationale = ( + f"Phase A back-fill for {_MIGRATION_ID}: " + f"archetype={archetype} (was Role.clone_cap={cap})" + ) + result = team_manager_mcp.create_role_slot( + scope=chartering_scope, + role=role_ent.id, + seniority=seniority, + rank=rank, + rationale=rationale, + ) + if "error" in result: + summary["warnings"].append({ + "kind": "create_role_slot-failed", + "role": role_ent.id, + "rank": rank, + "detail": result["error"], + }) + continue + summary["slots_created"] += 1 + summary["actions"].append({ + "kind": "created-slot", + "slot": result["id"], + "archetype": archetype, + "role": role_ent.id, + "rank": rank, + }) + if rank == 1: + archetype_to_slot_rank1[role_ent.id] = result["id"] + + # --- Phase A.3: emit role-slot-fill Bindings ------------------- + for ba in _iter_active_role_assignment_bindings(project_root): + spec = ba.spec or {} + target = spec.get("target") + subject = spec.get("subject") + if not target or not target.startswith("ROLE-"): + summary["warnings"].append({ + "kind": "skip-binding-non-role-target", + "binding": ba.id, + "target": target, + }) + continue + slot_id = archetype_to_slot_rank1.get(target) + if not slot_id: + summary["warnings"].append({ + "kind": "skip-binding-no-rank1-slot", + "binding": ba.id, + "role": target, + }) + continue + # We require a TeamMember subject for role-slot-fill (the v2 + # schema says so). v1 may have used Actors as subjects; + # those cannot be migrated automatically. + if not str(subject).startswith("TEAMMEMBER-"): + summary["warnings"].append({ + "kind": "skip-binding-non-teammember-subject", + "binding": ba.id, + "subject": subject, + }) + continue + if _existing_role_slot_fill_for(project_root, slot_id, subject): + summary["fills_skipped"] += 1 + summary["actions"].append({ + "kind": "skip-fill-exists", + "slot": slot_id, + "team_member": subject, + }) + continue + if dry_run: + summary["fills_created"] += 1 + summary["actions"].append({ + "kind": "would-fill-slot", + "slot": slot_id, + "team_member": subject, + }) + continue + tm_slug = subject[len("TEAMMEMBER-"):] + result = team_manager_mcp.fill_role_slot( + id=slot_id, + team_member_slug=tm_slug, + rationale=( + f"Phase A back-fill for {_MIGRATION_ID}: " + f"parallel role-slot-fill for v1 {ba.id}" + ), + ) + if "error" in result: + summary["warnings"].append({ + "kind": "fill_role_slot-failed", + "slot": slot_id, + "team_member": subject, + "detail": result["error"], + }) + continue + summary["fills_created"] += 1 + summary["actions"].append({ + "kind": "filled-slot", + "slot": slot_id, + "team_member": subject, + "binding": result.get("binding", {}).get("id"), + }) + finally: + os.chdir(prev_cwd) + + return summary + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Apply MIG-20260509T213904-roleslot-phase-a Phase A " + "back-fill (idempotent)." + ) + ) + parser.add_argument("--chartering-scope", required=True, + help="SCOPE- the back-filled RoleSlots belong to") + parser.add_argument("--project-root", default=".", + help="Project root (defaults to cwd)") + parser.add_argument("--dry-run", action="store_true", + help="Print the plan without writing") + args = parser.parse_args(argv) + summary = apply( + Path(args.project_root), + args.chartering_scope, + dry_run=args.dry_run, + ) + if "error" in summary: + print(f"ERROR: {summary['error']}", file=sys.stderr) + return 2 + import json + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/context/skills/processkit/team-manager/scripts/test_team_manager.py b/src/context/skills/processkit/team-manager/scripts/test_team_manager.py index aa49869..de99ea2 100644 --- a/src/context/skills/processkit/team-manager/scripts/test_team_manager.py +++ b/src/context/skills/processkit/team-manager/scripts/test_team_manager.py @@ -75,6 +75,12 @@ def project_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: role_schema = _find_repo_root() / "context" / "schemas" / "role.yaml" if role_schema.is_file(): shutil.copy(role_schema, schemas_dir / "role.yaml") + # RoleSlot + Binding + Scope schemas — needed by the Phase A + # team-creator v2 RoleSlot tools (DEC-20260509_1906-CoolBadger). + for extra in ("roleslot.yaml", "binding.yaml", "scope.yaml"): + src_extra = _find_repo_root() / "context" / "schemas" / extra + if src_extra.is_file(): + shutil.copy(src_extra, schemas_dir / extra) monkeypatch.chdir(tmp_path) # Force processkit.paths.find_project_root to discover AGENTS.md in tmp_path @@ -1002,3 +1008,640 @@ def test_check_all_aggregate(server_mod, project_root: Path, assets_dir): assert report["summary"]["count"] == 2 assert "alice-chen" in report["members"] assert "bob-lee" in report["members"] + + +# --------------------------------------------------------------------------- +# RoleSlot tools — Phase A team-creator v2 +# DEC-20260509_1906-CoolBadger / ART-20260509_1836-SmartPanda +# --------------------------------------------------------------------------- + +def _open_slot(server_mod, **overrides): + """Helper: create a baseline RoleSlot for further tests to mutate.""" + payload = dict( + scope="SCOPE-q2-2026", + role="ROLE-software-engineer", + seniority="senior", + rank=1, + rationale="primary backend implementer for q2", + ) + payload.update(overrides) + return server_mod.create_role_slot(**payload) + + +def test_role_slot_create_get_happy_path(server_mod, project_root: Path): + r = _open_slot(server_mod) + assert r.get("state") == "open", r + expected_id = "SLOT-q2-2026-software-engineer-1" + assert r["id"] == expected_id + assert Path(r["path"]).is_file() + + got = server_mod.get_role_slot(expected_id) + assert got["id"] == expected_id + assert got["scope"] == "SCOPE-q2-2026" + assert got["role"] == "ROLE-software-engineer" + assert got["seniority"] == "senior" + assert got["rank"] == 1 + assert got["state"] == "open" + assert got["filled_by"] is None + assert got["rationale"] == "primary backend implementer for q2" + assert got["created"] + + +def test_role_slot_create_validates_inputs(server_mod, project_root: Path): + bad_scope = server_mod.create_role_slot( + scope="bad", role="ROLE-x", seniority="senior", rank=1, rationale="r", + ) + assert "error" in bad_scope and "SCOPE" in bad_scope["error"] + + bad_role = server_mod.create_role_slot( + scope="SCOPE-x", role="bad", seniority="senior", rank=1, rationale="r", + ) + assert "error" in bad_role and "ROLE" in bad_role["error"] + + bad_sen = server_mod.create_role_slot( + scope="SCOPE-x", role="ROLE-x", seniority="ninja", rank=1, rationale="r", + ) + assert "error" in bad_sen and "seniority" in bad_sen["error"] + + bad_rank = server_mod.create_role_slot( + scope="SCOPE-x", role="ROLE-x", seniority="senior", rank=0, rationale="r", + ) + assert "error" in bad_rank and "rank" in bad_rank["error"] + + bad_rationale = server_mod.create_role_slot( + scope="SCOPE-x", role="ROLE-x", seniority="senior", rank=1, rationale="", + ) + assert "error" in bad_rationale and "rationale" in bad_rationale["error"] + + +def test_role_slot_create_rejects_duplicate(server_mod, project_root: Path): + r1 = _open_slot(server_mod) + assert "error" not in r1, r1 + r2 = _open_slot(server_mod) + assert "error" in r2 and "already exists" in r2["error"] + + +def test_list_role_slots_filters(server_mod, project_root: Path): + _open_slot(server_mod) # SLOT-q2-2026-software-engineer-1 (open, senior) + _open_slot(server_mod, rank=2, rationale="parallel slot") + _open_slot( + server_mod, role="ROLE-product-manager", seniority="senior", + rationale="single PM", + ) + + all_slots = server_mod.list_role_slots() + assert {s["id"] for s in all_slots} == { + "SLOT-q2-2026-software-engineer-1", + "SLOT-q2-2026-software-engineer-2", + "SLOT-q2-2026-product-manager-1", + } + + by_role = server_mod.list_role_slots(role="ROLE-software-engineer") + assert len(by_role) == 2 + + by_state_open = server_mod.list_role_slots(state="open") + assert len(by_state_open) == 3 + + by_state_filled = server_mod.list_role_slots(state="filled") + assert by_state_filled == [] + + +def test_fill_role_slot_happy_path(server_mod, project_root: Path): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + open_r = _open_slot(server_mod) + slot_id = open_r["id"] + + fill_r = server_mod.fill_role_slot( + id=slot_id, + team_member_slug="alice-chen", + valid_from="2026-05-09", + valid_until="2026-08-01", + rationale="lead engineer for the q2 charter", + ) + assert fill_r.get("ok") is True, fill_r + assert fill_r["state"] == "filled" + assert fill_r["filled_by"] == "TEAMMEMBER-alice-chen" + assert fill_r["binding_id"].startswith("BIND-") + assert Path(fill_r["binding_path"]).is_file() + + # Slot file reflects filled state + got = server_mod.get_role_slot(slot_id) + assert got["state"] == "filled" + assert got["filled_by"] == "TEAMMEMBER-alice-chen" + + +def test_fill_role_slot_rejects_inactive_team_member(server_mod, project_root: Path): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + server_mod.deactivate_team_member("alice-chen") + r = _open_slot(server_mod) + fill = server_mod.fill_role_slot(id=r["id"], team_member_slug="alice-chen") + assert "error" in fill + assert "inactive" in fill["error"] + + +def test_fill_role_slot_rejects_unknown_team_member(server_mod, project_root: Path): + r = _open_slot(server_mod) + fill = server_mod.fill_role_slot(id=r["id"], team_member_slug="nobody") + assert "error" in fill + assert "not found" in fill["error"] + + +def test_close_role_slot_terminal(server_mod, project_root: Path): + r = _open_slot(server_mod) + close = server_mod.close_role_slot(r["id"], reason="charter closed early") + assert close.get("ok") is True + assert close["state"] == "closed" + assert close["closed_at"] + assert close["close_reason"] == "charter closed early" + + # Idempotent close + close2 = server_mod.close_role_slot(r["id"]) + assert close2.get("ok") is True + assert close2.get("already_closed") is True + + # Reverse transition rejected + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + fill_after_close = server_mod.fill_role_slot( + id=r["id"], team_member_slug="alice-chen", + ) + assert "error" in fill_after_close + assert "transition" in fill_after_close["error"] + + +def test_fill_role_slot_rejects_double_fill(server_mod, project_root: Path): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + server_mod.create_team_member( + name="Bob Lee", type="human", slug="bob-lee", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + r = _open_slot(server_mod) + server_mod.fill_role_slot(id=r["id"], team_member_slug="alice-chen") + + second = server_mod.fill_role_slot(id=r["id"], team_member_slug="bob-lee") + assert "error" in second + assert "already filled" in second["error"] + + +def test_resolver_pre_step_returns_filled_slot( + server_mod, + project_root: Path, + monkeypatch: pytest.MonkeyPatch, +): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + server_mod.set_active_interlocutor("alice-chen") + open_r = _open_slot(server_mod) + server_mod.fill_role_slot(id=open_r["id"], team_member_slug="alice-chen") + + # Stub the model resolver so the existing 8-layer code path runs + # cleanly and we can inspect the pre-step on top. + class Resolver: + @staticmethod + def _runtime_context(task_hints=None): + return {"harnesses": [], "allowed_providers": []} + + @staticmethod + def resolve(**kwargs): + return [], [] + + monkeypatch.setattr(server_mod, "_load_model_resolver", lambda: Resolver) + got = server_mod.get_interlocutor_runtime_binding(scope="SCOPE-q2-2026") + + assert got["configured"] is True + pre = got["binding"].get("roleslot_pre_step") + assert pre is not None, got["binding"] + assert pre["slot"]["id"] == open_r["id"] + assert pre["slot"]["state"] == "filled" + assert pre["team_member"]["id"] == "TEAMMEMBER-alice-chen" + # TeamMember.default_seniority overrides slot.seniority for model + # resolution if set (design §"Resolver impact" step 3). + assert pre["applied_seniority"] == "senior" + + +def test_resolver_pre_step_falls_through_when_no_slot( + server_mod, + project_root: Path, + monkeypatch: pytest.MonkeyPatch, +): + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + server_mod.set_active_interlocutor("alice-chen") + + class Resolver: + @staticmethod + def _runtime_context(task_hints=None): + return {"harnesses": [], "allowed_providers": []} + + @staticmethod + def resolve(**kwargs): + return [], [] + + monkeypatch.setattr(server_mod, "_load_model_resolver", lambda: Resolver) + got = server_mod.get_interlocutor_runtime_binding(scope="SCOPE-q2-2026") + + # No slot exists for (ROLE-software-engineer, senior, SCOPE-q2-2026) + # so the pre-step is silent and the response is identical to + # pre-RoleSlot behaviour. Phase A is additive (Q2 deferred). + assert got["configured"] is True + assert "roleslot_pre_step" not in got["binding"] + + +def test_resolver_pre_step_seniority_override_from_team_member( + server_mod, + project_root: Path, + monkeypatch: pytest.MonkeyPatch, +): + # Slot opens at seniority=senior; TeamMember declares + # default_seniority=expert. Per design §"Resolver impact" step 3 + # the TeamMember's default_seniority overrides the slot's seniority + # for model resolution. + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="expert", + ) + server_mod.set_active_interlocutor("alice-chen") + open_r = server_mod.create_role_slot( + scope="SCOPE-q2-2026", + role="ROLE-software-engineer", + seniority="expert", + rank=1, + rationale="expert backend implementer", + ) + server_mod.fill_role_slot(id=open_r["id"], team_member_slug="alice-chen") + + class Resolver: + @staticmethod + def _runtime_context(task_hints=None): + return {"harnesses": [], "allowed_providers": []} + + @staticmethod + def resolve(**kwargs): + return [], [] + + monkeypatch.setattr(server_mod, "_load_model_resolver", lambda: Resolver) + got = server_mod.get_interlocutor_runtime_binding(scope="SCOPE-q2-2026") + pre = got["binding"]["roleslot_pre_step"] + assert pre["slot"]["seniority"] == "expert" + assert pre["applied_seniority"] == "expert" + + +# --------------------------------------------------------------------------- +# team-creator v2 — archetype-catalog mapping loader (SUB-2 / LuckyWren) +# --------------------------------------------------------------------------- + +@pytest.fixture() +def team_creator_lib(): + """Import the team-creator scripts module from the in-repo source path.""" + repo_root = _find_repo_root() + tc_scripts = ( + repo_root + / "context" + / "skills" + / "processkit" + / "team-creator" + / "scripts" + ) + if str(tc_scripts) not in sys.path: + sys.path.insert(0, str(tc_scripts)) + if "team_creator_lib" in sys.modules: + del sys.modules["team_creator_lib"] + import team_creator_lib # noqa: F401 + return team_creator_lib + + +def test_archetype_catalog_mapping_kit_default_loads(team_creator_lib, tmp_path): + """The shipped kit-default mapping must validate and contain all 8 archetypes.""" + mapping = team_creator_lib.load_archetype_catalog_mapping(tmp_path) + assert mapping.source == "kit-default" + assert mapping.semantics is None + assert mapping.overrides == [] + assert set(mapping.archetypes) == set(team_creator_lib.ARCHETYPES) + pm = mapping.archetypes["project-manager"] + assert pm["role"] == "ROLE-product-manager" + assert pm["seniority"] == "senior" + assert pm.get("primary_contact") is True + # Two archetypes share the same catalog Role with different seniority. + sa = mapping.archetypes["senior-architect"] + ja = mapping.archetypes["junior-architect"] + assert sa["role"] == ja["role"] == "ROLE-solutions-architect" + assert sa["seniority"] == "senior" + assert ja["seniority"] == "specialist" + + +def test_archetype_catalog_mapping_project_delta_layers_correctly( + team_creator_lib, tmp_path +): + """Project override in delta mode replaces only the listed archetypes.""" + (tmp_path / "context" / "team").mkdir(parents=True) + proj = tmp_path / "context" / "team" / "archetype-catalog-mapping.yaml" + proj.write_text( + "apiVersion: processkit.projectious.work/v2\n" + "kind: ArchetypeCatalogMapping\n" + "spec:\n" + " archetypes:\n" + " developer:\n" + " role: ROLE-software-engineer\n" + " seniority: principal\n", + encoding="utf-8", + ) + mapping = team_creator_lib.load_archetype_catalog_mapping(tmp_path) + assert mapping.source == "project" + assert mapping.semantics == "delta" + # Override applies to the listed archetype: + assert mapping.archetypes["developer"]["seniority"] == "principal" + # Untouched archetypes inherit the kit default: + assert mapping.archetypes["project-manager"]["role"] == "ROLE-product-manager" + assert mapping.archetypes["assistant"]["role"] == "ROLE-assistant" + # The delta entry surfaces in `overrides` for the chartering DEC audit: + delta = [o for o in mapping.overrides + if o["archetype"] == "developer" and o["field"] == "seniority"] + assert len(delta) == 1 + assert delta[0]["kit_default"] == "senior" + assert delta[0]["project_value"] == "principal" + + +def test_archetype_catalog_mapping_replace_requires_all_archetypes( + team_creator_lib, tmp_path +): + """A replace-mode override missing archetypes is a hard error.""" + (tmp_path / "context" / "team").mkdir(parents=True) + proj = tmp_path / "context" / "team" / "archetype-catalog-mapping.yaml" + proj.write_text( + "override_semantics: replace\n" + "spec:\n" + " archetypes:\n" + " developer:\n" + " role: ROLE-software-engineer\n" + " seniority: senior\n", + encoding="utf-8", + ) + with pytest.raises(ValueError) as excinfo: + team_creator_lib.load_archetype_catalog_mapping(tmp_path) + assert "replace-mode override missing archetypes" in str(excinfo.value) + + +def test_archetype_catalog_mapping_reverse_lookup(team_creator_lib, tmp_path): + """archetype_for_role_slot resolves (ROLE, seniority) -> archetype name.""" + mapping = team_creator_lib.load_archetype_catalog_mapping(tmp_path) + assert team_creator_lib.archetype_for_role_slot( + "ROLE-software-engineer", "junior", mapping, + ) == "junior-developer" + assert team_creator_lib.archetype_for_role_slot( + "ROLE-software-engineer", "senior", mapping, + ) == "developer" + assert team_creator_lib.archetype_for_role_slot( + "ROLE-product-manager", "senior", mapping, + ) == "project-manager" + # No archetype for a fictional pair: + assert team_creator_lib.archetype_for_role_slot( + "ROLE-software-engineer", "principal", mapping, + ) is None + + +# --------------------------------------------------------------------------- +# Migration apply script — Phase A back-fill (idempotency + smoke test) +# --------------------------------------------------------------------------- + +@pytest.fixture() +def apply_migration_2139_module(): + here = Path(__file__).resolve().parent + if str(here) not in sys.path: + sys.path.insert(0, str(here)) + # Force a fresh import so the module re-binds ``server`` against the + # active project_root fixture. + for name in ("apply_migration_2139",): + if name in sys.modules: + del sys.modules[name] + import apply_migration_2139 # noqa: F401 + return apply_migration_2139 + + +def _seed_v1_archetype_role(project_root: Path, archetype: str, clone_cap: int = 1): + """Write a minimal v1 archetype-spawned Role file under context/roles/.""" + roles_dir = project_root / "context" / "roles" + roles_dir.mkdir(parents=True, exist_ok=True) + rid = f"ROLE-{archetype}" + body = ( + "---\n" + "apiVersion: processkit.projectious.work/v2\n" + "kind: Role\n" + "metadata:\n" + f" id: {rid}\n" + " created: 2026-04-01T00:00:00+00:00\n" + "spec:\n" + f" name: {archetype}\n" + " description: legacy v1 archetype-spawned role for back-fill test\n" + f" clone_cap: {clone_cap}\n" + "---\n\n" + f"# {rid}\n" + ) + (roles_dir / f"{rid}.md").write_text(body, encoding="utf-8") + return rid + + +def _seed_chartering_scope(project_root: Path, scope_id: str = "SCOPE-q2-2026"): + """Write a minimal Scope file so apply_migration_2139's existence check passes.""" + scopes_dir = project_root / "context" / "scopes" + scopes_dir.mkdir(parents=True, exist_ok=True) + body = ( + "---\n" + "apiVersion: processkit.projectious.work/v2\n" + "kind: Scope\n" + "metadata:\n" + f" id: {scope_id}\n" + " created: 2026-04-01T00:00:00+00:00\n" + "spec:\n" + " title: 2026-Q2 chartering scope (test)\n" + " state: active\n" + " description: test scope for SUB-2 apply-script smoke test\n" + "---\n\n" + f"# {scope_id}\n" + ) + (scopes_dir / f"{scope_id}.md").write_text(body, encoding="utf-8") + return scope_id + + +def _seed_v1_role_assignment_binding( + project_root: Path, + binding_id: str, + subject: str, + target: str, +): + """Write a minimal v1 Binding(type=role-assignment) under context/bindings/.""" + bind_dir = project_root / "context" / "bindings" + bind_dir.mkdir(parents=True, exist_ok=True) + body = ( + "---\n" + "apiVersion: processkit.projectious.work/v2\n" + "kind: Binding\n" + "metadata:\n" + f" id: {binding_id}\n" + " created: 2026-04-01T00:00:00+00:00\n" + "spec:\n" + " type: role-assignment\n" + f" subject: {subject}\n" + f" target: {target}\n" + " subject_kind: TeamMember\n" + " target_kind: Role\n" + " valid_from: '2026-04-01'\n" + "---\n\n" + f"# {binding_id}\n" + ) + (bind_dir / f"{binding_id}.md").write_text(body, encoding="utf-8") + return binding_id + + +def test_apply_migration_2139_smoke_creates_slot_and_fill( + server_mod, # noqa: ARG001 — establishes project_root + paths + project_root: Path, + apply_migration_2139_module, +): + """1 archetype-spawned Role + 1 role-assignment Binding -> 1 SLOT + 1 fill.""" + scope_id = _seed_chartering_scope(project_root) + _seed_v1_archetype_role(project_root, "developer", clone_cap=1) + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + _seed_v1_role_assignment_binding( + project_root, + binding_id="BIND-test-developer-alice", + subject="TEAMMEMBER-alice-chen", + target="ROLE-developer", + ) + + summary = apply_migration_2139_module.apply( + project_root, scope_id, dry_run=False, + ) + assert "error" not in summary, summary + assert summary["slots_created"] == 1, summary + assert summary["fills_created"] == 1, summary + assert summary["slots_skipped"] == 0 + assert summary["fills_skipped"] == 0 + + # The new SLOT exists under context/roleslots/ + slot_id = "SLOT-q2-2026-developer-1" + slot_path = project_root / "context" / "roleslots" / f"{slot_id}.md" + assert slot_path.is_file(), f"expected {slot_path} to exist" + + # The new role-slot-fill Binding exists under context/bindings/ + bind_dir = project_root / "context" / "bindings" + fills = [] + for path in bind_dir.glob("BIND-*.md"): + text = path.read_text(encoding="utf-8") + if "type: role-slot-fill" in text and slot_id in text: + fills.append(path) + assert len(fills) == 1, [p.name for p in bind_dir.glob('*.md')] + + +def test_apply_migration_2139_is_idempotent( + server_mod, # noqa: ARG001 + project_root: Path, + apply_migration_2139_module, +): + """Re-running apply produces no-ops (skips both slot create + fill).""" + scope_id = _seed_chartering_scope(project_root) + _seed_v1_archetype_role(project_root, "developer", clone_cap=1) + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + _seed_v1_role_assignment_binding( + project_root, + binding_id="BIND-test-developer-alice", + subject="TEAMMEMBER-alice-chen", + target="ROLE-developer", + ) + + first = apply_migration_2139_module.apply(project_root, scope_id) + second = apply_migration_2139_module.apply(project_root, scope_id) + assert "error" not in second, second + assert second["slots_created"] == 0 + assert second["fills_created"] == 0 + assert second["slots_skipped"] == first["slots_created"] + assert second["fills_skipped"] == first["fills_created"] + + +def test_apply_migration_2139_v2_native_project_is_no_op( + server_mod, # noqa: ARG001 + project_root: Path, + apply_migration_2139_module, +): + """A project with no v1 archetype Roles produces zero writes.""" + scope_id = _seed_chartering_scope(project_root) + summary = apply_migration_2139_module.apply(project_root, scope_id) + assert "error" not in summary, summary + assert summary["slots_created"] == 0 + assert summary["fills_created"] == 0 + assert summary["slots_skipped"] == 0 + assert summary["fills_skipped"] == 0 + + +def test_apply_migration_2139_dry_run_writes_nothing( + server_mod, # noqa: ARG001 + project_root: Path, + apply_migration_2139_module, +): + """--dry-run reports the plan but writes no SLOT or fill Binding.""" + scope_id = _seed_chartering_scope(project_root) + _seed_v1_archetype_role(project_root, "developer", clone_cap=2) + server_mod.create_team_member( + name="Alice Chen", type="human", slug="alice-chen", + default_role="ROLE-software-engineer", + default_seniority="senior", + ) + _seed_v1_role_assignment_binding( + project_root, + binding_id="BIND-test-developer-alice", + subject="TEAMMEMBER-alice-chen", + target="ROLE-developer", + ) + + summary = apply_migration_2139_module.apply( + project_root, scope_id, dry_run=True, + ) + assert summary["dry_run"] is True + assert summary["slots_created"] == 2 # planned, not written + assert summary["fills_created"] == 1 + # No actual files were written: + rs_dir = project_root / "context" / "roleslots" + assert not rs_dir.exists() or not list(rs_dir.glob("SLOT-*.md")) + + +def test_apply_migration_2139_rejects_missing_scope( + server_mod, # noqa: ARG001 + project_root: Path, # noqa: ARG001 — project_root activates path bootstrap + apply_migration_2139_module, +): + """Apply errors when the chartering Scope does not exist.""" + summary = apply_migration_2139_module.apply( + project_root, "SCOPE-does-not-exist", + ) + assert "error" in summary + assert "not found" in summary["error"]