mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Contributors and agents read the docs under `doc/` before they change code > - `doc/SPEC.md` and `doc/SPEC-implementation.md` were out of date. `doc/SPEC-implementation.md` became an append-only log: in 60 days it got about 1,645 new lines and lost 181, across 140 commits > - #20557 changed `AGENTS.md` so the code and its tests are the source of truth, and the specs are only background > - This pull request removes the two spec docs. It moves only the sections that live code comments cite into focused docs, and it repoints all other references > - The benefit is one less stale document that agents read and append to ## Linked Issues or Issue Description Refs #20557 (merged). This PR is the follow-up that removes the docs. It is based on `master`, which already contains #20557. **Issue type** Outdated content. The docs are out of date and conflict with the code. **Where is the issue?** `doc/SPEC.md`, `doc/SPEC-implementation.md`, and the code comments and docs that cite them. **What's wrong?** `doc/SPEC.md` and `doc/SPEC-implementation.md` are out of date, but agents still read them and append a paragraph with each fix. **Suggested fix** Delete both files. Move the sections that code cites into focused docs. Repoint the references. ## What Changed - Deleted `doc/SPEC.md` and `doc/SPEC-implementation.md`. - Moved these spec sections, because live code comments cite them: - §9.3.1 "Shared default-open issue writes" → new section in `doc/agent-permission-defaults.md`. - §9.8.1 "Issue-thread interaction resolver contract" → `doc/execution-semantics.md`, "Issue-thread interaction resolution". That section already had most of the contract. I added only the missing parts: the `supersedeOnUserComment` default, the canonical policy list, the addressee and cap rules, the rule that surfaces show the server's effective audience, and the cross-issue budget charge. The short policy summary at the top of the section now points to the list, so the definitions occur one time only. - §9.9 "Task Watchdog Authority Contract" → new "Authority contract" section in `doc/TASK-WATCHDOG.md`. It holds the allowed and disallowed mutation lists and the deferred capabilities. The atomic recovery batch, restoration verification, and interaction rules were already in `doc/execution-semantics.md` §11, so the new section links there and does not repeat them. I did not move the "Downstream acceptance criteria" checklist or the sentence about the CTO and Security sign-off. These were implementation-time notes; the tests now hold that coverage. - §9.10 "Company Skill Policy Contract" → new `doc/company-skill-policy.md`. No focused doc had this contract. I did not move the "Required regression tests" phase checklist. - Did not move any other part of the specs. - Repointed code comments that cited spec sections: - `packages/shared/src/issue-write-denial.ts` (§9.3) → `doc/agent-permission-defaults.md` - `server/src/routes/issues.ts` (§9.9) → `doc/TASK-WATCHDOG.md`, "Authority contract" - `server/src/routes/issues.ts` (§9.3, §9.8.1 cross-issue budget) → `doc/agent-permission-defaults.md` - `ui/src/lib/interaction-audience.ts` and `ui/src/components/InteractionGovernancePanel.tsx` (§9.8.1) → `doc/execution-semantics.md` - `ui/src/lib/skill-policy-denial.ts`, `ui/src/pages/CompanySkills.tsx`, `ui/src/pages/CompanySkills.production.tsx`, `ui/src/pages/SkillStudio.tsx` (§9.10) → `doc/company-skill-policy.md` - `server/src/services/recovery/ownership-invariant-report.ts` (monitor deadline wait path) → `doc/execution-semantics.md` §8. That section already lists the one-shot monitor as a valid wait path. - Resynced the git blob pin for `packages/shared/src/issue-write-denial.ts` in `tests/runner-e2e/source-sdk-public-inputs.json`. The comment edit changed the file's hash, and the Runner support suite checks that pin. I used `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --write`; only that one pin changed. - Updated `AGENTS.md` §1 so it does not name the deleted files. - Removed the links to the deleted files from `doc/plugins/PLUGIN_SPEC.md`. - Updated the `doc-maintenance` agent skill (`.agents/skills/doc-maintenance/`) so it no longer audits or stages `doc/SPEC.md`. - Removed or replaced references in `maps/README.md`, `maps/feature-map/README.md`, `maps/storybook-map/README.md`, `doc/PRODUCT.md`, `doc/CLIPHUB.md`, `doc/DEPLOYMENT-MODES.md`, `doc/DEVELOPING.md`, `doc/evals.md`, `doc/spec/agent-runs.md`, `doc/AGENTCOMPANIES_SPEC_INVENTORY.md`, `doc/connections/CONNECTOR-PLAYBOOK.md`, `doc/plugins/ideas-from-opencode.md`, `doc/execution-semantics.md`, `doc/TASK-WATCHDOG.md`, and `.devin/wiki.json`. - In `doc/execution-semantics.md`, the "admission is independent of cleanup and accounting" reference now states the rule inline: only a hard stop, an explicit pause, or an inactive company refuses admission. - Kept these references as they are: dated plans in `doc/plans/` and `docs/plans/`, the dated log `doc/logs/2026-05-24-cli-api-parity-e2e-log.md`, `releases/`, the transcript fixture `ui/src/fixtures/runTranscriptFixtures.ts` (it uses the filename only as sample text), and `docs/specs/external-task-protocol.md` (it refers to the Symphony project's own `SPEC.md`). ## Verification - `git grep -n "SPEC-implementation\|SPEC\.md"` (without `releases/`, and ignoring `PLUGIN_SPEC.md` citations) finds only the kept references listed above. - `grep -rn "§9\.\(3\|8\|9\|10\)" ui/src server/src packages/shared/src` finds no results. - `node scripts/check-maps.mjs`: "Maps aligned: 35 areas, 192 capability IDs". - `pnpm check:token-gates`: "All gates clean". - `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/source-sdk-public-type.test.ts tests/runner-e2e/source-sdk-evidence.test.ts`: 45 passed. These failed in CI before the pin resync. - `pnpm verify`: exit 2 ("Broader verification required") because `AGENTS.md` is a global CI input. Required CI supplies the broad coverage for this revision. - All code changes are comments only. No runtime behavior changes. The title uses `refactor:` because comments in source files changed. ## Risks - Low risk. The change removes docs and edits comments only. - A reader can lose context from spec sections that I did not move. The text stays in git history. The code and its tests are the source of truth. - External links to the two deleted files will return 404. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), in Claude Code with tool use (shell, file edits). Standard context window. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused checks above; CI supplies broad coverage) - [x] I have added or updated tests where applicable (not applicable: docs and comments only) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
import { Fragment } from "react";
|
|
import {
|
|
ISSUE_THREAD_INTERACTION_KINDS,
|
|
normalizeIssueThreadInteractionResolverPolicy,
|
|
type InteractionResolverGovernance,
|
|
type IssueThreadInteractionCanonicalResolverPolicy,
|
|
type IssueThreadInteractionKind,
|
|
type IssueThreadInteractionResolverPolicy,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { resolverPolicyLabel } from "../lib/interaction-audience";
|
|
|
|
const INTERACTION_KIND_LABELS: Record<IssueThreadInteractionKind, string> = {
|
|
suggest_tasks: "Suggested tasks",
|
|
ask_user_questions: "Ask user questions",
|
|
request_confirmation: "Confirmations",
|
|
request_checkbox_confirmation: "Checkbox confirmations",
|
|
request_item_verdicts: "Item verdicts",
|
|
connection_intent: "Connection requests",
|
|
};
|
|
|
|
/**
|
|
* Sentinel for "no override" — Radix Select disallows empty-string item values.
|
|
* Under the open-default contract (PAP-17280) an absent override *is* the open
|
|
* audience, so this sentinel is what a company sees selected until it
|
|
* deliberately narrows a kind.
|
|
*/
|
|
export const GOVERNANCE_UNSET = "default";
|
|
export type GovernanceSelectValue = typeof GOVERNANCE_UNSET | IssueThreadInteractionResolverPolicy;
|
|
|
|
export type GovernanceField = "defaultPolicy" | "cap";
|
|
|
|
/**
|
|
* Only *narrowing* policies are offered. `anyone` is the product default, so
|
|
* requesting it as a default override is a no-op, and capping at `anyone` cannot
|
|
* narrow anything — both collapse into the unset sentinel, which is presented as
|
|
* the visible default.
|
|
*/
|
|
const NARROWING_POLICIES: readonly IssueThreadInteractionCanonicalResolverPolicy[] = [
|
|
"not_creator",
|
|
"human_only",
|
|
];
|
|
|
|
const UNSET_LABELS: Record<GovernanceField, string> = {
|
|
defaultPolicy: "Anyone (default)",
|
|
cap: "No cap",
|
|
};
|
|
|
|
const UNSET_EFFECTS: Record<GovernanceField, string> = {
|
|
defaultPolicy: "New cards are open — the board or any agent can respond, including the one that asked.",
|
|
cap: "A request keeps whatever audience it asks for.",
|
|
};
|
|
|
|
const DEFAULT_POLICY_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
|
|
anyone: UNSET_EFFECTS.defaultPolicy,
|
|
not_creator: "New cards exclude the agent that created them, so the answer comes from someone else.",
|
|
human_only: "New cards wait for a person on the board. Agents are turned away.",
|
|
};
|
|
|
|
const CAP_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
|
|
anyone: UNSET_EFFECTS.cap,
|
|
not_creator: "Even a card that asks for Anyone is narrowed to exclude its creator.",
|
|
human_only: "Every card of this kind waits for a person, whatever it asked for.",
|
|
};
|
|
|
|
function governanceOptions(field: GovernanceField): {
|
|
value: GovernanceSelectValue;
|
|
label: string;
|
|
effect: string;
|
|
}[] {
|
|
const effects = field === "cap" ? CAP_EFFECTS : DEFAULT_POLICY_EFFECTS;
|
|
return [
|
|
{ value: GOVERNANCE_UNSET, label: UNSET_LABELS[field], effect: UNSET_EFFECTS[field] },
|
|
...NARROWING_POLICIES.map((policy) => ({
|
|
value: policy as GovernanceSelectValue,
|
|
label: resolverPolicyLabel(policy),
|
|
effect: effects[policy],
|
|
})),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The label a *closed* trigger must show. Derived from the value rather than
|
|
* looked up in the option list so an out-of-list value (a raw `anyone`, say)
|
|
* still renders a complete, truthful label instead of falling back to a lie.
|
|
*/
|
|
export function governanceValueLabel(field: GovernanceField, value: GovernanceSelectValue): string {
|
|
return value === GOVERNANCE_UNSET ? UNSET_LABELS[field] : resolverPolicyLabel(value);
|
|
}
|
|
|
|
/**
|
|
* Map a persisted override onto a select value. A stored `anyone` — including
|
|
* the deprecated `board_or_agents` alias — is the open default, so it shows as
|
|
* the unset sentinel rather than as a narrowing override.
|
|
*/
|
|
export function toGovernanceSelectValue(
|
|
policy: IssueThreadInteractionResolverPolicy | undefined,
|
|
): GovernanceSelectValue {
|
|
if (!policy) return GOVERNANCE_UNSET;
|
|
const canonical = normalizeIssueThreadInteractionResolverPolicy(policy);
|
|
return canonical === "anyone" ? GOVERNANCE_UNSET : canonical;
|
|
}
|
|
|
|
/**
|
|
* Apply a single (kind, field) change to a governance map immutably, pruning
|
|
* empty entries so the persisted object stays sparse (only real overrides).
|
|
*/
|
|
export function applyGovernanceChange(
|
|
current: InteractionResolverGovernance,
|
|
kind: IssueThreadInteractionKind,
|
|
field: GovernanceField,
|
|
value: GovernanceSelectValue,
|
|
): InteractionResolverGovernance {
|
|
const next: InteractionResolverGovernance = { ...current };
|
|
const entry = { ...(next[kind] ?? {}) };
|
|
if (value === GOVERNANCE_UNSET) {
|
|
delete entry[field];
|
|
} else {
|
|
entry[field] = value;
|
|
}
|
|
if (entry.defaultPolicy === undefined && entry.cap === undefined) {
|
|
delete next[kind];
|
|
} else {
|
|
next[kind] = entry;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function GovernanceSelect({
|
|
field,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
testId,
|
|
ariaLabel,
|
|
mobileLabel,
|
|
}: {
|
|
field: GovernanceField;
|
|
value: GovernanceSelectValue;
|
|
onChange: (value: GovernanceSelectValue) => void;
|
|
disabled?: boolean;
|
|
testId?: string;
|
|
ariaLabel: string;
|
|
mobileLabel: string;
|
|
}) {
|
|
const options = governanceOptions(field);
|
|
return (
|
|
<div className="min-w-0">
|
|
{/*
|
|
* Below `sm` the governance grid collapses to a single column (see the
|
|
* grid classes on the panel), detaching each select from its column
|
|
* header. Surface a mobile-only inline label so the control stays
|
|
* self-describing for sighted users, and always carry `aria-label` for
|
|
* screen-reader pairing. WCAG 2.1 SC 1.4.10 (Reflow) — design review R2.
|
|
*/}
|
|
<span className="mb-1 block text-xs font-medium text-muted-foreground uppercase tracking-wide sm:hidden">
|
|
{mobileLabel}
|
|
</span>
|
|
<Select value={value} onValueChange={(v) => onChange(v as GovernanceSelectValue)} disabled={disabled}>
|
|
<SelectTrigger
|
|
size="sm"
|
|
aria-label={ariaLabel}
|
|
// 208px is sized for the longest label the control can hold —
|
|
// `Anyone except creator` needs ~150px of text room, and 170px only
|
|
// left 122px after padding, gap and chevron, so it clipped even once
|
|
// the effect sentence was gone (PAP-17297).
|
|
className="w-full min-w-0 text-xs sm:w-(--sz-208px)"
|
|
data-testid={testId}
|
|
>
|
|
{/*
|
|
* Explicit children, not the default `<SelectValue />`. Radix portals
|
|
* the selected item's *whole* subtree into an empty value node, which
|
|
* dragged each option's effect sentence into the closed trigger and
|
|
* clipped the selected label (desktop truncated the prose, mobile cut
|
|
* `Anyone (default)` mid-label — PAP-17293/PAP-17297). Passing children
|
|
* sets `valueNodeHasChildren`, which suppresses that portal, so the
|
|
* trigger shows exactly the label and nothing else.
|
|
*/}
|
|
<SelectValue>{governanceValueLabel(field, value)}</SelectValue>
|
|
</SelectTrigger>
|
|
{/*
|
|
* Cap the option list so the effect sentences wrap instead of stretching
|
|
* the popover past a ~390px viewport (WCAG 2.1 SC 1.4.10 Reflow).
|
|
*/}
|
|
<SelectContent className="max-w-(--sz-280px) sm:max-w-(--sz-360px)">
|
|
{options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
// Keyboard typeahead matches on `textValue` when given; without it
|
|
// Radix would match against the effect prose too.
|
|
textValue={option.label}
|
|
className="text-xs"
|
|
>
|
|
{/*
|
|
* Effect preview lives inside the option so the consequence of a
|
|
* narrowing choice is legible at the moment of choosing, not only
|
|
* after saving (PAP-17280).
|
|
*/}
|
|
<span className="flex min-w-0 flex-col gap-0.5">
|
|
<span>{option.label}</span>
|
|
<span className="text-(length:--text-micro) text-muted-foreground">
|
|
{option.effect}
|
|
</span>
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Company-level interaction governance: the per-kind default audience and cap.
|
|
*
|
|
* The open default is the headline — interactions are resolvable by anyone in
|
|
* the company unless a row here narrows them — so the panel only offers
|
|
* narrowing choices and never presents an unrestricted card as board-required
|
|
* (PAP-17280, contract in `doc/execution-semantics.md`, "Issue-thread
|
|
* interaction resolution").
|
|
*/
|
|
export function InteractionGovernancePanel({
|
|
governance,
|
|
onChange,
|
|
isPending,
|
|
errorMessage,
|
|
}: {
|
|
governance: InteractionResolverGovernance;
|
|
onChange: (kind: IssueThreadInteractionKind, field: GovernanceField, value: GovernanceSelectValue) => void;
|
|
isPending?: boolean;
|
|
errorMessage?: string | null;
|
|
}) {
|
|
return (
|
|
<div className="space-y-4" data-testid="company-settings-interaction-governance-section">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Interaction governance
|
|
</div>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Thread interactions are open by default:{" "}
|
|
<span className="font-medium text-foreground">Anyone</span> in the organization — the
|
|
board or any agent, including the one that asked — can respond. Narrow a kind
|
|
only when you need to.{" "}
|
|
<span className="font-medium text-foreground">Default policy</span> is the
|
|
audience new cards get when the requester does not ask for one;{" "}
|
|
<span className="font-medium text-foreground">Cap</span> narrows every request of
|
|
that kind and can never widen one. Tool-approval confirmations always stay{" "}
|
|
<span className="font-medium text-foreground">Human only</span>.
|
|
</p>
|
|
{/*
|
|
* Responsive: below `sm` the row collapses to a single column so the
|
|
* two 170px selects never force horizontal overflow on a ~390px
|
|
* viewport (WCAG 2.1 SC 1.4.10 Reflow — design review R2). Each kind
|
|
* then stacks as: label → Default policy → Cap, each full-width with
|
|
* its own inline label. At `sm`+ it restores the aligned 3-col grid.
|
|
*/}
|
|
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-[1fr_auto_auto] sm:items-center sm:gap-x-4 sm:gap-y-2.5">
|
|
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
|
Kind
|
|
</div>
|
|
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
|
Default policy
|
|
</div>
|
|
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
|
Cap
|
|
</div>
|
|
{ISSUE_THREAD_INTERACTION_KINDS.map((kind) => {
|
|
const entry = governance[kind] ?? {};
|
|
const kindLabel = INTERACTION_KIND_LABELS[kind];
|
|
return (
|
|
<Fragment key={kind}>
|
|
<div className="text-sm font-medium sm:font-normal">{kindLabel}</div>
|
|
<GovernanceSelect
|
|
field="defaultPolicy"
|
|
testId={`governance-${kind}-default`}
|
|
ariaLabel={`Default resolver audience for ${kindLabel}`}
|
|
mobileLabel="Default policy"
|
|
value={toGovernanceSelectValue(entry.defaultPolicy)}
|
|
disabled={isPending}
|
|
onChange={(v) => onChange(kind, "defaultPolicy", v)}
|
|
/>
|
|
<GovernanceSelect
|
|
field="cap"
|
|
testId={`governance-${kind}-cap`}
|
|
ariaLabel={`Resolver cap for ${kindLabel}`}
|
|
mobileLabel="Cap"
|
|
value={toGovernanceSelectValue(entry.cap)}
|
|
disabled={isPending}
|
|
onChange={(v) => onChange(kind, "cap", v)}
|
|
/>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
{errorMessage ? (
|
|
<span className="text-xs text-destructive">{errorMessage}</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|