Repository navigation
feat: workspace review for other clients — your requests in one view, withdraw and remove from the TUI and serve, decisions for plugins #1716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/workspace-withdraw-retire
Are you sure you want to change the base?
Changes from all commits
eec563a
6a408eb
f53b51d
0d97fd5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,12 +56,36 @@ export interface Start { | |
| rules: { text: string; applies_to: string[]; review?: "pending" }[] | ||
| proposals: number | ||
| notices: RulesStore.Notice[] | ||
| /** Decisions on the person's skill and knowledge requests to the workspace since the last host session (approved, | ||
| * rejected, removed from the workspace). */ | ||
| review_decisions?: ReviewDecision[] | ||
| /** How many of the person's skill and knowledge requests wait for review, as last seen. */ | ||
| review_waiting?: number | ||
| /** The last check for proposed rules failed (no model available, …): said once, so the person can fix it. */ | ||
| check_failed?: string | ||
| /** "Remember … as a team rule" requests from a prompt that were not stored: said once. */ | ||
| unstored?: RulesStore.State["host_unstored"] | ||
| } | ||
|
|
||
| /** A skill or knowledge decision as a host session start reports it. */ | ||
| export interface ReviewDecision { | ||
| kind: "skill" | "knowledge" | ||
| public_id: string | ||
| name: string | ||
| status: string | ||
| reason?: string | ||
| reviewer?: string | ||
| at: string | ||
| } | ||
|
|
||
| /** How long a session start waits for the review poll (skill and knowledge decisions) before answering from what was | ||
| * kept: the poll is the only way a host-only person learns of them. */ | ||
| export const REVIEW_POLL_WAIT_MS = 3_000 | ||
|
|
||
| /** The key a host session start remembers a skill or knowledge decision by. */ | ||
| export const reviewSeenKey = (decision: Pick<ReviewDecision, "kind" | "public_id" | "status">) => | ||
| `${decision.kind}:${decision.public_id}:${decision.status}` | ||
|
|
||
| /** How long a session start waits for a pull before answering from the last one. */ | ||
| export const START_WAIT_MS = 3_000 | ||
| /** A project with no pulled rules yet waits longer: without them, nothing else here runs. */ | ||
|
|
@@ -159,12 +183,19 @@ export async function start(input: { | |
| const failed = state.last_check?.outcome.startsWith("failed") && isHostSession(state.last_check.session) && | ||
| !seen.has(RulesStore.hostCheckKey(state.last_check)) ? state.last_check : undefined | ||
| const unstored = state.host_unstored | ||
| // Skill and knowledge decisions: one bounded poll (nothing else polls in a host-only setup), then what is kept. | ||
| const review = await within(reviewForHost(input.directory, new Set(state.host_review_seen), Math.min(left(), REVIEW_POLL_WAIT_MS)), { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The new review poll remains behind the Prompt for AI agents |
||
| decisions: [] as ReviewDecision[], | ||
| waiting: 0, | ||
| }) | ||
| // Marked seen only if the write lands in time: otherwise they are said again next time, never lost. | ||
| if (input.session && (notices.length || failed || unstored.length)) | ||
| if (input.session && (notices.length || failed || unstored.length || review.decisions.length)) | ||
| await within( | ||
| RulesStore.update(input.root, (s) => { | ||
| const keys = [...notices.map(RulesStore.hostNoticeKey), ...(failed ? [RulesStore.hostCheckKey(failed)] : [])] | ||
| s.host_notices_seen = [...s.host_notices_seen, ...keys.filter((key) => !s.host_notices_seen.includes(key))] | ||
| const reviewKeys = review.decisions.map(reviewSeenKey) | ||
| s.host_review_seen = [...s.host_review_seen, ...reviewKeys.filter((key) => !s.host_review_seen.includes(key))] | ||
| s.host_unstored = s.host_unstored.filter((entry) => !unstored.some((said) => said.at === entry.at && said.text === entry.text)) | ||
| }).catch((error) => log.warn("host notices not marked seen", { error: errorText(error) })), | ||
| undefined, | ||
|
|
@@ -178,11 +209,48 @@ export async function start(input: { | |
| })), | ||
| proposals: (await within(RuleProposal.storedFor(input.root), [])).length, | ||
| notices, | ||
| ...(review.decisions.length ? { review_decisions: review.decisions } : {}), | ||
| ...(review.waiting ? { review_waiting: review.waiting } : {}), | ||
| ...(failed ? { check_failed: failed.outcome.replace(/^failed:\s*/, "") } : {}), | ||
| ...(unstored.length ? { unstored } : {}), | ||
| } | ||
| } | ||
|
|
||
| /** The person's skill and knowledge decisions not reported to a host yet (within `NOTICE_MAX_AGE_MS`), and how many of | ||
| * their requests wait. Polls the server once, within `waitMs`; past it (offline, a slow server), what was kept is used. | ||
| * Never throws. */ | ||
| export async function reviewForHost( | ||
| directory: string, | ||
| seen: Set<string>, | ||
| waitMs: number, | ||
| ): Promise<{ decisions: ReviewDecision[]; waiting: number }> { | ||
| try { | ||
| const { ReviewNotices } = await import("../workspace/review-notices") | ||
| await Promise.race([ | ||
| ReviewNotices.poll(directory).catch(() => undefined), | ||
| new Promise((resolve) => setTimeout(resolve, waitMs).unref?.()), | ||
| ]) | ||
| const state = await ReviewNotices.storedState(directory) | ||
| if (!state) return { decisions: [], waiting: 0 } | ||
| const now = Date.now() | ||
| const decisions = state.notices | ||
| .filter((n) => !seen.has(reviewSeenKey(n)) && !(now - Date.parse(n.at) > NOTICE_MAX_AGE_MS)) | ||
| .map((n): ReviewDecision => ({ | ||
| kind: n.kind, | ||
| public_id: n.public_id, | ||
| name: n.name, | ||
| status: n.status, | ||
| at: n.at, | ||
| ...(n.reason ? { reason: n.reason } : {}), | ||
| ...(n.reviewer ? { reviewer: n.reviewer } : {}), | ||
| })) | ||
| return { decisions, waiting: state.pending.length } | ||
| } catch (error) { | ||
| log.warn("host review decisions not read", { error: errorText(error) }) | ||
| return { decisions: [], waiting: 0 } | ||
| } | ||
| } | ||
|
|
||
| /** `start` as context for the host agent; empty when there is nothing to say. Server text is made printable, and the | ||
| * workspace name a bounded, quoted label. */ | ||
| export function context(start: Start, command = "/altimate-code:rules"): string { | ||
|
|
@@ -191,6 +259,7 @@ export function context(start: Start, command = "/altimate-code:rules"): string | |
| ...ruleLines(start, ws), | ||
| ...proposalLines(start, command), | ||
| ...noticeLines(start, ws), | ||
| ...reviewLines(start, ws, command), | ||
| ...(start.check_failed | ||
| ? [`The last check of the person's corrections for proposed team rules failed (${printable(start.check_failed)}). It needs a model altimate-code can use: \`altimate learn status\` shows the setting.`] | ||
| : []), | ||
|
|
@@ -247,6 +316,24 @@ function noticeLines(start: Start, ws: string): string[] { | |
| return [`Reviews of the person's proposed rules in ${ws} since their last session:`, ...start.notices.map(line)] | ||
| } | ||
|
|
||
| function reviewLines(start: Start, ws: string, command: string): string[] { | ||
| const what = (d: ReviewDecision) => (d.kind === "skill" ? "skill" : "knowledge base") | ||
| const line = (d: ReviewDecision) => { | ||
| const by = d.reviewer ? ` by ${printable(d.reviewer)}` : "" | ||
| const outcome = | ||
| d.status === "active" ? `approved${by}; it now reaches the workspace` : d.status === "retired" ? `removed from the workspace${by}` : `${printable(d.status)}${by}` | ||
| return `- ${what(d)} "${printable(d.name)}": ${outcome}${d.reason ? ` — ${printable(d.reason)}` : ""}` | ||
| } | ||
| const lines: string[] = [] | ||
| if (start.review_decisions?.length) | ||
| lines.push(`Decisions on the skills and knowledge bases the person sent to ${ws} for review, since their last session (tell them):`, ...start.review_decisions.map(line)) | ||
| if (start.review_waiting) { | ||
| const one = start.review_waiting === 1 | ||
| lines.push(`${one ? "One" : start.review_waiting} of the person's skill or knowledge requests ${one ? "is" : "are"} waiting for review in ${ws}; ${command} lists ${one ? "it" : "them"}.`) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION:
|
||
| } | ||
| return lines | ||
| } | ||
|
|
||
| /** "Remember this as a team rule: X", "save as a rule - X", "don't forget, team rule: X": a request that names a team rule | ||
| * ("as a rule" or "team rule", then ":" or "-"), and what it is. Only explicit wording: "remember to run the tests", | ||
| * "do you remember that rule, …?" or "remember this rule: …" is the conversation, not a team rule. The cue words are the | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION: the new
workspace review decisionscommand is undocumentedThis paragraph documents the review CLI and now mentions
/rulesand theserveroutes, but notaltimate-code workspace review decisions --json [--unseen] [--mark-seen] [--host H]that this PR adds for the Claude Code plugin, nor its deliberate always-JSON/always-exit-0 contract (which differs from the general "okistrueexactly when the exit code is0" statement above). Add it so the CLI reference matches the implementation.