mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +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>
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
/**
|
|
* Plain-language presentation of *who may resolve* an issue-thread interaction
|
|
* (PAP-17280, Phase 3 of the open-default resolver contract in
|
|
* `doc/execution-semantics.md`, "Issue-thread interaction resolution").
|
|
*
|
|
* The product default is open: an interaction created without an explicit
|
|
* `resolverPolicy` is resolvable by `anyone` in the company — the board or any
|
|
* agent, including the agent that created it. `not_creator`, `human_only`, and a
|
|
* named addressee are *narrowing* controls that a requester asks for on purpose.
|
|
*
|
|
* This module is presentation only. It never decides authority: the server
|
|
* evaluator owns that, and every downstream effect re-runs its own
|
|
* authorization (see the Phase 1S security verdict on PAP-17278). Everything
|
|
* here is derived from the server-provided snapshot fields
|
|
* (`requestedResolverPolicy`, `effectiveResolverPolicy`,
|
|
* `effectiveResolverPolicySource`, `resolverPolicyProvenance`) so the copy a
|
|
* reader sees agrees with the policy the server will apply.
|
|
*/
|
|
|
|
import {
|
|
ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES,
|
|
normalizeIssueThreadInteractionResolverPolicy,
|
|
type AttentionItem,
|
|
type AttentionResolverAudience,
|
|
type IssueThreadInteractionCanonicalResolverPolicy,
|
|
type IssueThreadInteractionEffectiveResolverPolicySource,
|
|
type IssueThreadInteractionResolverPolicy,
|
|
type IssueThreadInteractionResolverPolicyProvenance,
|
|
} from "@paperclipai/shared";
|
|
import type { IssueThreadInteraction } from "./issue-thread-interactions";
|
|
|
|
/**
|
|
* The open default. Creation surfaces present this first and requesters omit
|
|
* `resolverPolicy` to get it.
|
|
*/
|
|
export const DEFAULT_RESOLVER_POLICY: IssueThreadInteractionCanonicalResolverPolicy = "anyone";
|
|
|
|
/** Short label for a resolver audience — badges, select options, table cells. */
|
|
const RESOLVER_POLICY_LABELS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
|
|
anyone: "Anyone",
|
|
not_creator: "Anyone except creator",
|
|
human_only: "Human only",
|
|
};
|
|
|
|
/**
|
|
* Plain-language preview of what a policy *does*, phrased for a surface that is
|
|
* choosing it for future cards (company defaults and caps). Card-specific copy
|
|
* that names the real creator/addressee comes from
|
|
* {@link describeInteractionAudience}.
|
|
*/
|
|
const RESOLVER_POLICY_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
|
|
anyone:
|
|
"Anyone in the organization can respond — the board or any agent, including the one that asked.",
|
|
not_creator:
|
|
"Anyone in the organization except the agent that created the card, and its run. Use this when the answer has to come from someone else.",
|
|
human_only: "Only a person on the board can respond. Agents are turned away.",
|
|
};
|
|
|
|
/** Accepts canonical values and the deprecated `board_*` compatibility aliases. */
|
|
export function resolverPolicyLabel(policy: IssueThreadInteractionResolverPolicy): string {
|
|
return RESOLVER_POLICY_LABELS[normalizeIssueThreadInteractionResolverPolicy(policy)];
|
|
}
|
|
|
|
/** Accepts canonical values and the deprecated `board_*` compatibility aliases. */
|
|
export function resolverPolicyEffect(policy: IssueThreadInteractionResolverPolicy): string {
|
|
return RESOLVER_POLICY_EFFECTS[normalizeIssueThreadInteractionResolverPolicy(policy)];
|
|
}
|
|
|
|
/**
|
|
* Ordered choice list for every surface that picks a resolver audience. `anyone`
|
|
* is first and marked as the default so the open option reads as the normal
|
|
* path rather than a permission grant.
|
|
*/
|
|
export const RESOLVER_POLICY_CHOICES: readonly {
|
|
value: IssueThreadInteractionCanonicalResolverPolicy;
|
|
label: string;
|
|
effect: string;
|
|
isDefault: boolean;
|
|
}[] = ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES.map((value) => ({
|
|
value,
|
|
label: RESOLVER_POLICY_LABELS[value],
|
|
effect: RESOLVER_POLICY_EFFECTS[value],
|
|
isDefault: value === DEFAULT_RESOLVER_POLICY,
|
|
}));
|
|
|
|
/** Why an effective audience ended up narrower than the requested one. */
|
|
export type InteractionAudienceNarrowing =
|
|
| "requested"
|
|
| "company_cap"
|
|
| "governed_action"
|
|
| "addressee"
|
|
| "legacy_restriction";
|
|
|
|
export interface InteractionAudienceDescription {
|
|
/** Effective canonical policy the server will enforce. */
|
|
policy: IssueThreadInteractionCanonicalResolverPolicy;
|
|
/** Canonical policy the creator asked for (before caps/clamps). */
|
|
requestedPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
|
/** Short badge label for the effective audience. */
|
|
label: string;
|
|
/** One sentence naming exactly who can respond to *this* card. */
|
|
summary: string;
|
|
/**
|
|
* The same fact in a glanceable clause, for a dense surface that shows the
|
|
* audience beside compact decision buttons (collapsed attention rows).
|
|
*/
|
|
shortSummary: string;
|
|
/** True when the card is open to the whole company with no addressee. */
|
|
isOpen: boolean;
|
|
/** Set when something narrows the audience below the open default. */
|
|
narrowedBy: InteractionAudienceNarrowing | null;
|
|
/** Extra sentence explaining a narrowing the requester did not ask for. */
|
|
narrowedNote: string | null;
|
|
}
|
|
|
|
/**
|
|
* The server-evaluated facts an audience description is derived from. Both the
|
|
* full interaction snapshot (issue thread) and the attention feed's
|
|
* `resolverAudience` (collapsed queue rows) reduce to this shape, so one copy
|
|
* table serves both surfaces and they cannot drift.
|
|
*/
|
|
export interface InteractionAudienceFacts {
|
|
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
|
requestedResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
|
effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource;
|
|
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
|
|
hasAddressee: boolean;
|
|
isUserAddressee?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Describe the effective audience of one interaction.
|
|
*
|
|
* Precedence mirrors the server evaluator's *narrowing* order so the copy can
|
|
* never promise a wider audience than the API allows: `human_only` (including a
|
|
* governed-action clamp) beats a named addressee, which beats `not_creator`,
|
|
* which beats the open default.
|
|
*/
|
|
export function describeInteractionAudience({
|
|
interaction,
|
|
creatorLabel,
|
|
addresseeLabel,
|
|
}: {
|
|
interaction: IssueThreadInteraction;
|
|
/** Display label of the creating actor, when known. */
|
|
creatorLabel?: string | null;
|
|
/** Display label of the named addressee, when the card has one. */
|
|
addresseeLabel?: string | null;
|
|
}): InteractionAudienceDescription {
|
|
return describeResolverAudience({
|
|
facts: {
|
|
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
|
|
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
|
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
|
|
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
|
hasAddressee: Boolean(interaction.addresseeAgentId || interaction.addresseeUserId),
|
|
isUserAddressee: Boolean(interaction.addresseeUserId),
|
|
},
|
|
creatorLabel,
|
|
addresseeLabel,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Describe an effective audience from the server-evaluated facts alone — used
|
|
* where the full interaction is not loaded, such as a collapsed attention row
|
|
* (PAP-17287).
|
|
*/
|
|
export function describeResolverAudience({
|
|
facts,
|
|
creatorLabel,
|
|
addresseeLabel,
|
|
}: {
|
|
facts: InteractionAudienceFacts;
|
|
creatorLabel?: string | null;
|
|
addresseeLabel?: string | null;
|
|
}): InteractionAudienceDescription {
|
|
const policy = facts.effectiveResolverPolicy;
|
|
const requestedPolicy = facts.requestedResolverPolicy;
|
|
const hasAddressee = facts.hasAddressee;
|
|
const isUserAddressee = facts.isUserAddressee === true;
|
|
// `formatAssigneeUserLabel` returns the display-cased "You" for the signed-in
|
|
// reader, which is right for a badge and wrong mid-sentence ("Only You can
|
|
// respond."). Every use below is inside a sentence.
|
|
const midSentence = (label: string) => (label === "You" ? "you" : label);
|
|
const addressee = midSentence(
|
|
addresseeLabel?.trim() || (isUserAddressee ? "the addressed user" : "the addressed agent"),
|
|
);
|
|
const creator = midSentence(creatorLabel?.trim() || "the agent that created it");
|
|
|
|
const summary = isUserAddressee
|
|
? `Only ${addressee} can respond.`
|
|
: policy === "human_only"
|
|
? `${hasAddressee ? `Assigned to ${addressee}. ` : ""}Only a person on the board can respond — agents cannot resolve this card.`
|
|
: hasAddressee
|
|
? `Only ${addressee} or a person on the board can respond.`
|
|
: policy === "not_creator"
|
|
? `Anyone in the organization except ${creator} can respond.`
|
|
: "Anyone in the organization can respond — the board or any agent, including the one that asked.";
|
|
|
|
// Same fact, fewer words: a collapsed row has to answer "is this mine to
|
|
// decide?" in one glance, next to the buttons that act on the answer.
|
|
const shortSummary = isUserAddressee
|
|
? `Only ${addressee} can respond`
|
|
: policy === "human_only"
|
|
? hasAddressee
|
|
? `Assigned to ${addressee} · board only`
|
|
: "Only the board can respond"
|
|
: hasAddressee
|
|
? `Only ${addressee} or the board can respond`
|
|
: policy === "not_creator"
|
|
? `Anyone except ${creator} can respond`
|
|
: "Anyone can respond";
|
|
|
|
const source = facts.effectiveResolverPolicySource;
|
|
const provenance = facts.resolverPolicyProvenance;
|
|
|
|
// Narrowing the requester did *not* ask for is the only thing worth an extra
|
|
// sentence: a governed-action clamp, a company cap, or a card created before
|
|
// open resolution existed. An explicitly requested restriction is already
|
|
// fully described by `summary`.
|
|
const narrowedNote = source === "governed_action"
|
|
? "This card runs a governed action, so it stays human-only whatever audience was requested."
|
|
: source === "company_cap"
|
|
? `Organization interaction governance narrowed this from ${RESOLVER_POLICY_LABELS[requestedPolicy]} to ${RESOLVER_POLICY_LABELS[policy]}.`
|
|
: provenance === "legacy_inherited_restriction"
|
|
? "Created before Anyone became the default, so it stays restricted. A new card would be open."
|
|
: null;
|
|
|
|
const narrowedBy: InteractionAudienceNarrowing | null = source === "governed_action"
|
|
? "governed_action"
|
|
: source === "company_cap"
|
|
? "company_cap"
|
|
: provenance === "legacy_inherited_restriction"
|
|
? "legacy_restriction"
|
|
: policy !== "anyone"
|
|
? "requested"
|
|
: hasAddressee
|
|
? "addressee"
|
|
: null;
|
|
|
|
return {
|
|
policy,
|
|
requestedPolicy,
|
|
// A named addressee owns the response, so the label must not read "Anyone"
|
|
// while the sentence next to it names one actor. `human_only` wins for an
|
|
// agent addressee, while a user addressee is the narrower human audience.
|
|
label: (policy !== "human_only" || isUserAddressee) && hasAddressee
|
|
? "Addressed"
|
|
: RESOLVER_POLICY_LABELS[policy],
|
|
summary,
|
|
shortSummary,
|
|
isOpen: policy === "anyone" && !hasAddressee,
|
|
narrowedBy,
|
|
narrowedNote,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Audience of an `issue_thread_interaction` attention row, from the server
|
|
* metadata the feed ships with the item (PAP-17287). Returns `null` for every
|
|
* other source kind and for a feed built before the metadata existed, so a
|
|
* caller renders nothing rather than guessing a policy client-side.
|
|
*/
|
|
export function describeAttentionResolverAudience(
|
|
item: Pick<AttentionItem, "sourceKind" | "resolverAudience">,
|
|
): InteractionAudienceDescription | null {
|
|
if (item.sourceKind !== "issue_thread_interaction") return null;
|
|
const audience: AttentionResolverAudience | null | undefined = item.resolverAudience;
|
|
if (!audience) return null;
|
|
return describeResolverAudience({
|
|
facts: {
|
|
effectiveResolverPolicy: audience.effectiveResolverPolicy,
|
|
requestedResolverPolicy: audience.requestedResolverPolicy,
|
|
effectiveResolverPolicySource: audience.effectiveResolverPolicySource,
|
|
resolverPolicyProvenance: audience.resolverPolicyProvenance,
|
|
hasAddressee: Boolean(audience.addresseeAgentId || audience.addresseeUserId),
|
|
isUserAddressee: Boolean(audience.addresseeUserId),
|
|
},
|
|
creatorLabel: audience.createdByAgentName,
|
|
addresseeLabel: audience.addresseeName,
|
|
});
|
|
}
|