Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pstack",
"version": "0.15.25-shared.8",
"version": "0.15.25-shared.9",
"description": "Shared pstack engineering workflows for Claude Code and Codex, with one configuration and host-specific runtime adapters.",
"author": {
"name": "Local adaptation; upstream Lauren Tan"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pstack-codex",
"version": "0.15.25-shared.8",
"version": "0.15.25-shared.9",
"description": "Shared pstack engineering workflows for Codex and Claude Code, with one configuration and native host adapters.",
"author": {
"name": "Lauren Tan; local Codex adaptation"
Expand Down
4 changes: 3 additions & 1 deletion ADAPTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The 0.15.25 sync (upstream 0.15.22–0.15.25, #633 and #634: #595, #597, #602, #

Model tiering by role follows upstream again. Poteto-mode's Subagents section restores upstream's "Code delegates tier by difficulty" routing that earlier adaptations dropped: each code playbook's delegate reads its role, the hardest changes read `hardest tasks`, prose and judgment read `judgment and prose`, and verification workers read `verification workers`. The models come from `config.json`, where every role still inherits the parent by default, instead of upstream's Cursor slug defaults, and effort still moves per job within the role's model. `how`, `why` and `reflect` name their role keys, such as `how explorer` and `reflect judgment`, so a configured role is the one they read.

`verification workers` is a local role with no upstream line. Upstream runs orchestrate's verifier and show-me-your-work's handback review on a different model family than the work. On Codex or Claude Code another family needs an `xagent:` entry, so verification takes its own role instead of sharing `swarm workers` with fan-out workers, and each host can point it at the other vendor while swarms keep their own worker. A swarm's verify lanes, as in autopilot's swarm-verify, and the handback review read it too. On T3 a candidate role picks the candidate whose resolved model family differs from the checked work's, because a provider name alone does not identify the family.
`verification workers` is a local role with no upstream line. Upstream runs orchestrate's verifier and show-me-your-work's handback review on a different model family than the work. On Codex or Claude Code another family needs an `xagent:` entry, so verification takes its own role instead of sharing `swarm workers` with fan-out workers, and each host can point it at the other vendor while swarms keep their own worker. A swarm's verify lanes, as in autopilot's swarm-verify, and the handback review read it too. On T3 the helper requires `--checked` for this role and picks the candidate whose model family differs from the checked work's, read from model IDs, because a provider name alone does not identify the family and the parent-provider default would choose the parent's own family.

Three local rules keep cross-vendor panels usable. Shared `auxiliary_panel_members` names external members whose unresolved outcome is a reported gap rather than a blocker, so a supporting vendor's outage does not stop a review its primary members finished; upstream reaches the same availability by falling back within a family, which xagent entries never do. Native agent definitions own their effort: on Codex and Claude Code a worker's effort goes to a native agent type only when its definition sets none, so `reasoning_effort` mainly reaches `xagent:` workers, whose CLIs otherwise run at the user's interactive default. Shared `xagent_effort_caps` keeps that effort within what each external CLI's model accepts: an unsupported level is a `DROPOUT`, never a quiet downgrade, so a shared `xhigh` would otherwise drop every seat of a CLI whose model stops at `high`, as agy's `gemini-3.8-flash` does. setup-pstack proposes a cross-vendor layout for multi-party steps when another vendor's CLI is signed in, because this fork's `inherit-parent` defaults stay one family where upstream's defaults pair two.

Expand All @@ -67,6 +67,8 @@ The `0.15.25-shared.3` T3 update keeps the same shared configuration and adds a

The `0.15.25-shared.4` follow-up adds opt-in T3 candidate roles and same-family `@latest` selectors. A candidate role chooses one provider, preferring the current parent or the first configured candidate; an explicit `--provider` can select another configured candidate. Failures never walk the list. Family selection uses canonical numeric releases in the task's live catalog, freezes concrete IDs for that task and preserves exact pins. It does not add a second registry or change xagent defaults. Each new delegate is classified by its own phase and difficulty; ordinary implementation returns to its ordinary role after a hard design. The optional `hardest architect runners` panel supports Fable/Astra designs without rewriting other panels. Native roles and their inheritance remain separate. Both installed helpers must be upgraded before storing the new candidate-object shape, and existing sessions must reload the new runtime. [Routing examples](./docs/t3-model-routing.md) cover the contract and compatibility boundary.

The `0.15.25-shared.9` update aligns a T3 parent with the cross-vendor work native parents gained in `shared.6` through `shared.8`. T3's own instructions prefer native subagents for same-provider work and send explicitly T3-owned child tasks through `delegate_task`; the overlay now states that every pstack worker is such a task, which keeps it consistent with the shared runtime's rule that host instructions take precedence. It also says `hosts.t3`, not a native agent definition, owns a T3 child's effort. With the live catalog, the parent-provider default resolved `verification workers` to the parent's own family, so the role now requires `--checked`. The resolver reads families from model IDs, since one T3 provider can route Claude, GPT, Gemini and Grok models. It marks auxiliary panel seats in the preview, so the panel rule needs no second lookup. setup-pstack proposes a T3 cross-vendor layout with T3 seats for vendors the catalog lists and xagent only for the rest. `scripts/t3-targets.test.mjs` covers these cases and fails against the previous resolver.

## watch-pr merge gates

`watch-pr` fails closed on the merge gates GitHub enforces, a local port of michael-denyer/pstack-claude (MIT) [#95](https://github.com/michael-denyer/pstack-claude/pull/95), [#223](https://github.com/michael-denyer/pstack-claude/pull/223) and [#245](https://github.com/michael-denyer/pstack-claude/pull/245), with [#124](https://github.com/michael-denyer/pstack-claude/pull/124)'s `satisfies never` check on the merge-gate hint. `LICENSE` carries that project's copyright line. Upstream reported `READY` for PRs GitHub would not merge: it gated only closed, draft and `CHANGES_REQUESTED` PRs, accepted `BLOCKED` beside a passing rollup, and passed unknown mergeability. `READY` now requires `mergeable: MERGEABLE` and a settled merge state. `mergeStateStatus: BEHIND` stops at the `behind-base` merge gate without waiting for checks, since updating restarts them. `reviewDecision: REVIEW_REQUIRED` stops at `review-required`, and `BLOCKED` beside a passing rollup at `merge-blocked`; both wait for pending checks first, as `draft-pr` does. All three exit 6 in single, `--stack` and `--queued-stack` mode. Unknown mergeability is a `mergeability-unknown` wait, so `--once` ends it as `TIMEOUT`. PR facts come from one GraphQL query that reads the base branch's current commit, `baseRef.target.oid`, instead of `gh pr view`, whose `baseRefOid` is the commit the PR recorded. Each open snapshot reads the facts again after its checks and retries as `snapshot-changed` when one moved, except mergeability GitHub computed between the reads. An open PR without a head or base commit fails closed, and `READY`'s proof names both. Babysit treats `review-required` as the owner-approval wait, `behind-base` as a rebase to report and `merge-blocked` as a rule to name. Shipping does step 4's move for `behind-base` and stops at the other two. `watch-pr/merge-gates.test.ts` covers each gate and failed on the previous source; upstream's watch-pr tests change only where they pinned one facts read per snapshot, the `gh pr view` response, or the waiting decision's `pending` field. Keep these edits when a sync touches watch-pr.
Expand Down
4 changes: 2 additions & 2 deletions bin/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ function printJson(value) {
function runCli(args) {
const [command, ...rest] = args;
if (command === "resolve") {
const options = parseOptions(rest, new Set(["host", "config", "catalog", "role", "panel", "effort", "provider"]));
const options = parseOptions(rest, new Set(["host", "config", "catalog", "role", "panel", "effort", "provider", "checked"]));
if (options.host !== T3) throw new Error("resolve requires --host t3");
if (!options.catalog) throw new Error("resolve requires --catalog JSON-file from orchestrator_capabilities");
if (options.effort !== undefined && !REASONING_EFFORTS.has(options.effort)) {
Expand Down Expand Up @@ -418,7 +418,7 @@ function runCli(args) {
printJson(updateConfig({ host: options.host, patch, configPath: options.config ?? getDefaultConfigPath() }));
return;
}
throw new Error("Usage: config.mjs show --host codex|claude|t3|antigravity [--config path] | resolve --host t3 --catalog JSON-file (--role name | --panel name) [--effort level] [--provider candidate-provider] [--config path] | init [--config path] | update [--host codex|claude|t3] --input JSON-file [--config path]");
throw new Error("Usage: config.mjs show --host codex|claude|t3|antigravity [--config path] | resolve --host t3 --catalog JSON-file (--role name | --panel name) [--effort level] [--provider candidate-provider | --checked entry-or-model] [--config path] | init [--config path] | update [--host codex|claude|t3] --input JSON-file [--config path]");
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
Expand Down
71 changes: 60 additions & 11 deletions bin/t3-targets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@
// This module neither launches tasks nor reads or changes configuration.
const EFFORT_IDS = new Set(["effort", "reasoningEffort", "thinking"]);
const EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"];
// A provider can route to any vendor, so the family comes only from the model ID's last segment.
const FAMILY = /^(claude|gpt|gemini|grok)-/;
// Its worker must differ in family from the work it checks, which the parent provider's default cannot ensure.
const CROSS_FAMILY_ROLE = "verification workers";

function familyOf(modelId) {
if (typeof modelId !== "string") return null;
const name = modelId.slice(modelId.lastIndexOf("/") + 1).replace(/@latest$/, "").toLowerCase();
return FAMILY.exec(name)?.[1] ?? null;
}

// The model part of a policy entry or a --checked value: a T3 target or bare model ID loses only its provider,
// and an xagent agent without a model has none.
function modelOf(entry, catalog) {
if (entry === "inherit-parent" || entry === "auto") return catalog.inheritedModel;
const slash = entry.indexOf("/");
if (entry.startsWith("xagent:")) return slash < 0 ? null : entry.slice(slash + 1);
return entry.slice(slash + 1);
}

function unique(rows, predicate, label) {
const matches = Array.isArray(rows) ? rows.filter(predicate) : [];
Expand Down Expand Up @@ -81,9 +100,9 @@ function workerFor(entry, catalog, requestedEffort, effortCaps) {
const agent = slash < 0 ? spec : spec.slice(0, slash);
const cap = effortCaps[agent];
const lowered = requestedEffort !== null && cap !== undefined && EFFORT_ORDER.indexOf(requestedEffort) > EFFORT_ORDER.indexOf(cap);
const model = modelOf(entry, catalog);
return {
entry, transport: "xagent", agent,
model: slash < 0 ? null : spec.slice(slash + 1),
entry, transport: "xagent", agent, model, family: familyOf(model),
effort: lowered ? cap : requestedEffort,
warnings: lowered ? [`${entry}: requested effort ${requestedEffort} is above its xagent_effort_caps entry, so ${cap} is sent`] : [],
};
Expand All @@ -97,7 +116,7 @@ function workerFor(entry, catalog, requestedEffort, effortCaps) {
// T3 replaces target.options; catalog control defaults are not parent selections.
return {
entry, transport: "t3", target: null,
resolved: { providerInstanceId: providerId, model: model.id },
resolved: { providerInstanceId: providerId, model: model.id }, family: familyOf(model.id),
effort: { requested: requestedEffort, sent: null, option: null },
warnings: requestedEffort === null ? [] : [
`${providerId}/${model.id}: requested effort ${requestedEffort} is not sent to preserve all parent model options; choose an explicit model target to set effort independently`,
Expand All @@ -109,17 +128,18 @@ function workerFor(entry, catalog, requestedEffort, effortCaps) {
if (effort.sent !== null) target.options = { [effort.option]: effort.sent };
return {
entry, transport: "t3", target,
resolved: { providerInstanceId: providerId, model: model.id }, effort, warnings,
resolved: { providerInstanceId: providerId, model: model.id }, family: familyOf(model.id), effort, warnings,
};
}

/**
* @param {object} policy Effective output of config.mjs show --host t3.
* @param {object} catalog Raw orchestrator_capabilities structured result for this task.
* @param {{role?: string, panel?: string, effort?: string, provider?: string}} selection One role or panel, with optional per-job effort and candidate provider.
* @param {{role?: string, panel?: string, effort?: string, provider?: string, checked?: string}} selection One role or panel,
* with optional per-job effort, candidate provider, or the entry or model that produced the work being checked.
* @returns {{selection: object, workers: object[]}} Ordered arguments, never an execution receipt.
*/
export function resolveT3Targets(policy, catalog, { role, panel, effort, provider }) {
export function resolveT3Targets(policy, catalog, { role, panel, effort, provider, checked }) {
if ((role === undefined) === (panel === undefined) || !(role ?? panel).trim()) {
throw new Error("resolve requires exactly one nonempty --role or --panel");
}
Expand All @@ -128,28 +148,57 @@ export function resolveT3Targets(policy, catalog, { role, panel, effort, provide
|| typeof catalog.inheritedModel !== "string" || !catalog.inheritedModel.trim()) {
throw new Error("T3 catalog requires providers, inheritedProviderInstanceId and inheritedModel from the current thread");
}
if (provider !== undefined && checked !== undefined) throw new Error("use --provider or --checked, not both");
if (checked !== undefined && !checked.trim()) throw new Error("--checked requires a nonempty entry or model");
if (role === CROSS_FAMILY_ROLE && provider === undefined && checked === undefined) {
throw new Error(`role ${role} requires --checked <entry or model that produced the checked work>, or --provider with a stated reason`);
}
const kind = role === undefined ? "panel" : "role";
const name = role ?? panel;
const choices = kind === "role" ? policy.roles : policy.panels;
const defaulted = !Object.hasOwn(choices, name);
const value = defaulted ? (kind === "role" ? policy.defaultModel : policy.defaultPanel) : choices[name];
const selection = { kind, name, defaulted };
const checkedFamily = checked === undefined ? null : familyOf(modelOf(checked, catalog));
// Unknown on either side never counts as a different family.
const crossFamily = family => checkedFamily !== null && family !== null && family !== checkedFamily;
if (checked !== undefined) selection.checked = { entry: checked, family: checkedFamily };
let entries;
if (kind === "role" && typeof value === "object") {
const candidates = value.candidates;
const providerMatch = candidates.find(entry => entry.slice(0, entry.indexOf("/")) === (provider ?? catalog.inheritedProviderInstanceId));
if (provider !== undefined && !providerMatch) throw new Error(`role ${name}: provider ${provider} is not a configured candidate`);
const entry = providerMatch ?? candidates[0];
selection.strategy = provider !== undefined ? "explicit-provider" : providerMatch ? "parent-provider" : "first-candidate";
const providerOf = entry => entry.slice(0, entry.indexOf("/"));
let entry;
if (provider !== undefined) {
entry = candidates.find(candidate => providerOf(candidate) === provider);
if (!entry) throw new Error(`role ${name}: provider ${provider} is not a configured candidate`);
selection.strategy = "explicit-provider";
} else {
const parent = candidates.find(candidate => providerOf(candidate) === catalog.inheritedProviderInstanceId);
const ordered = parent ? [parent, ...candidates.filter(candidate => candidate !== parent)] : candidates;
entry = ordered.find(candidate => crossFamily(familyOf(modelOf(candidate, catalog))));
selection.strategy = entry ? "cross-family" : parent ? "parent-provider" : "first-candidate";
entry ??= ordered[0];
}
selection.entry = entry;
entries = [entry];
} else {
if (provider !== undefined) throw new Error("--provider requires a role with configured candidates");
entries = kind === "role" ? [value] : value;
}
const requestedEffort = effort ?? policy.reasoning_effort ?? null;
const auxiliary = new Set(policy.auxiliary_panel_members ?? []);
return {
selection,
workers: entries.map(entry => workerFor(entry, catalog, requestedEffort, policy.xagent_effort_caps ?? {})),
workers: entries.map(entry => {
const worker = workerFor(entry, catalog, requestedEffort, policy.xagent_effort_caps ?? {});
// The auxiliary listing only relaxes panel seats; a role never becomes optional.
worker.auxiliary = kind === "panel" && auxiliary.has(entry);
if (checked !== undefined && !crossFamily(worker.family)) {
worker.warnings.push(worker.family !== null && worker.family === checkedFamily
? `${entry}: same ${worker.family} family as the checked work; label it same-family review`
: `${entry}: the family of ${worker.family === null ? "this worker" : "the checked work"} is unknown, so it is not cross-family evidence`);
}
return worker;
}),
};
}
Loading
Loading