From 2f3b2e47e497cb71868e11235c14661e911287bb Mon Sep 17 00:00:00 2001 From: wangchongfei <11957989+Walternater@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:39:39 +0800 Subject: [PATCH 01/18] feat(recall): tune promotion thresholds by replacing the promote workflow The four gates findPromotionCandidates applied were constants, so every team lived with the bar a large org needs: a small team can never reach MIN_USERS tuned for many members, and tuning meant editing source. The gates now live in workflows/teamai-promote-learning.yaml's defaults.inputs (min_confidence, min_upvoted, min_users, min_age_days), shipped at the values the constants held. A team tunes its bar by replacing that workflow file, keeping the defaults it does not change. One shared schema (PromotionThresholdsSchema) parses the resolved workflow's defaults, refusing an out-of-range gate with the file and the field named instead of falling back silently. The listing, the promote-candidate and promote-publish steps, and the dashboard all resolve the same workflow through resolvePromotionThresholds, so a listing never offers a learning a step then rejects. Closes #1829 Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + src/__tests__/maintenance-promote.test.ts | 25 ++++++ src/__tests__/promotion-thresholds.test.ts | 95 ++++++++++++++++++++++ src/dashboard/locales.ts | 2 +- src/index.ts | 5 +- src/maintenance/promote.ts | 27 ++++-- src/maintenance/promotion-publish.ts | 5 +- src/maintenance/promotion-thresholds.ts | 51 ++++++++++++ src/viz-render.ts | 2 +- src/viz.ts | 13 +-- workflows/teamai-promote-learning.yaml | 5 +- 11 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 src/__tests__/promotion-thresholds.test.ts create mode 100644 src/maintenance/promotion-thresholds.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ef3559fb..8b40c61b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features +- The promotion gates `teamai recall promote` lists with live in the `teamai-promote-learning` workflow's `defaults.inputs` (`min_confidence`, `min_upvoted`, `min_users`, `min_age_days`), shipped at the values the constants held. A team tunes its bar by replacing that workflow file, keeping the defaults it does not change; a gate set out of range refuses the listing with the file and the field named, rather than falling back silently. The listing, the `promote-candidate` and `promote-publish` steps, and the dashboard all resolve the same workflow, so none offers a learning another rejects (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - A team changes one file of a teamai default hook, rule or workflow folder by keeping that file in a folder of the default's name; every other file stays teamai's and keeps its improvements. Contracts such as the recall rule's kept lines and a hook default's keys are checked on the resulting folder. A team file that replaces no file of the default: pull warns, names it and keeps teamai's file, and `teamai review --branch` fails. A run of such a folder records the teamai version it took files from, and `teamai list --json` marks each file's layer. - Agents and scorers can be folders. `agents/[/]/AGENT.md` is delivered like a rule folder and names its files with `${TEAMAI_AGENT_DIR}`; Claude Code gets the user-scope directory in `additionalDirectories`, and another tool that cannot read it gets a warning. `scorers/[/]/SCORER.md` names its files with `${TEAMAI_SCORER_DIR}`, and the judge reads them in its evidence directory. - `teamai update` records every TeamAI copy this machine runs from in `~/.teamai/installations.json`, and `teamai update` and `teamai doctor` list them. With `updatePolicy: auto` and `updateOtherInstallations: true`, an update of a running copy that is already current can update one other registered npm-global copy per run under its own `--prefix`; npm exiting 0 without changing that copy is a failure. Volta, vendored and local copies are listed with their manual remedy and are never passed to npm (for [#1753](https://github.com/Tencent/teamai-cli/issues/1753)). diff --git a/src/__tests__/maintenance-promote.test.ts b/src/__tests__/maintenance-promote.test.ts index 309db7ba61..ee030deba7 100644 --- a/src/__tests__/maintenance-promote.test.ts +++ b/src/__tests__/maintenance-promote.test.ts @@ -158,6 +158,31 @@ describe('findPromotionCandidates', () => { const candidates = await findPromotionCandidates([learningsDir], votesDir, undefined); expect(candidates.find((c) => c.docId === 'too-new')).toBeUndefined(); }); + it('applies the thresholds a team tunes, over the shipped gates', async () => { + const learningsDir = path.join(tmpDir, 'learnings'); + const votesDir = path.join(tmpDir, 'votes'); + fs.mkdirSync(learningsDir, { recursive: true }); + fs.mkdirSync(votesDir, { recursive: true }); + + // Adopted and confident, but 3 days old: only the age gate keeps it out under the defaults. + const recentDate = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + makeLearning(learningsDir, 'young-star', { date: recentDate }); + makeHighVotes(votesDir, 'young-star'); + makeLearning(learningsDir, 'settled'); + makeHighVotes(votesDir, 'settled'); + + // The shipped gates admit the settled learning and keep the young one out. + expect((await findPromotionCandidates([learningsDir], votesDir, undefined)).map((c) => c.docId)).toEqual(['settled']); + + // A team's lower bar admits both. + const lower = { minConfidence: 0.9, minUpvoted: 5, minUsers: 2, minAgeDays: 2 }; + expect((await findPromotionCandidates([learningsDir], votesDir, undefined, { thresholds: lower })).map((c) => c.docId).sort()).toEqual(['settled', 'young-star']); + + // A team's higher bar keeps out even the learning the defaults admit. + const higher = { minConfidence: 0.9, minUpvoted: 20, minUsers: 2, minAgeDays: 14 }; + expect(await findPromotionCandidates([learningsDir], votesDir, undefined, { thresholds: higher })).toEqual([]); + }); + it('finds namespaced learnings when active namespaces are provided', async () => { const learningsDir = path.join(tmpDir, 'learnings'); const votesDir = path.join(tmpDir, 'votes'); diff --git a/src/__tests__/promotion-thresholds.test.ts b/src/__tests__/promotion-thresholds.test.ts new file mode 100644 index 0000000000..51160c48be --- /dev/null +++ b/src/__tests__/promotion-thresholds.test.ts @@ -0,0 +1,95 @@ +// -*- coding: utf-8 -*- +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { parsePromotionThresholds, resolvePromotionThresholds } from '../maintenance/promotion-thresholds.js'; +import { DEFAULT_PROMOTION_THRESHOLDS } from '../maintenance/promote.js'; +import { syncScorersThenWorkflows } from '../scorers/team-sync.js'; +import type { LocalConfig } from '../types.js'; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function testConfig(): LocalConfig { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-promotion-thresholds-')); + dirs.push(root); + const knowledge = path.join(root, 'knowledge'); + fs.mkdirSync(path.join(knowledge, 'workflows'), { recursive: true }); + + return { + repo: { localPath: knowledge, remote: 'local', kind: 'self' }, + username: 'tester', + scope: 'project', + additionalRoles: [], + projectRoot: root, + dataHome: path.join(root, 'data'), + }; +} + +const replaced = (defaults: string) => `schemaVersion: v1alpha1 +name: teamai-promote-learning +description: Promote with the bar this team can reach. +inputs: { learning: string, category: string, min_confidence: number, min_upvoted: number, min_users: number, min_age_days: number } +defaults: + inputs: { ${defaults} } +steps: + - id: learning + command: teamai recall maintenance promote-candidate --learning "$ID" + env: { ID: "{{ inputs.learning }}" } + output: { title: string, content: string, suggested: string } +`; + +describe('parsePromotionThresholds', () => { + it('ships the defaults when the workflow names no gates', () => { + expect(parsePromotionThresholds(undefined, 'teamai-promote-learning.yaml')).toEqual(DEFAULT_PROMOTION_THRESHOLDS); + expect(parsePromotionThresholds({}, 'teamai-promote-learning.yaml')).toEqual(DEFAULT_PROMOTION_THRESHOLDS); + }); + + it('merges a partial override, each named gate on its own', () => { + expect(parsePromotionThresholds({ min_confidence: 0.7 }, 'wf.yaml')).toEqual({ ...DEFAULT_PROMOTION_THRESHOLDS, minConfidence: 0.7 }); + expect(parsePromotionThresholds({ min_age_days: 7, min_users: 3 }, 'wf.yaml')).toEqual({ ...DEFAULT_PROMOTION_THRESHOLDS, minAgeDays: 7, minUsers: 3 }); + }); + + it('accepts the boundary values', () => { + expect(parsePromotionThresholds({ min_confidence: 0, min_age_days: 0, min_upvoted: 1, min_users: 1 }, 'wf.yaml')) + .toEqual({ minConfidence: 0, minUpvoted: 1, minUsers: 1, minAgeDays: 0 }); + }); + + it.each([ + ['min_confidence', 1.5], ['min_confidence', 'high'], ['min_confidence', -0.1], + ['min_upvoted', 0], ['min_upvoted', 2.5], ['min_users', 0], ['min_age_days', -1], + ])('refuses %s = %j with the field and the file named', (key, value) => { + expect(() => parsePromotionThresholds({ [key]: value }, 'workflows/teamai-promote-learning.yaml')) + .toThrow(`workflows/teamai-promote-learning.yaml: defaults.inputs.${key} must be`); + }); +}); + +describe('resolvePromotionThresholds', () => { + it('resolves the shipped defaults while the team replaces no workflow', async () => { + const config = testConfig(); + await syncScorersThenWorkflows(config); + + expect(await resolvePromotionThresholds(config)).toEqual(DEFAULT_PROMOTION_THRESHOLDS); + }); + + it('resolves the gates of the workflow file the team replaced it with', async () => { + const config = testConfig(); + fs.writeFileSync(path.join(config.repo.localPath, 'workflows', 'teamai-promote-learning.yaml'), replaced('min_confidence: 0.7, min_upvoted: 3, min_users: 2, min_age_days: 7')); + await syncScorersThenWorkflows(config); + + expect(await resolvePromotionThresholds(config)).toEqual({ minConfidence: 0.7, minUpvoted: 3, minUsers: 2, minAgeDays: 7 }); + }); + + it('refuses a replaced workflow whose gate is out of range', async () => { + const config = testConfig(); + fs.writeFileSync(path.join(config.repo.localPath, 'workflows', 'teamai-promote-learning.yaml'), replaced('min_confidence: 2')); + await syncScorersThenWorkflows(config); + + await expect(resolvePromotionThresholds(config)).rejects.toThrow('defaults.inputs.min_confidence must be'); + }); +}); diff --git a/src/dashboard/locales.ts b/src/dashboard/locales.ts index b567f30f1d..6b043455d9 100644 --- a/src/dashboard/locales.ts +++ b/src/dashboard/locales.ts @@ -307,7 +307,7 @@ export const dashboardMessages = { "Suggested for Archive": "建议归档", "Stale (Needs Quality Update)": "过期知识(需要质量更新)", "None right now.": "目前没有候选。", - "Learnings with high confidence (≥0.90) and adoption across multiple users — good candidates to promote to formal skills, rules, or docs.": "置信度较高(≥0.90)且被多位用户采用的经验,可考虑晋升为正式技能、规则或文档。", + "Learnings with high confidence and adoption across multiple users — good candidates to promote to formal skills, rules, or docs.": "置信度较高且被多位用户采用的经验,可考虑晋升为正式技能、规则或文档。", "How to promote": "如何晋升", "— Review a candidate below, then run (each entry shows its own ready-to-copy command):": "— 审查以下候选后运行对应命令:", "Learnings below confidence threshold or inactive for >180 days.": "置信度低于阈值或超过 180 天未活动的经验。", diff --git a/src/index.ts b/src/index.ts index cccff92da9..f42c72a495 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2603,7 +2603,10 @@ async function promotionListing(localConfig: LocalConfig, options: { dryRun: boo if (!paths) return undefined; - return { paths, candidates: await findPromotionCandidates(paths.learningsReadDirs, paths.votesDir, knowledgeCheckout, { namespaces }) }; + const { resolvePromotionThresholds } = await import('./maintenance/promotion-thresholds.js'); + const thresholds = await resolvePromotionThresholds(localConfig); + + return { paths, candidates: await findPromotionCandidates(paths.learningsReadDirs, paths.votesDir, knowledgeCheckout, { namespaces, thresholds }) }; } /** diff --git a/src/maintenance/promote.ts b/src/maintenance/promote.ts index ef277f0a87..eba897ba1f 100644 --- a/src/maintenance/promote.ts +++ b/src/maintenance/promote.ts @@ -27,10 +27,18 @@ export interface PromotionCandidate { suggestedCategory: PromotionCategory; } -const MIN_CONFIDENCE = 0.90; -const MIN_UPVOTED = 5; -const MIN_USERS = 2; -const MIN_AGE_DAYS = 14; +/** The four gates {@link findPromotionCandidates} applies, a team tunes by replacing `workflows/teamai-promote-learning.yaml`. */ +export const PromotionThresholdsSchema = z.strictObject({ + minConfidence: z.number().min(0).max(1), + minUpvoted: z.number().int().min(1), + minUsers: z.number().int().min(1), + minAgeDays: z.number().int().min(0), +}); + +/** The four gates, as resolved from a team's workflow; the shipped defaults when the team replaces none. */ +export type PromotionThresholds = z.infer; + +export const DEFAULT_PROMOTION_THRESHOLDS: PromotionThresholds = { minConfidence: 0.90, minUpvoted: 5, minUsers: 2, minAgeDays: 14 }; /** * Find learnings eligible for promotion to formal knowledge. A learning marked @@ -42,6 +50,8 @@ const MIN_AGE_DAYS = 14; */ export interface PromotionCandidatesOptions { namespaces?: readonly string[]; + /** The gates to apply; the shipped defaults when absent, so unit callers and `--repo` views stay unchanged. */ + thresholds?: PromotionThresholds; } export async function findPromotionCandidates( @@ -55,6 +65,7 @@ export async function findPromotionCandidates( const opts = knowledgeCheckout instanceof Object ? knowledgeCheckout : options; const namespaces = opts?.namespaces; + const { minConfidence, minUpvoted, minUsers, minAgeDays } = opts?.thresholds ?? DEFAULT_PROMOTION_THRESHOLDS; const confidenceMap = await computeAllConfidence(votesDir); const eligible: Array & { content: string; promotedTo?: string }> = []; @@ -66,15 +77,15 @@ export async function findPromotionCandidates( const docId = file.replace(/\.md$/i, ''); const confidence = confidenceMap.get(docId) ?? 0; - if (confidence < MIN_CONFIDENCE) continue; + if (confidence < minConfidence) continue; const docVotes = perDoc.get(docId); if (!docVotes) continue; - if (docVotes.upvoted < MIN_UPVOTED) continue; + if (docVotes.upvoted < minUpvoted) continue; - if (docVotes.users.size < MIN_USERS) continue; + if (docVotes.users.size < minUsers) continue; const content = await readFileSafe(absPath); @@ -96,7 +107,7 @@ export async function findPromotionCandidates( if (date) { const ageInDays = (now - new Date(date).getTime()) / (1000 * 60 * 60 * 24); - if (ageInDays < MIN_AGE_DAYS) continue; + if (ageInDays < minAgeDays) continue; } eligible.push({ diff --git a/src/maintenance/promotion-publish.ts b/src/maintenance/promotion-publish.ts index dd838ec1af..fb46cb2b78 100644 --- a/src/maintenance/promotion-publish.ts +++ b/src/maintenance/promotion-publish.ts @@ -14,6 +14,7 @@ import { createPrWithFallback } from '../push.js'; import { getProvider } from '../providers/index.js'; import { log } from '../utils/logger.js'; import { resolveMaintenancePaths } from './paths.js'; +import { resolvePromotionThresholds } from './promotion-thresholds.js'; import { fetchPromotionRefs, findPromotionCandidates, markPromoted, writePromotion, type PromotionCandidate, type PromotionCategory } from './promote.js'; /** What `publish` writes: the learning rewritten for its category, or the learning as written when the AI could not rewrite it. */ @@ -68,7 +69,9 @@ async function publish( return withSyncLock(localConfig, async () => { const { repoPath: knowledgeCheckout, votesDir, learningsReadDirs } = await resolveMaintenancePaths(localConfig, { holdsSyncLock: true }); await fetchPromotionRefs(knowledgeCheckout); - const candidate = promotionCandidate(await findPromotionCandidates(learningsReadDirs, votesDir, knowledgeCheckout, { namespaces: options.namespaces }), learningId); + // The gates the listing used, so a learning it offered under a team's tuned thresholds is found here too. + const thresholds = await resolvePromotionThresholds(localConfig); + const candidate = promotionCandidate(await findPromotionCandidates(learningsReadDirs, votesDir, knowledgeCheckout, { namespaces: options.namespaces, thresholds }), learningId); const message = `[teamai] Promote ${candidate.docId} to ${options.category}`; const description = `Category: ${options.category}\n\n${options.reason}`; diff --git a/src/maintenance/promotion-thresholds.ts b/src/maintenance/promotion-thresholds.ts new file mode 100644 index 0000000000..b4cde8ba07 --- /dev/null +++ b/src/maintenance/promotion-thresholds.ts @@ -0,0 +1,51 @@ +// -*- coding: utf-8 -*- +import { z } from 'zod'; + +import type { JsonObject, LocalConfig } from '../types.js'; +import { resolveStart } from '../workflow/cache.js'; +import { DEFAULT_PROMOTION_THRESHOLDS, PromotionThresholdsSchema, type PromotionThresholds } from './promote.js'; + +/** The workflow that owns the promotion gates (#1829); a team tunes them by replacing this file, keeping the defaults it does not change. */ +export const PROMOTION_WORKFLOW = 'teamai-promote-learning'; + +/** + * The four gates out of a workflow's `defaults.inputs`, each absent key keeping its shipped default. A present key + * that does not fit refuses the run, rather than falling back silently: a tuned gate that never took effect would + * list what the team did not mean to promote. + */ +export function parsePromotionThresholds(inputs: JsonObject | undefined, source: string): PromotionThresholds { + const thresholds: PromotionThresholds = { ...DEFAULT_PROMOTION_THRESHOLDS }; + + if (inputs === undefined) return thresholds; + + const read = (name: K, yamlKey: string, field: z.ZodType, range: string): void => { + const value = inputs[yamlKey]; + + if (value === undefined) return; + + const parsed = field.safeParse(value); + + if (!parsed.success) throw new Error(`${source}: defaults.inputs.${yamlKey} must be ${range}`); + thresholds[name] = parsed.data; + }; + + const { minConfidence, minUpvoted, minUsers, minAgeDays } = PromotionThresholdsSchema.shape; + + read('minConfidence', 'min_confidence', minConfidence, 'a number from 0 to 1'); + read('minUpvoted', 'min_upvoted', minUpvoted, 'a whole number of at least 1'); + read('minUsers', 'min_users', minUsers, 'a whole number of at least 1'); + read('minAgeDays', 'min_age_days', minAgeDays, 'a whole number of at least 0'); + + return thresholds; +} + +/** + * The gates this workspace's `teamai-promote-learning` resolves to: the team's replaced file's `defaults.inputs` + * over the shipped defaults, exactly as a `recall promote ` run would resolve the workflow. The listing and + * the promote step read this one resolution, so a listing never offers what the step then rejects. + */ +export async function resolvePromotionThresholds(localConfig: LocalConfig): Promise { + const { workflow } = await resolveStart(localConfig, PROMOTION_WORKFLOW); + + return parsePromotionThresholds(workflow?.definition.defaults?.inputs, workflow?.relativePath ?? PROMOTION_WORKFLOW); +} diff --git a/src/viz-render.ts b/src/viz-render.ts index fb5c2d8ce6..dc237401eb 100644 --- a/src/viz-render.ts +++ b/src/viz-render.ts @@ -360,7 +360,7 @@ function renderAuthorsSection(authors: AuthorStat[]): string { /** Render the promotable learnings maintenance block as an HTML string. */ function renderPromotionBlock(promote: PromotionCandidate[]): string { const intro = - '

Learnings with high confidence (≥0.90) and adoption across ' + + '

Learnings with high confidence and adoption across ' + 'multiple users — good candidates to promote to formal skills, rules, or docs.

'; const guide = diff --git a/src/viz.ts b/src/viz.ts index b9bf706869..973227a8f0 100644 --- a/src/viz.ts +++ b/src/viz.ts @@ -25,7 +25,8 @@ import { getProjectSearchIndexPath, } from './types.js'; import type { LocalConfig, SearchIndexEntry } from './types.js'; -import { findPromotionCandidates, type PromotionCandidate } from './maintenance/promote.js'; +import { findPromotionCandidates, DEFAULT_PROMOTION_THRESHOLDS, type PromotionCandidate } from './maintenance/promote.js'; +import { resolvePromotionThresholds } from './maintenance/promotion-thresholds.js'; import { findPruneCandidates, type PruneCandidate } from './maintenance/prune.js'; import { findStaleEntries, type StaleEntry } from './maintenance/quality-update.js'; import { renderReport } from './viz-render.js'; @@ -588,11 +589,13 @@ export async function buildVizData(paths: VizPaths): Promise { const namespaces = paths.learningsNamespaces.ok ? paths.learningsNamespaces.namespaces : undefined; const [promote, prune, stale] = await Promise.all([ - findPromotionCandidates(paths.learningsDirs, paths.votesDir, paths.promotionCheckout, { namespaces }).catch((err: Error): PromotionCandidate[] => { - console.warn(`Warning: could not load promotion candidates: ${err.message}`); + (paths.scope ? resolvePromotionThresholds(paths.scope) : Promise.resolve(DEFAULT_PROMOTION_THRESHOLDS)) + .then((thresholds) => findPromotionCandidates(paths.learningsDirs, paths.votesDir, paths.promotionCheckout, { namespaces, thresholds })) + .catch((err: Error): PromotionCandidate[] => { + console.warn(`Warning: could not load promotion candidates: ${err.message}`); - return []; - }), + return []; + }), findPruneCandidates(paths.learningsDirs, paths.votesDir, { namespaces }).catch((err: Error): PruneCandidate[] => { console.warn(`Warning: could not load prune candidates: ${err.message}`); diff --git a/workflows/teamai-promote-learning.yaml b/workflows/teamai-promote-learning.yaml index b6246d4a61..30ed16ceff 100644 --- a/workflows/teamai-promote-learning.yaml +++ b/workflows/teamai-promote-learning.yaml @@ -3,7 +3,10 @@ name: teamai-promote-learning description: Promote an adopted, high-confidence learning to a skill, rule or doc - the AI picks the category unless the person gave one, rewrites the learning for it and opens a pull request that says why, then marks it promoted. Use when the person asks to promote a learning that teamai recall promote lists. invoke: ask private: true -inputs: { learning: string, category: string } +inputs: { learning: string, category: string, min_confidence: number, min_upvoted: number, min_users: number, min_age_days: number } +# The gates recall promote lists with; a team tunes them by replacing this file's defaults (#1829). +defaults: + inputs: { min_confidence: 0.9, min_upvoted: 5, min_users: 2, min_age_days: 14 } ends: { unconfigured-tool: failed } steps: - id: learning From 8f50c3bf5d1c85e713450f6565fa395dba1d67c3 Mon Sep 17 00:00:00 2001 From: wangchongfei <11957989+Walternater@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:04:59 +0800 Subject: [PATCH 02/18] fix(recall): surface invalid promotion gates in the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on this PR ([P2] on src/viz.ts): a replacement workflow with an out-of-range gate made threshold resolution fail, and the dashboard caught the error into an empty promote list, displaying 'None right now.' — a green that checked nothing, the same misconfiguration shape the CLI side refuses loudly. The error now rides the maintenance payload (VizData.maintenance .promoteError, into buildImprovement's output) and renders as a warning in both report HTML and the dashboard's Promote tab, with the message escaped and the label translated. Empty-because-nothing-eligible and empty-because-the-gates-are-broken no longer look alike. Co-Authored-By: Claude Code --- src/__tests__/dashboard-improvement.test.ts | 7 +++++++ src/__tests__/dashboard-ui.test.ts | 15 +++++++++++++++ src/dashboard/improvement-client.ts | 2 +- src/dashboard/improvement.ts | 4 ++++ src/dashboard/locales.ts | 2 ++ src/viz-render.ts | 10 +++++++--- src/viz.ts | 8 +++++++- 7 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/__tests__/dashboard-improvement.test.ts b/src/__tests__/dashboard-improvement.test.ts index 6e7b479c47..fe01283a8b 100644 --- a/src/__tests__/dashboard-improvement.test.ts +++ b/src/__tests__/dashboard-improvement.test.ts @@ -56,6 +56,13 @@ describe('the Improvement Center payload', () => { expect(data.daily.find((d) => d.date === '2026-10-20')).toMatchObject({ ended: 3, prompts: 6, costMicros: 500_000, members: 2 }); }); + it('carries a promotion-gates error into the maintenance payload, so the dashboard can say why the list is empty', async () => { + const data = await buildImprovement(paths(), { ...noMaintenance, promoteError: 'workflows/teamai-promote-learning.yaml: defaults.inputs.min_confidence must be a number from 0 to 1' }, 0); + + expect(data.maintenance.promote).toEqual([]); + expect(data.maintenance.promoteError).toContain('defaults.inputs.min_confidence'); + }); + it('adds up friction across members without naming anyone', async () => { writeStats('ana', { interventions: { sessions: 10, interrupt: 2, toolReject: 1, correction: 3 } }); writeStats('bo', { interventions: { sessions: 5, interrupt: 1, toolReject: 0, correction: 0 } }); diff --git a/src/__tests__/dashboard-ui.test.ts b/src/__tests__/dashboard-ui.test.ts index 4d92a7097d..44770df03e 100644 --- a/src/__tests__/dashboard-ui.test.ts +++ b/src/__tests__/dashboard-ui.test.ts @@ -101,6 +101,21 @@ describe('unified dashboard', () => { expect(sections.context).toContain('<img'); }); + it('shows a promotion-gates error instead of an empty list, escaped, with its translation registered', () => { + const data: VizData = { + generatedAt: new Date().toISOString(), root: '/tmp/fixture', source: { scope: 'team', label: 'Team' }, + totalEntries: 0, totalRecalls: 0, overallCoveragePct: 0, contributorCount: 0, + coverage: [], topRecalled: [], silent: [], relations: { resourceCount: 0, relationCount: 0, withoutRelation: [], unresolved: [], silentAndWithoutRelation: [] }, + trend: [], authors: [], + maintenance: { promote: [], prune: [], stale: [], promoteError: 'workflows/teamai-promote-learning.yaml: defaults.inputs.min_confidence must be a number from 0 to 1 not html' }, + }; + const { maintenance } = renderDashboardReport(data); + expect(maintenance).toContain('Promotion gates could not be read'); + expect(maintenance).toContain('defaults.inputs.min_confidence'); + expect(maintenance).not.toContain('not html'); + expect(Object.hasOwn(dashboardMessages, 'Promotion gates could not be read')).toBe(true); + }); + it('adds a Scorers page whose every label has a zh-CN translation (#1182)', () => { const html = getDashboardHtml(3721); expect(html).toContain("scorers: 'Scorers'"); diff --git a/src/dashboard/improvement-client.ts b/src/dashboard/improvement-client.ts index 7b99d98891..78e65d23c8 100644 --- a/src/dashboard/improvement-client.ts +++ b/src/dashboard/improvement-client.ts @@ -105,7 +105,7 @@ function improvement(){ const lists={ prune:['teamai recall maintenance --prune --archive',mtTable(['Learning','Confidence','Last activity','Reason','Adoption'],m.prune.map(c=>[e(c.name),Math.round(c.confidence*100)+'%',e((c.lastActivity||'').slice(0,10)||'—'),e(c.reason),adEvidence(c.name.replace(/[.]md$/i,''))]))], stale:['teamai recall maintenance --update-quality',mtTable(['Entry','Type','Recalls','Upvotes','Members','Adoption'],m.stale.map(c=>[e(c.docId),e(c.type),number(c.recalled),number(c.upvoted),number(c.users),adEvidence(c.docId)]))], - promote:['teamai recall promote ',mtTable(['Learning','Category','Confidence','Upvotes','Adoption','Command'],m.promote.map(c=>[e(c.title),e(c.category),Math.round(c.confidence*100)+'%',number(c.upvoted),adEvidence(c.docId),'']))], + promote:['teamai recall promote ',m.promoteError?'

'+label('Promotion gates could not be read:')+' '+e(m.promoteError)+'

':mtTable(['Learning','Category','Confidence','Upvotes','Adoption','Command'],m.promote.map(c=>[e(c.title),e(c.category),Math.round(c.confidence*100)+'%',number(c.upvoted),adEvidence(c.docId),'']))], }; const sel=lists[tab]?tab:'prune', count=tabs.find(x=>x[0]===sel)[2]; const maintHtml='

'+label('Knowledge maintenance')+' '+number(m.prune.length+m.stale.length+m.promote.length)+'

'+label('Candidates, not actions taken')+'
' diff --git a/src/dashboard/improvement.ts b/src/dashboard/improvement.ts index 4086eb5d0f..4240751a38 100644 --- a/src/dashboard/improvement.ts +++ b/src/dashboard/improvement.ts @@ -40,6 +40,7 @@ export interface ImprovementData { prune: Array<{ name: string; confidence: number; lastActivity: string; reason: string }>; stale: Array<{ docId: string; type: string; recalled: number; upvoted: number; users: number }>; silent: number; + promoteError?: string; }; adoption: { /** Docs some session recalled or upvoted, most recalled sessions first. */ @@ -77,6 +78,8 @@ interface MaintenanceInput { promote: Array<{ docId: string; title: string; suggestedCategory: string; confidence: number; upvotedCount: number; userCount: number }>; prune: Array<{ filename: string; confidence: number; lastActivity: string; reason: string }>; stale: Array<{ docId: string; type: string; recalledCount: number; upvotedCount: number; userCount: number }>; + /** Why the promote list is empty, when that is a failure to read the gates and not "nothing eligible yet". */ + promoteError?: string; } async function docAdoption(votesDir: string): Promise { @@ -242,6 +245,7 @@ export async function buildImprovement( prune: maintenance.prune.slice(0, LIST_LIMIT).map((c) => ({ name: c.filename, confidence: c.confidence, lastActivity: c.lastActivity, reason: c.reason })), stale: maintenance.stale.slice(0, LIST_LIMIT).map((c) => ({ docId: c.docId, type: c.type, recalled: c.recalledCount, upvoted: c.upvotedCount, users: c.userCount })), silent, + promoteError: maintenance.promoteError, }, adoption: { docs: await docAdoption(paths.votesDir), overriddenScorers: await overriddenScorers(paths.scope) }, }; diff --git a/src/dashboard/locales.ts b/src/dashboard/locales.ts index 6b043455d9..ead39fa304 100644 --- a/src/dashboard/locales.ts +++ b/src/dashboard/locales.ts @@ -307,6 +307,8 @@ export const dashboardMessages = { "Suggested for Archive": "建议归档", "Stale (Needs Quality Update)": "过期知识(需要质量更新)", "None right now.": "目前没有候选。", + "Promotion gates could not be read": "无法读取晋升门槛。", + "Promotion gates could not be read:": "无法读取晋升门槛:", "Learnings with high confidence and adoption across multiple users — good candidates to promote to formal skills, rules, or docs.": "置信度较高且被多位用户采用的经验,可考虑晋升为正式技能、规则或文档。", "How to promote": "如何晋升", "— Review a candidate below, then run (each entry shows its own ready-to-copy command):": "— 审查以下候选后运行对应命令:", diff --git a/src/viz-render.ts b/src/viz-render.ts index dc237401eb..9eb383d990 100644 --- a/src/viz-render.ts +++ b/src/viz-render.ts @@ -358,7 +358,7 @@ function renderAuthorsSection(authors: AuthorStat[]): string { } /** Render the promotable learnings maintenance block as an HTML string. */ -function renderPromotionBlock(promote: PromotionCandidate[]): string { +function renderPromotionBlock(promote: PromotionCandidate[], promoteError?: string): string { const intro = '

Learnings with high confidence and adoption across ' + 'multiple users — good candidates to promote to formal skills, rules, or docs.

'; @@ -369,7 +369,11 @@ function renderPromotionBlock(promote: PromotionCandidate[]): string { 'teamai recall promote <learning-id>'; if (promote.length === 0) { - return `

Promotable Learnings

${intro}${guide}

None right now.

`; + const empty = promoteError + ? `

Promotion gates could not be read

${escapeHtml(promoteError)}

` + : '

None right now.

'; + + return `

Promotable Learnings

${intro}${guide}${empty}
`; } const rows = promote @@ -463,7 +467,7 @@ function renderStaleBlock(stale: StaleEntry[]): string { /** Render the full maintenance console section combining promote, prune, and stale blocks. */ function renderMaintenanceSection(data: VizData): string { - const promote = renderPromotionBlock(data.maintenance.promote); + const promote = renderPromotionBlock(data.maintenance.promote, data.maintenance.promoteError); const prune = renderPruneBlock(data.maintenance.prune); const stale = renderStaleBlock(data.maintenance.stale); diff --git a/src/viz.ts b/src/viz.ts index 973227a8f0..c8c4d09d74 100644 --- a/src/viz.ts +++ b/src/viz.ts @@ -84,6 +84,8 @@ export interface MaintenanceCandidates { promote: PromotionCandidate[]; prune: PruneCandidate[]; stale: StaleEntry[]; + /** Why the promote list is empty, when that is a failure to read the gates and not "nothing eligible yet". */ + promoteError?: string; } /** Where the report's data came from, shown to the reader. */ @@ -588,10 +590,14 @@ export async function buildVizData(paths: VizPaths): Promise { const namespaces = paths.learningsNamespaces.ok ? paths.learningsNamespaces.namespaces : undefined; + // A gate the team set out of range is surfaced in the report, not presented as "nothing eligible" (#1849 review). + let promoteError: string | undefined; + const [promote, prune, stale] = await Promise.all([ (paths.scope ? resolvePromotionThresholds(paths.scope) : Promise.resolve(DEFAULT_PROMOTION_THRESHOLDS)) .then((thresholds) => findPromotionCandidates(paths.learningsDirs, paths.votesDir, paths.promotionCheckout, { namespaces, thresholds })) .catch((err: Error): PromotionCandidate[] => { + promoteError = err.message; console.warn(`Warning: could not load promotion candidates: ${err.message}`); return []; @@ -626,7 +632,7 @@ export async function buildVizData(paths: VizPaths): Promise { relations, trend, authors, - maintenance: { promote, prune, stale }, + maintenance: { promote, prune, stale, promoteError }, }; } From 07dbd01c1b76573359b59e590dcef9bd9a679dd2 Mon Sep 17 00:00:00 2001 From: wangchongfei <11957989+Walternater@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:26:46 +0800 Subject: [PATCH 03/18] docs: say two members can see different promotion gates --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b40c61b76..3192057bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features -- The promotion gates `teamai recall promote` lists with live in the `teamai-promote-learning` workflow's `defaults.inputs` (`min_confidence`, `min_upvoted`, `min_users`, `min_age_days`), shipped at the values the constants held. A team tunes its bar by replacing that workflow file, keeping the defaults it does not change; a gate set out of range refuses the listing with the file and the field named, rather than falling back silently. The listing, the `promote-candidate` and `promote-publish` steps, and the dashboard all resolve the same workflow, so none offers a learning another rejects (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). +- The promotion gates `teamai recall promote` lists with live in the `teamai-promote-learning` workflow's `defaults.inputs` (`min_confidence`, `min_upvoted`, `min_users`, `min_age_days`), shipped at the values the constants held. A team tunes its bar by replacing that workflow file, keeping the defaults it does not change; a gate set out of range refuses the listing with the file and the field named, rather than falling back silently. The listing, the `promote-candidate` and `promote-publish` steps, and the dashboard all resolve the same workflow, so none offers a learning another rejects. A member on an older teamai keeps the hard-coded gates, so upgrade every member before tuning them, or two members see different listings (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - A team changes one file of a teamai default hook, rule or workflow folder by keeping that file in a folder of the default's name; every other file stays teamai's and keeps its improvements. Contracts such as the recall rule's kept lines and a hook default's keys are checked on the resulting folder. A team file that replaces no file of the default: pull warns, names it and keeps teamai's file, and `teamai review --branch` fails. A run of such a folder records the teamai version it took files from, and `teamai list --json` marks each file's layer. - Agents and scorers can be folders. `agents/[/]/AGENT.md` is delivered like a rule folder and names its files with `${TEAMAI_AGENT_DIR}`; Claude Code gets the user-scope directory in `additionalDirectories`, and another tool that cannot read it gets a warning. `scorers/[/]/SCORER.md` names its files with `${TEAMAI_SCORER_DIR}`, and the judge reads them in its evidence directory. - `teamai update` records every TeamAI copy this machine runs from in `~/.teamai/installations.json`, and `teamai update` and `teamai doctor` list them. With `updatePolicy: auto` and `updateOtherInstallations: true`, an update of a running copy that is already current can update one other registered npm-global copy per run under its own `--prefix`; npm exiting 0 without changing that copy is a failure. Volta, vendored and local copies are listed with their manual remedy and are never passed to npm (for [#1753](https://github.com/Tencent/teamai-cli/issues/1753)). From 3ed50c12e4a8bca59b4af11a41fdbdd394b887c9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 14:46:34 +0100 Subject: [PATCH 04/18] feat(hooks)!: keep a default hook's parameters in params.yaml A default hook's parameters lived in HOOK.yaml's `with:`, so the spelling a team tunes a default with would differ for every other kind of default folder, where the marker is the resource itself (WORKFLOW.yaml, RULE.md) and cannot hold them. They move to a params.yaml beside it: a team folder holding only its params.yaml keeps teamai's HOOK.yaml and texts, and a key it leaves out keeps teamai's value, merged key by key as before. HOOK.yaml of a default hook now holds only description. Pull and `teamai review --branch` refuse one that keeps `with:` and say where its keys go. Tested: default-hook-replacements (params.yaml tunes min_tools with only that file in the team folder; out-of-range, unknown key and a leftover `with:` refused with the file named), dashboard default-hook comparison and review-branch refusal; npm run typecheck and lint. --- CHANGELOG.md | 1 + docs/guide/dashboard.md | 2 +- docs/guide/sharing.md | 12 +-- docs/guide/zh-CN/dashboard.md | 2 +- docs/guide/zh-CN/sharing.md | 12 +-- hooks/teamai-friction-reminder/HOOK.yaml | 13 --- hooks/teamai-friction-reminder/README.md | 16 ++- .../teamai-friction-reminder/README.zh-CN.md | 16 ++- hooks/teamai-friction-reminder/params.yaml | 12 +++ hooks/teamai-mr-hint/HOOK.yaml | 3 - hooks/teamai-mr-hint/README.md | 14 ++- hooks/teamai-mr-hint/README.zh-CN.md | 14 ++- hooks/teamai-mr-hint/params.yaml | 2 + .../dashboard-layered-resources-view.test.ts | 2 +- .../dashboard-layered-resources.test.ts | 10 +- .../default-hook-replacements.test.ts | 19 ++-- src/__tests__/review-branch.test.ts | 4 +- src/default-hooks.ts | 99 ++++++++++--------- 18 files changed, 128 insertions(+), 125 deletions(-) create mode 100644 hooks/teamai-friction-reminder/params.yaml create mode 100644 hooks/teamai-mr-hint/params.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 3192057bfe..938bcae81a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes +- **A default hook's parameters live in `params.yaml`, not in `HOOK.yaml`'s `with:`.** `hooks/teamai-friction-reminder/` and `hooks/teamai-mr-hint/` ship `params.yaml`, and a team folder holding only its own `params.yaml` keeps teamai's `HOOK.yaml` and texts. A key it leaves out keeps teamai's value. Pull refuses a team `HOOK.yaml` that still has `with:` and names `params.yaml`, so move those keys there (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - **A team folder named like a teamai default holds only the files it changes.** `hooks/teamai-/`, `rules/teamai-/` and `workflows/teamai-/` at the team root take every file they leave out, marker included, from teamai's default, and a file of the team's replaces teamai's of that name with its language variants. A lone `rules/teamai-recall/.md`, which pull delivered as a rule of its own, is now part of teamai's recall rule. Have every member upgrade before you commit a folder that holds only some files: an older teamai reads `rules/teamai-recall/block.md` as an always-on rule of its own. - **`TEAMAI_AGENT_DIR` is a reserved team env name.** It names an agent folder's own copy, so a team value for it is ignored and pull warns about it. - **A skill can be one file, so a file under `skills/` can be a skill.** `skills/[/].md` whose frontmatter has a `description` is delivered as `/SKILL.md`. Notes without one, such as `NOTES.md` or `README.md`, stay notes. diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 0832c9bbc5..3d791f129b 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -313,7 +313,7 @@ teamai-recall rules/teamai-recall/ Team root Folder Refused rules/teamai-recall/ is not used, so teamai's recall rule stays in force: rules/teamai-recall/RULE.md does not contain "Nothing was searched:". Fix the folder in the team repo. ``` -Rule files stay on the rule list above. Default hooks lists teamai's five hints: where each comes from, how many `with:` parameters and texts the team's folder changes, and, opened, each parameter's teamai and team value, the changed ones marked, and the file each text is read from for the team's language. +Rule files stay on the rule list above. Default hooks lists teamai's five hints: where each comes from, how many parameters and texts the team's folder changes, and, opened, each parameter's teamai and team value, the changed ones marked, and the file each text is read from for the team's language. The Overview counts the problems and links here: diff --git a/docs/guide/sharing.md b/docs/guide/sharing.md index cf4dac896a..7a6f017737 100644 --- a/docs/guide/sharing.md +++ b/docs/guide/sharing.md @@ -506,13 +506,11 @@ agy then refuses the command with `tool call denied by pre-tool hook: rm -rf is ### Default hooks -teamai's own hints keep their thresholds and texts in hook folders. A folder of the same name at the root of the team repo changes one. Put in it only the files you change; every other file, `HOOK.yaml` included, stays teamai's and keeps getting teamai's updates. To remind only after longer sessions, in the team's words: +teamai's own hints keep their thresholds and texts in hook folders. A folder of the same name at the root of the team repo changes one. Put in it only the files you change; every other file stays teamai's and keeps getting teamai's updates. To remind only after longer sessions, in the team's words: ```yaml -# hooks/teamai-friction-reminder/HOOK.yaml -description: Remind after longer sessions only -with: - min_tools: 30 +# hooks/teamai-friction-reminder/params.yaml +min_tools: 30 ``` ```markdown @@ -530,7 +528,7 @@ After `teamai pull`, the reminder waits for 30 tool calls and opens with that li | `teamai-env-hint` | The session-start note naming the scope's team env variables and secrets | | `teamai-package-hint` | The session-start note that the team's package declarations changed | -- `HOOK.yaml` holds `description` and `with:`. A key left out of `with:` keeps teamai's value. +- `params.yaml` holds the parameters, and a key left out of it keeps teamai's value. `HOOK.yaml` holds only `description`. - A text is `.md`, with `{{ name }}` variables. With `language: en` or `zh`, `.en.md` or `.zh.md` is used when the folder has it. - A text you write replaces all of teamai's languages of it: with your `problem.md` and no `problem.zh.md`, a `zh` team reads your `problem.md`. Write `problem.md` too when you write only one language. - A file teamai's folder does not have replaces nothing. Pull warns, names it, and leaves it out. A misspelled `problems.md` shows up this way instead of silently doing nothing. When your folder has its own `HOOK.yaml`, it may add files. @@ -540,7 +538,7 @@ After `teamai pull`, the reminder waits for 30 tool calls and opens with that li Pull checks the folder your files and teamai's make together. It refuses one that sets a value out of range or a key the default does not take, reads a variable a text does not offer, lacks the base `.md` of a text you wrote in one language, or has a `command`, and keeps teamai's folder in force: ```text -hooks/teamai-friction-reminder/ is refused, and teamai's default stays in force until it is fixed: HOOK.yaml: with.min_tools must be a whole number from 1 to 1000 +hooks/teamai-friction-reminder/ is refused, and teamai's default stays in force until it is fixed: params.yaml: min_tools must be a whole number from 1 to 1000 ``` A replacement replaces for the whole team, so pull refuses one in a namespace, such as `hooks/payments/teamai-friction-reminder/`. `teamai review --branch` fails a replacement pull refuses, or one with a file that replaces nothing, before you merge it. diff --git a/docs/guide/zh-CN/dashboard.md b/docs/guide/zh-CN/dashboard.md index a0d452612c..d977f17613 100644 --- a/docs/guide/zh-CN/dashboard.md +++ b/docs/guide/zh-CN/dashboard.md @@ -313,7 +313,7 @@ teamai-recall rules/teamai-recall/ 团队仓库根级别 目录 已拒绝 rules/teamai-recall/ is not used, so teamai's recall rule stays in force: rules/teamai-recall/RULE.md does not contain "Nothing was searched:". Fix the folder in the team repo. ``` -规则文件仍在上面的规则列表里。默认 Hook 列出 teamai 的五个提示:各自来自哪里,团队目录改了几个 `with:` 参数和几段文本;展开后逐个对照参数的 teamai 值和团队值并标出改动,以及每段文本按团队语言取自哪个文件。 +规则文件仍在上面的规则列表里。默认 Hook 列出 teamai 的五个提示:各自来自哪里,团队目录改了几个参数和几段文本;展开后逐个对照参数的 teamai 值和团队值并标出改动,以及每段文本按团队语言取自哪个文件。 总览页显示问题数量,并链接到这里: diff --git a/docs/guide/zh-CN/sharing.md b/docs/guide/zh-CN/sharing.md index 906ebd6eb1..173dd08548 100644 --- a/docs/guide/zh-CN/sharing.md +++ b/docs/guide/zh-CN/sharing.md @@ -497,13 +497,11 @@ agy 随后拒绝该命令:`tool call denied by pre-tool hook: rm -rf is blocke ### 默认 hook -teamai 自带提示的阈值和文案也放在 hook 目录里。在团队仓库根目录放一个同名目录,就能修改它。目录里只放你要改的文件;其他文件(包括 `HOOK.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。比如只在较长的会话后提醒,并换成团队自己的话: +teamai 自带提示的阈值和文案也放在 hook 目录里。在团队仓库根目录放一个同名目录,就能修改它。目录里只放你要改的文件;其他文件仍用 teamai 的,并继续获得 teamai 的更新。比如只在较长的会话后提醒,并换成团队自己的话: ```yaml -# hooks/teamai-friction-reminder/HOOK.yaml -description: Remind after longer sessions only -with: - min_tools: 30 +# hooks/teamai-friction-reminder/params.yaml +min_tools: 30 ``` ```markdown @@ -521,7 +519,7 @@ with: | `teamai-env-hint` | 会话开始时说明这个作用域有哪些团队环境变量和密钥 | | `teamai-package-hint` | 会话开始时说明团队的包声明变了 | -- `HOOK.yaml` 里有 `description` 和 `with:`。`with:` 里没写的键沿用 teamai 的值。 +- `params.yaml` 里放参数,没写的键沿用 teamai 的值。`HOOK.yaml` 里只有 `description`。 - 文案是 `.md`,用 `{{ name }}` 引用变量。设了 `language: en` 或 `zh` 时,目录里有 `.en.md` 或 `.zh.md` 就用它。 - 你写的文案会替换 teamai 这段文案的所有语言版本:有你的 `problem.md`、没有 `problem.zh.md` 时,`zh` 团队读到的是你的 `problem.md`。只写一种语言时,也要写 `problem.md`。 - teamai 目录里没有的文件不替换任何东西。pull 会警告、点出文件名,并忽略它。拼错的 `problems.md` 会这样暴露出来,而不是悄悄不起作用。你的目录有自己的 `HOOK.yaml` 时,可以添加文件。 @@ -531,7 +529,7 @@ with: pull 检查你的文件和 teamai 的文件合起来的目录。值超出范围、写了默认 hook 不认的键、引用了文案不提供的变量、只写了一种语言却缺少基础的 `.md`,或带了 `command` 时,pull 会拒绝它,teamai 自带的目录继续生效: ```text -hooks/teamai-friction-reminder/ is refused, and teamai's default stays in force until it is fixed: HOOK.yaml: with.min_tools must be a whole number from 1 to 1000 +hooks/teamai-friction-reminder/ is refused, and teamai's default stays in force until it is fixed: params.yaml: min_tools must be a whole number from 1 to 1000 ``` 替换对整个团队生效,所以放在 namespace 里的替换(比如 `hooks/payments/teamai-friction-reminder/`)会被 pull 拒绝。`teamai review --branch` 会在合并前把 pull 拒绝的替换,或带有不替换任何文件的替换,判为失败。 diff --git a/hooks/teamai-friction-reminder/HOOK.yaml b/hooks/teamai-friction-reminder/HOOK.yaml index 6556d6a8ce..f207a9ee04 100644 --- a/hooks/teamai-friction-reminder/HOOK.yaml +++ b/hooks/teamai-friction-reminder/HOOK.yaml @@ -1,14 +1 @@ description: Suggest sharing what a session with friction taught -with: - min_tools: 15 - min_score: 20 - weights: { interrupt: 20, reject: 20, correction: 20 } - tool_error_tiers: - - { min: 8, points: 25 } - - { min: 5, points: 18 } - - { min: 3, points: 10 } - skill_bonus: 5 - diversity_bonus_max: 5 - knowledge_gap_bonus: 20 - low_quality_bonus: 10 - low_quality_threshold: 5.0 diff --git a/hooks/teamai-friction-reminder/README.md b/hooks/teamai-friction-reminder/README.md index f1b1549254..042307bbd0 100644 --- a/hooks/teamai-friction-reminder/README.md +++ b/hooks/teamai-friction-reminder/README.md @@ -5,7 +5,7 @@ English | [中文](README.zh-CN.md) - **Runs**: At Stop - **Input**: The session's tool calls, interruptions, refusals, corrections, failing retries and recall hits - **Output**: A reminder to share what the session taught -- **Files**: `HOOK.yaml` holds teamai's thresholds and weights. Each `.md` file is one text of the reminder +- **Files**: `params.yaml` holds teamai's thresholds and weights. Each `.md` file is one text of the reminder ## When it reminds @@ -55,17 +55,15 @@ flowchart TD ## Replace it -Put only the files you change in `hooks/teamai-friction-reminder/` at the root of the team repo. Every other file, `HOOK.yaml` included, stays teamai's and keeps getting teamai's updates. +Put only the files you change in `hooks/teamai-friction-reminder/` at the root of the team repo. Every other file, `params.yaml` included, stays teamai's and keeps getting teamai's updates. ```yaml -# hooks/teamai-friction-reminder/HOOK.yaml -description: Remind only after longer sessions -with: - min_tools: 30 - weights: { reject: 30 } # interrupt and correction keep teamai's values +# hooks/teamai-friction-reminder/params.yaml +min_tools: 30 +weights: { reject: 30 } # interrupt and correction keep teamai's values ``` -Push the folder. The next `teamai pull` checks your files with teamai's other files and delivers them together. A key you leave out of `with:` keeps teamai's value from its `HOOK.yaml`. +Push the folder. The next `teamai pull` checks your files with teamai's other files and delivers them together. A key you leave out of `params.yaml` keeps teamai's value from its `params.yaml`. ## Parameters @@ -101,7 +99,7 @@ Push the folder. The next `teamai pull` checks your files with teamai's other fi | The replacement | What pull does | |---|---| -| Misses a text, sets a value out of range, sets a key the table above does not list, or sets a `command` | Names the field and keeps teamai's folder | +| Misses a text, sets a value out of range, sets a key the table above does not list, keeps `with:` in `HOOK.yaml`, or sets a `command` | Names the field and keeps teamai's folder | | Uses a variable its text does not offer | The same | | Sits in a namespace, such as `hooks/frontend/teamai-friction-reminder/` | The same. A replacement goes at the root of the team repo | diff --git a/hooks/teamai-friction-reminder/README.zh-CN.md b/hooks/teamai-friction-reminder/README.zh-CN.md index 1916a73d54..943d72132d 100644 --- a/hooks/teamai-friction-reminder/README.zh-CN.md +++ b/hooks/teamai-friction-reminder/README.zh-CN.md @@ -5,7 +5,7 @@ - **运行时机**: Stop 时 - **输入**: 会话的工具调用、打断、拒绝、纠正、失败后的重试和 recall 命中 - **输出**: 提醒分享这次会话学到的东西 -- **文件**: `HOOK.yaml` 保存 teamai 的阈值和权重。每个 `.md` 文件是提醒的一段文案 +- **文件**: `params.yaml` 保存 teamai 的阈值和权重。每个 `.md` 文件是提醒的一段文案 ## 何时提醒 @@ -55,17 +55,15 @@ flowchart TD ## 替换它 -在团队仓库根目录的 `hooks/teamai-friction-reminder/` 里只放你要改的文件。其他文件(包括 `HOOK.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。 +在团队仓库根目录的 `hooks/teamai-friction-reminder/` 里只放你要改的文件。其他文件(包括 `params.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。 ```yaml -# hooks/teamai-friction-reminder/HOOK.yaml -description: Remind only after longer sessions -with: - min_tools: 30 - weights: { reject: 30 } # interrupt and correction keep teamai's values +# hooks/teamai-friction-reminder/params.yaml +min_tools: 30 +weights: { reject: 30 } # interrupt and correction keep teamai's values ``` -推送这个文件夹。下一次 `teamai pull` 会把你的文件和 teamai 的其他文件一起检查并交付。`with:` 里没写的键,沿用 teamai 的 `HOOK.yaml` 里的值。 +推送这个文件夹。下一次 `teamai pull` 会把你的文件和 teamai 的其他文件一起检查并交付。`params.yaml` 里没写的键,沿用 teamai 的 `params.yaml` 里的值。 ## 参数 @@ -101,7 +99,7 @@ with: | 替换 | pull 怎么做 | |---|---| -| 缺少某段文案、设置了超出范围的值、设置了上表没有列出的键,或设置了 `command` | 指出是哪个字段,继续使用 teamai 的文件夹 | +| 缺少某段文案、设置了超出范围的值、设置了上表没有列出的键、在 `HOOK.yaml` 里保留了 `with:`,或设置了 `command` | 指出是哪个字段,继续使用 teamai 的文件夹 | | 使用了文案不提供的变量 | 同上 | | 放在命名空间里,例如 `hooks/frontend/teamai-friction-reminder/` | 同上。替换要放在团队仓库根目录 | diff --git a/hooks/teamai-friction-reminder/params.yaml b/hooks/teamai-friction-reminder/params.yaml new file mode 100644 index 0000000000..0ef00ea7d9 --- /dev/null +++ b/hooks/teamai-friction-reminder/params.yaml @@ -0,0 +1,12 @@ +min_tools: 15 +min_score: 20 +weights: { interrupt: 20, reject: 20, correction: 20 } +tool_error_tiers: + - { min: 8, points: 25 } + - { min: 5, points: 18 } + - { min: 3, points: 10 } +skill_bonus: 5 +diversity_bonus_max: 5 +knowledge_gap_bonus: 20 +low_quality_bonus: 10 +low_quality_threshold: 5.0 diff --git a/hooks/teamai-mr-hint/HOOK.yaml b/hooks/teamai-mr-hint/HOOK.yaml index e69612906b..57dc962e19 100644 --- a/hooks/teamai-mr-hint/HOOK.yaml +++ b/hooks/teamai-mr-hint/HOOK.yaml @@ -1,4 +1 @@ description: Offer to turn recently merged MRs into learnings at session start -with: - lookback_days: 7 - max_mrs: 10 diff --git a/hooks/teamai-mr-hint/README.md b/hooks/teamai-mr-hint/README.md index 55e698d1b4..6d5ba9e453 100644 --- a/hooks/teamai-mr-hint/README.md +++ b/hooks/teamai-mr-hint/README.md @@ -5,7 +5,7 @@ English | [中文](README.zh-CN.md) - **Runs**: At session start, after the hook returns. The session's next prompt shows the offer, or the session start itself where teamai gives the tool no prompt hook - **Input**: The MRs merged in the project's repository in the last `lookback_days`, except the ones it already offered - **Output**: An offer to turn them into learnings with [`teamai-import-from-mr`](../../workflows/teamai-import-from-mr/README.md) -- **Files**: `HOOK.yaml` holds teamai's parameters. Each `.md` file is one text of the offer +- **Files**: `params.yaml` holds teamai's parameters. Each `.md` file is one text of the offer ## When it offers @@ -30,16 +30,14 @@ The agent cannot start the workflow in a session that teamai itself launched. ## Replace it -Put only the files you change in `hooks/teamai-mr-hint/` at the root of the team repo. Every other file, `HOOK.yaml` included, stays teamai's and keeps getting teamai's updates. +Put only the files you change in `hooks/teamai-mr-hint/` at the root of the team repo. Every other file, `params.yaml` included, stays teamai's and keeps getting teamai's updates. ```yaml -# hooks/teamai-mr-hint/HOOK.yaml -description: Offer the last two weeks of MRs -with: - lookback_days: 14 # max_mrs keeps teamai's value +# hooks/teamai-mr-hint/params.yaml +lookback_days: 14 # max_mrs keeps teamai's value ``` -Push the folder. The next `teamai pull` checks your files with teamai's other files and delivers them together. A key you leave out of `with:` keeps teamai's value from its `HOOK.yaml`. +Push the folder. The next `teamai pull` checks your files with teamai's other files and delivers them together. A key you leave out of `params.yaml` keeps teamai's value from its `params.yaml`. ## Parameters @@ -68,7 +66,7 @@ Both texts take `{{ count }}`, `{{ mr_lines }}` and `{{ command }}`. `{{ mr_line | The replacement | What pull does | |---|---| -| Misses a text, sets a value out of range, sets a key the table above does not list, or sets a `command` | Names the field and keeps teamai's folder | +| Misses a text, sets a value out of range, sets a key the table above does not list, keeps `with:` in `HOOK.yaml`, or sets a `command` | Names the field and keeps teamai's folder | | Uses a variable its text does not offer | The same | | Sits in a namespace, such as `hooks/frontend/teamai-mr-hint/` | The same. A replacement goes at the root of the team repo | diff --git a/hooks/teamai-mr-hint/README.zh-CN.md b/hooks/teamai-mr-hint/README.zh-CN.md index adf1d92b74..6d08b2382c 100644 --- a/hooks/teamai-mr-hint/README.zh-CN.md +++ b/hooks/teamai-mr-hint/README.zh-CN.md @@ -5,7 +5,7 @@ - **运行时机**: 会话开始时,在 hook 返回之后。提议在会话的下一条提示词时显示;teamai 没有给工具装提示词 hook 时,在会话开始时显示 - **输入**: 项目仓库在最近 `lookback_days` 天内合并的 MR,已经提议过的除外 - **输出**: 一条提议:用 [`teamai-import-from-mr`](../../workflows/teamai-import-from-mr/README.zh-CN.md) 把它们整理成 learning -- **文件**: `HOOK.yaml` 保存 teamai 的参数。每个 `.md` 文件是提议的一段文案 +- **文件**: `params.yaml` 保存 teamai 的参数。每个 `.md` 文件是提议的一段文案 ## 何时提议 @@ -30,16 +30,14 @@ flowchart TD ## 替换它 -在团队仓库根目录的 `hooks/teamai-mr-hint/` 里只放你要改的文件。其他文件(包括 `HOOK.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。 +在团队仓库根目录的 `hooks/teamai-mr-hint/` 里只放你要改的文件。其他文件(包括 `params.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。 ```yaml -# hooks/teamai-mr-hint/HOOK.yaml -description: Offer the last two weeks of MRs -with: - lookback_days: 14 # max_mrs keeps teamai's value +# hooks/teamai-mr-hint/params.yaml +lookback_days: 14 # max_mrs keeps teamai's value ``` -推送这个文件夹。下一次 `teamai pull` 会把你的文件和 teamai 的其他文件一起检查并交付。`with:` 里没写的键,沿用 teamai 的 `HOOK.yaml` 里的值。 +推送这个文件夹。下一次 `teamai pull` 会把你的文件和 teamai 的其他文件一起检查并交付。`params.yaml` 里没写的键,沿用 teamai 的 `params.yaml` 里的值。 ## 参数 @@ -68,7 +66,7 @@ with: | 替换 | pull 怎么做 | |---|---| -| 缺少某段文案、设置了超出范围的值、设置了上表没有列出的键,或设置了 `command` | 指出是哪个字段,继续使用 teamai 的文件夹 | +| 缺少某段文案、设置了超出范围的值、设置了上表没有列出的键、在 `HOOK.yaml` 里保留了 `with:`,或设置了 `command` | 指出是哪个字段,继续使用 teamai 的文件夹 | | 使用了文案不提供的变量 | 同上 | | 放在命名空间里,例如 `hooks/frontend/teamai-mr-hint/` | 同上。替换要放在团队仓库根目录 | diff --git a/hooks/teamai-mr-hint/params.yaml b/hooks/teamai-mr-hint/params.yaml new file mode 100644 index 0000000000..ad78f31e03 --- /dev/null +++ b/hooks/teamai-mr-hint/params.yaml @@ -0,0 +1,2 @@ +lookback_days: 7 +max_mrs: 10 diff --git a/src/__tests__/dashboard-layered-resources-view.test.ts b/src/__tests__/dashboard-layered-resources-view.test.ts index a41a0f52ed..27e6b9839f 100644 --- a/src/__tests__/dashboard-layered-resources-view.test.ts +++ b/src/__tests__/dashboard-layered-resources-view.test.ts @@ -33,7 +33,7 @@ function views(language: 'en' | 'zh-CN' = 'en'): Views & { keys: string[] } { const DEPLOY_TWICE = 'workflows/deploy.yaml:1: field "(root)": Workflow "deploy" is defined twice in one place, as workflows/deploy.yaml and as workflows/deploy/; a workflow defined twice has no runnable version'; const HTTP_FOLDER = 'mcp/jira-lite/MCP.yaml: transport http is refused in a folder, which holds the files of a server teamai starts (stdio). A URL server carries no files: declare it in mcp.yaml. mcp was not applied this run, so your installed team MCP servers are unchanged. Fix the file in the team repo and push.'; const IMPORT_BLOCKED = 'teamai\'s default teamai-import-from-repo cannot start: step "enrich" with.wiki_root is not an input workflow "teamai-codebase-enrich" declares'; -const MR_HINT_REFUSED = 'hooks/teamai-mr-hint/ is refused, and teamai\'s default stays in force until it is fixed: HOOK.yaml: with.lookback_days must be a whole number from 1 to 90'; +const MR_HINT_REFUSED = 'hooks/teamai-mr-hint/ is refused, and teamai\'s default stays in force until it is fixed: params.yaml: lookback_days must be a whole number from 1 to 90'; const DATA: Served = { configured: true, diff --git a/src/__tests__/dashboard-layered-resources.test.ts b/src/__tests__/dashboard-layered-resources.test.ts index 55a9b541f1..6f0fdf1407 100644 --- a/src/__tests__/dashboard-layered-resources.test.ts +++ b/src/__tests__/dashboard-layered-resources.test.ts @@ -292,11 +292,11 @@ async function defaultHook(name: string, config: TeamaiConfig = teamConfig): Pro } describe('default hooks on the Resources page', () => { - it('compares each with: parameter with teamai\'s, and names the texts the team\'s folder changes', async () => { + it('compares each parameter with teamai\'s, and names the texts the team\'s folder changes', async () => { const folder = path.join(repo, 'hooks', 'teamai-friction-reminder'); await fse.copy(shippedFolder('teamai-friction-reminder'), folder); await write({ - 'hooks/teamai-friction-reminder/HOOK.yaml': 'description: Ours\nwith:\n min_tools: 30\n weights: { reject: 30 }\n', + 'hooks/teamai-friction-reminder/params.yaml': 'min_tools: 30\nweights: { reject: 30 }\n', 'hooks/teamai-friction-reminder/task.md': 'Our task: {{ task }}\n', }); @@ -320,14 +320,14 @@ describe('default hooks on the Resources page', () => { const reminder = await defaultHook('teamai-friction-reminder'); expect([reminder.state, reminder.texts.filter((text) => text.replaced).map((text) => text.text)]).toEqual([ - { kind: 'replaced', path: 'hooks/teamai-friction-reminder/', inherited: { teamaiVersion: PACKAGE_VERSION, own: 1, teamai: 17 } }, + { kind: 'replaced', path: 'hooks/teamai-friction-reminder/', inherited: { teamaiVersion: PACKAGE_VERSION, own: 1, teamai: 18 } }, ['task'], ]); }); it('names why pull refuses a replacement, with the value it refuses, and lists it once', async () => { await fse.copy(shippedFolder('teamai-friction-reminder'), path.join(repo, 'hooks', 'teamai-friction-reminder')); - await write({ 'hooks/teamai-friction-reminder/HOOK.yaml': 'description: Ours\nwith:\n min_tools: 5000\n' }); + await write({ 'hooks/teamai-friction-reminder/params.yaml': 'min_tools: 5000\n' }); const reminder = await defaultHook('teamai-friction-reminder'); const hooks = await listOf('hooks'); @@ -336,7 +336,7 @@ describe('default hooks on the Resources page', () => { { kind: 'refused', path: 'hooks/teamai-friction-reminder/', - messages: ['hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: HOOK.yaml: with.min_tools must be a whole number from 1 to 1000'], + messages: ['hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: params.yaml: min_tools must be a whole number from 1 to 1000'], }, { key: 'min_tools', teamai: 15, team: 5000 }, null, diff --git a/src/__tests__/default-hook-replacements.test.ts b/src/__tests__/default-hook-replacements.test.ts index bfa3f9cdb5..38250d3cba 100644 --- a/src/__tests__/default-hook-replacements.test.ts +++ b/src/__tests__/default-hook-replacements.test.ts @@ -138,8 +138,8 @@ describe('a team replacement of teamai-friction-reminder', () => { }); }); - it('reminds after as few tool calls as its min_tools says, the parameters it leaves out keeping teamai\'s values', async () => { - await replace('teamai-friction-reminder', { 'HOOK.yaml': 'description: Ask sooner\nwith:\n min_tools: 3\n' }); + it('reminds after as few tool calls as its params.yaml min_tools says, the parameters it leaves out keeping teamai\'s values', async () => { + await own('teamai-friction-reminder', { 'params.yaml': 'min_tools: 3\n' }); await reconcileTeamHooksForConfig(teamConfig, member()); @@ -223,13 +223,18 @@ describe('pull refuses a replacement of a default hook that breaks its contract, it.each<{ broken: string; files: Record; refusal: string }>([ { broken: 'a parameter out of its range', - files: { 'HOOK.yaml': 'with:\n min_tools: 5000\n' }, - refusal: 'hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: HOOK.yaml: with.min_tools must be a whole number from 1 to 1000', + files: { 'params.yaml': 'min_tools: 5000\n' }, + refusal: 'hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: params.yaml: min_tools must be a whole number from 1 to 1000', }, { broken: 'a parameter the default does not take', - files: { 'HOOK.yaml': 'with:\n max_tools: 30\n' }, - refusal: 'hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: HOOK.yaml: with.max_tools is not a parameter of teamai-friction-reminder', + files: { 'params.yaml': 'max_tools: 30\n' }, + refusal: 'hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: params.yaml: max_tools is not a parameter of teamai-friction-reminder', + }, + { + broken: 'its parameters left in HOOK.yaml', + files: { 'HOOK.yaml': 'description: Ask sooner\nwith:\n min_tools: 3\n' }, + refusal: 'hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: HOOK.yaml: with is not a key of a default hook, whose parameters are in params.yaml. Move the keys of with to params.yaml', }, { broken: 'a variable its text does not offer', @@ -244,7 +249,7 @@ describe('pull refuses a replacement of a default hook that breaks its contract, { broken: 'an event', files: { 'HOOK.yaml': 'description: Remind at the end\nevent: Stop\n' }, - refusal: 'hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: HOOK.yaml: event is not a key of a default hook, whose HOOK.yaml holds description and with', + refusal: 'hooks/teamai-friction-reminder/ is refused, and teamai\'s default stays in force until it is fixed: HOOK.yaml: event is not a key of a default hook, whose HOOK.yaml holds only description', }, { broken: 'a text in one language without its base file', diff --git a/src/__tests__/review-branch.test.ts b/src/__tests__/review-branch.test.ts index f6282ae7d9..dd14c0ccb6 100644 --- a/src/__tests__/review-branch.test.ts +++ b/src/__tests__/review-branch.test.ts @@ -659,8 +659,8 @@ describe('reviewBranchDir — read-only health checks for a pushed branch (#776) it('fails a replacement of a default hook that pull refuses, with what pull prints', async () => { const shipped = path.join(process.cwd(), 'hooks', 'teamai-friction-reminder'); await fse.copy(shipped, path.join(repoDir, 'hooks', 'teamai-friction-reminder')); - const marker = path.join(repoDir, 'hooks', 'teamai-friction-reminder', 'HOOK.yaml'); - await fse.writeFile(marker, (await fse.readFile(marker, 'utf8')).replace('min_tools: 15', 'min_tools: 5000')); + const params = path.join(repoDir, 'hooks', 'teamai-friction-reminder', 'params.yaml'); + await fse.writeFile(params, (await fse.readFile(params, 'utf8')).replace('min_tools: 15', 'min_tools: 5000')); const review = await reviewBranchDir(BRANCH, repoDir); diff --git a/src/default-hooks.ts b/src/default-hooks.ts index 63f931be48..62ecbe58c7 100644 --- a/src/default-hooks.ts +++ b/src/default-hooks.ts @@ -12,11 +12,11 @@ import { parseYaml } from './utils/yaml.js'; // // teamai's hints keep their logic in hook-dispatch. Their texts // and thresholds are hooks// folders in the npm package, -// where a team's own hook folders would be: HOOK.yaml holds -// `with:`, each text is .md with optional .en.md and -// .zh.md variants. Code keeps the contract of each: the -// parameters with their ranges, and the variables each text -// offers, as `{{ name }}`. +// where a team's own hook folders would be: params.yaml holds +// the parameters, each text is .md with optional +// .en.md and .zh.md variants. Code keeps the contract +// of each: the parameters with their ranges, and the variables +// each text offers, as `{{ name }}`. // /** A whole number from `min` to `max`. */ @@ -32,10 +32,10 @@ const ToolErrorTiersSchema = z.array(z.strictObject({ min: whole(1, 1000), point .max(5, { error: 'must list from 1 to 5 tiers' }) .refine((tiers) => tiers.every((tier, index) => index === 0 || tier.min < (tiers[index - 1]?.min ?? Infinity)), { error: 'must list its tiers with min strictly descending' }); -/** No parameter: `with:` is empty or left out. */ +/** No parameter: the folder has no params.yaml. */ const NoParamsSchema = z.strictObject({}); -/** The `with:` parameters of each default hook folder, by name, with their ranges. */ +/** The parameters of each default hook folder, by name, with their ranges. */ const PARAMS = { 'teamai-friction-reminder': z.strictObject({ min_tools: whole(1, 1000), @@ -75,7 +75,7 @@ const TEXTS = { 'teamai-package-hint': { message: ['summary'] }, } as const satisfies Record>; -/** The `with:` parameters of default hook `TName`, once read. */ +/** The parameters of default hook `TName`, once read. */ export type DefaultHookParams = z.infer<(typeof PARAMS)[TName]>; /** Each name's schema of its own parameters. */ @@ -154,13 +154,16 @@ function withOver(base: JsonObject, own: JsonValue | undefined): JsonValue { return merged; } -/** What one issue with `with:` of default hook `name` says, naming the parameter: one line per key it does not take. */ +/** The file of a default folder that holds its parameters. */ +const PARAMS_FILE = 'params.yaml'; + +/** What one issue with the parameters of default hook `name` says, naming the parameter: one line per key it does not take. */ function paramProblems(name: DefaultHookName, issue: z.core.$ZodIssue): string[] { - const at = (keys: readonly PropertyKey[]): string => ['with', ...keys.map(String)].join('.'); + const at = (keys: readonly PropertyKey[]): string => keys.map(String).join('.'); - if (issue.code === 'unrecognized_keys') return issue.keys.map((key) => `HOOK.yaml: ${at([...issue.path, key])} is not a parameter of ${name}`); + if (issue.code === 'unrecognized_keys') return issue.keys.map((key) => `params.yaml: ${at([...issue.path, key])} is not a parameter of ${name}`); - return [`HOOK.yaml: ${at(issue.path)} ${issue.message}`]; + return [`params.yaml: ${issue.path.length > 0 ? `${at(issue.path)} ` : ''}${issue.message}`]; } /** Whether `texts` holds every text default hook `name` renders. */ @@ -168,34 +171,44 @@ function holdsEveryText(name: TName, texts: Parti return Object.keys(TEXTS[name]).every((text) => texts[text] !== undefined); } -/** A folder read as default hook `TName`: the hook, its texts, and the `with:` it was written with, or why its files do not make one. */ +/** A folder read as default hook `TName`: the hook, its texts, and the parameters its params.yaml sets, or why its files do not make one. */ type FolderRead = - | { readonly ok: true; readonly hook: DefaultHook; readonly with: JsonObject; readonly texts: Readonly>> } - | { readonly ok: false; readonly problems: string[]; readonly with: JsonObject }; + | { readonly ok: true; readonly hook: DefaultHook; readonly params: JsonObject; readonly texts: Readonly>> } + | { readonly ok: false; readonly problems: string[]; readonly params: JsonObject }; + +/** The mapping YAML file `file` of a folder holds, empty when the folder has none, or why it is not one. */ +function readMapping(file: FolderFile | undefined, shown: string): { ok: true; mapping: JsonObject } | { ok: false; problem: string } { + try { + const parsed = parseYaml(file ? FolderFiles.text(file.bytes.toString('utf8')) : ''); + + if (parsed === null || parsed === undefined) return { ok: true, mapping: {} }; + + return isJsonObject(parsed) ? { ok: true, mapping: parsed } : { ok: false, problem: `${shown} must be a mapping` }; + } catch (error) { + return { ok: false, problem: `${shown} does not parse: ${error instanceof Error ? error.message : String(error)}` }; + } +} -/** Default hook `name` from the files of its folder, its `with:` over `base` (teamai's, for a team's replacement), or why they do not make one. */ +/** Default hook `name` from the files of its folder, its parameters over `base` (teamai's, for a team's replacement), or why they do not make one. */ function readFolder(name: TName, files: readonly FolderFile[], base: JsonObject): FolderRead { const byPath = new Map(files.map((file) => [file.path, file])); const refusals: string[] = []; - const marker = byPath.get(FOLDER_MARKERS.hooks.marker); - let raw: JsonObject = {}; - - try { - const parsed = parseYaml(marker ? FolderFiles.text(marker.bytes.toString('utf8')) : ''); + const marker = readMapping(byPath.get(FOLDER_MARKERS.hooks.marker), FOLDER_MARKERS.hooks.marker); - if (isJsonObject(parsed)) raw = parsed; - else refusals.push('HOOK.yaml must be a mapping of description and with'); + if (!marker.ok) refusals.push(marker.problem); - // The folder's name says which behaviour of teamai's it configures, so it runs nothing of its own. - for (const key of Object.keys(raw)) { - if (key === 'command') refusals.push('HOOK.yaml: command is not taken by a default hook, which runs teamai\'s own handler. Remove it, or give a hook of your own another name'); - else if (key !== 'description' && key !== 'with') refusals.push(`HOOK.yaml: ${key} is not a key of a default hook, whose HOOK.yaml holds description and with`); - } - } catch (error) { - refusals.push(`HOOK.yaml does not parse: ${error instanceof Error ? error.message : String(error)}`); + // The folder's name says which behaviour of teamai's it configures, so it runs nothing of its own. + for (const key of Object.keys(marker.ok ? marker.mapping : {})) { + if (key === 'command') refusals.push('HOOK.yaml: command is not taken by a default hook, which runs teamai\'s own handler. Remove it, or give a hook of your own another name'); + else if (key === 'with') refusals.push('HOOK.yaml: with is not a key of a default hook, whose parameters are in params.yaml. Move the keys of with to params.yaml'); + else if (key !== 'description') refusals.push(`HOOK.yaml: ${key} is not a key of a default hook, whose HOOK.yaml holds only description`); } - const params = PARAMS_BY_NAME[name].safeParse(withOver(base, raw.with)); + const own = readMapping(byPath.get(PARAMS_FILE), PARAMS_FILE); + + if (!own.ok) refusals.push(own.problem); + const set = own.ok ? own.mapping : {}; + const params = PARAMS_BY_NAME[name].safeParse(withOver(base, set)); if (!params.success) refusals.push(...params.error.issues.flatMap((issue) => paramProblems(name, issue))); @@ -235,9 +248,7 @@ function readFolder(name: TName, files: readonly texts[text] = variants; } - const own = isJsonObject(raw.with) ? raw.with : {}; - - if (!params.success || refusals.length > 0 || !holdsEveryText(name, texts)) return { ok: false, problems: refusals, with: own }; + if (!params.success || refusals.length > 0 || !holdsEveryText(name, texts)) return { ok: false, problems: refusals, params: set }; const hook: DefaultHook = { params: params.data, @@ -248,11 +259,11 @@ function readFolder(name: TName, files: readonly }, }; - return { ok: true, hook, with: own, texts }; + return { ok: true, hook, params: set, texts }; } /** The folder teamai ships for `name`. It is part of teamai's package, so one that cannot be read is a teamai bug, and throws. */ -async function shipped(name: TName): Promise<{ hook: DefaultHook; with: JsonObject; texts: Readonly>> }> { +async function shipped(name: TName): Promise<{ hook: DefaultHook; params: JsonObject; texts: Readonly>> }> { const dir = path.join(packageRoot(), 'hooks', name); const read = await FolderFiles.read(dir); @@ -276,7 +287,7 @@ async function load(name: TName, scope: LocalConf const read = await FolderFiles.read(dir); if (read.kind === 'files') { - const folder = readFolder(name, read.files, teamai.with); + const folder = readFolder(name, read.files, teamai.params); if (folder.ok) return folder.hook; log.debug(`default hook ${name}: the team's folder at ${dir} is not used, teamai's is: ${folder.problems.join('; ')}`); @@ -288,12 +299,12 @@ async function load(name: TName, scope: LocalConf /** Why pull refuses `files`, a team folder that would replace default hook `name`; none when it may replace it. */ async function problems(name: DefaultHookName, files: readonly FolderFile[]): Promise { - const folder = readFolder(name, files, (await shipped(name)).with); + const folder = readFolder(name, files, (await shipped(name)).params); return folder.ok ? [] : folder.problems; } -/** How a team's folder differs from teamai's: each `with:` parameter, and each text by the file a hint reads. Names and values, never a text. */ +/** How a team's folder differs from teamai's: each parameter, and each text by the file a hint reads. Names and values, never a text. */ export interface DefaultHookComparison { /** `weights.reject` for a parameter of a mapping. `team` is null where the team's folder leaves a parameter out. */ readonly params: ReadonlyArray<{ readonly key: string; readonly teamai: JsonValue | null; readonly team: JsonValue | null }>; @@ -304,15 +315,15 @@ export interface DefaultHookComparison { /** `team`, the files of a team's folder replacing default hook `name` (null for none), against teamai's, for `language`. */ async function compare(name: DefaultHookName, team: readonly FolderFile[] | null, language: HintLanguage): Promise { const teamai = await shipped(name); - const own = team === null ? null : readFolder(name, team, teamai.with); - const ownWith = own?.with ?? {}; + const own = team === null ? null : readFolder(name, team, teamai.params); + const ownParams = own?.params ?? {}; // The hint reads the team's texts only when its folder is valid; otherwise teamai's. const inForce = own?.ok ? own.texts : teamai.texts; const keys = (...objects: JsonObject[]): string[] => [...new Set(objects.flatMap((object) => Object.keys(object)))]; - const params = keys(teamai.with, ownWith).flatMap((key) => { - const base = teamai.with[key]; - const set = ownWith[key]; + const params = keys(teamai.params, ownParams).flatMap((key) => { + const base = teamai.params[key]; + const set = ownParams[key]; if (isJsonObject(base) && (set === undefined || isJsonObject(set))) { return keys(base, set ?? {}).map((inner) => ({ key: `${key}.${inner}`, teamai: base[inner] ?? null, team: set?.[inner] ?? null })); From 95665ca15efaa863d348dada5322983b9dde913b Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 14:55:13 +0100 Subject: [PATCH 05/18] refactor(defaults): one contract for the default folders code reads A default hook's folder and the recall rule's folder are both defaults whose files code reads, but each kept its own contract: DefaultHooks for the hooks' parameters and texts, RECALL_FILES in builtin-rules for the lines recall's texts keep. A third kind, workflows, is about to need the same, so the contract moves to one module, DefaultFolders in src/default-folders.ts: by kind and name, the parameters' schema and, per text, the variables it offers and the lines it must keep. Pull, the hints, recall and the dashboard read through it. The recall folder now gets the hooks' checks too: it names every missing line in one pass and refuses a `{{ }}` no text offers. The `${TEAMAI_RULE_DIR}` refusal becomes a rule of the rules kind. Tested: recall-folder, review-branch, dashboard-layered-resources and every hint suite unchanged except for the recall refusal now listing both missing lines; npm run typecheck and lint. --- CHANGELOG.md | 1 + docs/dev/glossary.md | 4 + src/__tests__/contribute-check-phase2.test.ts | 4 +- src/__tests__/contribute-check.test.ts | 4 +- .../dashboard-layered-resources-view.test.ts | 2 +- .../dashboard-layered-resources.test.ts | 2 +- ...-hooks.test.ts => default-folders.test.ts} | 4 +- src/__tests__/default-hooks-golden.test.ts | 8 +- src/__tests__/helpers/windows-tests.ts | 2 +- src/__tests__/mr-hint.test.ts | 4 +- src/__tests__/recall-folder.test.ts | 6 +- src/__tests__/recall-scope-isolation.test.ts | 4 +- src/__tests__/review-branch.test.ts | 4 +- src/__tests__/todowrite-hint.test.ts | 4 +- src/builtin-rules.ts | 80 +--- src/contribute-check.ts | 8 +- src/dashboard/layered-resources.ts | 14 +- src/default-folders.ts | 442 ++++++++++++++++++ src/default-hooks.ts | 350 -------------- src/hook-handlers.ts | 8 +- src/mr-hint.ts | 14 +- src/pkg/pkg-hint.ts | 6 +- src/resources/hooks.ts | 14 +- src/todowrite-hint.ts | 6 +- 24 files changed, 529 insertions(+), 466 deletions(-) rename src/__tests__/{default-hooks.test.ts => default-folders.test.ts} (95%) create mode 100644 src/default-folders.ts delete mode 100644 src/default-hooks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 938bcae81a..1024db3cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ All notable changes to this project will be documented in this file. See [standa - `teamai doctor` checks `~/.teamai/bin/teamai` when a tool's hook file has an entry that starts teamai through it. When the file is gone, out of date, or names a Node or a teamai that no longer exists, the hook still runs but waits for a login shell first; doctor names the hook files and says to run `teamai pull`. ### Changed +- A team's `rules/teamai-recall/` is checked against the same contract as a default hook's folder. Pull names every line a text is missing, not only the first, and refuses a recall text that holds `{{ }}`, which the recall rule offers no variable for (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - Pull reads every folder marker, so the skipped-folders warnings are gone: the doctor check, the list in `teamai status` and the dashboard's Skipped folders panel. A checkout record from an older release that lists skipped folders makes the next pull sync in full once, so it delivers them. - `teamai push` refuses to put a rule, agent or scorer file inside a folder of its type: `/ holds , so the file would be one of its files, not a resource of its own.` A scorer folder pushes as one scorer. - A teamai default scorer is replaced by a file only, `scorers/teamai-.md`. Pull warns about `scorers/teamai-/SCORER.md`, names the file to keep instead, and teamai's default keeps running. diff --git a/docs/dev/glossary.md b/docs/dev/glossary.md index 288b83bde9..7d390e22b4 100644 --- a/docs/dev/glossary.md +++ b/docs/dev/glossary.md @@ -80,6 +80,10 @@ _Avoid_: level, source, scope (that is a **scope**) A **resource** teamai ships, named `teamai-`. A team replaces it, or some of its files, with a folder of the same name at the **team root**. Short form: default, as in default workflow or default scorer. _Avoid_: built-in, shipped, preset +**Default parameters**: +The values of a **teamai default** a team may tune, in `params.yaml` of its **resource folder**. Read the same everywhere the default's code runs, inside a workflow run or outside one, and set for the whole team, unlike a workflow input, which one run sets. A team's `params.yaml` sets only the keys it changes; every other key keeps teamai's value. +_Avoid_: settings, config, `with:` (a step's inputs to another workflow) + **Team root**: The top of the **team repo**, outside any namespace. _Avoid_: repo root (ambiguous with the **workspace**'s own repo) diff --git a/src/__tests__/contribute-check-phase2.test.ts b/src/__tests__/contribute-check-phase2.test.ts index 67df16d071..d252212a61 100644 --- a/src/__tests__/contribute-check-phase2.test.ts +++ b/src/__tests__/contribute-check-phase2.test.ts @@ -4,10 +4,10 @@ import path from 'node:path'; import os from 'node:os'; import { applyPhase2Adjustments, hasGitCommitInSession, contributeCheckForSession } from '../contribute-check.js'; import { appendEvent } from '../dashboard-collector.js'; -import { DefaultHooks } from '../default-hooks.js'; +import { DefaultFolders } from '../default-folders.js'; /** teamai's friction reminder parameters: knowledge_gap_bonus 20 and low_quality_bonus 10. */ -const { params: frictionParams } = await DefaultHooks.load('teamai-friction-reminder', null); +const { params: frictionParams } = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-friction-reminder' }, null); const CONTRIBUTE_KNOWLEDGE_GAP_BONUS = 20; const CONTRIBUTE_LOW_QUALITY_BONUS = 10; diff --git a/src/__tests__/contribute-check.test.ts b/src/__tests__/contribute-check.test.ts index 329adac8b3..5a3a16ca48 100644 --- a/src/__tests__/contribute-check.test.ts +++ b/src/__tests__/contribute-check.test.ts @@ -11,7 +11,7 @@ import { } from '../contribute-check.js'; import { appendEvent } from '../dashboard-collector.js'; import { CONTRIBUTE_FASTPATH_TTL_MS } from '../types.js'; -import { DefaultHooks } from '../default-hooks.js'; +import { DefaultFolders } from '../default-folders.js'; import type { ContributeState, DashboardEvent } from '../types.js'; /** min_tools and min_score of hooks/teamai-friction-reminder/HOOK.yaml, teamai's default. */ @@ -19,7 +19,7 @@ const CONTRIBUTE_BASE_THRESHOLD = 15; const CONTRIBUTE_SMART_THRESHOLD = 20; /** teamai's friction reminder parameters. */ -const { params: frictionParams } = await DefaultHooks.load('teamai-friction-reminder', null); +const { params: frictionParams } = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-friction-reminder' }, null); // ─── Test helpers ────────────────────────────────────────── diff --git a/src/__tests__/dashboard-layered-resources-view.test.ts b/src/__tests__/dashboard-layered-resources-view.test.ts index 27e6b9839f..b7dbc3d16e 100644 --- a/src/__tests__/dashboard-layered-resources-view.test.ts +++ b/src/__tests__/dashboard-layered-resources-view.test.ts @@ -130,7 +130,7 @@ const DATA: Served = { ], }; -const RECALL_REFUSED = 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: rules/teamai-recall/RULE.md does not contain "Nothing was searched:". Fix the folder in the team repo.'; +const RECALL_REFUSED = 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: RULE.md does not contain "Nothing was searched:". Fix the folder in the team repo.'; /** A rule folder in force, the team's recall folder pull refuses, and teamai's recall rule that stays in force. */ const RULES: LayeredResourceList = { diff --git a/src/__tests__/dashboard-layered-resources.test.ts b/src/__tests__/dashboard-layered-resources.test.ts index 6f0fdf1407..ebe497fb0f 100644 --- a/src/__tests__/dashboard-layered-resources.test.ts +++ b/src/__tests__/dashboard-layered-resources.test.ts @@ -511,7 +511,7 @@ describe('rule folders on the Resources page', () => { expect(recall.map((resource) => [resource.layer.kind, resource.path, resource.state, resource.folder?.variant, resource.folder?.files.map((file) => file.path).sort()])).toEqual([ ['root', 'rules/teamai-recall/', { kind: 'refused', - message: 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: rules/teamai-recall/RULE.md does not contain "Nothing was searched:". Fix the folder in the team repo.', + message: 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: RULE.md does not contain "Nothing was searched:"; RULE.md does not contain "Recall skips the older index". Fix the folder in the team repo.', }, 'RULE.md', ['README.md', 'README.zh-CN.md', 'RULE.md', 'block-direct.md', 'block.en.md', 'block.md']], ['teamai', 'rules/teamai-recall/', { kind: 'in-force' }, 'RULE.en.md', ['README.md', 'README.zh-CN.md', 'RULE.en.md', 'RULE.md', 'block-direct.md', 'block.en.md', 'block.md']], ]); diff --git a/src/__tests__/default-hooks.test.ts b/src/__tests__/default-folders.test.ts similarity index 95% rename from src/__tests__/default-hooks.test.ts rename to src/__tests__/default-folders.test.ts index 5fb3df7fbc..f9d3688bbc 100644 --- a/src/__tests__/default-hooks.test.ts +++ b/src/__tests__/default-folders.test.ts @@ -8,14 +8,14 @@ vi.mock('../utils/logger.js', async (importOriginal) => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), persist: vi.fn(), dim: vi.fn() }, })); -import { DefaultHooks } from '../default-hooks.js'; +import { DefaultFolders } from '../default-folders.js'; import { buildHandlerRegistry } from '../hook-handlers.js'; import { deliverHookFolders, resolveTeamHooks } from '../resources/hooks.js'; import { TeamaiConfigSchema, type LocalConfig } from '../types.js'; describe('a default hook text', () => { it('fills its variables in one pass, so a value that reads like a variable is printed as written', async () => { - const mrHint = await DefaultHooks.load('teamai-mr-hint', null); + const mrHint = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-mr-hint' }, null); expect(mrHint.text('offer-command', 'en').render({ count: '1', diff --git a/src/__tests__/default-hooks-golden.test.ts b/src/__tests__/default-hooks-golden.test.ts index 6ad40c99bf..f06fb12dc1 100644 --- a/src/__tests__/default-hooks-golden.test.ts +++ b/src/__tests__/default-hooks-golden.test.ts @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { contributeCheckForSession, writeContributeState } from '../contribute-check.js'; -import { DefaultHooks } from '../default-hooks.js'; +import { DefaultFolders } from '../default-folders.js'; import { buildHandlerRegistry } from '../hook-handlers.js'; import { buildHintMessage as buildMrHint, type MRSummary } from '../mr-hint.js'; import { buildPackageHintMessage } from '../pkg/pkg-hint.js'; @@ -84,13 +84,13 @@ describe('hint texts before and after their move into hook folders', () => { { name: 'offer-command', offersWorkflow: false }, { name: 'offer-workflow', offersWorkflow: true }, ])('merged-MR hint $name', async ({ name, offersWorkflow }) => { - const mrHint = await DefaultHooks.load('teamai-mr-hint', null); + const mrHint = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-mr-hint' }, null); for (const language of LANGUAGES) expect(buildMrHint(MRS, mrHint, { language: asOption(language), offersWorkflow })).toBe(GOLDEN['teamai-mr-hint']?.[name]?.[language]); }); it('TodoWrite hint', async () => { - const todoWriteHint = await DefaultHooks.load('teamai-todowrite-hint', null); + const todoWriteHint = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-todowrite-hint' }, null); for (const language of LANGUAGES) expect(buildTodoWriteHint(todoWriteHint, asOption(language))).toBe(GOLDEN['teamai-todowrite-hint']?.message?.[language]); }); @@ -101,7 +101,7 @@ describe('hint texts before and after their move into hook folders', () => { { name: 'plugins-and-marketplace', counts: [0, 2, 1] }, { name: 'all', counts: [3, 1, 2] }, ] as const)('package hint $name', async ({ name, counts: [npm, claude, marketplace] }) => { - const packageHint = await DefaultHooks.load('teamai-package-hint', null); + const packageHint = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-package-hint' }, null); for (const language of LANGUAGES) expect(buildPackageHintMessage(packageHint, { npm, claude, marketplace }, asOption(language))).toBe(GOLDEN['teamai-package-hint']?.[name]?.[language]); }); diff --git a/src/__tests__/helpers/windows-tests.ts b/src/__tests__/helpers/windows-tests.ts index 7775a0794e..c9d18de5e7 100644 --- a/src/__tests__/helpers/windows-tests.ts +++ b/src/__tests__/helpers/windows-tests.ts @@ -14,7 +14,7 @@ export const WINDOWS_TESTS = [ 'src/__tests__/command-shell.test.ts', 'src/__tests__/cursor-tool-roots.test.ts', 'src/__tests__/cursor-transcript-stop.test.ts', - 'src/__tests__/default-hooks.test.ts', + 'src/__tests__/default-folders.test.ts', 'src/__tests__/delivered-folders.test.ts', 'src/__tests__/dsh-hooks.test.ts', 'src/__tests__/git-process-tree.test.ts', diff --git a/src/__tests__/mr-hint.test.ts b/src/__tests__/mr-hint.test.ts index 15c53219c5..68773c523b 100644 --- a/src/__tests__/mr-hint.test.ts +++ b/src/__tests__/mr-hint.test.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { parseRemoteToRepo, buildHintMessage, computeMrHintOutput, stashMrHint, takePendingMrHint } from '../mr-hint.js'; import type { MRSummary } from '../mr-hint.js'; -import { DefaultHooks } from '../default-hooks.js'; +import { DefaultFolders } from '../default-folders.js'; import { getRemoteUrl } from '../utils/git.js'; vi.mock('node:child_process', async (importOriginal) => { @@ -19,7 +19,7 @@ vi.mock('../utils/git.js', async (importOriginal) => { return { ...actual, getRemoteUrl: vi.fn(actual.getRemoteUrl) }; }); -const mrHint = await DefaultHooks.load('teamai-mr-hint', null); +const mrHint = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-mr-hint' }, null); describe('parseRemoteToRepo', () => { it('parses TGit HTTPS URL with simple path', () => { diff --git a/src/__tests__/recall-folder.test.ts b/src/__tests__/recall-folder.test.ts index 0250af6867..5bf51a2854 100644 --- a/src/__tests__/recall-folder.test.ts +++ b/src/__tests__/recall-folder.test.ts @@ -87,7 +87,7 @@ describe('a team replacement of rules/teamai-recall/ (#1787)', () => { expect(await deployed('en')).toBe(golden('rule.en.md')); expect(await blocks('en')).toMatchObject({ recall: golden('block.en.md'), directRecall: golden('block-direct.en.md') }); expect(vi.mocked(log.warn).mock.calls.map(([message]) => String(message))).toContain( - 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: rules/teamai-recall/RULE.en.md does not contain ' + 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: RULE.en.md does not contain ' + '"Recall skips the older index". Fix the folder in the team repo.', ); }); @@ -159,8 +159,8 @@ describe('a team replacement of rules/teamai-recall/ (#1787)', () => { expect(await blocks()).toMatchObject({ recall: golden('block.md'), directRecall: golden('block-direct.md') }); expect(vi.mocked(log.warn).mock.calls.map(([message]) => String(message))).toContain( - 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: rules/teamai-recall/block.md names ${TEAMAI_RULE_DIR}, ' - + 'but a recall file is delivered as its text, and no other file of the folder is. Put what it points at in the file. Fix the folder in the team repo.', + 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: block.md names ${TEAMAI_RULE_DIR}, ' + + 'but the text of a teamai default rule is delivered alone, without the other files of its folder. Put what it points at in the file. Fix the folder in the team repo.', ); }); diff --git a/src/__tests__/recall-scope-isolation.test.ts b/src/__tests__/recall-scope-isolation.test.ts index 80f4fee3a9..94656fc280 100644 --- a/src/__tests__/recall-scope-isolation.test.ts +++ b/src/__tests__/recall-scope-isolation.test.ts @@ -27,7 +27,7 @@ import { buildIndex } from '../utils/search-index.js'; import { getTeamaiHome, type LocalConfig } from '../types.js'; import { readRecallQuality } from '../recall-quality.js'; import { applyPhase2Adjustments } from '../contribute-check.js'; -import { DefaultHooks } from '../default-hooks.js'; +import { DefaultFolders } from '../default-folders.js'; import { queryCodeKnowledge } from '../code-knowledge-recall.js'; import { incrementRecalled } from '../votes.js'; @@ -307,7 +307,7 @@ describe('recall scope isolation (issue #73)', () => { expect(readRecallQuality(hookSessionId)).toEqual( expect.objectContaining({ hitCount: 0, missCount: 1 }), ); - expect(applyPhase2Adjustments((await DefaultHooks.load('teamai-friction-reminder', null)).params, 0, hookSessionId).isKnowledgeGap).toBe(true); + expect(applyPhase2Adjustments((await DefaultFolders.load({ kind: 'hooks', name: 'teamai-friction-reminder' }, null)).params, 0, hookSessionId).isKnowledgeGap).toBe(true); }); it('records under the Pi session, not the outer one, when recall runs from a Pi shell started by Claude Code', async () => { diff --git a/src/__tests__/review-branch.test.ts b/src/__tests__/review-branch.test.ts index dd14c0ccb6..ee27dd2a3b 100644 --- a/src/__tests__/review-branch.test.ts +++ b/src/__tests__/review-branch.test.ts @@ -642,8 +642,8 @@ describe('reviewBranchDir — read-only health checks for a pushed branch (#776) level: 'fail', check: 'structure', path: 'rules/teamai-recall/', - message: 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: rules/teamai-recall/RULE.md does not contain ' - + '"Nothing was searched:". Fix the folder in the team repo.', + message: 'rules/teamai-recall/ is not used, so teamai\'s recall rule stays in force: RULE.md does not contain ' + + '"Nothing was searched:"; RULE.md does not contain "Recall skips the older index". Fix the folder in the team repo.', }]]); }); diff --git a/src/__tests__/todowrite-hint.test.ts b/src/__tests__/todowrite-hint.test.ts index b9cb9f27c0..06cdc991fb 100644 --- a/src/__tests__/todowrite-hint.test.ts +++ b/src/__tests__/todowrite-hint.test.ts @@ -22,9 +22,9 @@ import { shouldSkipTodoWriteHint, getTodoWriteHintCachePath, } from '../todowrite-hint.js'; -import { DefaultHooks } from '../default-hooks.js'; +import { DefaultFolders } from '../default-folders.js'; -const todoWriteHint = await DefaultHooks.load('teamai-todowrite-hint', null); +const todoWriteHint = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-todowrite-hint' }, null); describe('buildHintMessage', () => { it('contains the recall subagent reference and the [teamai:] prefix', () => { diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index 7fbb0ef2b4..2bfec2d5f1 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -9,12 +9,12 @@ import fs from 'node:fs/promises'; import crypto from 'node:crypto'; import type { DeliveryRecorder } from './git-exclude-delivered.js'; import { recordDelivered, type DeliveryLedger } from './resources/delivered-copies.js'; -import { isLanguageVariant, RESOURCE_LAYOUTS, Resources, type ShippedFile, type TeamaiLayer } from './resources/resolver.js'; -import { DeliveredFolders } from './resources/delivered-folders.js'; -import { FolderFiles } from './resources/folder-files.js'; +import { RESOURCE_LAYOUTS, Resources, type ShippedFile, type TeamaiLayer } from './resources/resolver.js'; +import { FolderFiles, type FolderFile } from './resources/folder-files.js'; import { InheritedFolders, type Inheritance } from './resources/inherited-folders.js'; import { RuleFolders } from './resources/rule-folders.js'; import { warnOnce } from './utils/warn-once.js'; +import { DefaultFolders } from './default-folders.js'; /** The marker of a rule folder, as the rule layout names it. */ const RULE_MARKER = RESOURCE_LAYOUTS.rules.marker; @@ -213,15 +213,8 @@ export interface RecallTexts { readonly replaced: boolean; } -/** - * What a recall folder holds, text by text: the base file, which a team replacement must have, and the lines every - * language of it must keep, because recall's own output and doctor point at them (#1787). - */ -const RECALL_FILES = [ - { stem: 'RULE', lines: ['Nothing was searched:', 'Recall skips the older index'] }, - { stem: 'block', lines: [] }, - { stem: 'block-direct', lines: ['Nothing was searched:'] }, -] as const; +/** teamai's recall rule as a default folder, whose texts and the lines they keep `DefaultFolders` holds. */ +const RECALL_DEFAULT = { kind: 'rules', name: RECALL_RULE_NAME } as const; /** A recall folder: where it is, its path from its repo's root as pull names it, and whether it is the team's. */ export interface RecallFolder { @@ -245,7 +238,7 @@ async function recallFolder(repoPath: string | undefined): Promise /** The text of each file a recall folder gives, by its path in it, with the team files that replace nothing; or why it cannot be read. */ type RecallFiles = - | { readonly kind: 'files'; readonly texts: ReadonlyMap; readonly strays: readonly string[]; readonly inheritance: Inheritance | null } + | { readonly kind: 'files'; readonly files: readonly FolderFile[]; readonly texts: ReadonlyMap; readonly strays: readonly string[]; readonly inheritance: Inheritance | null } | { readonly kind: 'invalid'; readonly problem: string }; /** @@ -263,54 +256,28 @@ async function recallFiles(folder: RecallFolder): Promise { return { kind: 'files', + files: merged.files, texts: new Map(merged.files.map((file) => [file.path, FolderFiles.text(file.bytes.toString('utf8'))])), strays: merged.strays, inheritance: teamai === null ? null : InheritedFolders.inheritanceOf(merged, getCurrentVersion()), }; } -/** Why the recall files `texts` of the folder shown as `shown` break the contract (`RECALL_FILES`), or null when they keep it. */ -function recallFolderProblem(texts: ReadonlyMap, shown: string): string | null { - for (const { stem, lines } of RECALL_FILES) { - if (!texts.has(`${stem}.md`)) return `${shown}${stem}.md is missing`; - - for (const [name, content] of [...texts].filter(([found]) => found === `${stem}.md` || isLanguageVariant(`${stem}.md`, found))) { - if (DeliveredFolders.mentions(content, 'rules')) { - return `${shown}${name} names \${TEAMAI_RULE_DIR}, but a recall file is delivered as its text, and no other file of the folder is. ` - + 'Put what it points at in the file'; - } - - const missing = lines.find((line) => !content.includes(line)); - - if (missing !== undefined) return `${shown}${name} does not contain "${missing}"`; - } - } - - return null; -} - -/** The recall texts of `texts`, the files of `folder`, for `language`, each language falling back to the folder's own base file. */ -function readRecallTexts(folder: RecallFolder, texts: ReadonlyMap, language: TeamaiConfig['language']): RecallTexts | null { - const pick = (stem: string): { name: string; content: string } | null => { - const variant = language === undefined ? undefined : `${stem}.${language}.md`; - const name = variant !== undefined && texts.has(variant) ? variant : `${stem}.md`; - const content = texts.get(name); - - return content === undefined ? null : { name, content }; - }; - - const [base, block, directBlock] = [pick('RULE'), pick('block'), pick('block-direct')]; - const baseText = texts.get(RULE_MARKER); +/** The recall texts `files` give for `language`, or why they break the contract `DefaultFolders` holds for it. */ +async function readRecallTexts(folder: RecallFolder, files: Extract, language: TeamaiConfig['language']): Promise<{ ok: true; texts: RecallTexts } | { ok: false; problem: string }> { + const read = await DefaultFolders.read(RECALL_DEFAULT, files.files); - if (base === null || baseText === undefined || block === null || directBlock === null) return null; + if (!read.ok) return { ok: false, problem: read.problems.join('; ') }; + const recall = read.folder; + const variant = recall.text('RULE', language); const rule = RuleFolders.composeText({ rule: { name: RECALL_RULE_NAME, type: 'rules', sourcePath: path.join(folder.dir, RULE_MARKER ?? ''), relativePath: `${folder.path}${RULE_MARKER}`, folder: true }, - base: baseText, - variant: base.name === RULE_MARKER ? null : base, + base: recall.text('RULE', undefined).render({}), + variant: variant.language === null ? null : { name: variant.file, content: variant.render({}) }, }); - return { rule, block: block.content, directBlock: directBlock.content, replaced: folder.team }; + return { ok: true, texts: { rule, block: recall.text('block', language).render({}), directBlock: recall.text('block-direct', language).render({}), replaced: folder.team } }; } /** The recall texts that apply, teamai's folder, and the team's `rules/teamai-recall/` folder: in force, or refused with what pull prints. */ @@ -328,7 +295,7 @@ export interface RecallResolution { /** * The recall rule and blocks for `language` with the team repo at `repoPath` (#1787): its `rules/teamai-recall/` - * folder over teamai's when the result keeps the contract (`RECALL_FILES`), else teamai's. + * folder over teamai's when the result keeps the contract (`DefaultFolders`), else teamai's. */ export async function resolveRecall(language: TeamaiConfig['language'], repoPath?: string): Promise { const folder = await recallFolder(repoPath); @@ -337,20 +304,19 @@ export async function resolveRecall(language: TeamaiConfig['language'], repoPath if (folder.team) { const files = await recallFiles(folder); - const problem = files.kind === 'invalid' ? files.problem : recallFolderProblem(files.texts, folder.path); - const texts = problem === null && files.kind === 'files' ? readRecallTexts(folder, files.texts, language) : null; + const read = files.kind === 'files' ? await readRecallTexts(folder, files, language) : { ok: false as const, problem: files.problem }; strays = files.kind === 'files' ? files.strays.map((file) => InheritedFolders.strayMessage(folder.path, file)) : []; - if (texts !== null && files.kind === 'files') return { texts, teamai: TEAMAI_RECALL, team: { kind: 'replaces', folder, files: [...files.texts.keys()], inheritance: files.inheritance }, strays }; - refused = { kind: 'refused', folder, message: `${folder.path} is not used, so teamai's recall rule stays in force: ${problem ?? `${folder.path} cannot be read`}. Fix the folder in the team repo.` }; + if (read.ok && files.kind === 'files') return { texts: read.texts, teamai: TEAMAI_RECALL, team: { kind: 'replaces', folder, files: [...files.texts.keys()], inheritance: files.inheritance }, strays }; + refused = { kind: 'refused', folder, message: `${folder.path} is not used, so teamai's recall rule stays in force: ${read.ok ? `${folder.path} cannot be read` : read.problem}. Fix the folder in the team repo.` }; } const own = await recallFiles(TEAMAI_RECALL); - const texts = own.kind === 'files' ? readRecallTexts(TEAMAI_RECALL, own.texts, language) : null; + const read = own.kind === 'files' ? await readRecallTexts(TEAMAI_RECALL, own, language) : null; - if (texts === null) throw new Error(`teamai's ${RECALL_RULE_NAME} folder cannot be read in ${TEAMAI_RULES.root}; reinstall teamai.`); + if (read === null || !read.ok) throw new Error(`teamai's ${RECALL_RULE_NAME} folder cannot be read in ${TEAMAI_RULES.root}; reinstall teamai.`); - return { texts, teamai: TEAMAI_RECALL, team: refused, strays }; + return { texts: read.texts, teamai: TEAMAI_RECALL, team: refused, strays }; } /** The recall texts `resolveRecall` finds, naming the team's refused folder once. */ diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 1b3578a874..211bcc5e75 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -12,7 +12,7 @@ import { resolveHookCwd } from './utils/hook-cwd.js'; import { redactWithEnv } from './utils/redact.js'; import type { ContributeState, DashboardEvent, LocalConfig, SessionShareOffered, SessionFriction } from './types.js'; import { CONTRIBUTE_FASTPATH_TTL_MS, CONTRIBUTE_GIT_COMMIT_DOWNWEIGHT, SessionShareOfferedSchema } from './types.js'; -import { DefaultHooks, type DefaultHook, type DefaultHookParams, type HintLanguage } from './default-hooks.js'; +import { DefaultFolders, type DefaultFolder, type DefaultFolderParams, type TextLanguage } from './default-folders.js'; import { getUserHome } from './utils/home.js'; import { HookStdinSchema, stdinString, type HookStdin } from './hook-dispatch.js'; @@ -332,7 +332,7 @@ export async function readReminderDecisions(): Promise> { } /** The thresholds and weights of the friction reminder, from `with:` of hooks/teamai-friction-reminder/HOOK.yaml. */ -export type FrictionReminderParams = DefaultHookParams<'teamai-friction-reminder'>; +export type FrictionReminderParams = DefaultFolderParams<'hooks', 'teamai-friction-reminder'>; /** Points from the tool-error retry gradient (the first tier whose min the count reaches wins). */ function toolErrorPoints(tiers: FrictionReminderParams['tool_error_tiers'], toolError: number): number { @@ -614,7 +614,7 @@ const SHARE_LEARNINGS_DESCRIBE = 'teamai workflow start --describe teamai-share- * Build the STDOUT hint from the same context that drove the score, with `reminder`'s texts: the headline, the task * when there is one, and the action, separated by a blank line. */ -function buildHint({ friction, promptSummary, isKnowledgeGap, offersShareLearnings }: HintContext, reminder: DefaultHook<'teamai-friction-reminder'>, language: HintLanguage): string { +function buildHint({ friction, promptSummary, isKnowledgeGap, offersShareLearnings }: HintContext, reminder: DefaultFolder<'hooks', 'teamai-friction-reminder'>, language: TextLanguage): string { const reasons = formatFrictionReasons(friction).join(', '); const headline = reasons @@ -681,7 +681,7 @@ export async function contributeCheckForSession( return { hint: null }; } - const reminder = await DefaultHooks.load('teamai-friction-reminder', scope); + const reminder = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-friction-reminder' }, scope); const { params } = reminder; // Shared debounce for Layer 1 (skip events read) and Layer 2 (reuse cached diff --git a/src/dashboard/layered-resources.ts b/src/dashboard/layered-resources.ts index ad836ede7d..c429f26f43 100644 --- a/src/dashboard/layered-resources.ts +++ b/src/dashboard/layered-resources.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { RECALL_RULE_NAME, resolveRecall, type RecallFolder } from '../builtin-rules.js'; import { loadCachedSuite, suiteDirectories, syncTeamDefinitions, type SyncTeamResult } from '../benchmark/suite-cache.js'; -import { DefaultHooks, type DefaultHookComparison } from '../default-hooks.js'; +import { DefaultFolders, type DefaultFolderComparison } from '../default-folders.js'; import { DefaultWorkflows } from '../default-workflows.js'; import { activeEntryNamespaces, describeEntryFailure, entryLayout, type EntryFailure, type ResolvedEntry } from '../namespaced-entries.js'; import { LayeredEntries, type LayeredEntryType, type PlaceFolderRead } from '../resources/layered-entries.js'; @@ -114,7 +114,7 @@ export interface LayeredResourceList { } /** One of teamai's default hooks: teamai's folder, or the team's that replaces it, and how the two differ. */ -export interface DefaultHookResource extends DefaultHookComparison { +export interface DefaultHookResource extends DefaultFolderComparison { readonly name: string; /** `refused`: pull's messages about the team's folder, while teamai's stays in force. */ readonly state: @@ -298,7 +298,7 @@ const HOOKS: EntryType = { readFolder: readHookFolder, deliveredAt: (config, definition, { namespace }) => hookFolderPath(config, definition, namespace), runs: (definition, dest) => ({ command: DeliveredFolders.render(definition.hook.command, { type: 'hooks', dest }), args: [], env: [] }), - listedElsewhere: (item) => item.layer === 'root' && item.source.kind === 'folder' && DefaultHooks.isName(item.name), + listedElsewhere: (item) => item.layer === 'root' && item.source.kind === 'folder' && DefaultFolders.isName('hooks', item.name), refusal: teamScriptRefusal, deliversRefused: false, }; @@ -631,20 +631,20 @@ async function defaultHookList(config: LocalConfig, language: TeamaiConfig['lang const replacements = new Map((await readDefaultHookReplacements(config)).map((replacement) => [replacement.name, replacement])); // Each hook's comparison reads its own folders; one that throws is a teamai bug, and fails the page's data. - return Promise.all(DefaultHooks.NAMES.map(async (name): Promise => { + return Promise.all(DefaultFolders.names('hooks').map(async (name): Promise => { const replacement = replacements.get(name); - if (replacement === undefined) return { name, state: { kind: 'teamai' }, ...await DefaultHooks.compare(name, null, language), delivered: null }; + if (replacement === undefined) return { name, state: { kind: 'teamai' }, ...await DefaultFolders.compare({ kind: 'hooks', name: name }, null, language), delivered: null }; if (replacement.kind === 'refused') { - return { name, state: { kind: 'refused', path: replacement.path, messages: replacement.messages }, ...await DefaultHooks.compare(name, replacement.files, language), delivered: null }; + return { name, state: { kind: 'refused', path: replacement.path, messages: replacement.messages }, ...await DefaultFolders.compare({ kind: 'hooks', name: name }, replacement.files, language), delivered: null }; } const { dest, files } = replacement.delivery; const { inheritance } = replacement; const inherited = inheritance ? { teamaiVersion: inheritance.teamaiVersion, own: files.length - inheritance.files.length, teamai: inheritance.files.length } : null; - return { name, state: { kind: 'replaced', path: replacement.path, inherited }, ...await DefaultHooks.compare(name, files, language), delivered: { path: dest, present: await pathExists(dest) } }; + return { name, state: { kind: 'replaced', path: replacement.path, inherited }, ...await DefaultFolders.compare({ kind: 'hooks', name: name }, files, language), delivered: { path: dest, present: await pathExists(dest) } }; })); } diff --git a/src/default-folders.ts b/src/default-folders.ts new file mode 100644 index 0000000000..718914a0b1 --- /dev/null +++ b/src/default-folders.ts @@ -0,0 +1,442 @@ +import path from 'node:path'; +import { z } from 'zod'; +import { FOLDER_MARKERS } from './folder-markers.js'; +import { packageRoot } from './package-info.js'; +import { DeliveredFolders } from './resources/delivered-folders.js'; +import { FolderFiles, type FolderFile } from './resources/folder-files.js'; +import { isJsonObject, type JsonObject, type JsonValue, type LocalConfig, type TeamaiConfig } from './types.js'; +import { log } from './utils/logger.js'; +import { parseYaml } from './utils/yaml.js'; + +// ─── Default folders (#1787, #1829) ────────────────────── +// +// A teamai default whose files code reads, such as a hint's hook +// folder or the recall rule's folder, keeps what a team may change +// in files of its folder in the npm package: params.yaml holds its +// parameters, each text is .md with optional .en.md and +// .zh.md variants. A team folder of the same name changes any +// of them. Code keeps the contract of each: the parameters with +// their ranges, the variables each text offers, as `{{ name }}`, +// and the lines a text must keep. +// + +/** A whole number from `min` to `max`. */ +function whole(min: number, max: number) { + const error = `must be a whole number from ${min} to ${max}`; + + return z.number({ error }).int({ error }).min(min, { error }).max(max, { error }); +} + +/** The tool-error tiers, the first whose `min` the count reaches giving its points. */ +const ToolErrorTiersSchema = z.array(z.strictObject({ min: whole(1, 1000), points: whole(0, 100) }), { error: 'must be a list of { min, points } tiers' }) + .min(1, { error: 'must list from 1 to 5 tiers' }) + .max(5, { error: 'must list from 1 to 5 tiers' }) + .refine((tiers) => tiers.every((tier, index) => index === 0 || tier.min < (tiers[index - 1]?.min ?? Infinity)), { error: 'must list its tiers with min strictly descending' }); + +/** No parameter: the folder has no params.yaml. */ +const NoParamsSchema = z.strictObject({}); + +/** The parameters of each default folder, by kind and name, with their ranges. */ +const PARAMS = { + hooks: { + 'teamai-friction-reminder': z.strictObject({ + min_tools: whole(1, 1000), + min_score: whole(1, 1000), + weights: z.strictObject({ interrupt: whole(0, 100), reject: whole(0, 100), correction: whole(0, 100) }, { error: 'must be a mapping of interrupt, reject and correction' }), + tool_error_tiers: ToolErrorTiersSchema, + skill_bonus: whole(0, 100), + diversity_bonus_max: whole(0, 100), + knowledge_gap_bonus: whole(0, 100), + low_quality_bonus: whole(0, 100), + low_quality_threshold: z.number({ error: 'must be a number from 0 to 100' }).min(0, { error: 'must be a number from 0 to 100' }).max(100, { error: 'must be a number from 0 to 100' }), + }), + 'teamai-mr-hint': z.strictObject({ lookback_days: whole(1, 90), max_mrs: whole(1, 100) }), + 'teamai-todowrite-hint': NoParamsSchema, + 'teamai-env-hint': NoParamsSchema, + 'teamai-package-hint': NoParamsSchema, + }, + rules: { + 'teamai-recall': NoParamsSchema, + }, +} as const; + +/** What a text of a default folder offers and keeps: the variables it may read, and the lines every language of it must hold. */ +interface TextContract { + readonly variables?: readonly string[]; + readonly required?: readonly string[]; +} + +/** The texts each default folder gives code, by kind and name, with what each offers and keeps. */ +const TEXTS = { + hooks: { + 'teamai-friction-reminder': { + problem: {}, + 'problem-reasons': { variables: ['reasons'] }, + gap: {}, + 'gap-reasons': { variables: ['reasons'] }, + task: { variables: ['task'] }, + action: {}, + 'action-gap': {}, + 'action-share-learnings': { variables: ['command'] }, + }, + 'teamai-mr-hint': { 'offer-command': { variables: ['count', 'mr_lines', 'command'] }, 'offer-workflow': { variables: ['count', 'mr_lines', 'command'] } }, + 'teamai-todowrite-hint': { message: {} }, + 'teamai-env-hint': { variables: { variables: ['variables'] }, secrets: { variables: ['secrets'] }, exec: {}, 'secrets-rule': {} }, + 'teamai-package-hint': { message: { variables: ['summary'] } }, + }, + rules: { + // Recall's own output and doctor point at these lines. + 'teamai-recall': { RULE: { required: ['Nothing was searched:', 'Recall skips the older index'] }, block: {}, 'block-direct': { required: ['Nothing was searched:'] } }, + }, +} as const satisfies { readonly [TKind in keyof typeof PARAMS]: { readonly [TName in keyof (typeof PARAMS)[TKind]]: Readonly> } }; + +/** A kind of resource some of whose teamai defaults code reads, by the folder's files. */ +export type DefaultFolderKind = keyof typeof PARAMS; + +/** The name of a teamai default folder of kind `TKind`: `hooks/teamai-mr-hint/` is `teamai-mr-hint`. */ +export type DefaultFolderName = keyof (typeof PARAMS)[TKind] & keyof (typeof TEXTS)[TKind] & string; + +/** The parameters of default folder `TName` of kind `TKind`, once read. */ +export type DefaultFolderParams> = + (typeof PARAMS)[TKind][TName] extends z.ZodType ? TParams : never; + +/** Each folder's schema of its own parameters. */ +type ParamsSchemas = { readonly [TKind in DefaultFolderKind]: { readonly [TName in DefaultFolderName]: z.ZodType> } }; + +/** `PARAMS`, typed so that a lookup by a generic kind and name keeps its parameters' type. */ +const PARAMS_BY_NAME: ParamsSchemas = PARAMS; + +/** The text contracts of every default folder of each kind, by name. */ +type TextContracts = { readonly [TKind in DefaultFolderKind]: Readonly>>> }; + +/** `TEXTS`, read by any kind and name. */ +const TEXTS_BY_NAME: TextContracts = TEXTS; + +/** A text of default folder `TName` of kind `TKind`: `problem` is `problem.md`. */ +export type DefaultFolderTextName> = keyof (typeof TEXTS)[TKind][TName] & string; + +/** The variables text `TText` of a default folder offers. */ +type TextVariable, TText extends DefaultFolderTextName> = + (typeof TEXTS)[TKind][TName][TText] extends { readonly variables: readonly (infer TVariable extends string)[] } ? TVariable : never; + +/** A teamai default folder of kind `TKind`: `{ kind: 'hooks', name: 'teamai-mr-hint' }` is `hooks/teamai-mr-hint/`. */ +export interface DefaultFolderRef> { + readonly kind: TKind; + readonly name: TName; +} + +/** The language a text is picked for: `teamai.yaml` `language`, undefined when unset. */ +export type TextLanguage = TeamaiConfig['language']; + +/** One text, picked for a language: the file it was read from, its language (null for the base file), and its content filled in. */ +export interface PickedText { + readonly file: string; + readonly language: NonNullable | null; + render(values: Readonly>): string; +} + +/** A default folder, read: its parameters, and its texts. */ +export interface DefaultFolder> { + readonly params: DefaultFolderParams; + /** Text `text` for `language`: `..md` when the folder has it, else `.md`. */ + text>(text: TText, language: TextLanguage): PickedText>; +} + +/** The names of kind `kind`'s default folders. */ +function names(kind: TKind): Array> { + return Object.keys(PARAMS[kind]).filter((name): name is DefaultFolderName => isName(kind, name)); +} + +/** Whether `name` is one of teamai's default folders of kind `kind`, which a team folder of that name at the root changes. */ +function isName(kind: TKind, name: string): name is DefaultFolderName { + return Object.hasOwn(PARAMS[kind], name) && Object.hasOwn(TEXTS[kind], name); +} + +/** `{{ name }}`: a variable a text reads. */ +const VARIABLE = /\{\{([^{}]*)\}\}/g; + +/** `content` with each `{{ name }}` replaced by its value, in one pass: a value is never read again for variables. */ +function substitute(content: string, values: Readonly>): string { + return content.replace(VARIABLE, (source, inner: string) => values[inner.trim()] ?? source); +} + +/** A text's files: its base file, and the variants the folder has. */ +interface TextVariants { + base: string; + en?: string; + zh?: string; +} + +/** The variant of a text read for `language`: `..md` when the folder has it, else `.md`. */ +function pick(text: string, variants: TextVariants, language: TextLanguage): { file: string; language: NonNullable | null; content: string } { + const variant = language ? variants[language] : undefined; + + return variant !== undefined && language ? { file: `${text}.${language}.md`, language, content: variant } : { file: `${text}.md`, language: null, content: variants.base }; +} + +/** + * `base` with the keys of `own` over it (#1787, common rule 5): a key `own` leaves out keeps `base`'s value, and a + * mapping in both is merged one level down, so `weights: { reject: 30 }` keeps the other weights. A list is replaced. + */ +function withOver(base: JsonObject, own: JsonObject): JsonObject { + const merged: JsonObject = { ...base }; + + for (const [key, value] of Object.entries(own)) { + const under = base[key]; + merged[key] = isJsonObject(under) && isJsonObject(value) ? { ...under, ...value } : value; + } + + return merged; +} + +/** What one issue with the parameters of default folder `name` says, naming the parameter: one line per key it does not take. */ +function paramProblems(name: string, issue: z.core.$ZodIssue): string[] { + const at = (keys: readonly PropertyKey[]): string => keys.map(String).join('.'); + + if (issue.code === 'unrecognized_keys') return issue.keys.map((key) => `params.yaml: ${at([...issue.path, key])} is not a parameter of ${name}`); + + return [`params.yaml: ${issue.path.length > 0 ? `${at(issue.path)} ` : ''}${issue.message}`]; +} + +/** The mapping YAML file `file` of a folder holds, empty when the folder has none, or why it is not one. */ +function readMapping(file: FolderFile | undefined, shown: string): { ok: true; mapping: JsonObject } | { ok: false; problem: string } { + try { + const parsed = parseYaml(file ? FolderFiles.text(file.bytes.toString('utf8')) : ''); + + if (parsed === null || parsed === undefined) return { ok: true, mapping: {} }; + + return isJsonObject(parsed) ? { ok: true, mapping: parsed } : { ok: false, problem: `${shown} must be a mapping` }; + } catch (error) { + return { ok: false, problem: `${shown} does not parse: ${error instanceof Error ? error.message : String(error)}` }; + } +} + +/** Why the marker of a default folder of kind `kind` breaks its contract. A default hook runs teamai's handler, so its HOOK.yaml holds only description. */ +function markerProblems(kind: DefaultFolderKind, byPath: ReadonlyMap): string[] { + switch (kind) { + case 'hooks': { + const marker = readMapping(byPath.get(FOLDER_MARKERS.hooks.marker), FOLDER_MARKERS.hooks.marker); + + if (!marker.ok) return [marker.problem]; + + return Object.keys(marker.mapping).flatMap((key) => { + if (key === 'command') return ['HOOK.yaml: command is not taken by a default hook, which runs teamai\'s own handler. Remove it, or give a hook of your own another name']; + + if (key === 'with') return ['HOOK.yaml: with is not a key of a default hook, whose parameters are in params.yaml. Move the keys of with to params.yaml']; + + return key === 'description' ? [] : [`HOOK.yaml: ${key} is not a key of a default hook, whose HOOK.yaml holds only description`]; + }); + } + + case 'rules': + // RULE.md is one of the folder's texts. + return []; + default: { + const unhandled: never = kind; + + return unhandled; + } + } +} + +/** Why `content`, text `file` of a default folder of kind `kind`, breaks how that kind is delivered. */ +function deliveryProblems(kind: DefaultFolderKind, file: string, content: string): string[] { + switch (kind) { + case 'hooks': + return []; + case 'rules': + return DeliveredFolders.mentions(content, 'rules') + ? [`${file} names \${TEAMAI_RULE_DIR}, but the text of a teamai default rule is delivered alone, without the other files of its folder. Put what it points at in the file`] + : []; + default: { + const unhandled: never = kind; + + return unhandled; + } + } +} + +/** Whether `texts` holds every text of default folder `ref`. */ +function holdsEveryText>(ref: DefaultFolderRef, texts: Partial>): texts is Record, TextVariants> { + return Object.keys(TEXTS_BY_NAME[ref.kind][ref.name] ?? {}).every((text) => texts[text] !== undefined); +} + +/** The texts of a folder read, each with its variants. */ +type FolderTexts = Readonly>>; + +/** A folder read as default folder `ref`: the folder, its texts, and the parameters its params.yaml sets, or why its files do not make one. */ +type FolderRead> = + | { readonly ok: true; readonly folder: DefaultFolder; readonly params: JsonObject; readonly texts: FolderTexts } + | { readonly ok: false; readonly problems: string[]; readonly params: JsonObject }; + +/** The texts of `contracts` read from `byPath`, each variant checked against its text's contract, with why any breaks it. */ +function readTexts(kind: DefaultFolderKind, contracts: Readonly>, byPath: ReadonlyMap) { + const texts: Partial> = {}; + const refusals: string[] = []; + + for (const [text, { variables = [], required = [] }] of Object.entries(contracts)) { + // A text with a variable it does not offer, or anything in braces that is not a variable, would print as written. + const readVariant = (file: string): string | undefined => { + const found = byPath.get(file); + + if (!found) return undefined; + const content = FolderFiles.text(found.bytes.toString('utf8')); + + for (const [source, inner = ''] of content.matchAll(VARIABLE)) { + if (variables.includes(inner.trim())) continue; + const offers = variables.length > 0 ? variables.map((variable) => `{{ ${variable} }}`).join(', ') : 'no variable'; + refusals.push(`${file} reads ${source}, which the ${text} text does not offer: it offers ${offers}`); + } + + for (const line of required.filter((kept) => !content.includes(kept))) refusals.push(`${file} does not contain "${line}"`); + refusals.push(...deliveryProblems(kind, file, content)); + + return content; + }; + + const base = readVariant(`${text}.md`); + + if (base === undefined) { + refusals.push(`${text}.md is missing: the folder replaces the ${text} text in another language, which replaces every language of it. Add ${text}.md`); + continue; + } + + const variants: TextVariants = { base }; + const en = readVariant(`${text}.en.md`); + const zh = readVariant(`${text}.zh.md`); + + if (en !== undefined) variants.en = en; + + if (zh !== undefined) variants.zh = zh; + texts[text] = variants; + } + + return { texts, refusals }; +} + +/** Default folder `ref` from the files of its folder, its parameters over `base` (teamai's, for a team's replacement), or why they do not make one. */ +function readFolder>(ref: DefaultFolderRef, files: readonly FolderFile[], base: JsonObject): FolderRead { + const byPath = new Map(files.map((file) => [file.path, file])); + const own = readMapping(byPath.get('params.yaml'), 'params.yaml'); + const set = own.ok ? own.mapping : {}; + const schema: z.ZodType> = PARAMS_BY_NAME[ref.kind][ref.name]; + const params = schema.safeParse(withOver(base, set)); + const { texts, refusals: textRefusals } = readTexts(ref.kind, TEXTS_BY_NAME[ref.kind][ref.name] ?? {}, byPath); + + const refusals = [ + ...markerProblems(ref.kind, byPath), + ...(own.ok ? [] : [own.problem]), + ...(params.success ? [] : params.error.issues.flatMap((issue) => paramProblems(ref.name, issue))), + ...textRefusals, + ]; + + if (!params.success || refusals.length > 0 || !holdsEveryText(ref, texts)) return { ok: false, problems: refusals, params: set }; + + const folder: DefaultFolder = { + params: params.data, + text(text, language) { + const picked = pick(text, texts[text], language); + + return { file: picked.file, language: picked.language, render: (values) => substitute(picked.content, values) }; + }, + }; + + return { ok: true, folder, params: set, texts }; +} + +/** The folder teamai ships for `ref`. It is part of teamai's package, so one that cannot be read is a teamai bug, and throws. */ +async function shipped>(ref: DefaultFolderRef): Promise<{ folder: DefaultFolder; params: JsonObject; texts: FolderTexts }> { + const dir = path.join(packageRoot(), ref.kind, ref.name); + const files = await FolderFiles.read(dir); + + if (files.kind === 'invalid') throw new Error(`teamai's default folder ${dir} cannot be read: ${files.problems.map(FolderFiles.describe).join(', ')}`); + const folder = readFolder(ref, files.files, {}); + + if (!folder.ok) throw new Error(`teamai's default folder ${dir} is broken: ${folder.problems.join('; ')}`); + + return folder; +} + +/** + * `files`, the folder a team's replacement and teamai's make together, read as default folder `ref`, or why pull + * refuses it, one line each. + */ +async function read>(ref: DefaultFolderRef, files: readonly FolderFile[]): Promise<{ ok: true; folder: DefaultFolder } | { ok: false; problems: string[] }> { + const own = readFolder(ref, files, (await shipped(ref)).params); + + return own.ok ? { ok: true, folder: own.folder } : { ok: false, problems: own.problems }; +} + +/** + * Default hook `ref` as `scope` reads it: the team's replacement pull delivered to the scope, else teamai's folder. + * With no scope (teamai is not set up where the hook runs), teamai's folder. + */ +async function load>(ref: DefaultFolderRef<'hooks', TName>, scope: LocalConfig | null): Promise> { + const teamai = await shipped(ref); + + if (scope) { + const dir = DeliveredFolders.pathOf(scope, { type: 'hooks', namespace: null, name: ref.name }); + const delivered = await FolderFiles.read(dir); + + if (delivered.kind === 'files') { + const folder = readFolder(ref, delivered.files, teamai.params); + + if (folder.ok) return folder.folder; + log.debug(`default hook ${ref.name}: the team's folder at ${dir} is not used, teamai's is: ${folder.problems.join('; ')}`); + } + } + + return teamai.folder; +} + +/** Why pull refuses `files`, a team folder that would replace default folder `ref`; none when it may replace it. */ +async function problems>(ref: DefaultFolderRef, files: readonly FolderFile[]): Promise { + const folder = await read(ref, files); + + return folder.ok ? [] : folder.problems; +} + +/** How a team's folder differs from teamai's: each parameter, and each text by the file it is read from. Names and values, never a text. */ +export interface DefaultFolderComparison { + /** `weights.reject` for a parameter of a mapping. `team` is null where the team's folder leaves a parameter out. */ + readonly params: ReadonlyArray<{ readonly key: string; readonly teamai: JsonValue | null; readonly team: JsonValue | null }>; + /** `file`: the variant read for the language. `replaced`: what it reads differs from what teamai's folder gives. */ + readonly texts: ReadonlyArray<{ readonly text: string; readonly file: string; readonly replaced: boolean }>; +} + +/** `team`, the files of a team's folder replacing default folder `ref` (null for none), against teamai's, for `language`. */ +async function compare>(ref: DefaultFolderRef, team: readonly FolderFile[] | null, language: TextLanguage): Promise { + const teamai = await shipped(ref); + const own = team === null ? null : readFolder(ref, team, teamai.params); + const ownParams = own?.params ?? {}; + // A folder is read with the team's texts only when it is valid; otherwise teamai's. + const inForce = own?.ok ? own.texts : teamai.texts; + const keys = (...objects: JsonObject[]): string[] => [...new Set(objects.flatMap((object) => Object.keys(object)))]; + + const params = keys(teamai.params, ownParams).flatMap((key) => { + const base = teamai.params[key]; + const set = ownParams[key]; + + if (isJsonObject(base) && (set === undefined || isJsonObject(set))) { + return keys(base, set ?? {}).map((inner) => ({ key: `${key}.${inner}`, teamai: base[inner] ?? null, team: set?.[inner] ?? null })); + } + + return [{ key, teamai: base ?? null, team: set ?? null }]; + }); + + const picked = (texts: FolderTexts, text: string) => { + const variants = texts[text]; + + return variants ? pick(text, variants, language) : null; + }; + + const texts = Object.keys(TEXTS_BY_NAME[ref.kind][ref.name] ?? {}).map((text) => { + const used = picked(inForce, text); + + return { text, file: used?.file ?? `${text}.md`, replaced: used?.content !== picked(teamai.texts, text)?.content }; + }); + + return { params, texts }; +} + +export const DefaultFolders = { names, isName, read, load, problems, compare } as const; diff --git a/src/default-hooks.ts b/src/default-hooks.ts deleted file mode 100644 index 62ecbe58c7..0000000000 --- a/src/default-hooks.ts +++ /dev/null @@ -1,350 +0,0 @@ -import path from 'node:path'; -import { z } from 'zod'; -import { FOLDER_MARKERS } from './folder-markers.js'; -import { packageRoot } from './package-info.js'; -import { DeliveredFolders } from './resources/delivered-folders.js'; -import { FolderFiles, type FolderFile } from './resources/folder-files.js'; -import { isJsonObject, type JsonObject, type JsonValue, type LocalConfig, type TeamaiConfig } from './types.js'; -import { log } from './utils/logger.js'; -import { parseYaml } from './utils/yaml.js'; - -// ─── Default hooks (#1787) ─────────────────────────────── -// -// teamai's hints keep their logic in hook-dispatch. Their texts -// and thresholds are hooks// folders in the npm package, -// where a team's own hook folders would be: params.yaml holds -// the parameters, each text is .md with optional -// .en.md and .zh.md variants. Code keeps the contract -// of each: the parameters with their ranges, and the variables -// each text offers, as `{{ name }}`. -// - -/** A whole number from `min` to `max`. */ -function whole(min: number, max: number) { - const error = `must be a whole number from ${min} to ${max}`; - - return z.number({ error }).int({ error }).min(min, { error }).max(max, { error }); -} - -/** The tool-error tiers, the first whose `min` the count reaches giving its points. */ -const ToolErrorTiersSchema = z.array(z.strictObject({ min: whole(1, 1000), points: whole(0, 100) }), { error: 'must be a list of { min, points } tiers' }) - .min(1, { error: 'must list from 1 to 5 tiers' }) - .max(5, { error: 'must list from 1 to 5 tiers' }) - .refine((tiers) => tiers.every((tier, index) => index === 0 || tier.min < (tiers[index - 1]?.min ?? Infinity)), { error: 'must list its tiers with min strictly descending' }); - -/** No parameter: the folder has no params.yaml. */ -const NoParamsSchema = z.strictObject({}); - -/** The parameters of each default hook folder, by name, with their ranges. */ -const PARAMS = { - 'teamai-friction-reminder': z.strictObject({ - min_tools: whole(1, 1000), - min_score: whole(1, 1000), - weights: z.strictObject({ interrupt: whole(0, 100), reject: whole(0, 100), correction: whole(0, 100) }, { error: 'must be a mapping of interrupt, reject and correction' }), - tool_error_tiers: ToolErrorTiersSchema, - skill_bonus: whole(0, 100), - diversity_bonus_max: whole(0, 100), - knowledge_gap_bonus: whole(0, 100), - low_quality_bonus: whole(0, 100), - low_quality_threshold: z.number({ error: 'must be a number from 0 to 100' }).min(0, { error: 'must be a number from 0 to 100' }).max(100, { error: 'must be a number from 0 to 100' }), - }), - 'teamai-mr-hint': z.strictObject({ lookback_days: whole(1, 90), max_mrs: whole(1, 100) }), - 'teamai-todowrite-hint': NoParamsSchema, - 'teamai-env-hint': NoParamsSchema, - 'teamai-package-hint': NoParamsSchema, -} as const; - -/** The name of a teamai default hook folder, `hooks//`. */ -export type DefaultHookName = keyof typeof PARAMS; - -/** The texts each default hook folder renders, by name, with the variables each offers; a text with no variable offers none. */ -const TEXTS = { - 'teamai-friction-reminder': { - problem: [], - 'problem-reasons': ['reasons'], - gap: [], - 'gap-reasons': ['reasons'], - task: ['task'], - action: [], - 'action-gap': [], - 'action-share-learnings': ['command'], - }, - 'teamai-mr-hint': { 'offer-command': ['count', 'mr_lines', 'command'], 'offer-workflow': ['count', 'mr_lines', 'command'] }, - 'teamai-todowrite-hint': { message: [] }, - 'teamai-env-hint': { variables: ['variables'], secrets: ['secrets'], exec: [], 'secrets-rule': [] }, - 'teamai-package-hint': { message: ['summary'] }, -} as const satisfies Record>; - -/** The parameters of default hook `TName`, once read. */ -export type DefaultHookParams = z.infer<(typeof PARAMS)[TName]>; - -/** Each name's schema of its own parameters. */ -type ParamsSchemas = { readonly [TName in DefaultHookName]: z.ZodType> }; - -/** `PARAMS`, typed so that a lookup by a generic name keeps its parameters' type. */ -const PARAMS_BY_NAME: ParamsSchemas = PARAMS; - -/** A text of default hook `TName`: `problem` is `problem.md`. */ -export type DefaultHookTextName = keyof (typeof TEXTS)[TName] & string; - -/** The variables text `TText` of default hook `TName` offers. */ -type TextVariable> = - (typeof TEXTS)[TName][TText] extends readonly (infer TVariable extends string)[] ? TVariable : never; - -/** The language a text is picked for: `teamai.yaml` `language`, undefined when unset. */ -export type HintLanguage = TeamaiConfig['language']; - -/** One text, picked for a language: the language of the variant it came from (null for the base file), and its content filled in. */ -export interface PickedText { - readonly language: NonNullable | null; - render(values: Readonly>): string; -} - -/** A default hook folder, read: its parameters, and its texts. */ -export interface DefaultHook { - readonly params: DefaultHookParams; - /** Text `text` for `language`: `..md` when the folder has it, else `.md`. */ - text>(text: TText, language: HintLanguage): PickedText>; -} - -const NAMES = Object.keys(PARAMS).filter((name): name is DefaultHookName => name in PARAMS); - -/** Whether `name` is one of teamai's default hook folders, which a team folder of that name at the root replaces. */ -function isName(name: string): name is DefaultHookName { - return NAMES.some((known) => known === name); -} - -/** `{{ name }}`: a variable a text reads. */ -const VARIABLE = /\{\{([^{}]*)\}\}/g; - -/** `content` with each `{{ name }}` replaced by its value, in one pass: a value is never read again for variables. */ -function substitute(content: string, values: Readonly>): string { - return content.replace(VARIABLE, (read, inner: string) => values[inner.trim()] ?? read); -} - -/** A text's files: its base file, and the variants the folder has. */ -interface TextVariants { - base: string; - en?: string; - zh?: string; -} - -/** The variant of a text a hint reads for `language`: `..md` when the folder has it, else `.md`. */ -function pick(variants: TextVariants, language: HintLanguage): { language: NonNullable | null; content: string } { - const variant = language ? variants[language] : undefined; - - return variant !== undefined && language ? { language, content: variant } : { language: null, content: variants.base }; -} - -/** - * `base` with the keys of `own` over it (#1787, common rule 5): a key `own` leaves out keeps `base`'s value, and a - * mapping in both is merged one level down, so `weights: { reject: 30 }` keeps the other weights. A list is replaced. - */ -function withOver(base: JsonObject, own: JsonValue | undefined): JsonValue { - if (own === undefined || own === null) return base; - - if (!isJsonObject(own)) return own; - const merged: JsonObject = { ...base }; - - for (const [key, value] of Object.entries(own)) { - const under = base[key]; - merged[key] = isJsonObject(under) && isJsonObject(value) ? { ...under, ...value } : value; - } - - return merged; -} - -/** The file of a default folder that holds its parameters. */ -const PARAMS_FILE = 'params.yaml'; - -/** What one issue with the parameters of default hook `name` says, naming the parameter: one line per key it does not take. */ -function paramProblems(name: DefaultHookName, issue: z.core.$ZodIssue): string[] { - const at = (keys: readonly PropertyKey[]): string => keys.map(String).join('.'); - - if (issue.code === 'unrecognized_keys') return issue.keys.map((key) => `params.yaml: ${at([...issue.path, key])} is not a parameter of ${name}`); - - return [`params.yaml: ${issue.path.length > 0 ? `${at(issue.path)} ` : ''}${issue.message}`]; -} - -/** Whether `texts` holds every text default hook `name` renders. */ -function holdsEveryText(name: TName, texts: Partial>): texts is Record, TextVariants> { - return Object.keys(TEXTS[name]).every((text) => texts[text] !== undefined); -} - -/** A folder read as default hook `TName`: the hook, its texts, and the parameters its params.yaml sets, or why its files do not make one. */ -type FolderRead = - | { readonly ok: true; readonly hook: DefaultHook; readonly params: JsonObject; readonly texts: Readonly>> } - | { readonly ok: false; readonly problems: string[]; readonly params: JsonObject }; - -/** The mapping YAML file `file` of a folder holds, empty when the folder has none, or why it is not one. */ -function readMapping(file: FolderFile | undefined, shown: string): { ok: true; mapping: JsonObject } | { ok: false; problem: string } { - try { - const parsed = parseYaml(file ? FolderFiles.text(file.bytes.toString('utf8')) : ''); - - if (parsed === null || parsed === undefined) return { ok: true, mapping: {} }; - - return isJsonObject(parsed) ? { ok: true, mapping: parsed } : { ok: false, problem: `${shown} must be a mapping` }; - } catch (error) { - return { ok: false, problem: `${shown} does not parse: ${error instanceof Error ? error.message : String(error)}` }; - } -} - -/** Default hook `name` from the files of its folder, its parameters over `base` (teamai's, for a team's replacement), or why they do not make one. */ -function readFolder(name: TName, files: readonly FolderFile[], base: JsonObject): FolderRead { - const byPath = new Map(files.map((file) => [file.path, file])); - const refusals: string[] = []; - const marker = readMapping(byPath.get(FOLDER_MARKERS.hooks.marker), FOLDER_MARKERS.hooks.marker); - - if (!marker.ok) refusals.push(marker.problem); - - // The folder's name says which behaviour of teamai's it configures, so it runs nothing of its own. - for (const key of Object.keys(marker.ok ? marker.mapping : {})) { - if (key === 'command') refusals.push('HOOK.yaml: command is not taken by a default hook, which runs teamai\'s own handler. Remove it, or give a hook of your own another name'); - else if (key === 'with') refusals.push('HOOK.yaml: with is not a key of a default hook, whose parameters are in params.yaml. Move the keys of with to params.yaml'); - else if (key !== 'description') refusals.push(`HOOK.yaml: ${key} is not a key of a default hook, whose HOOK.yaml holds only description`); - } - - const own = readMapping(byPath.get(PARAMS_FILE), PARAMS_FILE); - - if (!own.ok) refusals.push(own.problem); - const set = own.ok ? own.mapping : {}; - const params = PARAMS_BY_NAME[name].safeParse(withOver(base, set)); - - if (!params.success) refusals.push(...params.error.issues.flatMap((issue) => paramProblems(name, issue))); - - const texts: Partial> = {}; - - for (const [text, offered] of Object.entries(TEXTS[name])) { - // A text with a variable it does not offer, or anything in braces that is not a variable, would print as written. - const read = (file: string): string | undefined => { - const found = byPath.get(file); - - if (!found) return undefined; - const content = FolderFiles.text(found.bytes.toString('utf8')); - - for (const [source, inner = ''] of content.matchAll(VARIABLE)) { - if (offered.includes(inner.trim())) continue; - const offers = offered.length > 0 ? offered.map((variable) => `{{ ${variable} }}`).join(', ') : 'no variable'; - refusals.push(`${file} reads ${source}, which the ${text} text does not offer: it offers ${offers}`); - } - - return content; - }; - - const baseText = read(`${text}.md`); - - if (baseText === undefined) { - refusals.push(`${text}.md is missing: the folder replaces the ${text} text in another language, which replaces every language of it. Add ${text}.md`); - continue; - } - - const variants: TextVariants = { base: baseText }; - const en = read(`${text}.en.md`); - const zh = read(`${text}.zh.md`); - - if (en !== undefined) variants.en = en; - - if (zh !== undefined) variants.zh = zh; - texts[text] = variants; - } - - if (!params.success || refusals.length > 0 || !holdsEveryText(name, texts)) return { ok: false, problems: refusals, params: set }; - - const hook: DefaultHook = { - params: params.data, - text(text, language) { - const picked = pick(texts[text], language); - - return { language: picked.language, render: (values) => substitute(picked.content, values) }; - }, - }; - - return { ok: true, hook, params: set, texts }; -} - -/** The folder teamai ships for `name`. It is part of teamai's package, so one that cannot be read is a teamai bug, and throws. */ -async function shipped(name: TName): Promise<{ hook: DefaultHook; params: JsonObject; texts: Readonly>> }> { - const dir = path.join(packageRoot(), 'hooks', name); - const read = await FolderFiles.read(dir); - - if (read.kind === 'invalid') throw new Error(`teamai's default hook folder ${dir} cannot be read: ${read.problems.map(FolderFiles.describe).join(', ')}`); - const folder = readFolder(name, read.files, {}); - - if (!folder.ok) throw new Error(`teamai's default hook folder ${dir} is broken: ${folder.problems.join('; ')}`); - - return folder; -} - -/** - * Default hook `name` as `scope` reads it: the team's replacement pull delivered to the scope, else teamai's folder. - * With no scope (teamai is not set up where the hook runs), teamai's folder. - */ -async function load(name: TName, scope: LocalConfig | null): Promise> { - const teamai = await shipped(name); - - if (scope) { - const dir = DeliveredFolders.pathOf(scope, { type: 'hooks', namespace: null, name }); - const read = await FolderFiles.read(dir); - - if (read.kind === 'files') { - const folder = readFolder(name, read.files, teamai.params); - - if (folder.ok) return folder.hook; - log.debug(`default hook ${name}: the team's folder at ${dir} is not used, teamai's is: ${folder.problems.join('; ')}`); - } - } - - return teamai.hook; -} - -/** Why pull refuses `files`, a team folder that would replace default hook `name`; none when it may replace it. */ -async function problems(name: DefaultHookName, files: readonly FolderFile[]): Promise { - const folder = readFolder(name, files, (await shipped(name)).params); - - return folder.ok ? [] : folder.problems; -} - -/** How a team's folder differs from teamai's: each parameter, and each text by the file a hint reads. Names and values, never a text. */ -export interface DefaultHookComparison { - /** `weights.reject` for a parameter of a mapping. `team` is null where the team's folder leaves a parameter out. */ - readonly params: ReadonlyArray<{ readonly key: string; readonly teamai: JsonValue | null; readonly team: JsonValue | null }>; - /** `file`: the variant the hint in force reads for the language. `replaced`: what it reads differs from what teamai's folder gives. */ - readonly texts: ReadonlyArray<{ readonly text: string; readonly file: string; readonly replaced: boolean }>; -} - -/** `team`, the files of a team's folder replacing default hook `name` (null for none), against teamai's, for `language`. */ -async function compare(name: DefaultHookName, team: readonly FolderFile[] | null, language: HintLanguage): Promise { - const teamai = await shipped(name); - const own = team === null ? null : readFolder(name, team, teamai.params); - const ownParams = own?.params ?? {}; - // The hint reads the team's texts only when its folder is valid; otherwise teamai's. - const inForce = own?.ok ? own.texts : teamai.texts; - const keys = (...objects: JsonObject[]): string[] => [...new Set(objects.flatMap((object) => Object.keys(object)))]; - - const params = keys(teamai.params, ownParams).flatMap((key) => { - const base = teamai.params[key]; - const set = ownParams[key]; - - if (isJsonObject(base) && (set === undefined || isJsonObject(set))) { - return keys(base, set ?? {}).map((inner) => ({ key: `${key}.${inner}`, teamai: base[inner] ?? null, team: set?.[inner] ?? null })); - } - - return [{ key, teamai: base ?? null, team: set ?? null }]; - }); - - const read = (texts: Readonly>>, text: string) => { - const variants = texts[text]; - - return variants ? pick(variants, language) : null; - }; - - const texts = Object.keys(TEXTS[name]).map((text) => { - const picked = read(inForce, text); - - return { text, file: picked?.language ? `${text}.${picked.language}.md` : `${text}.md`, replaced: picked?.content !== read(teamai.texts, text)?.content }; - }); - - return { params, texts }; -} - -export const DefaultHooks = { NAMES, isName, load, problems, compare } as const; diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index b908d5a32a..e9f4a17138 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -967,12 +967,12 @@ const todowriteHintHandler: HookHandler = { const { shouldSkipTodoWriteHint, buildHintMessage } = await import('./todowrite-hint.js'); if (shouldSkipTodoWriteHint(deriveSessionId(stdin))) return null; - const { DefaultHooks } = await import('./default-hooks.js'); + const { DefaultFolders } = await import('./default-folders.js'); return JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', - additionalContext: buildHintMessage(await DefaultHooks.load('teamai-todowrite-hint', config), await teamAgentLanguage(config)), + additionalContext: buildHintMessage(await DefaultFolders.load({ kind: 'hooks', name: 'teamai-todowrite-hint' }, config), await teamAgentLanguage(config)), }, }); }, @@ -1098,8 +1098,8 @@ const teamEnvHintHandler: HookHandler = { return description ? `${name} (${description})` : name; }).join(', '); - const { DefaultHooks } = await import('./default-hooks.js'); - const hook = await DefaultHooks.load('teamai-env-hint', config); + const { DefaultFolders } = await import('./default-folders.js'); + const hook = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-env-hint' }, config); const language = await teamAgentLanguage(config); return JSON.stringify({ diff --git a/src/mr-hint.ts b/src/mr-hint.ts index 87ddea737d..432a48c75b 100644 --- a/src/mr-hint.ts +++ b/src/mr-hint.ts @@ -15,7 +15,7 @@ import { type LocalConfig, JsonObjectSchema } from './types.js'; import type { SessionRun } from './launch-context.js'; import { WorkflowOffers, type Proposal } from './workflow-offers.js'; import type { OutputSchema } from './workflow/types.js'; -import { DefaultHooks, type DefaultHook, type HintLanguage } from './default-hooks.js'; +import { DefaultFolders, type DefaultFolder, type TextLanguage } from './default-folders.js'; // ─── MR Hint data flow ────────────────────────────────── // @@ -406,8 +406,8 @@ async function listGitHubMergedMRs( */ export function buildHintMessage( mrs: MRSummary[], - hook: DefaultHook<'teamai-mr-hint'>, - { language, offersWorkflow = false }: { language?: HintLanguage; offersWorkflow?: boolean } = {}, + hook: DefaultFolder<'hooks', 'teamai-mr-hint'>, + { language, offersWorkflow = false }: { language?: TextLanguage; offersWorkflow?: boolean } = {}, ): string { // One run for every MR: teamai-import-from-mr takes several URLs, publishes the // accepted learnings once and updates each repository's teamwiki once (#1104). @@ -491,7 +491,7 @@ type MrHint = z.infer; * nothing to hint or the scope's switch turns it off. Writes nothing: showing it records it ({@link recordShown}). */ async function computeMrHint( - language: HintLanguage, + language: TextLanguage, localConfig?: LocalConfig | null, session?: { sessionId: string; cwd: string; run: SessionRun }, ): Promise { @@ -531,7 +531,7 @@ async function computeMrHint( // Load cache to filter already-hinted MRs const alreadyHinted = new Set(loadCache(owner, repo).hintedMrIds); - const hook = await DefaultHooks.load('teamai-mr-hint', localConfig ?? null); + const hook = await DefaultFolders.load({ kind: 'hooks', name: 'teamai-mr-hint' }, localConfig ?? null); const { lookback_days: lookbackDays, max_mrs: limit } = hook.params; // Query the MRs merged in the past lookback_days days @@ -584,7 +584,7 @@ function hookOutput(hookEventName: 'SessionStart' | 'UserPromptSubmit', message: * — safe to call from the hook dispatcher (which has already consumed STDIN) as well as from the standalone command. */ export async function computeMrHintOutput( - language: HintLanguage, + language: TextLanguage, localConfig?: LocalConfig | null, session?: { sessionId: string; cwd: string; run: SessionRun }, ): Promise { @@ -606,7 +606,7 @@ function pendingPath(sessionId: string): string { * session start that finds nothing new drops that one. Nothing is recorded until the hint is shown. */ export async function stashMrHint( - language: HintLanguage, + language: TextLanguage, localConfig: LocalConfig | null, session: { sessionId: string; cwd: string; run: SessionRun }, ): Promise { diff --git a/src/pkg/pkg-hint.ts b/src/pkg/pkg-hint.ts index 563aeeca4f..43fc026f8a 100644 --- a/src/pkg/pkg-hint.ts +++ b/src/pkg/pkg-hint.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { z } from 'zod'; import { detectProjectConfig, loadLocalConfig, loadTeamAgentLanguage } from '../config.js'; -import { DefaultHooks, type DefaultHook, type HintLanguage } from '../default-hooks.js'; +import { DefaultFolders, type DefaultFolder, type TextLanguage } from '../default-folders.js'; import { ensureDir, readJson, writeJsonAtomic } from '../utils/fs.js'; import { getUserHome } from '../utils/home.js'; import { log } from '../utils/logger.js'; @@ -29,7 +29,7 @@ export interface DeclaredPackageCounts { } /** The package hint: `hook`'s `message.md` (its variant for `language`), its `{{ summary }}` naming what the team declares. */ -export function buildPackageHintMessage(hook: DefaultHook<'teamai-package-hint'>, { npm, claude, marketplace }: DeclaredPackageCounts, language: HintLanguage): string { +export function buildPackageHintMessage(hook: DefaultFolder<'hooks', 'teamai-package-hint'>, { npm, claude, marketplace }: DeclaredPackageCounts, language: TextLanguage): string { const summary = [ npm > 0 ? `${npm} npm package${npm === 1 ? '' : 's'}` : '', claude > 0 ? `${claude} Claude plugin${claude === 1 ? '' : 's'}` : '', @@ -93,7 +93,7 @@ async function computePackageHintState(cwd: string): Promise { // A teamai- name only replaces a default (#1787, common rule 5), at the root, as builtin: overrides apply to the // whole team; defaultHookReplacements reads it there. - if (DefaultHooks.isName(name)) { + if (DefaultFolders.isName('hooks', name)) { return { kind: 'skipped', note: namespace === null ? null : `${folderPath} is refused, and teamai's default stays in force: a default hook is replaced at the root of the team repo only. Move it to hooks/${name}/` }; } @@ -298,8 +298,8 @@ function isTeamScriptCommand(command: string): boolean { * prints about it, while teamai's default stays in force. `files` is null for a folder that cannot be read. */ export type DefaultHookReplacement = ( - | { readonly kind: 'delivered'; readonly name: DefaultHookName; readonly path: string; readonly delivery: FolderDelivery } - | { readonly kind: 'refused'; readonly name: DefaultHookName; readonly path: string; readonly files: readonly FolderFile[] | null; readonly messages: readonly string[] } + | { readonly kind: 'delivered'; readonly name: DefaultFolderName<'hooks'>; readonly path: string; readonly delivery: FolderDelivery } + | { readonly kind: 'refused'; readonly name: DefaultFolderName<'hooks'>; readonly path: string; readonly files: readonly FolderFile[] | null; readonly messages: readonly string[] } ) & { /** What pull warns about each team file that replaces no file of teamai's folder, and `review --branch` fails. */ readonly strays: readonly string[]; @@ -312,7 +312,7 @@ export type DefaultHookReplacement = ( * make, what pull prints when it refuses them, and what it warns about the team files that replace nothing. */ interface DefaultHookFolder { - readonly name: DefaultHookName; + readonly name: DefaultFolderName<'hooks'>; readonly path: string; readonly read: FolderFilesRead; readonly messages: readonly string[]; @@ -326,12 +326,12 @@ async function readDefaultHookFolders(root: string): Promise { } /** The reminder emitted via additionalContext: `message.md` of `hook`, or `message.en.md` when language is `en`. */ -export function buildHintMessage(hook: DefaultHook<'teamai-todowrite-hint'>, language?: HintLanguage): string { +export function buildHintMessage(hook: DefaultFolder<'hooks', 'teamai-todowrite-hint'>, language?: TextLanguage): string { return hook.text('message', language).render({}); } @@ -170,7 +170,7 @@ export async function todoWriteHint(tool?: string): Promise { const hookOutput = JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', - additionalContext: buildHintMessage(await DefaultHooks.load('teamai-todowrite-hint', scope), language), + additionalContext: buildHintMessage(await DefaultFolders.load({ kind: 'hooks', name: 'teamai-todowrite-hint' }, scope), language), }, }); From d2075153ab56cd730a36c6b4938b8092ea468d4e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:03:32 +0100 Subject: [PATCH 06/18] docs(changelog): describe default hook parameters once The hook folders and their move to params.yaml are both unreleased, so a Breaking Changes entry for HOOK.yaml's with: described a state no release had. The feature entry now says where the parameters live. --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1024db3cd1..1a3410c4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to this project will be documented in this file. See [standa - A team hook can be a folder, `hooks/[/]/HOOK.yaml` beside the scripts it runs, with the keys of a `hooks.yaml` entry except `id`, `roles` and `projects`; `${TEAMAI_HOOK_DIR}` in `command` names the folder. In single-repo, Claude Code's command names it through `$CLAUDE_PROJECT_DIR`. `requireTeamScripts` keeps a folder hook whose command names `${TEAMAI_HOOK_DIR}`. `${TEAMAI_HOOK_DIR}` in `hooks.yaml`, or an id that is both an entry and a folder, stops team hooks for the run, and `teamai review --branch` fails a branch with either, or with a `HOOK.yaml` that does not parse. A single-repo worktree's folders are removed on uninstall. Once a worktree is removed, the next pull of another worktree, or `teamai doctor --fix`, removes the team hook entries it left in HOME (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A team MCP server can be a folder, `mcp/[/]/MCP.yaml` beside the files it runs, with the keys of an `mcp.yaml` server except `name`, `roles` and `projects`, stdio only; `${TEAMAI_MCP_DIR}` in `command`, `args` and `env` names the folder for every tool `mcp/mcp.yaml` reaches. In single-repo each tool's MCP file holds the worktree's absolute path. `requires` and `sharing.mcp.allowedCommands` apply to the runner, as for any server. A folder with `transport: http` or `sse`, `${TEAMAI_MCP_DIR}` in `mcp.yaml`, or a name that is both a server there and a folder stops team MCP for the run and keeps what is installed (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A benchmark suite can be a folder, `benchmarks/[/]/SUITE.yaml`; `suite.yaml` keeps working. A directory holding both is invalid, and pull and `teamai benchmark try` name both files. Docs and messages show `SUITE.yaml` (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). -- teamai's hints keep their thresholds and texts in default hook folders the package ships: `hooks/teamai-friction-reminder/`, `teamai-mr-hint/`, `teamai-todowrite-hint/`, `teamai-env-hint/` and `teamai-package-hint/`, each with a `README.md` listing its parameters, ranges, variables and switches. A team replaces one with a folder of the same name at the root of its repo: `HOOK.yaml` holds `description` and `with:`, where a key left out keeps teamai's value, and each text is `.md` with `{{ name }}` variables. Pull refuses a replacement that sets a parameter out of range or one the default does not take, reads a variable its text does not offer, leaves a text out, has a `command`, or sits in a namespace, names why, and keeps teamai's folder in force. Each hint reads the replacement in every tool, the older `teamai contribute-check`, `mr-hint` and `todowrite-hint` entry points included. With `language: en` or `zh`, a hint prints `.en.md` or `.zh.md` when its folder has one. With no replacement every hint prints what it did, except that a team with `language: zh` now sees the friction reminder in Chinese (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). +- teamai's hints keep their thresholds and texts in default hook folders the package ships: `hooks/teamai-friction-reminder/`, `teamai-mr-hint/`, `teamai-todowrite-hint/`, `teamai-env-hint/` and `teamai-package-hint/`, each with a `README.md` listing its parameters, ranges, variables and switches. A team replaces one with a folder of the same name at the root of its repo, holding only the files it changes: `params.yaml` holds the parameters, where a key left out keeps teamai's value, `HOOK.yaml` holds only `description`, and each text is `.md` with `{{ name }}` variables. Pull refuses a replacement that sets a parameter out of range or one the default does not take, reads a variable its text does not offer, leaves a text out, has a `command`, or sits in a namespace, names why, and keeps teamai's folder in force. Each hint reads the replacement in every tool, the older `teamai contribute-check`, `mr-hint` and `todowrite-hint` entry points included. With `language: en` or `zh`, a hint prints `.en.md` or `.zh.md` when its folder has one. With no replacement every hint prints what it did, except that a team with `language: zh` now sees the friction reminder in Chinese (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A team rule can be a folder: `rules/[/]/RULE.md` beside the files it sends the agent to, named by its path as a rule file is. `RULE..md` gives the body for a `language` under the frontmatter of `RULE.md`. Pull copies the folder to each worktree's `.teamai/delivered/rules/`, which git ignores, and puts its path, with forward slashes, in place of `${TEAMAI_RULE_DIR}`: every tool reads it there, a tool that reads nothing outside the project by its path from the project root. A user-scope rule names `~/.teamai/rules/`, granted to Claude Code through `permissions.additionalDirectories` in `~/.claude/settings.json` (uninstall takes it back), and pull warns once for each other tool that may ask before reading it. Push proposes nothing from a folder's copies. teamai's recall rule is now the folder `rules/teamai-recall/`, which a team replaces with its own at the root of the team repo; pull refuses a replacement missing a line recall needs or holding a link, keeps teamai's, and `teamai review --branch` fails it. Upgrade every member before committing a rule folder (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - The dashboard's Resources page has a Resource layers panel: for the selected workspace, every definition of each workflow, hook, MCP server, benchmark suite and rule folder, with its layer (teamai's default, the team root, or a namespace), whether it is a file, folder or entry, what it replaces, and whether pull uses it, with what pull prints and the fix when it does not. A folder's row lists its files with size and executable mark, the total against a workflow folder's 1 MB limit, where pull delivers it, and the command it runs; a rule folder's names the language file its text comes from and where each tool reads `${TEAMAI_RULE_DIR}`. teamai's recall rule and its five default hooks are listed beside the team's replacements, the hooks with each `with:` parameter against teamai's, and a default workflow that cannot start against the team's workflows is shown as held. The Overview counts the problems and links there. Names, paths and sizes only, never a file's content or an env value (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - OpenClaw, Hermes and DeepSeek Harness write team MCP servers and HTTP-delivered models to their user config files, through the same pull, `teamai mcp list`, doctor and uninstall as other tools. Workspace MCP ownership and reporting stay with the workspace: uninstall keeps entries another workspace still owns and preserves member edits. Unreadable ownership records keep the config and retry records intact; removal counts include only entries actually deleted. Delivered models are added beside the member's settings, without changing the current model, primary or fallbacks. @@ -143,7 +143,6 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **A default hook's parameters live in `params.yaml`, not in `HOOK.yaml`'s `with:`.** `hooks/teamai-friction-reminder/` and `hooks/teamai-mr-hint/` ship `params.yaml`, and a team folder holding only its own `params.yaml` keeps teamai's `HOOK.yaml` and texts. A key it leaves out keeps teamai's value. Pull refuses a team `HOOK.yaml` that still has `with:` and names `params.yaml`, so move those keys there (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - **A team folder named like a teamai default holds only the files it changes.** `hooks/teamai-/`, `rules/teamai-/` and `workflows/teamai-/` at the team root take every file they leave out, marker included, from teamai's default, and a file of the team's replaces teamai's of that name with its language variants. A lone `rules/teamai-recall/.md`, which pull delivered as a rule of its own, is now part of teamai's recall rule. Have every member upgrade before you commit a folder that holds only some files: an older teamai reads `rules/teamai-recall/block.md` as an always-on rule of its own. - **`TEAMAI_AGENT_DIR` is a reserved team env name.** It names an agent folder's own copy, so a team value for it is ignored and pull warns about it. - **A skill can be one file, so a file under `skills/` can be a skill.** `skills/[/].md` whose frontmatter has a `description` is delivered as `/SKILL.md`. Notes without one, such as `NOTES.md` or `README.md`, stay notes. From b276047b7f8e191a89cbec069aadbc756ef7a3b8 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:08:06 +0100 Subject: [PATCH 07/18] feat(recall)!: tune promotion gates in the promote workflow's params.yaml Why: the four promotion gates were constants a team could not change. The earlier version on this branch put them in the workflow's defaults.inputs, which made them per-run inputs that the listing and the dashboard, which run no workflow, had to read by parsing the workflow file, and which a caller could override per run. What: teamai-promote-learning is now a folder. Its params.yaml holds min_confidence, min_upvoted, min_users and min_age_days, and the DefaultFolders contract owns their schema and ranges. A team folder holding only params.yaml keeps teamai's WORKFLOW.yaml and its other values. The workflow cache checks the team's params.yaml at pull, so pull and `teamai review --branch` refuse a value out of range or an unknown key, and the version pull last took stays in force. The listing, promote-publish and the dashboard read the gates through DefaultFolders. With `--repo`, the dashboard reads them from that repo's folder. findPromotionCandidates takes one named object with the gates required. The inputs, defaults.inputs, promotion-thresholds.ts and the dashboard's promoteError are removed. Proof: default-folders tests cover a key set over teamai's, a refusal that keeps the last version, and a first refusal that leaves teamai's. A review-branch case covers the refusal before merge, a viz case covers the --repo folder, and maintenance-promote applies tuned gates. The unit suites touched and the recall-promote-publish and namespaced-learnings-maintenance e2e files pass, and typecheck and lint are clean. --- CHANGELOG.md | 2 +- docs/dev/workflow-engine.md | 2 +- docs/guide/knowledge.md | 2 +- docs/guide/workflow.md | 4 +- docs/guide/zh-CN/knowledge.md | 2 +- docs/guide/zh-CN/workflow.md | 4 +- src/__tests__/dashboard-improvement.test.ts | 7 - src/__tests__/dashboard-ui.test.ts | 15 --- src/__tests__/default-folders.test.ts | 68 ++++++++++ src/__tests__/maintenance-promote.test.ts | 46 ++++--- .../maintenance-votes-readonly.test.ts | 2 +- src/__tests__/promotion-thresholds.test.ts | 95 -------------- src/__tests__/review-branch.test.ts | 12 ++ src/__tests__/viz.test.ts | 13 ++ src/dashboard/improvement-client.ts | 2 +- src/dashboard/improvement.ts | 4 - src/dashboard/locales.ts | 2 - src/default-folders.ts | 82 ++++++++++-- src/index.ts | 6 +- src/maintenance/promote.ts | 48 +++---- src/maintenance/promotion-publish.ts | 7 +- src/maintenance/promotion-thresholds.ts | 51 -------- src/viz-render.ts | 10 +- src/viz.ts | 17 +-- src/workflow/cache.ts | 21 ++- workflows/teamai-promote-learning/README.md | 122 ++++++++++++++++++ .../teamai-promote-learning/README.zh-CN.md | 122 ++++++++++++++++++ .../WORKFLOW.yaml} | 5 +- workflows/teamai-promote-learning/params.yaml | 4 + 29 files changed, 498 insertions(+), 279 deletions(-) delete mode 100644 src/__tests__/promotion-thresholds.test.ts delete mode 100644 src/maintenance/promotion-thresholds.ts create mode 100644 workflows/teamai-promote-learning/README.md create mode 100644 workflows/teamai-promote-learning/README.zh-CN.md rename workflows/{teamai-promote-learning.yaml => teamai-promote-learning/WORKFLOW.yaml} (94%) create mode 100644 workflows/teamai-promote-learning/params.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3410c4da..6c0fefea07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features -- The promotion gates `teamai recall promote` lists with live in the `teamai-promote-learning` workflow's `defaults.inputs` (`min_confidence`, `min_upvoted`, `min_users`, `min_age_days`), shipped at the values the constants held. A team tunes its bar by replacing that workflow file, keeping the defaults it does not change; a gate set out of range refuses the listing with the file and the field named, rather than falling back silently. The listing, the `promote-candidate` and `promote-publish` steps, and the dashboard all resolve the same workflow, so none offers a learning another rejects. A member on an older teamai keeps the hard-coded gates, so upgrade every member before tuning them, or two members see different listings (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). +- The gates `teamai recall promote` applies, `min_confidence`, `min_upvoted`, `min_users` and `min_age_days`, are the `params.yaml` of the default workflow folder `teamai-promote-learning`, shipped at the values the code held. A team tunes them with `workflows/teamai-promote-learning/params.yaml` at its root, holding only the keys it changes; teamai's `WORKFLOW.yaml` and its other values stay in force. The listing, the `promote-candidate` and `promote-publish` steps and the dashboard read the same file, so none offers a learning another rejects. Pull and `teamai review --branch` refuse a value out of range or a key the workflow does not take, naming the file and the field, and the version pull last took stays in force. A member on an older teamai keeps the old gates, so upgrade every member before tuning them (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - A team changes one file of a teamai default hook, rule or workflow folder by keeping that file in a folder of the default's name; every other file stays teamai's and keeps its improvements. Contracts such as the recall rule's kept lines and a hook default's keys are checked on the resulting folder. A team file that replaces no file of the default: pull warns, names it and keeps teamai's file, and `teamai review --branch` fails. A run of such a folder records the teamai version it took files from, and `teamai list --json` marks each file's layer. - Agents and scorers can be folders. `agents/[/]/AGENT.md` is delivered like a rule folder and names its files with `${TEAMAI_AGENT_DIR}`; Claude Code gets the user-scope directory in `additionalDirectories`, and another tool that cannot read it gets a warning. `scorers/[/]/SCORER.md` names its files with `${TEAMAI_SCORER_DIR}`, and the judge reads them in its evidence directory. - `teamai update` records every TeamAI copy this machine runs from in `~/.teamai/installations.json`, and `teamai update` and `teamai doctor` list them. With `updatePolicy: auto` and `updateOtherInstallations: true`, an update of a running copy that is already current can update one other registered npm-global copy per run under its own `--prefix`; npm exiting 0 without changing that copy is a failure. Volta, vendored and local copies are listed with their manual remedy and are never passed to npm (for [#1753](https://github.com/Tencent/teamai-cli/issues/1753)). diff --git a/docs/dev/workflow-engine.md b/docs/dev/workflow-engine.md index 47ee92578b..487d76ee5d 100644 --- a/docs/dev/workflow-engine.md +++ b/docs/dev/workflow-engine.md @@ -81,7 +81,7 @@ A default whose scorer is asked once per item of a session (a delivered skill, a - **A fallback reads why a step failed, never only that it failed.** `currentStep` in `scope.ts` gives a settled step's `failure` (code and message, current pass only) to expressions and templates; `stepFieldSchema` in `expression.ts` types it, and `undeclaredField` in `parse-context.ts` refuses a field it lacks. A migrated default that kept a success-shaped fallback guards it on the code, and ends the run `failed` on a code the fallback must not hide, such as `tool_unconfigured` for a tool the member names that cannot run: `continue_on_failure` alone would let the run succeed with nothing done. A command that exits `INPUT_REJECTED_EXIT` (65) fails `input_rejected`, and one that exits any other non-zero status fails `command_failed` (`commandEnd` in `protocol.ts`), so a failed command step's `failure` is never null. `explainFailedSteps` in `workflow-commands.ts` offers no retry for `input_rejected`, and says `command_failed` by its message alone, which names the status. Under a shortcut, whose progress already showed a command's stderr, it names a failed command step by its exit status only, so each message is said once. ```yaml fragment - # workflows/teamai-promote-learning.yaml, abridged + # workflows/teamai-promote-learning/WORKFLOW.yaml, abridged ends: { unconfigured-tool: failed } steps: - id: content diff --git a/docs/guide/knowledge.md b/docs/guide/knowledge.md index 1e741b76e9..876684b599 100644 --- a/docs/guide/knowledge.md +++ b/docs/guide/knowledge.md @@ -205,7 +205,7 @@ Confidence is an activity score from 0 to 1, not a probability that the knowledg ### Promoting Learnings -A learning can become a skill, rule, or doc when its confidence is at least 0.90, it has at least 5 upvotes, at least 2 contributors, and it has existed for at least 14 days. +A learning can become a skill, rule, or doc when its confidence is at least 0.90, it has at least 5 upvotes, at least 2 contributors, and it has existed for at least 14 days. A team changes these in a `params.yaml` of `workflows/teamai-promote-learning/` at the root of its repo, holding only the keys it changes, such as `min_users: 1`; the [workflow's README](https://github.com/Tencent/teamai-cli/blob/main/workflows/teamai-promote-learning/README.md) lists them and their ranges. Members on a teamai older than the one that reads it keep the old gates, so upgrade every member first. Promoted skills use `skills//SKILL.md`, with missing `name` and `description` frontmatter filled in so tools can discover them. Rules and docs remain Markdown files in their respective directories. diff --git a/docs/guide/workflow.md b/docs/guide/workflow.md index 2a6e4cc69d..ad4353d4e1 100644 --- a/docs/guide/workflow.md +++ b/docs/guide/workflow.md @@ -1132,7 +1132,7 @@ teamai ships workflows a team gets without writing them: | `teamai-try-scorer` | tries scorer files on a session and on its first prompt alone, through `teamai-score` with `store: false`, so no label is stored. [`teamai score --scorer `](scorers.md#try-a-scorer-file) starts it. Private | never: `invoke: deny` | | `teamai-new-scorer` | writes a scorer with the person, tries it with `teamai-try-scorer`, and proposes `scorers/.md` to the team as a pull request from the [team workspace](#the-team-workspace). Inputs: `session`, `source` | always: `invoke: ask`, so an agent starts it once the person agrees | | `teamai-draft-learning-updates` | drafts an update of each doc, rule and skill the team recalls often but rarely adopts, as `.draft.md` beside it, through [`teamai recall maintenance --update-quality`](knowledge.md#knowledge-base-maintenance). Input: `knowledge_root`, the checkout that holds the entries, where the drafts land. Private | never by name: `invoke: deny`. The command runs it from an agent session too | -| `teamai-promote-learning` | promotes a learning to a skill, rule or doc: the AI picks the category, unless `--category` gives it, and rewrites the learning, which is pushed on a review branch with a pull request that says the category and why, through [`teamai recall promote `](knowledge.md#knowledge-base-maintenance). Inputs: `learning`, `category` (`''` to let the AI pick). Private | always: `invoke: ask`, so an agent starts it once the person agrees | +| `teamai-promote-learning` | promotes a learning to a skill, rule or doc: the AI picks the category, unless `--category` gives it, and rewrites the learning, which is pushed on a review branch with a pull request that says the category and why, through [`teamai recall promote `](knowledge.md#knowledge-base-maintenance). Inputs: `learning`, `category` (`''` to let the AI pick). Its `params.yaml` holds which learnings it takes. Private | always: `invoke: ask`, so an agent starts it once the person agrees | | `teamai-codebase-deep-enrich` | writes a project's component docs, five at a time, then its architecture overview and graph docs, and with `publish` commits and pushes them to the team repo that holds `wiki_root`, through [`teamai codebase --deep-enrich`](knowledge.md#codebase-knowledge-graph). Inputs: `wiki_root`, `project`, `max_modules` (`0` for all), `concurrency`, `publish` (default `false`). Private | never by name: `invoke: deny`. The command runs it from an agent session too | | `teamai-codebase-enrich` | asks what each module of a freshly extracted codebase does and which business domain it belongs to, and writes `_manifest.json` and `_domains.json` beside its evidence. Inputs: `wiki_root`, `project`. Private | never: `invoke: deny` | | `teamai-import-from-repo` | imports repositories or local checkouts into a teamwiki: extracts each one, runs `teamai-codebase-enrich` and `teamai-codebase-deep-enrich` on it, publishes it, then rebuilds and publishes the indexes once, through [`teamai import --dir`, `--from-repo`, `--from-repo-list` and `--from-org`](knowledge.md#codebase-knowledge-graph). Inputs: `wiki_root`, `mode`, `items`, `settings`, `skipped`, `concurrency`. Private | never by name: `invoke: deny`. The command runs it from an agent session too | @@ -1355,7 +1355,7 @@ Start from the file the package ships, edit it, push it, and pull: cp "$(npm root -g)/teamai-cli/workflows/teamai-share-learnings.yaml" workflows/teamai-share-learnings.yaml ``` -Three defaults are folders, at `workflows//WORKFLOW.yaml` in the package where earlier releases had `workflows/.yaml`: `teamai-import-from-mr`, `teamai-codebase-deep-enrich` and `teamai-import-from-repo`. A `workflows/.yaml` your team already has still replaces the default. +Four defaults are folders, at `workflows//WORKFLOW.yaml` in the package where earlier releases had `workflows/.yaml`: `teamai-import-from-mr`, `teamai-codebase-deep-enrich`, `teamai-import-from-repo` and `teamai-promote-learning`. A `workflows/.yaml` your team already has still replaces the default. A folder of the same name changes a default that is a folder. Put only the files you change in it; every other file, `WORKFLOW.yaml` included, stays teamai's and keeps getting teamai's updates. To have MR learnings in English name what the review left open, copy its prompts: diff --git a/docs/guide/zh-CN/knowledge.md b/docs/guide/zh-CN/knowledge.md index e3c677c253..4e8ee769c4 100644 --- a/docs/guide/zh-CN/knowledge.md +++ b/docs/guide/zh-CN/knowledge.md @@ -189,7 +189,7 @@ teamai recall maintenance --update-quality ### 晋升 Learnings -置信度至少 0.90、至少 5 次 upvote、至少 2 个贡献者、存在至少 14 天,可以升成 skill、rule 或 doc。 +置信度至少 0.90、至少 5 次 upvote、至少 2 个贡献者、存在至少 14 天,可以升成 skill、rule 或 doc。团队可以在团队仓库根目录的 `workflows/teamai-promote-learning/` 里放一个 `params.yaml` 修改这些值,只写要改的键,例如 `min_users: 1`;[这个工作流的 README](https://github.com/Tencent/teamai-cli/blob/main/workflows/teamai-promote-learning/README.zh-CN.md) 列出了这些键及其范围。还没升级 teamai 的成员仍用旧值,所以请先让每位成员升级。 晋升后的 skill 位于 `skills//SKILL.md`,缺少的 `name` 和 `description` 前置信息会自动补齐,便于工具识别。rule 和 doc 仍是各自目录下的 Markdown 文件。 diff --git a/docs/guide/zh-CN/workflow.md b/docs/guide/zh-CN/workflow.md index 347bab1e41..bba8fd7681 100644 --- a/docs/guide/zh-CN/workflow.md +++ b/docs/guide/zh-CN/workflow.md @@ -1132,7 +1132,7 @@ teamai 自带一些工作流,团队不用自己编写就能使用: | `teamai-try-scorer` | 通过 `store: false` 的 `teamai-score`,用评分器文件评判一个会话和只含其第一条提示的会话,不保存任何标签。由 [`teamai score --scorer `](scorers.md#试用评分器文件) 启动。私有 | 从不:`invoke: deny` | | `teamai-new-scorer` | 与用户一起编写评分器,用 `teamai-try-scorer` 试用,再从[团队工作区](#团队工作区)把 `scorers/.md` 作为拉取请求提交给团队。输入:`session`、`source` | 始终:`invoke: ask`,agent 在用户同意后启动它 | | `teamai-draft-learning-updates` | 为团队经常召回却很少采用的每个 doc、rule 和 skill 起草更新,写成它旁边的 `.draft.md`,由 [`teamai recall maintenance --update-quality`](knowledge.md#知识库维护) 启动。输入:`knowledge_root`,存放这些条目的 checkout,草稿写在其中。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | -| `teamai-promote-learning` | 把一条经验晋升为 skill、rule 或 doc:AI 选择类别(`--category` 给出时除外)并改写经验,结果推送到评审分支,并附带说明类别和理由的拉取请求,由 [`teamai recall promote `](knowledge.md#知识库维护) 启动。输入:`learning`、`category`(`''` 表示由 AI 选择)。私有 | 始终:`invoke: ask`,agent 在用户同意后启动它 | +| `teamai-promote-learning` | 把一条经验晋升为 skill、rule 或 doc:AI 选择类别(`--category` 给出时除外)并改写经验,结果推送到评审分支,并附带说明类别和理由的拉取请求,由 [`teamai recall promote `](knowledge.md#知识库维护) 启动。输入:`learning`、`category`(`''` 表示由 AI 选择)。它的 `params.yaml` 决定接受哪些经验。私有 | 始终:`invoke: ask`,agent 在用户同意后启动它 | | `teamai-codebase-deep-enrich` | 为项目的组件写设计文档(一次五个),然后写架构总览和图谱文档;设了 `publish` 时,把它们提交并推送到存放 `wiki_root` 的团队仓库,由 [`teamai codebase --deep-enrich`](knowledge.md#代码知识图谱) 启动。输入:`wiki_root`、`project`、`max_modules`(`0` 表示全部)、`concurrency`、`publish`(默认 `false`)。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | | `teamai-codebase-enrich` | 询问刚提取的代码库每个模块做什么、整体属于哪个业务域,并在它的 evidence 旁写入 `_manifest.json` 和 `_domains.json`。输入:`wiki_root`、`project`。私有 | 从不:`invoke: deny` | | `teamai-import-from-repo` | 把仓库或本地 checkout 导入 teamwiki:逐个提取,对它运行 `teamai-codebase-enrich` 和 `teamai-codebase-deep-enrich`,发布它,最后只重建并发布一次索引,由 [`teamai import --dir`、`--from-repo`、`--from-repo-list` 和 `--from-org`](knowledge.md#代码知识图谱) 启动。输入:`wiki_root`、`mode`、`items`、`settings`、`skipped`、`concurrency`。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | @@ -1355,7 +1355,7 @@ describe 为调用它的会话提议每个输入并附 `why`:会话第一次 cp "$(npm root -g)/teamai-cli/workflows/teamai-share-learnings.yaml" workflows/teamai-share-learnings.yaml ``` -有三个默认工作流是文件夹,在包中位于 `workflows//WORKFLOW.yaml`,早期版本是 `workflows/.yaml`:`teamai-import-from-mr`、`teamai-codebase-deep-enrich` 和 `teamai-import-from-repo`。团队已有的 `workflows/.yaml` 仍会替换默认工作流。 +有四个默认工作流是文件夹,在包中位于 `workflows//WORKFLOW.yaml`,早期版本是 `workflows/.yaml`:`teamai-import-from-mr`、`teamai-codebase-deep-enrich`、`teamai-import-from-repo` 和 `teamai-promote-learning`。团队已有的 `workflows/.yaml` 仍会替换默认工作流。 文件夹形式的默认工作流,用同名文件夹修改。里面只放你要改的文件;其他文件(包括 `WORKFLOW.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。比如想让英文的 MR learning 写出评审留下的未决问题,先复制它的提示词: diff --git a/src/__tests__/dashboard-improvement.test.ts b/src/__tests__/dashboard-improvement.test.ts index fe01283a8b..6e7b479c47 100644 --- a/src/__tests__/dashboard-improvement.test.ts +++ b/src/__tests__/dashboard-improvement.test.ts @@ -56,13 +56,6 @@ describe('the Improvement Center payload', () => { expect(data.daily.find((d) => d.date === '2026-10-20')).toMatchObject({ ended: 3, prompts: 6, costMicros: 500_000, members: 2 }); }); - it('carries a promotion-gates error into the maintenance payload, so the dashboard can say why the list is empty', async () => { - const data = await buildImprovement(paths(), { ...noMaintenance, promoteError: 'workflows/teamai-promote-learning.yaml: defaults.inputs.min_confidence must be a number from 0 to 1' }, 0); - - expect(data.maintenance.promote).toEqual([]); - expect(data.maintenance.promoteError).toContain('defaults.inputs.min_confidence'); - }); - it('adds up friction across members without naming anyone', async () => { writeStats('ana', { interventions: { sessions: 10, interrupt: 2, toolReject: 1, correction: 3 } }); writeStats('bo', { interventions: { sessions: 5, interrupt: 1, toolReject: 0, correction: 0 } }); diff --git a/src/__tests__/dashboard-ui.test.ts b/src/__tests__/dashboard-ui.test.ts index 44770df03e..4d92a7097d 100644 --- a/src/__tests__/dashboard-ui.test.ts +++ b/src/__tests__/dashboard-ui.test.ts @@ -101,21 +101,6 @@ describe('unified dashboard', () => { expect(sections.context).toContain('<img'); }); - it('shows a promotion-gates error instead of an empty list, escaped, with its translation registered', () => { - const data: VizData = { - generatedAt: new Date().toISOString(), root: '/tmp/fixture', source: { scope: 'team', label: 'Team' }, - totalEntries: 0, totalRecalls: 0, overallCoveragePct: 0, contributorCount: 0, - coverage: [], topRecalled: [], silent: [], relations: { resourceCount: 0, relationCount: 0, withoutRelation: [], unresolved: [], silentAndWithoutRelation: [] }, - trend: [], authors: [], - maintenance: { promote: [], prune: [], stale: [], promoteError: 'workflows/teamai-promote-learning.yaml: defaults.inputs.min_confidence must be a number from 0 to 1 not html' }, - }; - const { maintenance } = renderDashboardReport(data); - expect(maintenance).toContain('Promotion gates could not be read'); - expect(maintenance).toContain('defaults.inputs.min_confidence'); - expect(maintenance).not.toContain('not html'); - expect(Object.hasOwn(dashboardMessages, 'Promotion gates could not be read')).toBe(true); - }); - it('adds a Scorers page whose every label has a zh-CN translation (#1182)', () => { const html = getDashboardHtml(3721); expect(html).toContain("scorers: 'Scorers'"); diff --git a/src/__tests__/default-folders.test.ts b/src/__tests__/default-folders.test.ts index f9d3688bbc..6b2ea1330e 100644 --- a/src/__tests__/default-folders.test.ts +++ b/src/__tests__/default-folders.test.ts @@ -11,7 +11,9 @@ vi.mock('../utils/logger.js', async (importOriginal) => ({ import { DefaultFolders } from '../default-folders.js'; import { buildHandlerRegistry } from '../hook-handlers.js'; import { deliverHookFolders, resolveTeamHooks } from '../resources/hooks.js'; +import { syncScorersThenWorkflows } from '../scorers/team-sync.js'; import { TeamaiConfigSchema, type LocalConfig } from '../types.js'; +import { resolveStart } from '../workflow/cache.js'; describe('a default hook text', () => { it('fills its variables in one pass, so a value that reads like a variable is printed as written', async () => { @@ -71,3 +73,69 @@ describe('a team replacement of a default hook', () => { expect(context).toBe('[acme] Plan made: read the team runbook before you start.'); }); }); + +describe('a team\'s params.yaml for a default workflow', () => { + let root: string; + + beforeEach(async () => { + root = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai default workflow params ')); + }); + + afterEach(async () => { + await fse.remove(root); + }); + + function member(): LocalConfig { + return { + repo: { localPath: path.join(root, 'knowledge'), remote: 'local', kind: 'self' }, + username: 'tester', + scope: 'project', + additionalRoles: [], + projectRoot: root, + dataHome: path.join(root, 'data'), + }; + } + + const PROMOTE = { kind: 'workflows', name: 'teamai-promote-learning' } as const; + const TEAMAI = { min_confidence: 0.9, min_upvoted: 5, min_users: 2, min_age_days: 14 }; + + async function commit(params: string): Promise { + await fse.outputFile(path.join(root, 'knowledge', 'workflows', 'teamai-promote-learning', 'params.yaml'), params); + } + + it('sets the keys it holds over teamai\'s, and the workflow keeps teamai\'s steps', async () => { + const config = member(); + await commit('min_users: 1\n'); + + await syncScorersThenWorkflows(config); + + expect([(await DefaultFolders.load(PROMOTE, config)).params, (await resolveStart(config, PROMOTE.name)).workflow?.definition.steps[0]?.id]) + .toEqual([{ ...TEAMAI, min_users: 1 }, 'learning']); + }); + + it('is refused by pull with the file and the field named, and the version pull last took stays in force', async () => { + const config = member(); + await commit('min_users: 1\n'); + await syncScorersThenWorkflows(config); + await commit('min_confidence: 2\n'); + + const { invalid } = await syncScorersThenWorkflows(config); + + expect([invalid.map(({ message }) => message), (await DefaultFolders.load(PROMOTE, config)).params]).toEqual([ + ['workflows/teamai-promote-learning/params.yaml: min_confidence must be a number from 0 to 1'], + { ...TEAMAI, min_users: 1 }, + ]); + }); + + it('leaves teamai\'s values in force when pull never took a version', async () => { + const config = member(); + await commit('min_votes: 3\n'); + + const { invalid } = await syncScorersThenWorkflows(config); + + expect([invalid.map(({ message }) => message), (await DefaultFolders.load(PROMOTE, config)).params]).toEqual([ + ['workflows/teamai-promote-learning/params.yaml: min_votes is not a parameter of teamai-promote-learning'], + TEAMAI, + ]); + }); +}); diff --git a/src/__tests__/maintenance-promote.test.ts b/src/__tests__/maintenance-promote.test.ts index ee030deba7..7e0291f03c 100644 --- a/src/__tests__/maintenance-promote.test.ts +++ b/src/__tests__/maintenance-promote.test.ts @@ -1,12 +1,13 @@ // -*- coding: utf-8 -*- -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import YAML from 'yaml'; import matter from 'gray-matter'; -import { chooseCategory, findPromotionCandidates, markPromoted, writePromotion } from '../maintenance/promote.js'; +import { DefaultFolders } from '../default-folders.js'; +import { chooseCategory, findPromotionCandidates, markPromoted, writePromotion, type PromotionThresholds } from '../maintenance/promote.js'; import type { UserVotesV2 } from '../types.js'; let tmpDir: string; @@ -43,6 +44,12 @@ function makeHighVotes(votesDir: string, docId: string): void { } describe('findPromotionCandidates', () => { + let thresholds: PromotionThresholds; + + beforeAll(async () => { + ({ params: thresholds } = await DefaultFolders.load({ kind: 'workflows', name: 'teamai-promote-learning' }, null)); + }); + it.each([ ['unquoted recent date', '2026-11-06', false], ['quoted recent date', '"2026-11-06"', false], @@ -61,7 +68,7 @@ describe('findPromotionCandidates', () => { fs.writeFileSync(path.join(learningsDir, 'dated.md'), `---\ntitle: dated\ndate: ${date}\n---\nLearning content.\n`); makeHighVotes(votesDir, 'dated'); - const candidates = await findPromotionCandidates([learningsDir], votesDir); + const candidates = await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds }); expect(candidates.map((candidate) => candidate.docId)).toEqual(eligible ? ['dated'] : []); }); @@ -81,7 +88,7 @@ describe('findPromotionCandidates', () => { }; fs.writeFileSync(path.join(votesDir, 'jeff.yaml'), YAML.stringify(data)); - const candidates = await findPromotionCandidates([learningsDir], votesDir, undefined); + const candidates = await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds }); expect(candidates).toHaveLength(0); }); @@ -94,7 +101,7 @@ describe('findPromotionCandidates', () => { makeLearning(learningsDir, 'great-doc'); makeHighVotes(votesDir, 'great-doc'); - const candidates = await findPromotionCandidates([learningsDir], votesDir, undefined); + const candidates = await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds }); expect(candidates.length).toBeGreaterThan(0); expect(candidates[0].docId).toBe('great-doc'); expect(candidates[0].suggestedCategory).toBe('skills'); @@ -113,7 +120,7 @@ describe('findPromotionCandidates', () => { fs.writeFileSync(path.join(votesDir, `${user}.yaml`), `version: 2\ndeltas: {}\nvotes:\n half-adopted:\n recalled_count: 5\n upvoted_count: 2\n judge_upvoted_count: .nan\n judged_count: .nan\n last_recalled_at: ${recent}\n last_upvoted_at: ${recent}\n`); } - expect(await findPromotionCandidates([learningsDir], votesDir, undefined)).toEqual([]); + expect(await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds })).toEqual([]); }); it('suggests the category the learning\'s keywords point to', async () => { @@ -124,7 +131,7 @@ describe('findPromotionCandidates', () => { makeLearning(learningsDir, 'pin-versions', { body: 'Always pin dependency versions; never float them. A team convention.' }); makeHighVotes(votesDir, 'pin-versions'); - const candidates = await findPromotionCandidates([learningsDir], votesDir, undefined); + const candidates = await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds }); expect(candidates.map((c) => [c.docId, c.suggestedCategory])).toEqual([['pin-versions', 'rules']]); }); @@ -140,7 +147,7 @@ describe('findPromotionCandidates', () => { fs.writeFileSync(path.join(learningsDir, 'promoted.md'), content); makeHighVotes(votesDir, 'promoted'); - const candidates = await findPromotionCandidates([learningsDir], votesDir, undefined); + const candidates = await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds }); expect(candidates.find((c) => c.docId === 'promoted')).toBeUndefined(); }); @@ -155,10 +162,11 @@ describe('findPromotionCandidates', () => { makeLearning(learningsDir, 'too-new', { date: recentDate }); makeHighVotes(votesDir, 'too-new'); - const candidates = await findPromotionCandidates([learningsDir], votesDir, undefined); + const candidates = await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds }); expect(candidates.find((c) => c.docId === 'too-new')).toBeUndefined(); }); - it('applies the thresholds a team tunes, over the shipped gates', async () => { + + it('applies the thresholds a team tunes, over teamai\'s gates', async () => { const learningsDir = path.join(tmpDir, 'learnings'); const votesDir = path.join(tmpDir, 'votes'); fs.mkdirSync(learningsDir, { recursive: true }); @@ -171,16 +179,16 @@ describe('findPromotionCandidates', () => { makeLearning(learningsDir, 'settled'); makeHighVotes(votesDir, 'settled'); - // The shipped gates admit the settled learning and keep the young one out. - expect((await findPromotionCandidates([learningsDir], votesDir, undefined)).map((c) => c.docId)).toEqual(['settled']); + // teamai's gates admit the settled learning and keep the young one out. + expect((await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds })).map((c) => c.docId)).toEqual(['settled']); // A team's lower bar admits both. - const lower = { minConfidence: 0.9, minUpvoted: 5, minUsers: 2, minAgeDays: 2 }; - expect((await findPromotionCandidates([learningsDir], votesDir, undefined, { thresholds: lower })).map((c) => c.docId).sort()).toEqual(['settled', 'young-star']); + const lower = { ...thresholds, min_age_days: 2 }; + expect((await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds: lower })).map((c) => c.docId).sort()).toEqual(['settled', 'young-star']); - // A team's higher bar keeps out even the learning the defaults admit. - const higher = { minConfidence: 0.9, minUpvoted: 20, minUsers: 2, minAgeDays: 14 }; - expect(await findPromotionCandidates([learningsDir], votesDir, undefined, { thresholds: higher })).toEqual([]); + // A team's higher bar keeps out even the learning teamai's gates admit. + const higher = { ...thresholds, min_upvoted: 20 }; + expect(await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds: higher })).toEqual([]); }); it('finds namespaced learnings when active namespaces are provided', async () => { @@ -198,11 +206,11 @@ describe('findPromotionCandidates', () => { makeHighVotes(votesDir, path.join('k8s', 'helm-values')); // Without namespaces: only root-doc - const rootOnly = await findPromotionCandidates([learningsDir], votesDir); + const rootOnly = await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds }); expect(rootOnly.map((c) => c.docId)).toEqual(['root-doc']); // With namespaces: both root-doc and namespaced learning - const withNs = await findPromotionCandidates([learningsDir], votesDir, { namespaces: ['k8s'] }); + const withNs = await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, namespaces: ['k8s'], thresholds }); expect(withNs.map((c) => c.docId).sort()).toEqual([path.join('k8s', 'helm-values'), 'root-doc'].sort()); }); }); diff --git a/src/__tests__/maintenance-votes-readonly.test.ts b/src/__tests__/maintenance-votes-readonly.test.ts index 49491ab600..906b3730dd 100644 --- a/src/__tests__/maintenance-votes-readonly.test.ts +++ b/src/__tests__/maintenance-votes-readonly.test.ts @@ -88,7 +88,7 @@ describe('recall maintenance scans never rewrite a v1 votes file (#900 C7)', () writeV1Votes('bob', ['lesson']); const before = snapshot(votesDir); - await findPromotionCandidates([learningsDir], votesDir, undefined); + await findPromotionCandidates({ learningsDirs: [learningsDir], votesDir, thresholds: { min_confidence: 0.9, min_upvoted: 5, min_users: 2, min_age_days: 14 } }); expect(snapshot(votesDir)).toEqual(before); }); diff --git a/src/__tests__/promotion-thresholds.test.ts b/src/__tests__/promotion-thresholds.test.ts deleted file mode 100644 index 51160c48be..0000000000 --- a/src/__tests__/promotion-thresholds.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -// -*- coding: utf-8 -*- -import { afterEach, describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import { parsePromotionThresholds, resolvePromotionThresholds } from '../maintenance/promotion-thresholds.js'; -import { DEFAULT_PROMOTION_THRESHOLDS } from '../maintenance/promote.js'; -import { syncScorersThenWorkflows } from '../scorers/team-sync.js'; -import type { LocalConfig } from '../types.js'; - -const dirs: string[] = []; - -afterEach(() => { - for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); -}); - -function testConfig(): LocalConfig { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-promotion-thresholds-')); - dirs.push(root); - const knowledge = path.join(root, 'knowledge'); - fs.mkdirSync(path.join(knowledge, 'workflows'), { recursive: true }); - - return { - repo: { localPath: knowledge, remote: 'local', kind: 'self' }, - username: 'tester', - scope: 'project', - additionalRoles: [], - projectRoot: root, - dataHome: path.join(root, 'data'), - }; -} - -const replaced = (defaults: string) => `schemaVersion: v1alpha1 -name: teamai-promote-learning -description: Promote with the bar this team can reach. -inputs: { learning: string, category: string, min_confidence: number, min_upvoted: number, min_users: number, min_age_days: number } -defaults: - inputs: { ${defaults} } -steps: - - id: learning - command: teamai recall maintenance promote-candidate --learning "$ID" - env: { ID: "{{ inputs.learning }}" } - output: { title: string, content: string, suggested: string } -`; - -describe('parsePromotionThresholds', () => { - it('ships the defaults when the workflow names no gates', () => { - expect(parsePromotionThresholds(undefined, 'teamai-promote-learning.yaml')).toEqual(DEFAULT_PROMOTION_THRESHOLDS); - expect(parsePromotionThresholds({}, 'teamai-promote-learning.yaml')).toEqual(DEFAULT_PROMOTION_THRESHOLDS); - }); - - it('merges a partial override, each named gate on its own', () => { - expect(parsePromotionThresholds({ min_confidence: 0.7 }, 'wf.yaml')).toEqual({ ...DEFAULT_PROMOTION_THRESHOLDS, minConfidence: 0.7 }); - expect(parsePromotionThresholds({ min_age_days: 7, min_users: 3 }, 'wf.yaml')).toEqual({ ...DEFAULT_PROMOTION_THRESHOLDS, minAgeDays: 7, minUsers: 3 }); - }); - - it('accepts the boundary values', () => { - expect(parsePromotionThresholds({ min_confidence: 0, min_age_days: 0, min_upvoted: 1, min_users: 1 }, 'wf.yaml')) - .toEqual({ minConfidence: 0, minUpvoted: 1, minUsers: 1, minAgeDays: 0 }); - }); - - it.each([ - ['min_confidence', 1.5], ['min_confidence', 'high'], ['min_confidence', -0.1], - ['min_upvoted', 0], ['min_upvoted', 2.5], ['min_users', 0], ['min_age_days', -1], - ])('refuses %s = %j with the field and the file named', (key, value) => { - expect(() => parsePromotionThresholds({ [key]: value }, 'workflows/teamai-promote-learning.yaml')) - .toThrow(`workflows/teamai-promote-learning.yaml: defaults.inputs.${key} must be`); - }); -}); - -describe('resolvePromotionThresholds', () => { - it('resolves the shipped defaults while the team replaces no workflow', async () => { - const config = testConfig(); - await syncScorersThenWorkflows(config); - - expect(await resolvePromotionThresholds(config)).toEqual(DEFAULT_PROMOTION_THRESHOLDS); - }); - - it('resolves the gates of the workflow file the team replaced it with', async () => { - const config = testConfig(); - fs.writeFileSync(path.join(config.repo.localPath, 'workflows', 'teamai-promote-learning.yaml'), replaced('min_confidence: 0.7, min_upvoted: 3, min_users: 2, min_age_days: 7')); - await syncScorersThenWorkflows(config); - - expect(await resolvePromotionThresholds(config)).toEqual({ minConfidence: 0.7, minUpvoted: 3, minUsers: 2, minAgeDays: 7 }); - }); - - it('refuses a replaced workflow whose gate is out of range', async () => { - const config = testConfig(); - fs.writeFileSync(path.join(config.repo.localPath, 'workflows', 'teamai-promote-learning.yaml'), replaced('min_confidence: 2')); - await syncScorersThenWorkflows(config); - - await expect(resolvePromotionThresholds(config)).rejects.toThrow('defaults.inputs.min_confidence must be'); - }); -}); diff --git a/src/__tests__/review-branch.test.ts b/src/__tests__/review-branch.test.ts index ee27dd2a3b..aa39ecbd8d 100644 --- a/src/__tests__/review-branch.test.ts +++ b/src/__tests__/review-branch.test.ts @@ -671,6 +671,18 @@ describe('reviewBranchDir — read-only health checks for a pushed branch (#776) }]]); }); + it('fails a default workflow\'s params.yaml that pull refuses, with what pull prints', async () => { + await fse.outputFile(path.join(repoDir, 'workflows', 'teamai-promote-learning', 'params.yaml'), 'min_confidence: 2\n'); + + const review = await reviewBranchDir(BRANCH, repoDir); + + expect([review.ok, findings(review).filter((f) => f.level === 'fail').map(({ check, path: file, message }) => ({ check, file, message }))]).toEqual([false, [{ + check: 'workflows', + file: 'workflows/teamai-promote-learning/params.yaml', + message: 'workflows/teamai-promote-learning/params.yaml:1: field "(root)": workflows/teamai-promote-learning/params.yaml: min_confidence must be a number from 0 to 1', + }]]); + }); + const stray = (file: string): string => `${file} replaces no file of teamai's default, so it is not used and teamai's files stay in force. ` + 'Name it after the file it replaces, or replace the folder\'s marker too, so it can name the file.'; diff --git a/src/__tests__/viz.test.ts b/src/__tests__/viz.test.ts index d3d0d2a8f3..8a7ca2ea95 100644 --- a/src/__tests__/viz.test.ts +++ b/src/__tests__/viz.test.ts @@ -357,6 +357,19 @@ describe('buildVizData with explicit --repo', () => { ]; expect(allTitles.some((t) => t.includes('TEAM ONLY skill'))).toBe(true); }); + + it('lists the promotion candidates the repo\'s own params.yaml for teamai-promote-learning admits', async () => { + tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-viz-repo-')); + const put = (rel: string, content: string): Promise => fs.mkdir(path.dirname(path.join(tmpRepo, rel)), { recursive: true }).then(() => fs.writeFile(path.join(tmpRepo, rel), content, 'utf-8')); + await put('learnings/retry-deploys.md', '---\ntitle: Retry deploys\n---\nRetry a failed deploy once.\n'); + await put('votes/alice.yaml', votesYaml({ 'retry-deploys': { recalled_count: 1, upvoted_count: 1, last_recalled_at: new Date().toISOString() } })); + const listed = async (): Promise => (await buildVizData(await resolveVizRoot({ repo: tmpRepo }))).maintenance.promote.map((candidate) => candidate.docId); + + const teamais = await listed(); + await put('workflows/teamai-promote-learning/params.yaml', 'min_confidence: 0\nmin_upvoted: 1\nmin_users: 1\nmin_age_days: 0\n'); + + expect([teamais, await listed()]).toEqual([[], ['retry-deploys']]); + }); }); // ─── Relation counts in the KB health view (#1562 part 2) ───────────────────── diff --git a/src/dashboard/improvement-client.ts b/src/dashboard/improvement-client.ts index 78e65d23c8..7b99d98891 100644 --- a/src/dashboard/improvement-client.ts +++ b/src/dashboard/improvement-client.ts @@ -105,7 +105,7 @@ function improvement(){ const lists={ prune:['teamai recall maintenance --prune --archive',mtTable(['Learning','Confidence','Last activity','Reason','Adoption'],m.prune.map(c=>[e(c.name),Math.round(c.confidence*100)+'%',e((c.lastActivity||'').slice(0,10)||'—'),e(c.reason),adEvidence(c.name.replace(/[.]md$/i,''))]))], stale:['teamai recall maintenance --update-quality',mtTable(['Entry','Type','Recalls','Upvotes','Members','Adoption'],m.stale.map(c=>[e(c.docId),e(c.type),number(c.recalled),number(c.upvoted),number(c.users),adEvidence(c.docId)]))], - promote:['teamai recall promote ',m.promoteError?'

'+label('Promotion gates could not be read:')+' '+e(m.promoteError)+'

':mtTable(['Learning','Category','Confidence','Upvotes','Adoption','Command'],m.promote.map(c=>[e(c.title),e(c.category),Math.round(c.confidence*100)+'%',number(c.upvoted),adEvidence(c.docId),'']))], + promote:['teamai recall promote ',mtTable(['Learning','Category','Confidence','Upvotes','Adoption','Command'],m.promote.map(c=>[e(c.title),e(c.category),Math.round(c.confidence*100)+'%',number(c.upvoted),adEvidence(c.docId),'']))], }; const sel=lists[tab]?tab:'prune', count=tabs.find(x=>x[0]===sel)[2]; const maintHtml='

'+label('Knowledge maintenance')+' '+number(m.prune.length+m.stale.length+m.promote.length)+'

'+label('Candidates, not actions taken')+'
' diff --git a/src/dashboard/improvement.ts b/src/dashboard/improvement.ts index 4240751a38..4086eb5d0f 100644 --- a/src/dashboard/improvement.ts +++ b/src/dashboard/improvement.ts @@ -40,7 +40,6 @@ export interface ImprovementData { prune: Array<{ name: string; confidence: number; lastActivity: string; reason: string }>; stale: Array<{ docId: string; type: string; recalled: number; upvoted: number; users: number }>; silent: number; - promoteError?: string; }; adoption: { /** Docs some session recalled or upvoted, most recalled sessions first. */ @@ -78,8 +77,6 @@ interface MaintenanceInput { promote: Array<{ docId: string; title: string; suggestedCategory: string; confidence: number; upvotedCount: number; userCount: number }>; prune: Array<{ filename: string; confidence: number; lastActivity: string; reason: string }>; stale: Array<{ docId: string; type: string; recalledCount: number; upvotedCount: number; userCount: number }>; - /** Why the promote list is empty, when that is a failure to read the gates and not "nothing eligible yet". */ - promoteError?: string; } async function docAdoption(votesDir: string): Promise { @@ -245,7 +242,6 @@ export async function buildImprovement( prune: maintenance.prune.slice(0, LIST_LIMIT).map((c) => ({ name: c.filename, confidence: c.confidence, lastActivity: c.lastActivity, reason: c.reason })), stale: maintenance.stale.slice(0, LIST_LIMIT).map((c) => ({ docId: c.docId, type: c.type, recalled: c.recalledCount, upvoted: c.upvotedCount, users: c.userCount })), silent, - promoteError: maintenance.promoteError, }, adoption: { docs: await docAdoption(paths.votesDir), overriddenScorers: await overriddenScorers(paths.scope) }, }; diff --git a/src/dashboard/locales.ts b/src/dashboard/locales.ts index ead39fa304..6b043455d9 100644 --- a/src/dashboard/locales.ts +++ b/src/dashboard/locales.ts @@ -307,8 +307,6 @@ export const dashboardMessages = { "Suggested for Archive": "建议归档", "Stale (Needs Quality Update)": "过期知识(需要质量更新)", "None right now.": "目前没有候选。", - "Promotion gates could not be read": "无法读取晋升门槛。", - "Promotion gates could not be read:": "无法读取晋升门槛:", "Learnings with high confidence and adoption across multiple users — good candidates to promote to formal skills, rules, or docs.": "置信度较高且被多位用户采用的经验,可考虑晋升为正式技能、规则或文档。", "How to promote": "如何晋升", "— Review a candidate below, then run (each entry shows its own ready-to-copy command):": "— 审查以下候选后运行对应命令:", diff --git a/src/default-folders.ts b/src/default-folders.ts index 718914a0b1..0faa5db316 100644 --- a/src/default-folders.ts +++ b/src/default-folders.ts @@ -27,6 +27,13 @@ function whole(min: number, max: number) { return z.number({ error }).int({ error }).min(min, { error }).max(max, { error }); } +/** A number from 0 to 1. */ +function ratio() { + const error = 'must be a number from 0 to 1'; + + return z.number({ error }).min(0, { error }).max(1, { error }); +} + /** The tool-error tiers, the first whose `min` the count reaches giving its points. */ const ToolErrorTiersSchema = z.array(z.strictObject({ min: whole(1, 1000), points: whole(0, 100) }), { error: 'must be a list of { min, points } tiers' }) .min(1, { error: 'must list from 1 to 5 tiers' }) @@ -58,6 +65,9 @@ const PARAMS = { rules: { 'teamai-recall': NoParamsSchema, }, + workflows: { + 'teamai-promote-learning': z.strictObject({ min_confidence: ratio(), min_upvoted: whole(1, 1000), min_users: whole(1, 1000), min_age_days: whole(0, 3650) }), + }, } as const; /** What a text of a default folder offers and keeps: the variables it may read, and the lines every language of it must hold. */ @@ -88,6 +98,9 @@ const TEXTS = { // Recall's own output and doctor point at these lines. 'teamai-recall': { RULE: { required: ['Nothing was searched:', 'Recall skips the older index'] }, block: {}, 'block-direct': { required: ['Nothing was searched:'] } }, }, + workflows: { + 'teamai-promote-learning': {}, + }, } as const satisfies { readonly [TKind in keyof typeof PARAMS]: { readonly [TName in keyof (typeof PARAMS)[TKind]]: Readonly> } }; /** A kind of resource some of whose teamai defaults code reads, by the folder's files. */ @@ -231,6 +244,9 @@ function markerProblems(kind: DefaultFolderKind, byPath: ReadonlyMap>(ref: DefaultFolderRef<'hooks', TName>, scope: LocalConfig | null): Promise> { - const teamai = await shipped(ref); +async function teamFiles(ref: { readonly kind: LoadedKind; readonly name: string }, scope: LocalConfig): Promise<{ path: string; files: FolderFile[] } | null> { + switch (ref.kind) { + case 'hooks': { + const dir = DeliveredFolders.pathOf(scope, { type: 'hooks', namespace: null, name: ref.name }); + const delivered = await FolderFiles.read(dir); + + return delivered.kind === 'files' ? { path: dir, files: [...delivered.files] } : null; + } - if (scope) { - const dir = DeliveredFolders.pathOf(scope, { type: 'hooks', namespace: null, name: ref.name }); - const delivered = await FolderFiles.read(dir); + case 'workflows': { + const { resolveStart } = await import('./workflow/cache.js'); + const { workflow } = await resolveStart(scope, ref.name); + + // A team's single file replaces the folder whole, so teamai's parameters stay. + if (workflow?.source !== 'team' || workflow.files === undefined) return null; + + return { path: workflow.relativePath, files: workflow.files.map((file) => ({ path: file.path, executable: file.executable, bytes: Buffer.from(file.content, 'utf8') })) }; + } - if (delivered.kind === 'files') { - const folder = readFolder(ref, delivered.files, teamai.params); + default: { + const unhandled: never = ref.kind; - if (folder.ok) return folder.folder; - log.debug(`default hook ${ref.name}: the team's folder at ${dir} is not used, teamai's is: ${folder.problems.join('; ')}`); + return unhandled; } } +} + +/** + * Default `ref` as `scope` reads it: the team's folder that changes it, else teamai's. With no scope (teamai is not + * set up where the code runs), teamai's folder. Pull refuses a team folder that breaks the contract, so one read here + * keeps it unless it changed after pull; then teamai's is read. + */ +async function load>(ref: DefaultFolderRef, scope: LocalConfig | null): Promise> { + return chooseInForce(ref, scope ? await teamFiles(ref, scope) : null); +} + +/** Default `ref` as the team repo at `teamRoot` holds it, for a reader of a repo no pull took, such as `teamai dashboard --repo`. */ +async function loadAt>(ref: DefaultFolderRef, teamRoot: string): Promise> { + const dir = path.join(teamRoot, ref.kind, ref.name); + const team = await FolderFiles.read(dir); + + return chooseInForce(ref, team.kind === 'files' ? { path: dir, files: [...team.files] } : null); +} + +async function chooseInForce>(ref: DefaultFolderRef, team: { path: string; files: FolderFile[] } | null): Promise> { + const teamai = await shipped(ref); + + if (team === null) return teamai.folder; + const folder = readFolder(ref, team.files, teamai.params); + + if (folder.ok) return folder.folder; + log.debug(`default ${ref.kind} ${ref.name}: the team's folder at ${team.path} is not used, teamai's is: ${folder.problems.join('; ')}`); return teamai.folder; } @@ -439,4 +497,4 @@ async function compare; - -export const DEFAULT_PROMOTION_THRESHOLDS: PromotionThresholds = { minConfidence: 0.90, minUpvoted: 5, minUsers: 2, minAgeDays: 14 }; +/** The gates a learning passes to be a candidate, from the `params.yaml` of `teamai-promote-learning`. */ +export type PromotionThresholds = DefaultFolderParams<'workflows', 'teamai-promote-learning'>; /** - * Find learnings eligible for promotion to formal knowledge. A learning marked - * `promoted_to` stays out only while that target is in place in + * Where to look for learnings eligible for promotion to formal knowledge. A + * learning marked `promoted_to` stays out only while that target is in place in * `knowledgeCheckout`, the git checkout of the team's knowledge: the default * branch or an open review branch touched it. One whose review was closed * unmerged and its branch deleted is offered again. Without a checkout, the * marker alone keeps it out. */ -export interface PromotionCandidatesOptions { +export interface PromotionCandidatesQuery { + learningsDirs: readonly string[]; + votesDir: string; + knowledgeCheckout?: string; namespaces?: readonly string[]; - /** The gates to apply; the shipped defaults when absent, so unit callers and `--repo` views stay unchanged. */ - thresholds?: PromotionThresholds; + thresholds: PromotionThresholds; } -export async function findPromotionCandidates( - learningsDirs: readonly string[], - votesDir: string, - knowledgeCheckout?: string | PromotionCandidatesOptions, - options?: PromotionCandidatesOptions, -): Promise { - const checkout = knowledgeCheckout instanceof Object ? undefined : knowledgeCheckout; - - const opts = knowledgeCheckout instanceof Object ? knowledgeCheckout : options; - - const namespaces = opts?.namespaces; - const { minConfidence, minUpvoted, minUsers, minAgeDays } = opts?.thresholds ?? DEFAULT_PROMOTION_THRESHOLDS; +export async function findPromotionCandidates({ learningsDirs, votesDir, knowledgeCheckout: checkout, namespaces, thresholds }: PromotionCandidatesQuery): Promise { const confidenceMap = await computeAllConfidence(votesDir); const eligible: Array & { content: string; promotedTo?: string }> = []; @@ -77,15 +59,15 @@ export async function findPromotionCandidates( const docId = file.replace(/\.md$/i, ''); const confidence = confidenceMap.get(docId) ?? 0; - if (confidence < minConfidence) continue; + if (confidence < thresholds.min_confidence) continue; const docVotes = perDoc.get(docId); if (!docVotes) continue; - if (docVotes.upvoted < minUpvoted) continue; + if (docVotes.upvoted < thresholds.min_upvoted) continue; - if (docVotes.users.size < minUsers) continue; + if (docVotes.users.size < thresholds.min_users) continue; const content = await readFileSafe(absPath); @@ -107,7 +89,7 @@ export async function findPromotionCandidates( if (date) { const ageInDays = (now - new Date(date).getTime()) / (1000 * 60 * 60 * 24); - if (ageInDays < minAgeDays) continue; + if (ageInDays < thresholds.min_age_days) continue; } eligible.push({ diff --git a/src/maintenance/promotion-publish.ts b/src/maintenance/promotion-publish.ts index fb46cb2b78..d6c5e07272 100644 --- a/src/maintenance/promotion-publish.ts +++ b/src/maintenance/promotion-publish.ts @@ -13,8 +13,8 @@ import { parseJson } from '../utils/json.js'; import { createPrWithFallback } from '../push.js'; import { getProvider } from '../providers/index.js'; import { log } from '../utils/logger.js'; +import { DefaultFolders } from '../default-folders.js'; import { resolveMaintenancePaths } from './paths.js'; -import { resolvePromotionThresholds } from './promotion-thresholds.js'; import { fetchPromotionRefs, findPromotionCandidates, markPromoted, writePromotion, type PromotionCandidate, type PromotionCategory } from './promote.js'; /** What `publish` writes: the learning rewritten for its category, or the learning as written when the AI could not rewrite it. */ @@ -69,9 +69,8 @@ async function publish( return withSyncLock(localConfig, async () => { const { repoPath: knowledgeCheckout, votesDir, learningsReadDirs } = await resolveMaintenancePaths(localConfig, { holdsSyncLock: true }); await fetchPromotionRefs(knowledgeCheckout); - // The gates the listing used, so a learning it offered under a team's tuned thresholds is found here too. - const thresholds = await resolvePromotionThresholds(localConfig); - const candidate = promotionCandidate(await findPromotionCandidates(learningsReadDirs, votesDir, knowledgeCheckout, { namespaces: options.namespaces, thresholds }), learningId); + const { params: thresholds } = await DefaultFolders.load({ kind: 'workflows', name: 'teamai-promote-learning' }, localConfig); + const candidate = promotionCandidate(await findPromotionCandidates({ learningsDirs: learningsReadDirs, votesDir, knowledgeCheckout, namespaces: options.namespaces, thresholds }), learningId); const message = `[teamai] Promote ${candidate.docId} to ${options.category}`; const description = `Category: ${options.category}\n\n${options.reason}`; diff --git a/src/maintenance/promotion-thresholds.ts b/src/maintenance/promotion-thresholds.ts deleted file mode 100644 index b4cde8ba07..0000000000 --- a/src/maintenance/promotion-thresholds.ts +++ /dev/null @@ -1,51 +0,0 @@ -// -*- coding: utf-8 -*- -import { z } from 'zod'; - -import type { JsonObject, LocalConfig } from '../types.js'; -import { resolveStart } from '../workflow/cache.js'; -import { DEFAULT_PROMOTION_THRESHOLDS, PromotionThresholdsSchema, type PromotionThresholds } from './promote.js'; - -/** The workflow that owns the promotion gates (#1829); a team tunes them by replacing this file, keeping the defaults it does not change. */ -export const PROMOTION_WORKFLOW = 'teamai-promote-learning'; - -/** - * The four gates out of a workflow's `defaults.inputs`, each absent key keeping its shipped default. A present key - * that does not fit refuses the run, rather than falling back silently: a tuned gate that never took effect would - * list what the team did not mean to promote. - */ -export function parsePromotionThresholds(inputs: JsonObject | undefined, source: string): PromotionThresholds { - const thresholds: PromotionThresholds = { ...DEFAULT_PROMOTION_THRESHOLDS }; - - if (inputs === undefined) return thresholds; - - const read = (name: K, yamlKey: string, field: z.ZodType, range: string): void => { - const value = inputs[yamlKey]; - - if (value === undefined) return; - - const parsed = field.safeParse(value); - - if (!parsed.success) throw new Error(`${source}: defaults.inputs.${yamlKey} must be ${range}`); - thresholds[name] = parsed.data; - }; - - const { minConfidence, minUpvoted, minUsers, minAgeDays } = PromotionThresholdsSchema.shape; - - read('minConfidence', 'min_confidence', minConfidence, 'a number from 0 to 1'); - read('minUpvoted', 'min_upvoted', minUpvoted, 'a whole number of at least 1'); - read('minUsers', 'min_users', minUsers, 'a whole number of at least 1'); - read('minAgeDays', 'min_age_days', minAgeDays, 'a whole number of at least 0'); - - return thresholds; -} - -/** - * The gates this workspace's `teamai-promote-learning` resolves to: the team's replaced file's `defaults.inputs` - * over the shipped defaults, exactly as a `recall promote ` run would resolve the workflow. The listing and - * the promote step read this one resolution, so a listing never offers what the step then rejects. - */ -export async function resolvePromotionThresholds(localConfig: LocalConfig): Promise { - const { workflow } = await resolveStart(localConfig, PROMOTION_WORKFLOW); - - return parsePromotionThresholds(workflow?.definition.defaults?.inputs, workflow?.relativePath ?? PROMOTION_WORKFLOW); -} diff --git a/src/viz-render.ts b/src/viz-render.ts index 9eb383d990..dc237401eb 100644 --- a/src/viz-render.ts +++ b/src/viz-render.ts @@ -358,7 +358,7 @@ function renderAuthorsSection(authors: AuthorStat[]): string { } /** Render the promotable learnings maintenance block as an HTML string. */ -function renderPromotionBlock(promote: PromotionCandidate[], promoteError?: string): string { +function renderPromotionBlock(promote: PromotionCandidate[]): string { const intro = '

Learnings with high confidence and adoption across ' + 'multiple users — good candidates to promote to formal skills, rules, or docs.

'; @@ -369,11 +369,7 @@ function renderPromotionBlock(promote: PromotionCandidate[], promoteError?: stri 'teamai recall promote <learning-id>'; if (promote.length === 0) { - const empty = promoteError - ? `

Promotion gates could not be read

${escapeHtml(promoteError)}

` - : '

None right now.

'; - - return `

Promotable Learnings

${intro}${guide}${empty}
`; + return `

Promotable Learnings

${intro}${guide}

None right now.

`; } const rows = promote @@ -467,7 +463,7 @@ function renderStaleBlock(stale: StaleEntry[]): string { /** Render the full maintenance console section combining promote, prune, and stale blocks. */ function renderMaintenanceSection(data: VizData): string { - const promote = renderPromotionBlock(data.maintenance.promote, data.maintenance.promoteError); + const promote = renderPromotionBlock(data.maintenance.promote); const prune = renderPruneBlock(data.maintenance.prune); const stale = renderStaleBlock(data.maintenance.stale); diff --git a/src/viz.ts b/src/viz.ts index c8c4d09d74..1116d11167 100644 --- a/src/viz.ts +++ b/src/viz.ts @@ -25,8 +25,8 @@ import { getProjectSearchIndexPath, } from './types.js'; import type { LocalConfig, SearchIndexEntry } from './types.js'; -import { findPromotionCandidates, DEFAULT_PROMOTION_THRESHOLDS, type PromotionCandidate } from './maintenance/promote.js'; -import { resolvePromotionThresholds } from './maintenance/promotion-thresholds.js'; +import { DefaultFolders } from './default-folders.js'; +import { findPromotionCandidates, type PromotionCandidate } from './maintenance/promote.js'; import { findPruneCandidates, type PruneCandidate } from './maintenance/prune.js'; import { findStaleEntries, type StaleEntry } from './maintenance/quality-update.js'; import { renderReport } from './viz-render.js'; @@ -84,8 +84,6 @@ export interface MaintenanceCandidates { promote: PromotionCandidate[]; prune: PruneCandidate[]; stale: StaleEntry[]; - /** Why the promote list is empty, when that is a failure to read the gates and not "nothing eligible yet". */ - promoteError?: string; } /** Where the report's data came from, shown to the reader. */ @@ -589,15 +587,12 @@ export async function buildVizData(paths: VizPaths): Promise { ); const namespaces = paths.learningsNamespaces.ok ? paths.learningsNamespaces.namespaces : undefined; - - // A gate the team set out of range is surfaced in the report, not presented as "nothing eligible" (#1849 review). - let promoteError: string | undefined; + const promotion = { kind: 'workflows', name: 'teamai-promote-learning' } as const; const [promote, prune, stale] = await Promise.all([ - (paths.scope ? resolvePromotionThresholds(paths.scope) : Promise.resolve(DEFAULT_PROMOTION_THRESHOLDS)) - .then((thresholds) => findPromotionCandidates(paths.learningsDirs, paths.votesDir, paths.promotionCheckout, { namespaces, thresholds })) + (paths.scope ? DefaultFolders.load(promotion, paths.scope) : DefaultFolders.loadAt(promotion, paths.knowledgeRoot)) + .then(({ params: thresholds }) => findPromotionCandidates({ learningsDirs: paths.learningsDirs, votesDir: paths.votesDir, knowledgeCheckout: paths.promotionCheckout, namespaces, thresholds })) .catch((err: Error): PromotionCandidate[] => { - promoteError = err.message; console.warn(`Warning: could not load promotion candidates: ${err.message}`); return []; @@ -632,7 +627,7 @@ export async function buildVizData(paths: VizPaths): Promise { relations, trend, authors, - maintenance: { promote, prune, stale, promoteError }, + maintenance: { promote, prune, stale }, }; } diff --git a/src/workflow/cache.ts b/src/workflow/cache.ts index eab334ed9a..323c360086 100644 --- a/src/workflow/cache.ts +++ b/src/workflow/cache.ts @@ -22,6 +22,7 @@ import { LineCounter, parseDocument } from 'yaml'; import { formatDiagnostic, parseWorkflow, promptFileDiagnostics, resolveFolderFiles, stepField } from './parse.js'; import { locate, valueNode, type WorkflowFiles } from './parse-context.js'; import { stepWorkflow, type CachedWorkflow, type OutputSchema, type ResolvedWorkflow, type WorkflowDefinition, type WorkflowDiagnostic, type WorkflowStep } from './types.js'; +import { DefaultFolders } from '../default-folders.js'; import { DefaultWorkflows } from '../default-workflows.js'; import { WORKFLOW_MARKER, WorkflowFolder, type WorkflowFolderFile, type WorkflowFolderRead } from './folder.js'; import { InheritedFolders, type Inheritance } from '../resources/inherited-folders.js'; @@ -232,6 +233,17 @@ async function shippedWorkflows(): Promise> ))); } +/** + * Why the folder at `folderPath` breaks the contract of teamai's default folder `stem`, when it is one: each a + * diagnostic with no code, so the version pull last took keeps running. + */ +async function defaultFolderProblems(stem: string, folderPath: string, files: readonly WorkflowFolderFile[]): Promise { + if (!DefaultFolders.isName('workflows', stem)) return []; + const problems = await DefaultFolders.problems({ kind: 'workflows', name: stem }, files.map((file) => ({ path: file.path, executable: file.executable, bytes: Buffer.from(file.content, 'utf8') }))); + + return problems.map((problem) => ({ file: `${folderPath}params.yaml`, line: 1, column: 1, field: '(root)', message: `${folderPath}${problem}` })); +} + /** The cached team files in name order. */ async function listTeamWorkflows(config: LocalConfig): Promise { let files: string[]; @@ -431,7 +443,9 @@ export async function syncWorkflowDefinitions(config: LocalConfig, options: Sync if (folder) { content = folder.files.find((file) => file.path === WORKFLOW_MARKER)?.content ?? null; - const yaml = folder.files.flatMap((file) => (file.path !== WORKFLOW_MARKER && file.path.endsWith('.yaml') ? [file.path] : [])); + // A default's params.yaml is its parameters, not a workflow. + const data = DefaultFolders.isName('workflows', stem) ? 'params.yaml' : null; + const yaml = folder.files.flatMap((file) => (file.path !== WORKFLOW_MARKER && file.path !== data && file.path.endsWith('.yaml') ? [file.path] : [])); if (yaml.length === 1) log.info(`[workflows] ${source.path} is a workflow folder; ${yaml.join(', ')} in it is not a workflow`); else if (yaml.length > 1) log.info(`[workflows] ${source.path} is a workflow folder; ${yaml.join(', ')} in it are not workflows`); @@ -468,7 +482,10 @@ export async function syncWorkflowDefinitions(config: LocalConfig, options: Sync continue; } - const refused = await DefaultWorkflows.overrideProblems(relativePath, resolved.definition, config); + const refused = [ + ...await DefaultWorkflows.overrideProblems(relativePath, resolved.definition, config), + ...(folder ? await defaultFolderProblems(stem, source.path, folder.files) : []), + ]; if (refused.length > 0) { checked.push({ stem, relativePath, diagnostics: refused }); diff --git a/workflows/teamai-promote-learning/README.md b/workflows/teamai-promote-learning/README.md new file mode 100644 index 0000000000..a56e14edaa --- /dev/null +++ b/workflows/teamai-promote-learning/README.md @@ -0,0 +1,122 @@ +# teamai-promote-learning + +English | [中文](README.zh-CN.md) + +Promotes a team learning to a skill, rule or doc. The AI tool picks the category unless the person gave one, and rewrites the learning for it. teamai pushes the result on a review branch with a pull request that says the category and why, then marks the learning promoted. + +| Started by | | +|---|---| +| `teamai recall promote ` | `--category` gives the category | +| An agent session | `invoke: ask`, so an agent starts it once the person agrees | + +## Which learnings it promotes + +```mermaid +flowchart TD + learning([A learning]) --> conf{Confidence reaches min_confidence?} + conf -- no --> out([Not a candidate]) + conf -- yes --> up{Upvotes reach min_upvoted?} + up -- no --> out + up -- yes --> users{Members who voted reach min_users?} + users -- no --> out + users -- yes --> age{"Its date is min_age_days old, or it has none?"} + age -- no --> out + age -- yes --> placed{Its promoted_to target is in place?} + placed -- yes --> out + placed -- no --> cand([Candidate]) + classDef good fill:#2f8f4f,stroke:#1f6b39,color:#fff; + classDef skip fill:#6b6b6b,stroke:#4a4a4a,color:#fff; + class cand good; + class out skip; +``` + +`teamai recall promote` lists the candidates, the `learning` and `publish` steps take only a candidate, and the dashboard's Promote tab shows them. All of them read the same `params.yaml`. + +## How teamai's version runs + +```mermaid +flowchart TD + learning[Read the candidate] --> given{category given?} + given -- no --> ask["Ask the AI tool for the category and why"] + given -- yes --> category + ask --> category["Settle the category. When the AI could not choose, the keywords' suggestion"] + category --> content["Ask the AI tool to rewrite the learning for the category"] + content --> publish["Push it on a review branch with a pull request. When the AI could not rewrite it, the learning as written"] + publish --> mark(["Mark the learning promoted: exit_code"]) + classDef prompt fill:#6a4f8f,stroke:#513b6d,color:#fff; + classDef out fill:#2f8f4f,stroke:#1f6b39,color:#fff; + class ask,content prompt; + class mark out; +``` + +Purple marks a prompt the AI tool answers. An AI tool the member names that cannot run fails the run instead of falling back. `WORKFLOW.yaml` holds the steps. + +## Files + +| File | What it is | +|---|---| +| `WORKFLOW.yaml` | The steps, and the `output:` each step declares | +| `params.yaml` | Which learnings can be promoted | + +## Inputs + +| Input | Type | Meaning | +|---|---|---| +| `learning` | `string` | The learning's id, as `teamai recall promote` lists it | +| `category` | `string` | `skills`, `rules` or `docs`. `''` lets the AI tool pick | + +## Outputs + +| Output | Meaning | +|---|---| +| `exit_code` | `0` once the learning is marked promoted | + +## Parameters + +| Key | Range | What it sets | +|---|---|---| +| `min_confidence` | 0-1 | The confidence a learning needs, from its votes | +| `min_upvoted` | 1-1000 | The upvotes it needs, from every member together | +| `min_users` | 1-1000 | The members who must have voted on it | +| `min_age_days` | 0-3650 | How many days old its `date` must be. A learning with no `date` passes | + +## Replace it + +Put only the files you change in `workflows/teamai-promote-learning/` at the root of the team repo. Every other file, `WORKFLOW.yaml` included, stays teamai's and keeps getting teamai's updates. A small team that wants one other member's vote to be enough: + +```yaml +# workflows/teamai-promote-learning/params.yaml +min_users: 1 # the other keys keep teamai's values +``` + +Push the folder. After the next `teamai pull`, the listing, the steps and the dashboard all use it. A key you leave out of `params.yaml` keeps teamai's value from its `params.yaml`. + +A `WORKFLOW.yaml` of your own in the folder replaces the steps, and `params.yaml` still decides which learnings it takes. A `workflows/teamai-promote-learning.yaml` file replaces the folder whole, so teamai's parameters apply. + +| Keep | Why | +|---|---| +| The inputs and outputs above | `teamai recall promote` passes those inputs and reads that output | + +## What pull refuses + +| A team folder that | Pull | +|---|---| +| Sets a value out of range, or a key the table above does not list | Names the file and the field. The version pull last took stays in force, teamai's until it took one | + +`teamai review --branch` fails such a folder before you merge it. + +## Requires + +| Needs | Without it | +|---|---| +| An AI tool | The keywords' category and the learning as written are used. A tool the member names that cannot run fails the run | +| Push access to the team's knowledge | The learning stays a candidate, so a later run can promote it | + +## Settings that change it + +| Setting | Team `teamai.yaml` | Member config | Environment | Effect | +|---|---|---|---|---| +| AI tool | | `workflows.tools.default`, then `ai.tool` | `TEAMAI_AI_TOOL`, before both | Picks the tool that chooses the category and rewrites the learning | +| Driver | `workflows.driver` | `workflows.driver` | | Picks where the command starts the run. Unset means this machine | + +The member config is `~/.teamai/projects//config.yaml` in a workspace, then `~/.teamai/config.yaml`. The environment wins over the member config, and the member config wins over the team. See [member settings](https://github.com/Tencent/teamai-cli/blob/main/docs/guide/reference.md#member-settings) for the defaults. diff --git a/workflows/teamai-promote-learning/README.zh-CN.md b/workflows/teamai-promote-learning/README.zh-CN.md new file mode 100644 index 0000000000..3b27f883b9 --- /dev/null +++ b/workflows/teamai-promote-learning/README.zh-CN.md @@ -0,0 +1,122 @@ +# teamai-promote-learning + +[English](README.md) | 中文 + +把一条团队经验晋升为 skill、规则或文档。除非成员已经给出类别,否则由 AI 工具选出类别,并按该类别改写这条经验。teamai 把结果推送到一个评审分支,附带说明类别及原因的 pull request,然后把这条经验标记为已晋升。 + +| 启动方式 | | +|---|---| +| `teamai recall promote ` | `--category` 指定类别 | +| agent 会话 | `invoke: ask`,所以 agent 在成员同意后才启动它 | + +## 哪些经验可以晋升 + +```mermaid +flowchart TD + learning(["一条经验"]) --> conf{"置信度达到 min_confidence?"} + conf -- 否 --> out(["不是候选"]) + conf -- 是 --> up{"赞同数达到 min_upvoted?"} + up -- 否 --> out + up -- 是 --> users{"投过票的成员数达到 min_users?"} + users -- 否 --> out + users -- 是 --> age{"它的 date 已过 min_age_days 天,或没有 date?"} + age -- 否 --> out + age -- 是 --> placed{"它的 promoted_to 目标已存在?"} + placed -- 是 --> out + placed -- 否 --> cand(["候选"]) + classDef good fill:#2f8f4f,stroke:#1f6b39,color:#fff; + classDef skip fill:#6b6b6b,stroke:#4a4a4a,color:#fff; + class cand good; + class out skip; +``` + +`teamai recall promote` 列出候选,`learning` 和 `publish` 两个步骤只接受候选,dashboard 的 Promote 标签页展示它们。它们读的都是同一个 `params.yaml`。 + +## teamai 的版本如何运行 + +```mermaid +flowchart TD + learning["读取候选"] --> given{"给出了 category?"} + given -- 否 --> ask["询问 AI 工具:类别及原因"] + given -- 是 --> category + ask --> category["确定类别。AI 选不出时,用关键词建议的类别"] + category --> content["请 AI 工具按类别改写这条经验"] + content --> publish["推送到评审分支并开 pull request。AI 改写不了时,按原文发布"] + publish --> mark(["把经验标记为已晋升:exit_code"]) + classDef prompt fill:#6a4f8f,stroke:#513b6d,color:#fff; + classDef out fill:#2f8f4f,stroke:#1f6b39,color:#fff; + class ask,content prompt; + class mark out; +``` + +紫色表示由 AI 工具回答的提示词。成员指定的 AI 工具无法运行时,运行会失败,而不是退回其他做法。`WORKFLOW.yaml` 保存这些步骤。 + +## 文件 + +| 文件 | 是什么 | +|---|---| +| `WORKFLOW.yaml` | 步骤,以及每个步骤声明的 `output:` | +| `params.yaml` | 哪些经验可以晋升 | + +## 输入 + +| 输入 | 类型 | 含义 | +|---|---|---| +| `learning` | `string` | 经验的 id,即 `teamai recall promote` 列出的那个 | +| `category` | `string` | `skills`、`rules` 或 `docs`。`''` 表示让 AI 工具选 | + +## 输出 + +| 输出 | 含义 | +|---|---| +| `exit_code` | 经验被标记为已晋升后为 `0` | + +## 参数 + +| 键 | 范围 | 设置什么 | +|---|---|---| +| `min_confidence` | 0-1 | 经验按投票算出的置信度至少要多少 | +| `min_upvoted` | 1-1000 | 所有成员加起来至少要多少个赞同 | +| `min_users` | 1-1000 | 至少要多少位成员投过票 | +| `min_age_days` | 0-3650 | 它的 `date` 至少要过去多少天。没有 `date` 的经验直接通过 | + +## 替换它 + +在团队仓库根目录的 `workflows/teamai-promote-learning/` 里只放你要改的文件。其他文件(包括 `WORKFLOW.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。小团队希望另一位成员投一票就够: + +```yaml +# workflows/teamai-promote-learning/params.yaml +min_users: 1 # the other keys keep teamai's values +``` + +推送这个文件夹。下一次 `teamai pull` 之后,列表、步骤和 dashboard 都用它。`params.yaml` 里没写的键,沿用 teamai 的 `params.yaml` 里的值。 + +文件夹里放你自己的 `WORKFLOW.yaml` 会替换步骤,`params.yaml` 仍决定它接受哪些经验。`workflows/teamai-promote-learning.yaml` 单个文件会整体替换这个文件夹,此时用 teamai 的参数。 + +| 保留 | 原因 | +|---|---| +| 上面的输入和输出 | `teamai recall promote` 会传这些输入、读这个输出 | + +## pull 拒绝什么 + +| 团队文件夹 | pull 的处理 | +|---|---| +| 设置了超出范围的值,或上表没有列出的键 | 指出文件和字段。pull 上一次接受的版本继续生效,还没接受过时用 teamai 的 | + +`teamai review --branch` 会在合并前让这样的文件夹不通过。 + +## 依赖 + +| 需要 | 没有时 | +|---|---| +| AI 工具 | 用关键词建议的类别,并按原文发布。成员指定的工具无法运行时,运行失败 | +| 团队知识库的推送权限 | 经验仍是候选,之后的运行可以再晋升它 | + +## 影响它的设置 + +| 设置 | 团队 `teamai.yaml` | 成员配置 | 环境变量 | 作用 | +|---|---|---|---|---| +| AI 工具 | | `workflows.tools.default`,其次是 `ai.tool` | `TEAMAI_AI_TOOL`,优先于两者 | 选出决定类别、改写经验的工具 | +| 驱动 | `workflows.driver` | `workflows.driver` | | 选出命令在哪里启动运行。未设置表示本机 | + +成员配置在工作区里是 `~/.teamai/projects//config.yaml`,其次是 `~/.teamai/config.yaml`。环境变量优先于成员配置,成员配置优先于团队。默认值见[成员设置](https://github.com/Tencent/teamai-cli/blob/main/docs/guide/zh-CN/reference.md#成员设置)。 diff --git a/workflows/teamai-promote-learning.yaml b/workflows/teamai-promote-learning/WORKFLOW.yaml similarity index 94% rename from workflows/teamai-promote-learning.yaml rename to workflows/teamai-promote-learning/WORKFLOW.yaml index 30ed16ceff..b6246d4a61 100644 --- a/workflows/teamai-promote-learning.yaml +++ b/workflows/teamai-promote-learning/WORKFLOW.yaml @@ -3,10 +3,7 @@ name: teamai-promote-learning description: Promote an adopted, high-confidence learning to a skill, rule or doc - the AI picks the category unless the person gave one, rewrites the learning for it and opens a pull request that says why, then marks it promoted. Use when the person asks to promote a learning that teamai recall promote lists. invoke: ask private: true -inputs: { learning: string, category: string, min_confidence: number, min_upvoted: number, min_users: number, min_age_days: number } -# The gates recall promote lists with; a team tunes them by replacing this file's defaults (#1829). -defaults: - inputs: { min_confidence: 0.9, min_upvoted: 5, min_users: 2, min_age_days: 14 } +inputs: { learning: string, category: string } ends: { unconfigured-tool: failed } steps: - id: learning diff --git a/workflows/teamai-promote-learning/params.yaml b/workflows/teamai-promote-learning/params.yaml new file mode 100644 index 0000000000..b1b569781e --- /dev/null +++ b/workflows/teamai-promote-learning/params.yaml @@ -0,0 +1,4 @@ +min_confidence: 0.9 +min_upvoted: 5 +min_users: 2 +min_age_days: 14 From f1da753795939ba93e11e04eb409cc0ab519b1d0 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:13:54 +0100 Subject: [PATCH 08/18] feat(recall): tune which entries --update-quality drafts in params.yaml Why: the stale gates (5 recalls, at most 1 upvote, 2 members) and the five adopted learnings a draft prompt shows were constants. A small team never reached them, and a team could not change them without forking teamai. What: teamai-draft-learning-updates is a folder. Its params.yaml holds min_recalled, max_upvoted, min_users and max_related, checked by the DefaultFolders contract at pull and by `teamai review --branch`. findStaleEntries takes the gates, and findRelatedAdoptedLearnings takes the limit. The --dry-run listing, the stale and draft-context steps and the dashboard read them through DefaultFolders. The dashboard with --repo reads that repo's folder. Both READMEs follow the other workflow folders. Proof: a maintenance-quality-update case lists an entry once a team lowers min_users and drops it once min_recalled rises. It failed before the change. The touched unit suites, the three update-quality e2e files, typecheck and lint pass. --- CHANGELOG.md | 1 + docs/dev/tool-launch.md | 2 +- docs/guide/knowledge.md | 2 +- docs/guide/workflow.md | 4 +- docs/guide/zh-CN/knowledge.md | 2 +- docs/guide/zh-CN/workflow.md | 4 +- src/__tests__/draft-learning-updates.test.ts | 2 +- .../maintenance-quality-update.test.ts | 45 +++++-- .../maintenance-votes-readonly.test.ts | 3 +- src/default-folders.ts | 2 + src/index.ts | 12 +- src/maintenance/index.ts | 2 +- src/maintenance/quality-update.ts | 39 ++---- src/viz.ts | 18 ++- .../teamai-draft-learning-updates/README.md | 116 ++++++++++++++++++ .../README.zh-CN.md | 116 ++++++++++++++++++ .../WORKFLOW.yaml} | 0 .../teamai-draft-learning-updates/params.yaml | 4 + 18 files changed, 316 insertions(+), 58 deletions(-) create mode 100644 workflows/teamai-draft-learning-updates/README.md create mode 100644 workflows/teamai-draft-learning-updates/README.zh-CN.md rename workflows/{teamai-draft-learning-updates.yaml => teamai-draft-learning-updates/WORKFLOW.yaml} (100%) create mode 100644 workflows/teamai-draft-learning-updates/params.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0fefea07..18e9cf305c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features - The gates `teamai recall promote` applies, `min_confidence`, `min_upvoted`, `min_users` and `min_age_days`, are the `params.yaml` of the default workflow folder `teamai-promote-learning`, shipped at the values the code held. A team tunes them with `workflows/teamai-promote-learning/params.yaml` at its root, holding only the keys it changes; teamai's `WORKFLOW.yaml` and its other values stay in force. The listing, the `promote-candidate` and `promote-publish` steps and the dashboard read the same file, so none offers a learning another rejects. Pull and `teamai review --branch` refuse a value out of range or a key the workflow does not take, naming the file and the field, and the version pull last took stays in force. A member on an older teamai keeps the old gates, so upgrade every member before tuning them (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). +- `teamai recall maintenance --update-quality` reads which entries it drafts, `min_recalled`, `max_upvoted` and `min_users`, and how many adopted learnings each prompt shows, `max_related`, from the `params.yaml` of the default workflow folder `teamai-draft-learning-updates`, shipped at the values the code held. A team changes them with `workflows/teamai-draft-learning-updates/params.yaml` at its root; the `--dry-run` listing, the `stale` and `draft-context` steps and the dashboard read the same file, and pull refuses a value out of range or an unknown key as it does for the promotion gates (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - A team changes one file of a teamai default hook, rule or workflow folder by keeping that file in a folder of the default's name; every other file stays teamai's and keeps its improvements. Contracts such as the recall rule's kept lines and a hook default's keys are checked on the resulting folder. A team file that replaces no file of the default: pull warns, names it and keeps teamai's file, and `teamai review --branch` fails. A run of such a folder records the teamai version it took files from, and `teamai list --json` marks each file's layer. - Agents and scorers can be folders. `agents/[/]/AGENT.md` is delivered like a rule folder and names its files with `${TEAMAI_AGENT_DIR}`; Claude Code gets the user-scope directory in `additionalDirectories`, and another tool that cannot read it gets a warning. `scorers/[/]/SCORER.md` names its files with `${TEAMAI_SCORER_DIR}`, and the judge reads them in its evidence directory. - `teamai update` records every TeamAI copy this machine runs from in `~/.teamai/installations.json`, and `teamai update` and `teamai doctor` list them. With `updatePolicy: auto` and `updateOtherInstallations: true`, an update of a running copy that is already current can update one other registered npm-global copy per run under its own `--prefix`; npm exiting 0 without changing that copy is a failure. Volta, vendored and local copies are listed with their manual remedy and are never passed to npm (for [#1753](https://github.com/Tencent/teamai-cli/issues/1753)). diff --git a/docs/dev/tool-launch.md b/docs/dev/tool-launch.md index d34b84b544..628e288555 100644 --- a/docs/dev/tool-launch.md +++ b/docs/dev/tool-launch.md @@ -8,7 +8,7 @@ Each rule below spans more than one file. The rule names the code that owns it, ## How a feature asks an AI tool It writes an `answer` step in its default workflow. This is the step `recall maintenance --update-quality` runs, from -`workflows/teamai-draft-learning-updates.yaml`: +`workflows/teamai-draft-learning-updates/WORKFLOW.yaml`: ```yaml - id: ask diff --git a/docs/guide/knowledge.md b/docs/guide/knowledge.md index 876684b599..c5ffda50cd 100644 --- a/docs/guide/knowledge.md +++ b/docs/guide/knowledge.md @@ -195,7 +195,7 @@ teamai recall maintenance --prune --archive teamai recall maintenance --update-quality ``` -`--archive` puts aside learnings whose confidence is below 0.15. `--update-quality` writes a `.draft.md` beside each entry the team recalls often but rarely adopts. Rename it to `.md` when the draft is right; until then `teamai pull` delivers none of it. It runs the default workflow [`teamai-draft-learning-updates`](workflow.md#default-workflows), and exits 1 when an entry could not be drafted. +`--archive` puts aside learnings whose confidence is below 0.15. `--update-quality` writes a `.draft.md` beside each entry the team recalls often but rarely adopts. Rename it to `.md` when the draft is right; until then `teamai pull` delivers none of it. It runs the default workflow [`teamai-draft-learning-updates`](workflow.md#default-workflows), and exits 1 when an entry could not be drafted. By default an entry is drafted when members recalled it at least 5 times in all, at least 2 of them, and upvoted it at most once; a team changes that in `workflows/teamai-draft-learning-updates/params.yaml`, which its [README](https://github.com/Tencent/teamai-cli/blob/main/workflows/teamai-draft-learning-updates/README.md) describes. Digest and maintenance commands (`teamai digest`, `teamai recall maintenance`, and `teamai recall promote`) include the active learnings namespaces for the current scope alongside the shared root. diff --git a/docs/guide/workflow.md b/docs/guide/workflow.md index ad4353d4e1..4d4074ca96 100644 --- a/docs/guide/workflow.md +++ b/docs/guide/workflow.md @@ -1131,7 +1131,7 @@ teamai ships workflows a team gets without writing them: | `teamai-score` | labels one workflow pass or session with the team's [scorers](scorers.md#the-teamai-score-workflow). [`teamai score`](scorers.md#on-demand) and `teamai-share-session` start it | never: `invoke: deny`, so no agent session starts it | | `teamai-try-scorer` | tries scorer files on a session and on its first prompt alone, through `teamai-score` with `store: false`, so no label is stored. [`teamai score --scorer `](scorers.md#try-a-scorer-file) starts it. Private | never: `invoke: deny` | | `teamai-new-scorer` | writes a scorer with the person, tries it with `teamai-try-scorer`, and proposes `scorers/.md` to the team as a pull request from the [team workspace](#the-team-workspace). Inputs: `session`, `source` | always: `invoke: ask`, so an agent starts it once the person agrees | -| `teamai-draft-learning-updates` | drafts an update of each doc, rule and skill the team recalls often but rarely adopts, as `.draft.md` beside it, through [`teamai recall maintenance --update-quality`](knowledge.md#knowledge-base-maintenance). Input: `knowledge_root`, the checkout that holds the entries, where the drafts land. Private | never by name: `invoke: deny`. The command runs it from an agent session too | +| `teamai-draft-learning-updates` | drafts an update of each doc, rule and skill the team recalls often but rarely adopts, as `.draft.md` beside it, through [`teamai recall maintenance --update-quality`](knowledge.md#knowledge-base-maintenance). Input: `knowledge_root`, the checkout that holds the entries, where the drafts land. Its `params.yaml` holds which entries it drafts. Private | never by name: `invoke: deny`. The command runs it from an agent session too | | `teamai-promote-learning` | promotes a learning to a skill, rule or doc: the AI picks the category, unless `--category` gives it, and rewrites the learning, which is pushed on a review branch with a pull request that says the category and why, through [`teamai recall promote `](knowledge.md#knowledge-base-maintenance). Inputs: `learning`, `category` (`''` to let the AI pick). Its `params.yaml` holds which learnings it takes. Private | always: `invoke: ask`, so an agent starts it once the person agrees | | `teamai-codebase-deep-enrich` | writes a project's component docs, five at a time, then its architecture overview and graph docs, and with `publish` commits and pushes them to the team repo that holds `wiki_root`, through [`teamai codebase --deep-enrich`](knowledge.md#codebase-knowledge-graph). Inputs: `wiki_root`, `project`, `max_modules` (`0` for all), `concurrency`, `publish` (default `false`). Private | never by name: `invoke: deny`. The command runs it from an agent session too | | `teamai-codebase-enrich` | asks what each module of a freshly extracted codebase does and which business domain it belongs to, and writes `_manifest.json` and `_domains.json` beside its evidence. Inputs: `wiki_root`, `project`. Private | never: `invoke: deny` | @@ -1355,7 +1355,7 @@ Start from the file the package ships, edit it, push it, and pull: cp "$(npm root -g)/teamai-cli/workflows/teamai-share-learnings.yaml" workflows/teamai-share-learnings.yaml ``` -Four defaults are folders, at `workflows//WORKFLOW.yaml` in the package where earlier releases had `workflows/.yaml`: `teamai-import-from-mr`, `teamai-codebase-deep-enrich`, `teamai-import-from-repo` and `teamai-promote-learning`. A `workflows/.yaml` your team already has still replaces the default. +Five defaults are folders, at `workflows//WORKFLOW.yaml` in the package where earlier releases had `workflows/.yaml`: `teamai-import-from-mr`, `teamai-codebase-deep-enrich`, `teamai-import-from-repo`, `teamai-promote-learning` and `teamai-draft-learning-updates`. A `workflows/.yaml` your team already has still replaces the default. A folder of the same name changes a default that is a folder. Put only the files you change in it; every other file, `WORKFLOW.yaml` included, stays teamai's and keeps getting teamai's updates. To have MR learnings in English name what the review left open, copy its prompts: diff --git a/docs/guide/zh-CN/knowledge.md b/docs/guide/zh-CN/knowledge.md index 4e8ee769c4..52a3eede36 100644 --- a/docs/guide/zh-CN/knowledge.md +++ b/docs/guide/zh-CN/knowledge.md @@ -179,7 +179,7 @@ teamai recall maintenance --prune --archive teamai recall maintenance --update-quality ``` -`--archive` 收走置信度低于 0.15 的 learning。`--update-quality` 在团队经常召回却很少采用的每个条目旁写出 `.draft.md`,改好后把文件名改成 `.md`;改名之前 `teamai pull` 不会分发它。它运行默认工作流 [`teamai-draft-learning-updates`](workflow.md#默认工作流),有条目没能起草时以 1 退出。 +`--archive` 收走置信度低于 0.15 的 learning。`--update-quality` 在团队经常召回却很少采用的每个条目旁写出 `.draft.md`,改好后把文件名改成 `.md`;改名之前 `teamai pull` 不会分发它。它运行默认工作流 [`teamai-draft-learning-updates`](workflow.md#默认工作流),有条目没能起草时以 1 退出。默认情况下,成员一共召回至少 5 次、至少 2 位成员召回过、赞同不超过 1 次的条目会被起草;团队可以在 `workflows/teamai-draft-learning-updates/params.yaml` 里修改,见它的 [README](https://github.com/Tencent/teamai-cli/blob/main/workflows/teamai-draft-learning-updates/README.zh-CN.md)。 知识摘要与维护命令(`teamai digest`、`teamai recall maintenance` 及 `teamai recall promote`)均涵盖当前作用域内活跃的项目命名空间经验以及共享根目录经验。 diff --git a/docs/guide/zh-CN/workflow.md b/docs/guide/zh-CN/workflow.md index bba8fd7681..506be4008a 100644 --- a/docs/guide/zh-CN/workflow.md +++ b/docs/guide/zh-CN/workflow.md @@ -1131,7 +1131,7 @@ teamai 自带一些工作流,团队不用自己编写就能使用: | `teamai-score` | 用团队的[评分器](scorers.md#teamai-score-工作流)给一个工作流 pass 或会话打标签。由 [`teamai score`](scorers.md#按需评分) 和 `teamai-share-session` 启动 | 从不:`invoke: deny`,任何 agent 会话都不能启动它 | | `teamai-try-scorer` | 通过 `store: false` 的 `teamai-score`,用评分器文件评判一个会话和只含其第一条提示的会话,不保存任何标签。由 [`teamai score --scorer `](scorers.md#试用评分器文件) 启动。私有 | 从不:`invoke: deny` | | `teamai-new-scorer` | 与用户一起编写评分器,用 `teamai-try-scorer` 试用,再从[团队工作区](#团队工作区)把 `scorers/.md` 作为拉取请求提交给团队。输入:`session`、`source` | 始终:`invoke: ask`,agent 在用户同意后启动它 | -| `teamai-draft-learning-updates` | 为团队经常召回却很少采用的每个 doc、rule 和 skill 起草更新,写成它旁边的 `.draft.md`,由 [`teamai recall maintenance --update-quality`](knowledge.md#知识库维护) 启动。输入:`knowledge_root`,存放这些条目的 checkout,草稿写在其中。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | +| `teamai-draft-learning-updates` | 为团队经常召回却很少采用的每个 doc、rule 和 skill 起草更新,写成它旁边的 `.draft.md`,由 [`teamai recall maintenance --update-quality`](knowledge.md#知识库维护) 启动。输入:`knowledge_root`,存放这些条目的 checkout,草稿写在其中。它的 `params.yaml` 决定起草哪些条目。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | | `teamai-promote-learning` | 把一条经验晋升为 skill、rule 或 doc:AI 选择类别(`--category` 给出时除外)并改写经验,结果推送到评审分支,并附带说明类别和理由的拉取请求,由 [`teamai recall promote `](knowledge.md#知识库维护) 启动。输入:`learning`、`category`(`''` 表示由 AI 选择)。它的 `params.yaml` 决定接受哪些经验。私有 | 始终:`invoke: ask`,agent 在用户同意后启动它 | | `teamai-codebase-deep-enrich` | 为项目的组件写设计文档(一次五个),然后写架构总览和图谱文档;设了 `publish` 时,把它们提交并推送到存放 `wiki_root` 的团队仓库,由 [`teamai codebase --deep-enrich`](knowledge.md#代码知识图谱) 启动。输入:`wiki_root`、`project`、`max_modules`(`0` 表示全部)、`concurrency`、`publish`(默认 `false`)。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | | `teamai-codebase-enrich` | 询问刚提取的代码库每个模块做什么、整体属于哪个业务域,并在它的 evidence 旁写入 `_manifest.json` 和 `_domains.json`。输入:`wiki_root`、`project`。私有 | 从不:`invoke: deny` | @@ -1355,7 +1355,7 @@ describe 为调用它的会话提议每个输入并附 `why`:会话第一次 cp "$(npm root -g)/teamai-cli/workflows/teamai-share-learnings.yaml" workflows/teamai-share-learnings.yaml ``` -有四个默认工作流是文件夹,在包中位于 `workflows//WORKFLOW.yaml`,早期版本是 `workflows/.yaml`:`teamai-import-from-mr`、`teamai-codebase-deep-enrich`、`teamai-import-from-repo` 和 `teamai-promote-learning`。团队已有的 `workflows/.yaml` 仍会替换默认工作流。 +有五个默认工作流是文件夹,在包中位于 `workflows//WORKFLOW.yaml`,早期版本是 `workflows/.yaml`:`teamai-import-from-mr`、`teamai-codebase-deep-enrich`、`teamai-import-from-repo`、`teamai-promote-learning` 和 `teamai-draft-learning-updates`。团队已有的 `workflows/.yaml` 仍会替换默认工作流。 文件夹形式的默认工作流,用同名文件夹修改。里面只放你要改的文件;其他文件(包括 `WORKFLOW.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。比如想让英文的 MR learning 写出评审留下的未决问题,先复制它的提示词: diff --git a/src/__tests__/draft-learning-updates.test.ts b/src/__tests__/draft-learning-updates.test.ts index 147064e0a9..dc1c5683f0 100644 --- a/src/__tests__/draft-learning-updates.test.ts +++ b/src/__tests__/draft-learning-updates.test.ts @@ -33,7 +33,7 @@ describe('the stale step of teamai-draft-learning-updates', () => { fs.writeFileSync(path.join(repo, 'skills', 'deploy', 'SKILL.md'), '---\nname: deploy\n---\nDeploy it.\n'); recalledOften('deploy'); - const { entries } = await DraftLearningUpdates.stale(votesDir, repo); + const { entries } = await DraftLearningUpdates.stale(votesDir, repo, { min_recalled: 5, max_upvoted: 1, min_users: 2, max_related: 5 }); expect(entries).toEqual([{ doc_id: 'deploy', path: path.join(repo, 'skills', 'deploy', 'SKILL.md'), type: 'skills', recalled_count: 6, upvoted_count: 0, user_count: 2, diff --git a/src/__tests__/maintenance-quality-update.test.ts b/src/__tests__/maintenance-quality-update.test.ts index aa917e6b11..075d2f8bd9 100644 --- a/src/__tests__/maintenance-quality-update.test.ts +++ b/src/__tests__/maintenance-quality-update.test.ts @@ -1,5 +1,5 @@ // -*- coding: utf-8 -*- -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -11,7 +11,8 @@ vi.mock('../utils/logger.js', async (importOriginal) => ({ log: logMocks, })); -import { findStaleEntries, findRelatedAdoptedLearnings, type StaleEntry } from '../maintenance/quality-update.js'; +import { DefaultFolders } from '../default-folders.js'; +import { findStaleEntries, findRelatedAdoptedLearnings, type StaleEntry, type StaleGates } from '../maintenance/quality-update.js'; let tmp: string; let votesDir: string; @@ -19,6 +20,7 @@ let docsDir: string; let rulesDir: string; let skillsDir: string; let learningsDir: string; +let gates: StaleGates; /** A v2 votes file: v1 carries only `at`, so it never reaches a counter threshold. */ function writeVotes(user: string, table: Record): void { @@ -48,6 +50,10 @@ function stale(docId: string, dir = docsDir): StaleEntry { }; } +beforeAll(async () => { + ({ params: gates } = await DefaultFolders.load({ kind: 'workflows', name: 'teamai-draft-learning-updates' }, null)); +}); + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-quality-update-')); votesDir = path.join(tmp, 'votes'); @@ -69,7 +75,7 @@ describe('findStaleEntries locates an entry by the id its votes are kept under', writeVotes('alice', { guide: { recalled: 5, upvoted: 0 } }); writeVotes('bob', { guide: { recalled: 3, upvoted: 0 } }); - const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }); + const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }, gates); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ docId: 'guide', type: 'docs', recalledCount: 8, userCount: 2 }); @@ -80,7 +86,7 @@ describe('findStaleEntries locates an entry by the id its votes are kept under', writeVotes('alice', { naming: { recalled: 5, upvoted: 0 } }); writeVotes('bob', { naming: { recalled: 3, upvoted: 0 } }); - const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }); + const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }, gates); expect(entries[0]).toMatchObject({ docId: 'naming', type: 'rules' }); }); @@ -90,7 +96,7 @@ describe('findStaleEntries locates an entry by the id its votes are kept under', writeVotes('alice', { 'flat-skill': { recalled: 5, upvoted: 0 } }); writeVotes('bob', { 'flat-skill': { recalled: 3, upvoted: 0 } }); - const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }); + const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }, gates); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ docId: 'flat-skill', type: 'skills' }); @@ -102,7 +108,7 @@ describe('findStaleEntries locates an entry by the id its votes are kept under', writeVotes('alice', { 'refund-skill': { recalled: 5, upvoted: 0 } }); writeVotes('bob', { 'refund-skill': { recalled: 3, upvoted: 0 } }); - const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }); + const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }, gates); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ docId: 'refund-skill', type: 'skills' }); @@ -115,7 +121,7 @@ describe('findStaleEntries locates an entry by the id its votes are kept under', writeVotes('alice', { 'dup-skill': { recalled: 5, upvoted: 0 } }); writeVotes('bob', { 'dup-skill': { recalled: 3, upvoted: 0 } }); - const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }); + const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }, gates); expect(entries[0].path).toBe(path.join(skillsDir, 'dup-skill', 'SKILL.md')); }); @@ -125,18 +131,30 @@ describe('findStaleEntries locates an entry by the id its votes are kept under', writeVotes('alice', { '../escape': { recalled: 5, upvoted: 0 } }); writeVotes('bob', { '../escape': { recalled: 5, upvoted: 0 } }); - const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }); + const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }, gates); expect(entries).toEqual([]); }); }); +describe('findStaleEntries applies the gates it is given', () => { + it('lists an entry teamai\'s gates leave out once a team lowers the bar, and leaves one out once it raises it', async () => { + writeFile(path.join(docsDir, 'solo.md'), '# solo\n'); + writeFile(path.join(docsDir, 'shared.md'), '# shared\n'); + writeVotes('alice', { solo: { recalled: 9, upvoted: 0 }, shared: { recalled: 5, upvoted: 0 } }); + writeVotes('bob', { shared: { recalled: 1, upvoted: 0 } }); + const listed = async (set: StaleGates): Promise => (await findStaleEntries(votesDir, { docs: docsDir }, set)).map((entry) => entry.docId).sort(); + + expect([await listed(gates), await listed({ ...gates, min_users: 1 }), await listed({ ...gates, min_recalled: 7 })]).toEqual([['shared'], ['shared', 'solo'], []]); + }); +}); + describe('findStaleEntries reports an entry it could not place', () => { it('warns once when an entry clears every threshold but no file matches', async () => { writeVotes('alice', { 'gone-skill': { recalled: 5, upvoted: 0 } }); writeVotes('bob', { 'gone-skill': { recalled: 3, upvoted: 0 } }); - const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }); + const entries = await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }, gates); expect(entries).toEqual([]); expect(logMocks.warn).toHaveBeenCalledTimes(1); @@ -147,7 +165,7 @@ describe('findStaleEntries reports an entry it could not place', () => { writeVotes('alice', { 'a-learning': { recalled: 2, upvoted: 0 } }); writeVotes('bob', { 'a-learning': { recalled: 1, upvoted: 0 } }); - await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }); + await findStaleEntries(votesDir, { docs: docsDir, rules: rulesDir, skills: skillsDir }, gates); expect(logMocks.warn).not.toHaveBeenCalled(); }); @@ -176,7 +194,7 @@ describe('findRelatedAdoptedLearnings attaches what the entry is about', () => { for (const name of ['a', 'b', 'c', 'd', 'e']) votes[`doc-${name}`] = { recalled: 0, upvoted: 20 }; writeVotes('alice', votes); - const contents = await findRelatedAdoptedLearnings(stale('deploy-rollback'), votesDir, [learningsDir]); + const contents = await findRelatedAdoptedLearnings(stale('deploy-rollback'), votesDir, [learningsDir], { limit: gates.max_related }); expect(contents).toHaveLength(1); expect(contents[0]).toContain('deploy canary'); @@ -189,7 +207,7 @@ describe('findRelatedAdoptedLearnings attaches what the entry is about', () => { 'billing-invoice': { recalled: 2, upvoted: 1 }, }); - const contents = await findRelatedAdoptedLearnings(stale('deploy-rollback'), votesDir, [learningsDir]); + const contents = await findRelatedAdoptedLearnings(stale('deploy-rollback'), votesDir, [learningsDir], { limit: gates.max_related }); expect(contents).toEqual([]); }); @@ -225,6 +243,7 @@ describe('findRelatedAdoptedLearnings attaches what the entry is about', () => { }); const contents = await findRelatedAdoptedLearnings(stale('refund-policy'), votesDir, [learningsDir], { + limit: gates.max_related, namespaces: ['pay'], }); @@ -240,7 +259,7 @@ describe('findRelatedAdoptedLearnings attaches what the entry is about', () => { }); const before = fs.readFileSync(path.join(votesDir, 'alice.yaml'), 'utf-8'); - await findRelatedAdoptedLearnings(stale('deploy-rollback'), votesDir, [learningsDir]); + await findRelatedAdoptedLearnings(stale('deploy-rollback'), votesDir, [learningsDir], { limit: gates.max_related }); expect(fs.readFileSync(path.join(votesDir, 'alice.yaml'), 'utf-8')).toBe(before); }); diff --git a/src/__tests__/maintenance-votes-readonly.test.ts b/src/__tests__/maintenance-votes-readonly.test.ts index 906b3730dd..937f7d5c93 100644 --- a/src/__tests__/maintenance-votes-readonly.test.ts +++ b/src/__tests__/maintenance-votes-readonly.test.ts @@ -73,11 +73,12 @@ describe('recall maintenance scans never rewrite a v1 votes file (#900 C7)', () writeV1Votes('bob', ['guide']); const before = snapshot(votesDir); - await findStaleEntries(votesDir, { docs: docsDir }, { minRecalled: 1, minUsers: 1 }); + await findStaleEntries(votesDir, { docs: docsDir }, { min_recalled: 1, max_upvoted: 1, min_users: 1, max_related: 5 }); await findRelatedAdoptedLearnings( { docId: 'guide', path: path.join(docsDir, 'guide.md'), filename: 'guide.md' } as never, votesDir, [learningsDir], + { limit: 5 }, ); expect(snapshot(votesDir)).toEqual(before); diff --git a/src/default-folders.ts b/src/default-folders.ts index 0faa5db316..12b2bd0d32 100644 --- a/src/default-folders.ts +++ b/src/default-folders.ts @@ -66,6 +66,7 @@ const PARAMS = { 'teamai-recall': NoParamsSchema, }, workflows: { + 'teamai-draft-learning-updates': z.strictObject({ min_recalled: whole(1, 1000), max_upvoted: whole(0, 1000), min_users: whole(1, 1000), max_related: whole(0, 20) }), 'teamai-promote-learning': z.strictObject({ min_confidence: ratio(), min_upvoted: whole(1, 1000), min_users: whole(1, 1000), min_age_days: whole(0, 3650) }), }, } as const; @@ -99,6 +100,7 @@ const TEXTS = { 'teamai-recall': { RULE: { required: ['Nothing was searched:', 'Recall skips the older index'] }, block: {}, 'block-direct': { required: ['Nothing was searched:'] } }, }, workflows: { + 'teamai-draft-learning-updates': {}, 'teamai-promote-learning': {}, }, } as const satisfies { readonly [TKind in keyof typeof PARAMS]: { readonly [TName in keyof (typeof PARAMS)[TKind]]: Readonly> } }; diff --git a/src/index.ts b/src/index.ts index 6547a1a579..40679b9c90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2357,7 +2357,9 @@ const maintenanceCmd = recallCmd if (cmdOpts.updateQuality) { // Listing makes no AI call, so a dry run lists here and starts no run. const { findStaleEntries, reportStaleEntries } = await import('./maintenance/index.js'); - reportStaleEntries(await findStaleEntries(votesDir, { docs: `${repoPath}/docs`, rules: `${repoPath}/rules`, skills: `${repoPath}/skills` })); + const { DefaultFolders } = await import('./default-folders.js'); + const { params: gates } = await DefaultFolders.load({ kind: 'workflows', name: 'teamai-draft-learning-updates' }, localConfig); + reportStaleEntries(await findStaleEntries(votesDir, { docs: `${repoPath}/docs`, rules: `${repoPath}/rules`, skills: `${repoPath}/skills` }, gates)); return; } @@ -2373,7 +2375,9 @@ maintenanceCmd if (!paths) return; const { DraftLearningUpdates } = await import('./maintenance/index.js'); - process.stdout.write(`${JSON.stringify(await DraftLearningUpdates.stale(paths.votesDir, paths.repoPath))}\n`); + const { DefaultFolders } = await import('./default-folders.js'); + const { params: gates } = await DefaultFolders.load({ kind: 'workflows', name: 'teamai-draft-learning-updates' }, paths.config); + process.stdout.write(`${JSON.stringify(await DraftLearningUpdates.stale(paths.votesDir, paths.repoPath, gates))}\n`); }); maintenanceCmd @@ -2390,7 +2394,9 @@ maintenanceCmd const { DraftLearningUpdates } = await import('./maintenance/index.js'); const { safeDeliveredLearningsNamespaces } = await import('./workspace-children.js'); const namespaces = await safeDeliveredLearningsNamespaces(paths.config, 'recall maintenance'); - process.stdout.write(`${JSON.stringify(await DraftLearningUpdates.context({ path: cmdOpts.path, docId: cmdOpts.docId }, paths.votesDir, paths.learningsReadDirs, namespaces))}\n`); + const { DefaultFolders } = await import('./default-folders.js'); + const { params } = await DefaultFolders.load({ kind: 'workflows', name: 'teamai-draft-learning-updates' }, paths.config); + process.stdout.write(`${JSON.stringify(await DraftLearningUpdates.context({ path: cmdOpts.path, docId: cmdOpts.docId }, paths.votesDir, paths.learningsReadDirs, { limit: params.max_related, namespaces }))}\n`); }); maintenanceCmd diff --git a/src/maintenance/index.ts b/src/maintenance/index.ts index b75d7d794a..4d21041d84 100644 --- a/src/maintenance/index.ts +++ b/src/maintenance/index.ts @@ -9,7 +9,7 @@ export { annotateHotness, HOT_THRESHOLD, ZERO_EVIDENCE_WEIGHT } from './hot-cold export { findStaleEntries, reportStaleEntries, findRelatedAdoptedLearnings, DraftLearningUpdates } from './quality-update.js'; -export type { StaleEntry, QualityUpdateOptions } from './quality-update.js'; +export type { StaleEntry, StaleGates } from './quality-update.js'; export { findPromotionCandidates, writePromotion, fetchPromotionRefs, chooseCategory, PromotionCategorySchema } from './promote.js'; diff --git a/src/maintenance/quality-update.ts b/src/maintenance/quality-update.ts index 4674779677..2cd8988be4 100644 --- a/src/maintenance/quality-update.ts +++ b/src/maintenance/quality-update.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { z } from 'zod'; +import type { DefaultFolderParams } from '../default-folders.js'; import { DRAFT_SUFFIX, listDirs, listFiles, pathExists, readFileSafe, writeFile } from '../utils/fs.js'; import { parseJson } from '../utils/json.js'; @@ -20,16 +21,8 @@ export interface StaleEntry { type: string; } -export interface QualityUpdateOptions { - minRecalled?: number; - maxUpvoted?: number; - minUsers?: number; - dryRun?: boolean; -} - -const DEFAULT_MIN_RECALLED = 5; -const DEFAULT_MAX_UPVOTED = 1; -const DEFAULT_MIN_USERS = 2; +/** The gates an entry passes to be stale, and how many adopted learnings a draft's prompt shows, from the `params.yaml` of `teamai-draft-learning-updates`. */ +export type StaleGates = DefaultFolderParams<'workflows', 'teamai-draft-learning-updates'>; /** * Find docs/rules/skills that are frequently recalled but rarely adopted. @@ -38,12 +31,8 @@ const DEFAULT_MIN_USERS = 2; export async function findStaleEntries( votesDir: string, knowledgeDirs: { docs?: string; rules?: string; skills?: string }, - options: QualityUpdateOptions = {}, + gates: StaleGates, ): Promise { - const minRecalled = options.minRecalled ?? DEFAULT_MIN_RECALLED; - const maxUpvoted = options.maxUpvoted ?? DEFAULT_MAX_UPVOTED; - const minUsers = options.minUsers ?? DEFAULT_MIN_USERS; - const perDoc = new Map }>(); const voteFiles = await listFiles(votesDir); @@ -73,11 +62,11 @@ export async function findStaleEntries( const unresolved: string[] = []; for (const [docId, data] of perDoc) { - if (data.recalled < minRecalled) continue; + if (data.recalled < gates.min_recalled) continue; - if (data.upvoted > maxUpvoted) continue; + if (data.upvoted > gates.max_upvoted) continue; - if (data.users.size < minUsers) continue; + if (data.users.size < gates.min_users) continue; const found = await resolveEntry(docId, knowledgeDirs); @@ -219,7 +208,7 @@ export function reportStaleEntries(entries: StaleEntry[]): void { * AI to understand what the team actually preferred. */ export interface RelatedAdoptedLearningsOptions { - limit?: number; + limit: number; namespaces?: readonly string[]; } @@ -227,10 +216,8 @@ export async function findRelatedAdoptedLearnings( staleEntry: Pick, votesDir: string, learningsDirs: readonly string[], - options?: RelatedAdoptedLearningsOptions, + { limit, namespaces }: RelatedAdoptedLearningsOptions, ): Promise { - const limit = options?.limit ?? 5; - const namespaces = options?.namespaces; const perDoc = new Map(); const voteFiles = await listFiles(votesDir); @@ -380,8 +367,8 @@ export interface DraftEntry { * The `stale` step: lists the entries recalled often but rarely adopted, as `--dry-run` does, and says that their * drafts come next when there are any. */ -async function stale(votesDir: string, repoPath: string): Promise<{ entries: DraftEntry[] }> { - const entries = await findStaleEntries(votesDir, { docs: `${repoPath}/docs`, rules: `${repoPath}/rules`, skills: `${repoPath}/skills` }); +async function stale(votesDir: string, repoPath: string, gates: StaleGates): Promise<{ entries: DraftEntry[] }> { + const entries = await findStaleEntries(votesDir, { docs: `${repoPath}/docs`, rules: `${repoPath}/rules`, skills: `${repoPath}/skills` }, gates); reportStaleEntries(entries); if (entries.length > 0) log.info('\nGenerating AI-powered update drafts...'); @@ -397,11 +384,11 @@ async function stale(votesDir: string, repoPath: string): Promise<{ entries: Dra * The `context` step: the entry's content, and the learnings members adopted instead, as the part of the prompt that * shows them, or '' when there are none. */ -async function context(entry: { path: string; docId: string }, votesDir: string, learningsDirs: readonly string[], namespaces: readonly string[] | undefined): Promise<{ content: string; related: string }> { +async function context(entry: { path: string; docId: string }, votesDir: string, learningsDirs: readonly string[], options: RelatedAdoptedLearningsOptions): Promise<{ content: string; related: string }> { const content = await readFileSafe(entry.path); if (!content) throw new Error(`${entry.path} is empty or cannot be read, so there is nothing to draft an update of.`); - const related = await findRelatedAdoptedLearnings(entry, votesDir, learningsDirs, { namespaces }); + const related = await findRelatedAdoptedLearnings(entry, votesDir, learningsDirs, options); return { content, diff --git a/src/viz.ts b/src/viz.ts index 1116d11167..84074f6755 100644 --- a/src/viz.ts +++ b/src/viz.ts @@ -25,7 +25,7 @@ import { getProjectSearchIndexPath, } from './types.js'; import type { LocalConfig, SearchIndexEntry } from './types.js'; -import { DefaultFolders } from './default-folders.js'; +import { DefaultFolders, type DefaultFolderName } from './default-folders.js'; import { findPromotionCandidates, type PromotionCandidate } from './maintenance/promote.js'; import { findPruneCandidates, type PruneCandidate } from './maintenance/prune.js'; import { findStaleEntries, type StaleEntry } from './maintenance/quality-update.js'; @@ -587,11 +587,17 @@ export async function buildVizData(paths: VizPaths): Promise { ); const namespaces = paths.learningsNamespaces.ok ? paths.learningsNamespaces.namespaces : undefined; - const promotion = { kind: 'workflows', name: 'teamai-promote-learning' } as const; + + // `--repo` names a team repo no pull took, so its own folders are read. + const params = async >(name: TName) => { + const ref = { kind: 'workflows', name } as const; + + return (await (paths.scope ? DefaultFolders.load(ref, paths.scope) : DefaultFolders.loadAt(ref, paths.knowledgeRoot))).params; + }; const [promote, prune, stale] = await Promise.all([ - (paths.scope ? DefaultFolders.load(promotion, paths.scope) : DefaultFolders.loadAt(promotion, paths.knowledgeRoot)) - .then(({ params: thresholds }) => findPromotionCandidates({ learningsDirs: paths.learningsDirs, votesDir: paths.votesDir, knowledgeCheckout: paths.promotionCheckout, namespaces, thresholds })) + params('teamai-promote-learning') + .then((thresholds) => findPromotionCandidates({ learningsDirs: paths.learningsDirs, votesDir: paths.votesDir, knowledgeCheckout: paths.promotionCheckout, namespaces, thresholds })) .catch((err: Error): PromotionCandidate[] => { console.warn(`Warning: could not load promotion candidates: ${err.message}`); @@ -602,11 +608,11 @@ export async function buildVizData(paths: VizPaths): Promise { return []; }), - findStaleEntries(paths.votesDir, { + params('teamai-draft-learning-updates').then((gates) => findStaleEntries(paths.votesDir, { docs: path.join(paths.knowledgeRoot, 'docs'), rules: path.join(paths.knowledgeRoot, 'rules'), skills: path.join(paths.knowledgeRoot, 'skills'), - }).catch((err: Error): StaleEntry[] => { + }, gates)).catch((err: Error): StaleEntry[] => { console.warn(`Warning: could not load stale entries: ${err.message}`); return []; diff --git a/workflows/teamai-draft-learning-updates/README.md b/workflows/teamai-draft-learning-updates/README.md new file mode 100644 index 0000000000..57caf692d7 --- /dev/null +++ b/workflows/teamai-draft-learning-updates/README.md @@ -0,0 +1,116 @@ +# teamai-draft-learning-updates + +English | [中文](README.zh-CN.md) + +Drafts an update of each doc, rule and skill the team recalls often but rarely adopts. The AI tool rewrites the entry with the learnings members adopted instead, and teamai writes the draft beside it as `.draft.md` for a person to review. + +| Started by | | +|---|---| +| `teamai recall maintenance --update-quality` | `--dry-run` lists the entries and starts no run | +| An agent session | Never by name: `invoke: deny`. The command runs it from an agent session too | + +## Which entries it drafts + +```mermaid +flowchart TD + entry([A doc, rule or skill]) --> recalled{Recalled at least min_recalled times?} + recalled -- no --> out([Not drafted]) + recalled -- yes --> upvoted{Upvoted at most max_upvoted times?} + upvoted -- no --> out + upvoted -- yes --> users{Recalled by at least min_users members?} + users -- no --> out + users -- yes --> stale([Drafted]) + classDef good fill:#2f8f4f,stroke:#1f6b39,color:#fff; + classDef skip fill:#6b6b6b,stroke:#4a4a4a,color:#fff; + class stale good; + class out skip; +``` + +The counts add every member's votes. `--update-quality --dry-run`, the `stale` step and the dashboard's maintenance view list the same entries, from the same `params.yaml`. + +## How teamai's version runs + +```mermaid +flowchart TD + stale[List the entries to draft] --> each + subgraph each [For each entry. One that fails leaves the others] + context["Read the entry, and up to max_related learnings members adopted on its topic"] --> ask["Ask the AI tool to rewrite the entry"] + ask --> write["Write it beside the entry as entry.draft.md"] + end + each --> report(["Say each entry it could not draft: text, exit_code"]) + classDef prompt fill:#6a4f8f,stroke:#513b6d,color:#fff; + classDef out fill:#2f8f4f,stroke:#1f6b39,color:#fff; + class ask prompt; + class report out; +``` + +Purple marks a prompt the AI tool answers. `WORKFLOW.yaml` holds the steps. + +## Files + +| File | What it is | +|---|---| +| `WORKFLOW.yaml` | The steps, and the `output:` each step declares | +| `params.yaml` | Which entries it drafts, and how many adopted learnings each prompt shows | + +## Inputs + +| Input | Type | Meaning | +|---|---|---| +| `knowledge_root` | `string` | The checkout holding the entries, the team repo or a project's `.teamai/`. The drafts are written in it | + +## Outputs + +| Output | Meaning | +|---|---| +| `text` | Each entry it could not draft, and what to do with the drafts | +| `exit_code` | `1` when an entry could not be drafted. The other entries still get theirs | + +## Parameters + +| Key | Range | What it sets | +|---|---|---| +| `min_recalled` | 1-1000 | The recalls an entry needs, from every member together | +| `max_upvoted` | 0-1000 | The most upvotes it may have, from every member together | +| `min_users` | 1-1000 | The members who must have recalled it | +| `max_related` | 0-20 | How many adopted learnings the prompt shows. `0` shows none | + +## Replace it + +Put only the files you change in `workflows/teamai-draft-learning-updates/` at the root of the team repo. Every other file, `WORKFLOW.yaml` included, stays teamai's and keeps getting teamai's updates. A small team that wants an entry one member keeps recalling drafted too: + +```yaml +# workflows/teamai-draft-learning-updates/params.yaml +min_users: 1 # the other keys keep teamai's values +``` + +Push the folder. After the next `teamai pull`, the listing, the steps and the dashboard all use it. A key you leave out of `params.yaml` keeps teamai's value from its `params.yaml`. + +A `WORKFLOW.yaml` of your own in the folder replaces the steps, and `params.yaml` still decides which entries the `stale` step lists. A `workflows/teamai-draft-learning-updates.yaml` file replaces the folder whole, so teamai's parameters apply. + +| Keep | Why | +|---|---| +| The inputs and outputs above | `teamai recall maintenance --update-quality` passes that input and reads those outputs | + +## What pull refuses + +| A team folder that | Pull | +|---|---| +| Sets a value out of range, or a key the table above does not list | Names the file and the field. The version pull last took stays in force, teamai's until it took one | + +`teamai review --branch` fails such a folder before you merge it. + +## Requires + +| Needs | Without it | +|---|---| +| An AI tool | The entry is not drafted, and the others go on. The command exits 1 | + +## Settings that change it + +| Setting | Team `teamai.yaml` | Member config | Environment | Effect | +|---|---|---|---|---| +| AI tool | | `workflows.tools.default`, then `ai.tool` | `TEAMAI_AI_TOOL`, before both | Picks the tool that rewrites the entries | +| Driver | `workflows.driver` | `workflows.driver` | | Picks where the command starts the run. Unset means this machine | + +The member config is `~/.teamai/projects//config.yaml` in a workspace, then `~/.teamai/config.yaml`. The environment wins over the member config, and the member config wins over the team. See [member settings](https://github.com/Tencent/teamai-cli/blob/main/docs/guide/reference.md#member-settings) for the defaults. diff --git a/workflows/teamai-draft-learning-updates/README.zh-CN.md b/workflows/teamai-draft-learning-updates/README.zh-CN.md new file mode 100644 index 0000000000..99c519754c --- /dev/null +++ b/workflows/teamai-draft-learning-updates/README.zh-CN.md @@ -0,0 +1,116 @@ +# teamai-draft-learning-updates + +[English](README.md) | 中文 + +为团队经常召回却很少采用的每个 doc、rule 和 skill 起草更新。AI 工具参考成员实际采用的经验改写条目,teamai 把草稿写在条目旁边,命名为 `.draft.md`,供成员审阅。 + +| 启动方式 | | +|---|---| +| `teamai recall maintenance --update-quality` | `--dry-run` 只列出条目,不启动运行 | +| agent 会话 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | + +## 哪些条目会被起草 + +```mermaid +flowchart TD + entry(["一个 doc、rule 或 skill"]) --> recalled{"召回次数达到 min_recalled?"} + recalled -- 否 --> out(["不起草"]) + recalled -- 是 --> upvoted{"赞同数不超过 max_upvoted?"} + upvoted -- 否 --> out + upvoted -- 是 --> users{"召回过它的成员数达到 min_users?"} + users -- 否 --> out + users -- 是 --> stale(["起草"]) + classDef good fill:#2f8f4f,stroke:#1f6b39,color:#fff; + classDef skip fill:#6b6b6b,stroke:#4a4a4a,color:#fff; + class stale good; + class out skip; +``` + +计数把所有成员的投票加在一起。`--update-quality --dry-run`、`stale` 步骤和 dashboard 的维护视图列出的是同一批条目,读的都是同一个 `params.yaml`。 + +## teamai 的版本如何运行 + +```mermaid +flowchart TD + stale["列出要起草的条目"] --> each + subgraph each ["逐个条目处理。一个失败不影响其他条目"] + context["读取条目,以及成员在同一主题上采用过的最多 max_related 条经验"] --> ask["请 AI 工具改写条目"] + ask --> write["写在条目旁边,命名为 entry.draft.md"] + end + each --> report(["说明哪些条目没能起草:text、exit_code"]) + classDef prompt fill:#6a4f8f,stroke:#513b6d,color:#fff; + classDef out fill:#2f8f4f,stroke:#1f6b39,color:#fff; + class ask prompt; + class report out; +``` + +紫色表示由 AI 工具回答的提示词。`WORKFLOW.yaml` 保存这些步骤。 + +## 文件 + +| 文件 | 是什么 | +|---|---| +| `WORKFLOW.yaml` | 步骤,以及每个步骤声明的 `output:` | +| `params.yaml` | 起草哪些条目,以及每个提示词展示多少条已采用的经验 | + +## 输入 + +| 输入 | 类型 | 含义 | +|---|---|---| +| `knowledge_root` | `string` | 存放这些条目的 checkout,即团队仓库或项目的 `.teamai/`。草稿写在其中 | + +## 输出 + +| 输出 | 含义 | +|---|---| +| `text` | 没能起草的每个条目,以及如何处理草稿 | +| `exit_code` | 有条目没能起草时为 `1`。其他条目仍会得到草稿 | + +## 参数 + +| 键 | 范围 | 设置什么 | +|---|---|---| +| `min_recalled` | 1-1000 | 所有成员加起来至少要召回多少次 | +| `max_upvoted` | 0-1000 | 所有成员加起来最多能有多少个赞同 | +| `min_users` | 1-1000 | 至少要多少位成员召回过它 | +| `max_related` | 0-20 | 提示词展示多少条已采用的经验。`0` 表示不展示 | + +## 替换它 + +在团队仓库根目录的 `workflows/teamai-draft-learning-updates/` 里只放你要改的文件。其他文件(包括 `WORKFLOW.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。小团队希望某位成员反复召回的条目也被起草: + +```yaml +# workflows/teamai-draft-learning-updates/params.yaml +min_users: 1 # the other keys keep teamai's values +``` + +推送这个文件夹。下一次 `teamai pull` 之后,列表、步骤和 dashboard 都用它。`params.yaml` 里没写的键,沿用 teamai 的 `params.yaml` 里的值。 + +文件夹里放你自己的 `WORKFLOW.yaml` 会替换步骤,`params.yaml` 仍决定 `stale` 步骤列出哪些条目。`workflows/teamai-draft-learning-updates.yaml` 单个文件会整体替换这个文件夹,此时用 teamai 的参数。 + +| 保留 | 原因 | +|---|---| +| 上面的输入和输出 | `teamai recall maintenance --update-quality` 会传这个输入、读这些输出 | + +## pull 拒绝什么 + +| 团队文件夹 | pull 的处理 | +|---|---| +| 设置了超出范围的值,或上表没有列出的键 | 指出文件和字段。pull 上一次接受的版本继续生效,还没接受过时用 teamai 的 | + +`teamai review --branch` 会在合并前让这样的文件夹不通过。 + +## 依赖 + +| 需要 | 没有时 | +|---|---| +| AI 工具 | 该条目不会被起草,其他条目继续。命令以 1 退出 | + +## 影响它的设置 + +| 设置 | 团队 `teamai.yaml` | 成员配置 | 环境变量 | 作用 | +|---|---|---|---|---| +| AI 工具 | | `workflows.tools.default`,其次是 `ai.tool` | `TEAMAI_AI_TOOL`,优先于两者 | 选出改写条目的工具 | +| 驱动 | `workflows.driver` | `workflows.driver` | | 选出命令在哪里启动运行。未设置表示本机 | + +成员配置在工作区里是 `~/.teamai/projects//config.yaml`,其次是 `~/.teamai/config.yaml`。环境变量优先于成员配置,成员配置优先于团队。默认值见[成员设置](https://github.com/Tencent/teamai-cli/blob/main/docs/guide/zh-CN/reference.md#成员设置)。 diff --git a/workflows/teamai-draft-learning-updates.yaml b/workflows/teamai-draft-learning-updates/WORKFLOW.yaml similarity index 100% rename from workflows/teamai-draft-learning-updates.yaml rename to workflows/teamai-draft-learning-updates/WORKFLOW.yaml diff --git a/workflows/teamai-draft-learning-updates/params.yaml b/workflows/teamai-draft-learning-updates/params.yaml new file mode 100644 index 0000000000..e66a5cd121 --- /dev/null +++ b/workflows/teamai-draft-learning-updates/params.yaml @@ -0,0 +1,4 @@ +min_recalled: 5 +max_upvoted: 1 +min_users: 2 +max_related: 5 From 2ec0dcfeed2ff64805d9c94ca3a3b82cc1a9c39c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:16:46 +0100 Subject: [PATCH 09/18] refactor(duration): read a duration without the workflow parser durationMs moves from workflow/parse.ts to utils/duration.ts, unchanged, and its five importers follow. The default-folders contract, which every hook loads, is about to validate a duration parameter, and it should not load the workflow parser to do it. Typecheck and lint pass. No behaviour changes. --- src/benchmark/suite.ts | 2 +- src/score-verbs.ts | 2 +- src/session-flow/finished-sessions.ts | 2 +- src/utils/duration.ts | 10 ++++++++++ src/workflow-commands.ts | 2 +- src/workflow/parse.ts | 11 +---------- src/workflow/retention.ts | 2 +- .../WORKFLOW.yaml} | 0 8 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 src/utils/duration.ts rename workflows/{teamai-finished-sessions.yaml => teamai-finished-sessions/WORKFLOW.yaml} (100%) diff --git a/src/benchmark/suite.ts b/src/benchmark/suite.ts index 78357970b8..c01d51a103 100644 --- a/src/benchmark/suite.ts +++ b/src/benchmark/suite.ts @@ -11,7 +11,7 @@ import { LineCounter, parseDocument } from 'yaml'; import { parseYaml } from '../utils/yaml.js'; import { z } from 'zod'; import { isYamlNode, locate } from '../workflow/parse-context.js'; -import { durationMs } from '../workflow/parse.js'; +import { durationMs } from '../utils/duration.js'; import { SetupSchema, type Setup } from '../workflow/requirements.js'; import type { WorkflowDiagnostic } from '../workflow/types.js'; import { CommitSchema, TeamPathSchema } from '../workflow/clone.js'; diff --git a/src/score-verbs.ts b/src/score-verbs.ts index 6bea50f99f..7530682a55 100644 --- a/src/score-verbs.ts +++ b/src/score-verbs.ts @@ -21,7 +21,7 @@ import type { JsonObject, LocalConfig } from './types.js'; import { isSameRealPath } from './utils/fs.js'; import { createGit } from './utils/git.js'; import { log, setStderrOnly } from './utils/logger.js'; -import { durationMs } from './workflow/parse.js'; +import { durationMs } from './utils/duration.js'; import { isIsolated } from './workflow/workspace.js'; /** Where a verb runs: the process's environment, the scope of its directory, and the time it reads as now. */ diff --git a/src/session-flow/finished-sessions.ts b/src/session-flow/finished-sessions.ts index 4c518f5781..61a4a3fc51 100644 --- a/src/session-flow/finished-sessions.ts +++ b/src/session-flow/finished-sessions.ts @@ -22,7 +22,7 @@ import { pathExists } from '../utils/fs.js'; import { log } from '../utils/logger.js'; import { readWorkspaceChildren } from '../workspace-children.js'; import { runDeadline } from '../workflow/limits.js'; -import { durationMs } from '../workflow/parse.js'; +import { durationMs } from '../utils/duration.js'; import { startAsCli, type WorkflowClient } from '../workflow/protocol.js'; import { findRunConfig } from '../workflow/run-location.js'; import { wasLeased } from '../workflow/step-state.js'; diff --git a/src/utils/duration.ts b/src/utils/duration.ts new file mode 100644 index 0000000000..8991a5dee6 --- /dev/null +++ b/src/utils/duration.ts @@ -0,0 +1,10 @@ +const DURATION = /^(\d+)(ms|s|m|h|d)$/; +const DURATION_UNITS = new Map([['ms', 1], ['s', 1000], ['m', 60_000], ['h', 3_600_000], ['d', 86_400_000]]); + +/** A positive whole number of `ms`, `s`, `m`, `h`, or `d`, such as `30s` or `14d`, read as milliseconds; undefined otherwise. */ +export function durationMs(text: string): number | undefined { + const [, amount = '', unit = ''] = DURATION.exec(text.trim()) ?? []; + const ms = Number(amount) * (DURATION_UNITS.get(unit) ?? 0); + + return ms > 0 && Number.isSafeInteger(ms) ? ms : undefined; +} diff --git a/src/workflow-commands.ts b/src/workflow-commands.ts index 0c5d92b6b5..0c1ade199f 100644 --- a/src/workflow-commands.ts +++ b/src/workflow-commands.ts @@ -27,7 +27,7 @@ import { AttemptIdSchema, installOwner, readRun, RunIdSchema, type RunId } from import { commandExitMessage, inProcessClient, isRefusal, resumeRefusal, startAsCli, workflowNotFound, type InvokeCaller, type NextPrepare, type StartedRun } from './workflow/protocol.js'; import { parseInputsFlag, parseSkillCall, parseSkillResume, runSkillCall, sessionDrivesRun, skillContext } from './workflow/drivers/skill/skill-driver.js'; import { loadCachedWorkflow, namespaceConflictOf, resolveStart } from './workflow/cache.js'; -import { durationMs } from './workflow/parse.js'; +import { durationMs } from './utils/duration.js'; import { findRunConfig } from './workflow/run-location.js'; import { resolveRunMode } from './launch-context.js'; import { memberTools } from './tool-launch/member-tools.js'; diff --git a/src/workflow/parse.ts b/src/workflow/parse.ts index f9d535be9b..c451c071b7 100644 --- a/src/workflow/parse.ts +++ b/src/workflow/parse.ts @@ -17,6 +17,7 @@ import { } from './reads.js'; import { parseHitl } from './parse-hitl.js'; import { isDataBranch, JsonValueSchema, type JsonValue } from '../types.js'; +import { durationMs } from '../utils/duration.js'; import { outputMismatch } from './output.js'; import { roleDiagnostics } from './parse-roles.js'; import { loopProblems, stepLoops } from './loop.js'; @@ -75,8 +76,6 @@ const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; /** A scorer a step lists: a token, the name of a file in scorers/, or a scorer a run generates, whose name holds a colon. */ const SCORER_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; -const DURATION = /^(\d+)(ms|s|m|h|d)$/; -const DURATION_UNITS = new Map([['ms', 1], ['s', 1000], ['m', 60_000], ['h', 3_600_000], ['d', 86_400_000]]); /** The type names of the output shorthand. */ function isTypeName(name: string | undefined): name is 'string' | 'number' | 'boolean' { @@ -1947,14 +1946,6 @@ function parseSetup(node: Node | null | undefined, ctx: ParseContext): Setup | u return parsed.success && diagnostics.length === before ? parsed.data : undefined; } -/** A positive whole number of `ms`, `s`, `m`, `h`, or `d`, such as `30s` or `14d`, read as milliseconds; undefined otherwise. */ -export function durationMs(text: string): number | undefined { - const [, amount = '', unit = ''] = DURATION.exec(text.trim()) ?? []; - const ms = Number(amount) * (DURATION_UNITS.get(unit) ?? 0); - - return ms > 0 && Number.isSafeInteger(ms) ? ms : undefined; -} - /** A `timeout`, read with durationMs. */ function parseTimeout(node: Node | null | undefined, field: string, ctx: ParseContext): number | undefined { const ms = durationMs(scalarString(node) ?? ''); diff --git a/src/workflow/retention.ts b/src/workflow/retention.ts index fe9ff3d61d..3489c9323b 100644 --- a/src/workflow/retention.ts +++ b/src/workflow/retention.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import type { LocalConfig, TeamaiConfig } from '../types.js'; import type { RunState } from './types.js'; import { resolveKeptSetting } from './kept-setting.js'; -import { durationMs } from './parse.js'; +import { durationMs } from '../utils/duration.js'; export const DEFAULT_RETENTION = '14d'; diff --git a/workflows/teamai-finished-sessions.yaml b/workflows/teamai-finished-sessions/WORKFLOW.yaml similarity index 100% rename from workflows/teamai-finished-sessions.yaml rename to workflows/teamai-finished-sessions/WORKFLOW.yaml From 73e80ac334977dbb56cb569114a36744ce2b44c3 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:26:08 +0100 Subject: [PATCH 10/18] feat(sessions): set when a session is finished in params.yaml Why: an attended session counted as finished after six hours, written in three places that could disagree: `--idle 6h` in the workflow's steps, FINISHED_AFTER_MS for the usage report's outcomes and the dashboard, and "6 hours" in the share proposals and panel texts. A team that replaced the workflow with --idle 1h got sessions archived at one hour, while the dashboard and outcomes still waited six. What: teamai-finished-sessions is a folder whose params.yaml holds `idle: 6h`, from 10m to 7d, checked by the DefaultFolders contract. SessionWork.finishedAfter reads it, and becomes the one owner. The listing without --idle, the usage report's outcomes, the dashboard's sharing panel and the share proposals all use it. The panel and the proposals name the value. The steps no longer pass --idle, and FINISHED_AFTER_MS is gone. The dashboard's outcome texts, the guides and both new READMEs say the idle is the team's. Proof: a finished-sessions case lists a session idle 1.5h once the team's params.yaml sets 1h, and not before. An explicit --idle 6h still overrides it. The case failed before the change. A sharing-view case renders the team's 1h. The session, dashboard and default-folder suites, the score-drain and update-hook-outcomes e2e files, typecheck and lint pass. --- CHANGELOG.md | 1 + docs/guide/dashboard.md | 2 +- docs/guide/reporting.md | 2 +- docs/guide/scorers.md | 10 +- docs/guide/sharing.md | 2 +- docs/guide/workflow.md | 4 +- docs/guide/zh-CN/dashboard.md | 2 +- docs/guide/zh-CN/reporting.md | 2 +- docs/guide/zh-CN/scorers.md | 10 +- docs/guide/zh-CN/sharing.md | 2 +- docs/guide/zh-CN/workflow.md | 4 +- skill-data/core/references/commands.md | 2 +- .../dashboard-session-sharing-view.test.ts | 3 + src/__tests__/finished-sessions.test.ts | 15 ++- src/__tests__/session-share.test.ts | 4 +- src/dashboard-collector.ts | 2 +- src/dashboard/client.ts | 4 +- src/dashboard/locales.ts | 6 +- src/dashboard/session-sharing-view.ts | 4 +- src/dashboard/session-sharing.ts | 12 ++- src/default-folders.ts | 21 ++++ src/session-flow/finished-sessions.ts | 11 +- src/session-flow/session-cmd.ts | 2 +- src/session-flow/session-work.ts | 15 +-- src/session-outcomes.ts | 19 ++-- src/session-share.ts | 9 +- workflows/teamai-finished-sessions/README.md | 101 ++++++++++++++++++ .../teamai-finished-sessions/README.zh-CN.md | 101 ++++++++++++++++++ .../teamai-finished-sessions/WORKFLOW.yaml | 4 +- .../teamai-finished-sessions/params.yaml | 1 + 30 files changed, 311 insertions(+), 66 deletions(-) create mode 100644 workflows/teamai-finished-sessions/README.md create mode 100644 workflows/teamai-finished-sessions/README.zh-CN.md create mode 100644 workflows/teamai-finished-sessions/params.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e9cf305c..9a275880f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. See [standa - The gates `teamai recall promote` applies, `min_confidence`, `min_upvoted`, `min_users` and `min_age_days`, are the `params.yaml` of the default workflow folder `teamai-promote-learning`, shipped at the values the code held. A team tunes them with `workflows/teamai-promote-learning/params.yaml` at its root, holding only the keys it changes; teamai's `WORKFLOW.yaml` and its other values stay in force. The listing, the `promote-candidate` and `promote-publish` steps and the dashboard read the same file, so none offers a learning another rejects. Pull and `teamai review --branch` refuse a value out of range or a key the workflow does not take, naming the file and the field, and the version pull last took stays in force. A member on an older teamai keeps the old gates, so upgrade every member before tuning them (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - `teamai recall maintenance --update-quality` reads which entries it drafts, `min_recalled`, `max_upvoted` and `min_users`, and how many adopted learnings each prompt shows, `max_related`, from the `params.yaml` of the default workflow folder `teamai-draft-learning-updates`, shipped at the values the code held. A team changes them with `workflows/teamai-draft-learning-updates/params.yaml` at its root; the `--dry-run` listing, the `stale` and `draft-context` steps and the dashboard read the same file, and pull refuses a value out of range or an unknown key as it does for the promotion gates (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). +- How long an attended session is idle before it is finished is the `idle` of the default workflow folder `teamai-finished-sessions`'s `params.yaml`, `6h`, rather than `--idle 6h` in its steps and a constant elsewhere. A team sets it with `workflows/teamai-finished-sessions/params.yaml` at its root, from `10m` to `7d`, and the listing, the dashboard's session sharing panel, the share proposals and the usage report's outcomes all count a session finished at that time; the panel and the proposals name it. `teamai session list --finished --idle` still overrides it for one listing (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - A team changes one file of a teamai default hook, rule or workflow folder by keeping that file in a folder of the default's name; every other file stays teamai's and keeps its improvements. Contracts such as the recall rule's kept lines and a hook default's keys are checked on the resulting folder. A team file that replaces no file of the default: pull warns, names it and keeps teamai's file, and `teamai review --branch` fails. A run of such a folder records the teamai version it took files from, and `teamai list --json` marks each file's layer. - Agents and scorers can be folders. `agents/[/]/AGENT.md` is delivered like a rule folder and names its files with `${TEAMAI_AGENT_DIR}`; Claude Code gets the user-scope directory in `additionalDirectories`, and another tool that cannot read it gets a warning. `scorers/[/]/SCORER.md` names its files with `${TEAMAI_SCORER_DIR}`, and the judge reads them in its evidence directory. - `teamai update` records every TeamAI copy this machine runs from in `~/.teamai/installations.json`, and `teamai update` and `teamai doctor` list them. With `updatePolicy: auto` and `updateOtherInstallations: true`, an update of a running copy that is already current can update one other registered npm-global copy per run under its own `--prefix`; npm exiting 0 without changing that copy is a failure. Volta, vendored and local copies are listed with their manual remedy and are never passed to npm (for [#1753](https://github.com/Tencent/teamai-cli/issues/1753)). diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 3d791f129b..fe72819a7f 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -399,7 +399,7 @@ curl -s 'http://localhost:3721/api/tokens?days=30' | jq -r '.byModel[] | select( The Overview's **What finished sessions delivered** panel shows the sessions the usage report counted in the last 30 days for the selected workspace: the share that delivered a commit, pull request or learning, the rows per run mode and outcome with tokens and interventions per session (see [Usage reporting](reporting.md#what-your-sessions-delivered)), and the latest sessions with their commits, pull request links and learnings. -A session row and its details show the learnings `teamai contribute` recorded for it. Its commits and pull request are counted 6 hours after it goes quiet, after the row has left the list, so they show in the panel. These ids, shas and links stay on this machine. +A session row and its details show the learnings `teamai contribute` recorded for it. Its commits and pull request are counted once it has been quiet for the [`idle` of `teamai-finished-sessions`](scorers.md#teamai-finished-sessions), 6 hours unless the team changed it, after the row has left the list, so they show in the panel. These ids, shas and links stay on this machine. ## Session Save diff --git a/docs/guide/reporting.md b/docs/guide/reporting.md index cd5031073e..7d4c6eac6f 100644 --- a/docs/guide/reporting.md +++ b/docs/guide/reporting.md @@ -104,7 +104,7 @@ unattended: ### What your sessions delivered -A session of yours is added to `outcomes`, by its run mode and by what it delivered, by the next report once it has been idle for 6 hours. An unattended session is also added by the `teamai score --drain` that ends its job, so an ephemeral runner reports its sessions: +A session of yours is added to `outcomes`, by its run mode and by what it delivered, by the next report once it has been idle for the [`idle` of `teamai-finished-sessions`](scorers.md#teamai-finished-sessions), 6 hours unless the team changed it. An unattended session is also added by the `teamai score --drain` that ends its job, so an ephemeral runner reports its sessions: ```yaml outcomes: diff --git a/docs/guide/scorers.md b/docs/guide/scorers.md index c1b9a8e251..f8dd1ae10d 100644 --- a/docs/guide/scorers.md +++ b/docs/guide/scorers.md @@ -555,7 +555,7 @@ daily_limit: 10 # per data home: on a fresh CI |---|---|---| | `off` | Nothing. The default. | Nothing. | | `ask` | When the friction reminder offers [`teamai-share-learnings`](workflow.md#teamai-share-learnings-and-teamai-share-session), it proposes the scorers that sample the session, and they judge it once the person confirms. | Acts as `off`, since nobody is there to answer. | -| `auto` | A session is judged once nothing has happened in it for six hours, by the [`teamai-finished-sessions`](#teamai-finished-sessions) run a session start begins. | A session is judged when `teamai score --drain` runs, before the job ends. The default. | +| `auto` | A session is judged once nothing has happened in it for six hours, or the `idle` the team sets, by the [`teamai-finished-sessions`](#teamai-finished-sessions) run a session start begins. | A session is judged when `teamai score --drain` runs, before the job ends. The default. | Each mode resolves on its own, from the first of these that sets it: a member's `scoring.sessions` in `config.yaml`, the active namespaces' `scoring.yaml` files, the root `scorers/scoring.yaml`, the default. When two active namespaces disagree, the most restrictive policy wins (`off`, then `ask`, then `auto`), and so does the lowest `daily_limit`. A partial mapping such as `{ attended: ask }` leaves the other mode to the next level: @@ -732,7 +732,7 @@ The rubric is the [default scorer](#default-scorers) `scorers/teamai-adoption.md ```text teamai-finished-sessions private, invoke: deny, timeout 30m - sessions teamai session list --finished --idle 6h the finished sessions to archive or judge here + sessions teamai session list --finished the finished sessions to archive or judge here share teamai-share-session, for each its publish step, then its teamai-score step lapsed teamai score subjects --lapsed passes and sessions whose judging lapsed, and passes no run scored, each with its scorers rescore teamai-score, for each starts their judging again with those scorers only @@ -748,12 +748,12 @@ $ teamai workflow start teamai-finished-sessions --driver local --inputs '{"publ | Clock | Set in | Default | |---|---|---| -| A session counts as finished | `--idle` of the `sessions` step | 6 hours after its last activity; an unattended session once a drain reached it | +| A session counts as finished | `idle` of the folder's `params.yaml` | 6 hours after its last activity; an unattended session once a drain reached it | | A run ends | the file's `timeout` | 30 minutes; what it cancels lapses, and the next run starts it again | | A judge no driver here takes | the `timeout` of share-session's `score` step | 30 minutes; the session stays published and its judging lapses | | A lapse is started again | the scoring marks | until the fourth lapse, and only within 14 days of the run's last change; with the scorers its marks hold, a named one included, unsampled and, once named, never refused by `daily_limit`, and with no scorer the session policy would add now | -A team changes these in `workflows/teamai-finished-sessions.yaml` in its team repo, which replaces the default: `--idle 1h` takes a session one hour after its last activity, and `timeout` bounds a run. Steps can be added or reordered there too. What a team cannot change stays in the CLI: the automatic starts pass no agent evidence and only the policy inputs. +A team changes the idle time with `workflows/teamai-finished-sessions/params.yaml` in its team repo, holding `idle: 1h` to take a session one hour after its last activity, from `10m` to `7d`. The dashboard, the session sharing proposals and the usage report's outcomes count a session finished at the same time. A `WORKFLOW.yaml` of its own in that folder changes the steps and their `timeout`, and steps can be added or reordered there too; the folder's [README](https://github.com/Tencent/teamai-cli/blob/main/workflows/teamai-finished-sessions/README.md) lists what it keeps. What a team cannot change stays in the CLI: the automatic starts pass no agent evidence and only the policy inputs. ### Drain before an unattended job ends @@ -768,7 +768,7 @@ $ teamai score --drain Every unattended driver ends with `teamai score --drain`. The local driver runs it at the end of an unattended run, unless publishing and scoring of unattended sessions are both `off`, or the run is a `teamai-finished-sessions` run. The skill driver, T3, Paseo and a CI script run it as their last step. It works in this order: -1. In unattended mode, every session this data home captured as unattended counts as finished, the calling session included. Attended sessions keep the six-hour rule. +1. In unattended mode, every session this data home captured as unattended counts as finished, the calling session included. Attended sessions keep the idle rule, six hours unless the team changed it. 2. It starts a [`teamai-finished-sessions`](#teamai-finished-sessions) run here, as a session start does, and drives it on the driver `workflows.driver` names, else on the local driver. A run for a driver this machine does not run waits for it, and the drain says how to drive it here. The run archives the finished sessions with `publish: auto` for their mode and judges the sampled ones the policies allow, within `daily_limit`. It starts `teamai-score` again for each pass and session whose judging lapsed, for each `start: workflow` pass its run left unscored, and for the open passes and sessions the upgrade carried over from judge attempts, except a pass whose run last changed more than 14 days ago. A run already working on this data home is left to it, and the drain says so: run the drain again once it ends. 3. It publishes every pending label, and what a [`deliver: publish`](workflow.md#publish-to-a-data-branch) step left pending on a data branch, as `score --push` does, and the workflow run records a run could not publish, as `teamai pull` does. 4. With `publish: auto` for the current mode, it archives on `teamai-reports`, scrubbed, the step sessions of the runs driven here that are not archived yet. A step session it cannot archive from here, because teamai reads no sessions from its tool or this machine cannot read it, is listed once as skipped with the reason, and never tried again. The other step sessions of that run are still archived. The trial sessions of a [benchmark](benchmarks.md) run stay on the machine that ran it. diff --git a/docs/guide/sharing.md b/docs/guide/sharing.md index 7a6f017737..2cc81c28ef 100644 --- a/docs/guide/sharing.md +++ b/docs/guide/sharing.md @@ -766,7 +766,7 @@ sessions: publish: { attended: ask, unattended: auto } ``` -Attended `ask` proposes publishing when the friction reminder offers [`teamai-share-learnings`](workflow.md#teamai-share-learnings-and-teamai-share-session), and the person confirms it once, at the start. An agent that starts the run with `--confirmed` can confirm it for them, and teamai cannot check that they did. Otherwise a person's sessions are archived only by a command such as `teamai session push`. Unattended `ask` acts as `off`, since nobody is there to confirm. Attended `auto` archives a session once nothing has happened in it for six hours, in the [`teamai-finished-sessions`](scorers.md#teamai-finished-sessions) run a session start begins, which also judges finished sessions. A session pushed by hand before it finished is archived again once something happened in it after that push. Publishing always scrubs, which also rewrites local paths ([what a scrubbed session keeps](dashboard.md#what-a-scrubbed-session-keeps)). An HTTP team keeps no session archive, so there `sessions.publish` acts as `off`, and `teamai doctor` says so. +Attended `ask` proposes publishing when the friction reminder offers [`teamai-share-learnings`](workflow.md#teamai-share-learnings-and-teamai-share-session), and the person confirms it once, at the start. An agent that starts the run with `--confirmed` can confirm it for them, and teamai cannot check that they did. Otherwise a person's sessions are archived only by a command such as `teamai session push`. Unattended `ask` acts as `off`, since nobody is there to confirm. Attended `auto` archives a session once nothing has happened in it for six hours, or the `idle` the team sets, in the [`teamai-finished-sessions`](scorers.md#teamai-finished-sessions) run a session start begins, which also judges finished sessions. A session pushed by hand before it finished is archived again once something happened in it after that push. Publishing always scrubs, which also rewrites local paths ([what a scrubbed session keeps](dashboard.md#what-a-scrubbed-session-keeps)). An HTTP team keeps no session archive, so there `sessions.publish` acts as `off`, and `teamai doctor` says so. A member's `config.yaml` wins, one mode at a time. A mode it does not name falls through to `teamai.yaml`: diff --git a/docs/guide/workflow.md b/docs/guide/workflow.md index 4d4074ca96..379013f2c0 100644 --- a/docs/guide/workflow.md +++ b/docs/guide/workflow.md @@ -1261,7 +1261,7 @@ The proposal follows the team's [publish policy](sharing.md#sessions) and [scori | Input | Situation | Proposed | |---|---|---| | `publish` | attended publish policy `ask` (the default) | `true` | -| | `off`; or `auto`, which publishes the session once it has been idle 6 hours anyway | `false` | +| | `off`; or `auto`, which publishes the session once it has been idle for the `idle` of `teamai-finished-sessions`, 6 hours unless the team changed it, anyway | `false` | | `scorers` | attended scoring policy `ask` | the scorers with `sessions: true` that sample this session, the same ones `teamai-finished-sessions` picks under `auto` | | | `off` or `auto` | `[]` | | `publish`, `scorers` | a tool teamai reads no sessions from | `false`, `[]` | @@ -1355,7 +1355,7 @@ Start from the file the package ships, edit it, push it, and pull: cp "$(npm root -g)/teamai-cli/workflows/teamai-share-learnings.yaml" workflows/teamai-share-learnings.yaml ``` -Five defaults are folders, at `workflows//WORKFLOW.yaml` in the package where earlier releases had `workflows/.yaml`: `teamai-import-from-mr`, `teamai-codebase-deep-enrich`, `teamai-import-from-repo`, `teamai-promote-learning` and `teamai-draft-learning-updates`. A `workflows/.yaml` your team already has still replaces the default. +Six defaults are folders, at `workflows//WORKFLOW.yaml` in the package where earlier releases had `workflows/.yaml`: `teamai-import-from-mr`, `teamai-codebase-deep-enrich`, `teamai-import-from-repo`, `teamai-promote-learning`, `teamai-draft-learning-updates` and `teamai-finished-sessions`. A `workflows/.yaml` your team already has still replaces the default. A folder of the same name changes a default that is a folder. Put only the files you change in it; every other file, `WORKFLOW.yaml` included, stays teamai's and keeps getting teamai's updates. To have MR learnings in English name what the review left open, copy its prompts: diff --git a/docs/guide/zh-CN/dashboard.md b/docs/guide/zh-CN/dashboard.md index d977f17613..6145cc3927 100644 --- a/docs/guide/zh-CN/dashboard.md +++ b/docs/guide/zh-CN/dashboard.md @@ -399,7 +399,7 @@ curl -s 'http://localhost:3721/api/tokens?days=30' | jq -r '.byModel[] | select( 总览的**已结束会话的产出**面板显示当前工作区最近 30 天内被使用统计上报计入的会话:产出了 commit、拉取请求或经验的会话占比;按运行模式和产出分行,带每个会话的 token 和干预次数(参见[使用统计上报](reporting.md#你的会话产出了什么));以及最近的会话和它们的 commit、拉取请求链接和经验。 -会话行和会话详情显示 `teamai contribute` 为该会话记录的经验。它的 commit 和拉取请求在会话空闲 6 小时后才计入,那时会话行已离开列表,因此显示在上述面板中。这些 ID、哈希和链接只保存在本机。 +会话行和会话详情显示 `teamai contribute` 为该会话记录的经验。它的 commit 和拉取请求在会话空闲达到 [`teamai-finished-sessions` 的 `idle`](scorers.md#teamai-finished-sessions)(团队未修改时为 6 小时)后才计入,那时会话行已离开列表,因此显示在上述面板中。这些 ID、哈希和链接只保存在本机。 ## Session Save(会话存档) diff --git a/docs/guide/zh-CN/reporting.md b/docs/guide/zh-CN/reporting.md index a37857490b..57d3f286a8 100644 --- a/docs/guide/zh-CN/reporting.md +++ b/docs/guide/zh-CN/reporting.md @@ -103,7 +103,7 @@ unattended: ### 你的会话产出了什么 -你的一个会话空闲 6 小时后,下一次上报会把它计入 `outcomes`,按它的运行模式和它的产出分行。无人值守的会话还会由结束其任务的 `teamai score --drain` 计入,因此临时运行器也会上报它的会话: +你的一个会话空闲达到 [`teamai-finished-sessions` 的 `idle`](scorers.md#teamai-finished-sessions)(团队未修改时为 6 小时)后,下一次上报会把它计入 `outcomes`,按它的运行模式和它的产出分行。无人值守的会话还会由结束其任务的 `teamai score --drain` 计入,因此临时运行器也会上报它的会话: ```yaml outcomes: diff --git a/docs/guide/zh-CN/scorers.md b/docs/guide/zh-CN/scorers.md index 4dc6442417..9529c5df1f 100644 --- a/docs/guide/zh-CN/scorers.md +++ b/docs/guide/zh-CN/scorers.md @@ -555,7 +555,7 @@ daily_limit: 10 # 按数据目录计数:在 |---|---|---| | `off` | 什么都不做。默认值。 | 什么都不做。 | | `ask` | 摩擦提醒提供 [`teamai-share-learnings`](workflow.md#teamai-share-learnings-和-teamai-share-session) 时,它提议抽样选中该会话的评分器,成员确认后由它们评判。 | 等同于 `off`,因为没有人回答。 | -| `auto` | 会话六小时没有任何活动后,由会话启动时开始的 [`teamai-finished-sessions`](#teamai-finished-sessions) 运行评判。 | 在任务结束前运行 `teamai score --drain` 时评判。默认值。 | +| `auto` | 会话六小时(或团队设定的 `idle`)没有任何活动后,由会话启动时开始的 [`teamai-finished-sessions`](#teamai-finished-sessions) 运行评判。 | 在任务结束前运行 `teamai score --drain` 时评判。默认值。 | 每种模式单独解析,取下面第一个设置了它的位置:成员 `config.yaml` 中的 `scoring.sessions`、生效命名空间的 `scoring.yaml`、根目录的 `scorers/scoring.yaml`、默认值。两个生效命名空间不一致时,最严格的策略生效(`off`,其次 `ask`,最后 `auto`),`daily_limit` 也取最低值。只写一种模式的映射(例如 `{ attended: ask }`)会把另一种模式留给下一级: @@ -732,7 +732,7 @@ teamai:adoption:b27e04… (retry-policy) → ignored judged_count +1 ```text teamai-finished-sessions private, invoke: deny, timeout 30m - sessions teamai session list --finished --idle 6h the finished sessions to archive or judge here + sessions teamai session list --finished the finished sessions to archive or judge here share teamai-share-session, for each its publish step, then its teamai-score step lapsed teamai score subjects --lapsed passes and sessions whose judging lapsed, and passes no run scored, each with its scorers rescore teamai-score, for each starts their judging again with those scorers only @@ -748,12 +748,12 @@ $ teamai workflow start teamai-finished-sessions --driver local --inputs '{"publ | 时钟 | 设置位置 | 默认 | |---|---|---| -| 会话算作已结束 | `sessions` 步骤的 `--idle` | 最后一次活动后 6 小时;无人值守的会话在 drain 到达它时 | +| 会话算作已结束 | 文件夹 `params.yaml` 的 `idle` | 最后一次活动后 6 小时;无人值守的会话在 drain 到达它时 | | 运行结束 | 文件的 `timeout` | 30 分钟;它取消的工作会过期,下一次运行会重新开始 | | 本机没有 driver 领取的评审 | share-session 的 `score` 步骤的 `timeout` | 30 分钟;会话保持已发布,它的评审过期 | | 过期被重新开始 | 评分标记 | 直到第四次过期,且只在运行最后一次变更后的 14 天内;只用标记里的评分器,包括有人点名的那个,不抽样;点名过的不会被 `daily_limit` 拒绝;不加入会话策略此时会选的其他评分器 | -团队在团队仓库的 `workflows/teamai-finished-sessions.yaml` 中修改这些,它会替换默认文件:`--idle 1h` 在会话最后一次活动一小时后就处理它,`timeout` 限定一次运行的时长。也可以在那里添加或调整步骤的顺序。团队不能改变的部分留在 CLI 中:自动启动不传递 agent 证据,只传策略输入。 +团队在团队仓库的 `workflows/teamai-finished-sessions/params.yaml` 中修改空闲时长:写 `idle: 1h` 就在会话最后一次活动一小时后处理它,范围是 `10m` 到 `7d`。dashboard、会话分享的提议和用量报告的产出会在同一时间把会话算作已结束。在这个文件夹里放自己的 `WORKFLOW.yaml` 可以修改步骤和它们的 `timeout`,也可以添加或调整步骤的顺序;文件夹的 [README](https://github.com/Tencent/teamai-cli/blob/main/workflows/teamai-finished-sessions/README.zh-CN.md) 列出了需要保留的部分。团队不能改变的部分留在 CLI 中:自动启动不传递 agent 证据,只传策略输入。 ### 无人值守任务结束前运行 drain @@ -768,7 +768,7 @@ $ teamai score --drain 每个无人值守的 driver 都以 `teamai score --drain` 结束。本地驱动在无人值守运行结束时自动执行它,除非无人值守会话的发布和评判都设为 `off`,或该运行本身是 `teamai-finished-sessions` 运行。skill 驱动、T3、Paseo 和 CI 脚本把它作为最后一步执行。它按以下顺序工作: -1. 在无人值守模式下,本数据目录记录为无人值守的每个已捕获会话都算已结束,包括调用它的会话。有人值守的会话仍按六小时规则。 +1. 在无人值守模式下,本数据目录记录为无人值守的每个已捕获会话都算已结束,包括调用它的会话。有人值守的会话仍按空闲规则,团队未修改时为六小时。 2. 像会话启动那样在这里启动一次 [`teamai-finished-sessions`](#teamai-finished-sessions) 运行,并用 `workflows.driver` 指定的驱动(未指定则用本地驱动)驱动它。指定的驱动本机不运行时,运行会等待该驱动,drain 会说明如何在本机驱动它。该运行在会话所属模式的 `publish` 为 `auto` 时归档已结束的会话,并在 `daily_limit` 内评判策略允许的抽样会话。它为评审过期的每个 pass 和会话、运行未评分的每个 `start: workflow` pass,以及升级时从评审尝试带过来的未完成 pass 和会话重新启动 `teamai-score`,但运行最后一次变更已超过 14 天的 pass 除外。已在处理该数据目录的运行会被留给它,drain 会说明这一点:等它结束后再运行一次 drain。 3. 像 `score --push` 一样发布每个待发布的标签,以及 [`deliver: publish`](workflow.md#发布到数据分支) 步骤留在数据分支上待发布的内容,并像 `teamai pull` 一样发布运行当时没能发布的工作流运行记录。 4. 当前模式的 `publish` 为 `auto` 时,把在这里驱动的运行中尚未归档的步骤会话,脱敏后归档到 `teamai-reports`。无法从这里归档的步骤会话(teamai 读不到其工具的会话,或本机读不到这个会话)只列出一次已跳过及原因,之后不再重试。该运行的其他步骤会话照常归档。[基准测试](benchmarks.md)运行的 trial 会话留在运行它的机器上。 diff --git a/docs/guide/zh-CN/sharing.md b/docs/guide/zh-CN/sharing.md index 173dd08548..3d8714e6d5 100644 --- a/docs/guide/zh-CN/sharing.md +++ b/docs/guide/zh-CN/sharing.md @@ -758,7 +758,7 @@ sessions: publish: { attended: ask, unattended: auto } ``` -有人值守的 `ask` 会在摩擦提醒提供 [`teamai-share-learnings`](workflow.md#teamai-share-learnings-和-teamai-share-session) 时提议发布,成员在启动时确认一次。带 `--confirmed` 启动运行的 agent 可以替成员确认,teamai 无法核实。除此之外,成员的会话只会通过 `teamai session push` 等命令归档。无人值守的 `ask` 等同于 `off`,因为没有人在场确认。有人值守的 `auto` 在会话六小时没有任何活动后归档它,由会话启动时开始的 [`teamai-finished-sessions`](scorers.md#teamai-finished-sessions) 运行完成,该运行也评判已结束的会话。在结束前手动推送过的会话,在那次推送之后又有活动时会被再次归档。发布总会脱敏,同时改写本机路径(见[脱敏后的会话保留什么](dashboard.md#脱敏后的会话保留什么))。HTTP 团队没有会话归档,因此在那里 `sessions.publish` 等同于 `off`,`teamai doctor` 会说明这一点。 +有人值守的 `ask` 会在摩擦提醒提供 [`teamai-share-learnings`](workflow.md#teamai-share-learnings-和-teamai-share-session) 时提议发布,成员在启动时确认一次。带 `--confirmed` 启动运行的 agent 可以替成员确认,teamai 无法核实。除此之外,成员的会话只会通过 `teamai session push` 等命令归档。无人值守的 `ask` 等同于 `off`,因为没有人在场确认。有人值守的 `auto` 在会话六小时(或团队设定的 `idle`)没有任何活动后归档它,由会话启动时开始的 [`teamai-finished-sessions`](scorers.md#teamai-finished-sessions) 运行完成,该运行也评判已结束的会话。在结束前手动推送过的会话,在那次推送之后又有活动时会被再次归档。发布总会脱敏,同时改写本机路径(见[脱敏后的会话保留什么](dashboard.md#脱敏后的会话保留什么))。HTTP 团队没有会话归档,因此在那里 `sessions.publish` 等同于 `off`,`teamai doctor` 会说明这一点。 成员的 `config.yaml` 优先,按模式分别生效。它没有写的模式沿用 `teamai.yaml`: diff --git a/docs/guide/zh-CN/workflow.md b/docs/guide/zh-CN/workflow.md index 506be4008a..19aae1c025 100644 --- a/docs/guide/zh-CN/workflow.md +++ b/docs/guide/zh-CN/workflow.md @@ -1261,7 +1261,7 @@ agent 运行 describe,它为调用它的会话给每个输入提议一个 `val | 输入 | 情况 | 提议值 | |---|---|---| | `publish` | 有人值守的发布策略为 `ask`(默认) | `true` | -| | `off`;或 `auto`,会话空闲 6 小时后反正会发布 | `false` | +| | `off`;或 `auto`,会话空闲达到 `teamai-finished-sessions` 的 `idle`(团队未修改时为 6 小时)后反正会发布 | `false` | | `scorers` | 有人值守的评分策略为 `ask` | 设置了 `sessions: true` 且抽样选中该会话的评分器,与 `auto` 下 `teamai-finished-sessions` 选中的相同 | | | `off` 或 `auto` | `[]` | | `publish`、`scorers` | teamai 不读取其会话的工具 | `false`、`[]` | @@ -1355,7 +1355,7 @@ describe 为调用它的会话提议每个输入并附 `why`:会话第一次 cp "$(npm root -g)/teamai-cli/workflows/teamai-share-learnings.yaml" workflows/teamai-share-learnings.yaml ``` -有五个默认工作流是文件夹,在包中位于 `workflows//WORKFLOW.yaml`,早期版本是 `workflows/.yaml`:`teamai-import-from-mr`、`teamai-codebase-deep-enrich`、`teamai-import-from-repo`、`teamai-promote-learning` 和 `teamai-draft-learning-updates`。团队已有的 `workflows/.yaml` 仍会替换默认工作流。 +有六个默认工作流是文件夹,在包中位于 `workflows//WORKFLOW.yaml`,早期版本是 `workflows/.yaml`:`teamai-import-from-mr`、`teamai-codebase-deep-enrich`、`teamai-import-from-repo`、`teamai-promote-learning`、`teamai-draft-learning-updates` 和 `teamai-finished-sessions`。团队已有的 `workflows/.yaml` 仍会替换默认工作流。 文件夹形式的默认工作流,用同名文件夹修改。里面只放你要改的文件;其他文件(包括 `WORKFLOW.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。比如想让英文的 MR learning 写出评审留下的未决问题,先复制它的提示词: diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index c90da03266..930d133f09 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -449,7 +449,7 @@ Generated: do not edit by hand. Regenerate with - `--author ` — Filter by author - `--all` — List sessions across all projects in the team repo (not just the current one). With --finished: every finished session to share learnings from, not only those to archive and judge (no --publish or --scorers) - `--finished` — List the finished sessions of this machine to archive and judge, not the team's pushed ones - - `--idle ` — With --finished: how long an attended session has been idle to count as finished, such as 6h (default). An unattended one counts once a drain reached it. + - `--idle ` — With --finished: how long an attended session has been idle to count as finished, such as 1h. Default: the idle of teamai-finished-sessions's params.yaml, 6h unless the team changed it. An unattended one counts once a drain reached it. - `--within ` — With --finished --all: how recently a session was last active to be listed, such as 3d (default 7d) - `--publish ` — With --finished: policy (sessions.publish for the session's run mode decides, the default), always or never. Inside a workflow step, only a run a person started may pass always or never. - `--scorers ` — With --finished: a JSON array of scorers to judge every finished session with, unsampled; [] (the default) takes the scorers scoring.sessions: auto names. Inside a workflow step, only a run a person started may name scorers. diff --git a/src/__tests__/dashboard-session-sharing-view.test.ts b/src/__tests__/dashboard-session-sharing-view.test.ts index abd906329d..9720ebb81f 100644 --- a/src/__tests__/dashboard-session-sharing-view.test.ts +++ b/src/__tests__/dashboard-session-sharing-view.test.ts @@ -33,6 +33,7 @@ const defaults: Extract = { attended: { active: 2, finished: 1, waitingToArchive: 0, archived: 0 }, unattended: { waitingForDrain: 1, finished: 0, waitingToArchive: 0, archived: 3 }, }, + finishedAfter: '6h', stepSessionsWaitingToArchive: { attended: 0, unattended: 0 }, pendingLabels: 2, next: [{ mode: 'unattended', command: 'TEAMAI_MODE=unattended teamai score --drain' }], @@ -46,6 +47,8 @@ describe('the session sharing panel', () => { expect(shown).toContain('Attended Neither published nor scored'); expect(shown).toContain('Unattended Published and scored'); expect(shown).toContain('by teamai score --drain before the job ends'); + expect(text(render({ ...defaults, publish: { ...defaults.publish, attended: { policy: 'auto', source: { kind: 'default' } } }, finishedAfter: '1h' }).html)) + .toContain('Attended Published, not scored in the background, once a session has been idle for 1h'); expect(shown).toContain('Publish ask proposed when the friction reminder offers teamai-share-learnings Set by the default'); expect(shown).toContain('Score auto Set by the default'); expect(shown).toMatch(/1 waiting for a drain/); diff --git a/src/__tests__/finished-sessions.test.ts b/src/__tests__/finished-sessions.test.ts index 13066128cf..44232c2311 100644 --- a/src/__tests__/finished-sessions.test.ts +++ b/src/__tests__/finished-sessions.test.ts @@ -146,12 +146,19 @@ describe('session list --finished', () => { }); }); - it('takes a session idle for --idle, so a team file with --idle 1h takes it one hour after its last activity', async () => { + it('takes a session idle for --idle, else for the idle of the team\'s params.yaml, so 1h takes it one hour after its last activity', async () => { const config = await team({}, { scoring: { sessions: true } }); capturedSession(IDLE, 'attended', { idleFor: 1.5 * HOUR }); + const teamais = await listed(config, {}); + fs.mkdirSync(path.join(config.repo.localPath, 'workflows', 'teamai-finished-sessions'), { recursive: true }); + fs.writeFileSync(path.join(config.repo.localPath, 'workflows', 'teamai-finished-sessions', 'params.yaml'), 'idle: 1h\n'); + await syncScorersThenWorkflows(config); - expect(await listed(config, { idle: '6h' })).toEqual({ ok: true, list: [] }); - expect(await listed(config, { idle: '1h' })).toMatchObject({ ok: true, list: [{ session: IDLE }] }); + expect([teamais, await listed(config, {}), await listed(config, { idle: '6h' })]).toMatchObject([ + { ok: true, list: [] }, + { ok: true, list: [{ session: IDLE }] }, + { ok: true, list: [] }, + ]); }); it('publishes a session where sessions.publish is auto for its run mode, and with scoring off lists it with no scorer', async () => { @@ -748,7 +755,7 @@ describe('the start of teamai-finished-sessions', () => { }); it('ends a run of a team copy that sets no timeout once 30 minutes passed, so the session it was judging is started again', async () => { - const shipped = fs.readFileSync(path.resolve('workflows/teamai-finished-sessions.yaml'), 'utf8'); + const shipped = fs.readFileSync(path.resolve('workflows/teamai-finished-sessions/WORKFLOW.yaml'), 'utf8'); const config = await team({ 'workflows/teamai-finished-sessions.yaml': shipped.replace(/^timeout: .*\r?\n/mu, '') }, { scoring: { sessions: true } }); await syncScorersThenWorkflows(config); capturedSession(IDLE, 'attended'); diff --git a/src/__tests__/session-share.test.ts b/src/__tests__/session-share.test.ts index 267308a4ef..6bd8308637 100644 --- a/src/__tests__/session-share.test.ts +++ b/src/__tests__/session-share.test.ts @@ -134,7 +134,7 @@ describe('the session share offer', () => { }); expect((await offerOf(await member({ scorers, files: { 'teamai.yaml': 'team: acme\nrepo: local\nsessions:\n publish:\n attended: auto\n' } }))).publish).toEqual({ value: false, - why: `${PUBLISH_WHY} Not proposed: the attended publish policy is auto, so teamai publishes this session, scrubbed, once it has been idle 6 hours.`, + why: `${PUBLISH_WHY} Not proposed: the attended publish policy is auto, so teamai publishes this session, scrubbed, once it has been idle for 6h.`, }); }); @@ -147,7 +147,7 @@ describe('the session share offer', () => { }); expect((await offerOf(await member({ scorers, config: { scoring: { sessions: { attended: 'auto' } } } }))).scorers).toEqual({ value: [], - why: `${SCORERS_WHY} None proposed: the attended scoring policy is auto, so the background judge scores this session once it has been idle 6 hours. Name any scorer to judge with it.`, + why: `${SCORERS_WHY} None proposed: the attended scoring policy is auto, so the background judge scores this session once it has been idle for 6h. Name any scorer to judge with it.`, }); }); diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index 9bdd21dfa9..08e49db696 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -1775,7 +1775,7 @@ async function readLogRaw(filePath: string): Promise<{ events: DashboardEvent[]; return { events, outcomes }; } -/** How long compaction keeps a session outcome line: past the 6 hours a session takes to finish, and the dashboard's month. */ +/** How long compaction keeps a session outcome line: past the idle a session takes to finish, 7 days at most, and the dashboard's month. */ export const SESSION_OUTCOME_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; /** The session outcome events of the log at `eventsPath` (the default log when absent), in file order. */ diff --git a/src/dashboard/client.ts b/src/dashboard/client.ts index ae2fc8a75b..32f6a5e0f3 100644 --- a/src/dashboard/client.ts +++ b/src/dashboard/client.ts @@ -101,7 +101,7 @@ function outcomePanel() { const o=outcomeSummary, head=''; if(!o) return head+(outcomesError?errorPanel('Could not load session outcomes.','outcomes'):'
'+label('Loading…')+'
'); const retry=outcomesError?errorPanel('Could not refresh session outcomes. Any displayed values are the last successful result.','outcomes'):''; - if(!o.sessions) return head+retry+'

'+label('No finished session counted yet')+'

'+label('The usage report counts a session 6 hours after it goes quiet, when teamai pull reports usage. Its commits, pull requests and learnings then show here.')+'

'; + if(!o.sessions) return head+retry+'

'+label('No finished session counted yet')+'

'+label('The usage report counts a session once it has been quiet as long as teamai-finished-sessions waits, 6 hours unless your team changed it, when teamai pull reports usage. Its commits, pull requests and learnings then show here.')+'

'; const rows=Object.keys(outcomeModes).flatMap(mode=>Object.keys(outcomeKinds).filter(kind=>o.rows[mode]?.[kind]).map(kind=>[mode,kind,o.rows[mode][kind]])); const perSession=(r,value)=>value/r.sessions; return head+retry+'
'+label('Delivered')+'
'+label('Finished sessions with a commit, pull request or learning')+'
'+number(Math.round(o.delivered/o.sessions*100))+'%
'+number(o.delivered)+' / '+number(o.sessions)+' '+label('sessions')+'
' @@ -466,7 +466,7 @@ function showDetails(sessionId) { detailSection('Session activity','

'+e(s.cwd)+'

'+label('Started')+': '+e(when(s.startedAt))+'
'+label('Last activity')+': '+e(when(s.lastActivity))+'
'+label('Duration')+': '+e(duration(s))+' · '+label('Last tool')+': '+e(s.lastTool)+'

')+ detailSection('Human interventions','

'+label('Interrupt')+': '+number(iv.interrupt)+' · '+label('Tool reject')+': '+number(iv.toolReject)+' · '+label('Correction')+': '+number(iv.correction)+'

')+ detailSection('Token usage','

'+label('Input')+': '+number(usage.input)+' · '+label('Output')+': '+number(usage.output)+'
'+label('Cache read')+': '+number(usage.cacheRead)+' · '+label('Cache creation')+': '+number(usage.cacheCreation)+'

')+ - detailSection('Outcomes','

'+outcomesHtml(s.outcomes)+'

'+label('A learning shows here once contribute records it. Commits and pull requests are counted 6 hours after the session goes quiet, and show under What finished sessions delivered on the Overview.')+'

')+ + detailSection('Outcomes','

'+outcomesHtml(s.outcomes)+'

'+label('A learning shows here once contribute records it. Commits and pull requests are counted once the session has been quiet as long as teamai-finished-sessions waits, 6 hours unless your team changed it, and show under What finished sessions delivered on the Overview.')+'

')+ detailSection('User prompts','

'+number(s.promptCount)+' '+label('Prompt turns')+'

'+(s.prompts||[]).map((p,i)=>'

'+number(i+1)+'. '+e(p)+'

').join(''))+ detailSection('AI output','
'+ (s.stoppedOutput?renderMarkdown(s.stoppedOutput):'

'+label('No output captured yet.')+'

')+'
'); if(!dialog.open)dialog.showModal(); diff --git a/src/dashboard/locales.ts b/src/dashboard/locales.ts index 6b043455d9..0c79644077 100644 --- a/src/dashboard/locales.ts +++ b/src/dashboard/locales.ts @@ -483,7 +483,7 @@ export const dashboardMessages = { "Could not load session outcomes.": "无法加载会话产出。", "Could not refresh session outcomes. Any displayed values are the last successful result.": "无法刷新会话产出。当前显示的是上次成功的结果。", "No finished session counted yet": "尚无已统计的结束会话", - "The usage report counts a session 6 hours after it goes quiet, when teamai pull reports usage. Its commits, pull requests and learnings then show here.": "会话静默 6 小时后,teamai pull 上报用量时会统计该会话,之后其提交、拉取请求和经验会显示在这里。", + "The usage report counts a session once it has been quiet as long as teamai-finished-sessions waits, 6 hours unless your team changed it, when teamai pull reports usage. Its commits, pull requests and learnings then show here.": "会话静默达到 teamai-finished-sessions 的等待时长(团队未修改时为 6 小时)后,teamai pull 上报用量时会统计该会话,之后其提交、拉取请求和经验会显示在这里。", "Finished sessions with a commit, pull request or learning": "产出了提交、拉取请求或经验的已结束会话", "sessions": "个会话", "By run mode and outcome": "按运行模式与产出", @@ -495,7 +495,7 @@ export const dashboardMessages = { "Recently finished": "最近结束", "Local only: ids, shas and links never leave this machine": "仅本机:ID、提交哈希和链接不会离开此机器", "COUNTED": "统计时间", - "A learning shows here once contribute records it. Commits and pull requests are counted 6 hours after the session goes quiet, and show under What finished sessions delivered on the Overview.": "经验在 contribute 记录后显示在这里。提交和拉取请求在会话静默 6 小时后统计,显示在总览的「已结束会话的产出」中。", + "A learning shows here once contribute records it. Commits and pull requests are counted once the session has been quiet as long as teamai-finished-sessions waits, 6 hours unless your team changed it, and show under What finished sessions delivered on the Overview.": "经验在 contribute 记录后显示在这里。提交和拉取请求在会话静默达到 teamai-finished-sessions 的等待时长(团队未修改时为 6 小时)后统计,显示在总览的「已结束会话的产出」中。", "Benchmark results": "基准测试结果", "Model (source)": "模型(来源)", "Planned": "计划数", @@ -874,7 +874,7 @@ export const dashboardMessages = { "Published, not scored": "会发布,不评分", "Scored, not published": "会评分,不发布", "Neither published nor scored": "既不发布也不评分", - "in the background, once a session has been idle for 6 hours": "在会话空闲 6 小时后由后台完成", + "in the background, once a session has been idle for": "在后台完成,会话空闲时长达到", "by teamai score --drain before the job ends": "在任务结束前由 teamai score --drain 完成", "Publish": "发布", "Score": "评分", diff --git a/src/dashboard/session-sharing-view.ts b/src/dashboard/session-sharing-view.ts index 464ff66481..f690f3172e 100644 --- a/src/dashboard/session-sharing-view.ts +++ b/src/dashboard/session-sharing-view.ts @@ -5,7 +5,7 @@ export const sessionSharingView = ` const sharingModeNames = { attended: 'Attended', unattended: 'Unattended' }; const sharingModeSources = { TEAMAI_MODE: 'set by TEAMAI_MODE', CI: 'set by CI', default: 'neither TEAMAI_MODE nor CI is set' }; -const sharingWhen = { attended: 'in the background, once a session has been idle for 6 hours', unattended: 'by teamai score --drain before the job ends' }; +const sharingWhen = { attended: 'in the background, once a session has been idle for', unattended: 'by teamai score --drain before the job ends' }; const sharingAsk = { attended: 'proposed when the friction reminder offers teamai-share-learnings', unattended: 'acts as off: nobody is there to confirm' }; const sharingStates = { attended: [['active','active'],['finished','kept on this machine'],['waitingToArchive','waiting to be archived'],['archived','archived']], @@ -21,7 +21,7 @@ function sharingMode(s, mode) { :'
'+label('Score')+'
off'+label('Scoring is unavailable here')+'
'; return ''; diff --git a/src/dashboard/session-sharing.ts b/src/dashboard/session-sharing.ts index 9feedde18a..00ddd9f0fe 100644 --- a/src/dashboard/session-sharing.ts +++ b/src/dashboard/session-sharing.ts @@ -38,6 +38,8 @@ export type SessionSharing = /** The enabled tools teamai reads no sessions from: their sessions are never captured, published or judged. */ unreadTools: string[]; captured: CapturedSessions; + /** How long an attended session is idle before it is finished, as teamai-finished-sessions writes it, such as `6h`. */ + finishedAfter: string; /** The step sessions of the runs that ended here in each run mode that wait to be archived: none while `publish` is not `auto` for it. */ stepSessionsWaitingToArchive: Record; /** Labels judged here that a drain publishes to teamai-reports. */ @@ -53,9 +55,9 @@ type Configured = Extract; * active until it is finished, an unattended one waits for a drain until one reaches it; a finished one waits to be * archived while `publish` is `auto` for its mode. One whose file is gone and that was not archived is left out. */ -async function capturedHere(config: LocalConfig, now: string, publish: SessionPolicies): Promise { +async function capturedHere(config: LocalConfig, { now, idleMs }: { now: string; idleMs: number }, publish: SessionPolicies): Promise { const sessions = await SessionCaptures.list(config); - const finished = new Set((await SessionWork.finished(sessions, now, () => true)).map(sessionKey)); + const finished = new Set((await SessionWork.finished(sessions, now, () => true, idleMs)).map(sessionKey)); const counts: CapturedSessions = { attended: { active: 0, finished: 0, waitingToArchive: 0, archived: 0 }, @@ -136,16 +138,17 @@ function nextSteps( export async function readSessionSharing(config: LocalConfig, { env, platform, now }: { env: NodeJS.ProcessEnv; platform: NodeJS.Platform; now: string }): Promise { const unavailable = scoringUnavailable(config); - const [team, scored] = await Promise.all([ + const [team, scored, finishedAfter] = await Promise.all([ loadTeamConfig(config.repo.localPath), unavailable === undefined ? scopeScoring(config) : { ok: false as const, reason: unavailable }, + SessionWork.finishedAfter(config), ]); const publish = SessionPolicy.publish(config, team); const [scorers, captured, stepSessionsWaitingToArchive, pendingLabels] = await Promise.all([ scored.ok ? SessionWork.sessionScorers(config) : [], - capturedHere(config, now, publish.policies), + capturedHere(config, { now, idleMs: finishedAfter.ms }, publish.policies), stepsWaitingToArchive(config, publish.policies), LabelStore.unpublished(config), ]); @@ -158,6 +161,7 @@ export async function readSessionSharing(config: LocalConfig, { env, platform, n ? { state: 'ok', ...scored.sessions, scorers: scorers.map(({ scorer }) => scorer).sort() } : { state: 'unavailable', reason: scored.reason }, captured, + finishedAfter: finishedAfter.text, stepSessionsWaitingToArchive, pendingLabels, } satisfies Omit; diff --git a/src/default-folders.ts b/src/default-folders.ts index 12b2bd0d32..fe4982ca81 100644 --- a/src/default-folders.ts +++ b/src/default-folders.ts @@ -5,6 +5,7 @@ import { packageRoot } from './package-info.js'; import { DeliveredFolders } from './resources/delivered-folders.js'; import { FolderFiles, type FolderFile } from './resources/folder-files.js'; import { isJsonObject, type JsonObject, type JsonValue, type LocalConfig, type TeamaiConfig } from './types.js'; +import { durationMs } from './utils/duration.js'; import { log } from './utils/logger.js'; import { parseYaml } from './utils/yaml.js'; @@ -34,6 +35,24 @@ function ratio() { return z.number({ error }).min(0, { error }).max(1, { error }); } +/** A duration as a workflow's `timeout` writes it, from `min` to `max`, read as its text and its milliseconds. */ +function duration(min: string, max: string) { + const error = `must be a duration from ${min} to ${max}, such as 6h`; + const [low = 0, high = 0] = [durationMs(min), durationMs(max)]; + + return z.string({ error }).transform((text, ctx) => { + const ms = durationMs(text); + + if (ms === undefined || ms < low || ms > high) { + ctx.addIssue({ code: 'custom', message: error }); + + return z.NEVER; + } + + return { text, ms }; + }); +} + /** The tool-error tiers, the first whose `min` the count reaches giving its points. */ const ToolErrorTiersSchema = z.array(z.strictObject({ min: whole(1, 1000), points: whole(0, 100) }), { error: 'must be a list of { min, points } tiers' }) .min(1, { error: 'must list from 1 to 5 tiers' }) @@ -66,6 +85,7 @@ const PARAMS = { 'teamai-recall': NoParamsSchema, }, workflows: { + 'teamai-finished-sessions': z.strictObject({ idle: duration('10m', '7d') }), 'teamai-draft-learning-updates': z.strictObject({ min_recalled: whole(1, 1000), max_upvoted: whole(0, 1000), min_users: whole(1, 1000), max_related: whole(0, 20) }), 'teamai-promote-learning': z.strictObject({ min_confidence: ratio(), min_upvoted: whole(1, 1000), min_users: whole(1, 1000), min_age_days: whole(0, 3650) }), }, @@ -101,6 +121,7 @@ const TEXTS = { }, workflows: { 'teamai-draft-learning-updates': {}, + 'teamai-finished-sessions': {}, 'teamai-promote-learning': {}, }, } as const satisfies { readonly [TKind in keyof typeof PARAMS]: { readonly [TName in keyof (typeof PARAMS)[TKind]]: Readonly> } }; diff --git a/src/session-flow/finished-sessions.ts b/src/session-flow/finished-sessions.ts index 61a4a3fc51..496a52eb6a 100644 --- a/src/session-flow/finished-sessions.ts +++ b/src/session-flow/finished-sessions.ts @@ -93,7 +93,8 @@ export type Shareable = | Refusal; interface Request { - idleMs: number; + /** Null when `--idle` is not given, so the scope's teamai-finished-sessions decides. */ + idleMs: number | null; publish: (typeof PUBLISH)[number]; scorers: string[]; } @@ -113,8 +114,8 @@ function scorerNames(text: string): string[] | undefined { } } -function requestOf({ idle = '6h', publish = 'policy', scorers = '[]' }: ListOptions): Request | Refusal { - const idleMs = durationMs(idle); +function requestOf({ idle, publish = 'policy', scorers = '[]' }: ListOptions): Request | Refusal { + const idleMs = idle === undefined ? null : durationMs(idle); if (idleMs === undefined) return refused('invalid_arguments', `--idle ${idle} is not a duration. Pass a whole number of s, m or h, such as 6h.`); const policy = z.enum(PUBLISH).safeParse(publish); @@ -215,7 +216,7 @@ async function shareable({ within = SHARE_WITHIN, suite, sizes, ...options }: Sh if (!scope.ok) return scope; const since = Date.parse(ctx.now) - withinMs; - const finished = (await SessionWork.finished(await SessionCaptures.list(scope.config), ctx.now, () => true, request.idleMs)).filter((session) => session.activeAt >= since); + const finished = (await SessionWork.finished(await SessionCaptures.list(scope.config), ctx.now, () => true, request.idleMs ?? (await SessionWork.finishedAfter(scope.config)).ms)).filter((session) => session.activeAt >= since); if (suite !== undefined) return candidates(scope.config, finished, { dir: suite, sizes: parsedSizes?.sizes }); const found: ShareableSession[] = []; @@ -350,7 +351,7 @@ async function listIn(config: LocalConfig, request: Request, now: string): Promi if (!RUN_MODES.some(wanted)) return { ok: true, list: listed }; - for (const session of await SessionWork.finished(await SessionCaptures.list(config), now, wanted, request.idleMs)) { + for (const session of await SessionWork.finished(await SessionCaptures.list(config), now, wanted, request.idleMs ?? (await SessionWork.finishedAfter(config)).ms)) { const item = await itemOf(config, { wants, session, now }); if (item) listed.push(item); diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 99d8d363fa..e19fd999b7 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -774,7 +774,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .option('--author ', 'Filter by author') .option('--all', 'List sessions across all projects in the team repo (not just the current one). With --finished: every finished session to share learnings from, not only those to archive and judge (no --publish or --scorers)') .option('--finished', 'List the finished sessions of this machine to archive and judge, not the team\'s pushed ones') - .option('--idle ', 'With --finished: how long an attended session has been idle to count as finished, such as 6h (default). An unattended one counts once a drain reached it.') + .option('--idle ', 'With --finished: how long an attended session has been idle to count as finished, such as 1h. Default: the idle of teamai-finished-sessions\'s params.yaml, 6h unless the team changed it. An unattended one counts once a drain reached it.') .option('--within ', 'With --finished --all: how recently a session was last active to be listed, such as 3d (default 7d)') .option('--publish ', 'With --finished: policy (sessions.publish for the session\'s run mode decides, the default), always or never. Inside a workflow step, only a run a person started may pass always or never.') .option('--scorers ', 'With --finished: a JSON array of scorers to judge every finished session with, unsampled; [] (the default) takes the scorers scoring.sessions: auto names. Inside a workflow step, only a run a person started may name scorers.') diff --git a/src/session-flow/session-work.ts b/src/session-flow/session-work.ts index 0a73051083..9700bb3966 100644 --- a/src/session-flow/session-work.ts +++ b/src/session-flow/session-work.ts @@ -1,13 +1,14 @@ /** * A finished agent session as judge work (#1178, #1179). One redacted read gives the evidence of every judge call and * the context its labels carry. A session captured in a scope is judge work there once it is finished, for each scorer - * that sets `sessions: true`: an attended one once nothing happened in it for six hours, an unattended one once + * that sets `sessions: true`: an attended one once nothing happened in it for the `idle` of teamai-finished-sessions, an unattended one once * `teamai score --drain` reached it. teamai-finished-sessions lists it and teamai-score judges it. */ import fs from 'node:fs/promises'; import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import type { AgentObservation } from '../agent-observation.js'; +import { DefaultFolders } from '../default-folders.js'; import { knownSecrets } from '../env-resolution.js'; import type { RunMode } from '../launch-context.js'; import { cachedScorerNames, pinScorers, scorerNamespaces } from '../scorers/cache.js'; @@ -241,8 +242,10 @@ export function sessionLabel(verdict: Omit { + return (await DefaultFolders.load({ kind: 'workflows', name: 'teamai-finished-sessions' }, config)).params.idle; +} function sameSessionKey(label: ScoreLabel, scorer: string, sourceHash: string, subject: SessionSubject): boolean { return label.source === 'session' && label.scorer === scorer && label.rubric.sourceHash === sourceHash @@ -387,11 +390,11 @@ async function isGone(captured: CapturedSession): Promise { /** * The sessions of `captured` finished at `now`, oldest activity first, of the run modes `includes` keeps (a capture with - * no mode counts as attended): an attended session once nothing happened in it for `idleMs`, six hours unless given, an + * no mode counts as attended): an attended session once nothing happened in it for `idleMs`, as {@link finishedAfter} gives it, an * unattended one once a drain reached it (#1385), each with when something last happened in it. A session whose file * is gone is never finished. */ -async function finished(captured: readonly CapturedSession[], now: string, includes: (mode: RunMode) => boolean, idleMs = FINISHED_AFTER_MS): Promise> { +async function finished(captured: readonly CapturedSession[], now: string, includes: (mode: RunMode) => boolean, idleMs: number): Promise> { const events = await lastEvents(); const found: Array<{ captured: CapturedSession; activeAt: number }> = []; @@ -492,4 +495,4 @@ async function sampledScorers(config: LocalConfig, subject: SessionSubject, fact }); } -export const SessionWork = { finished, read, readPushed, scopeCwd, sampledScorers, samplingScorers, sessionScorers, generatedQuestions, pinsOf, isGone } as const; +export const SessionWork = { finishedAfter, finished, read, readPushed, scopeCwd, sampledScorers, samplingScorers, sessionScorers, generatedQuestions, pinsOf, isGone } as const; diff --git a/src/session-outcomes.ts b/src/session-outcomes.ts index ee571e83a3..688ee358d8 100644 --- a/src/session-outcomes.ts +++ b/src/session-outcomes.ts @@ -2,7 +2,7 @@ * What the member's finished sessions delivered, folded into the usage report once each (#1387). * * report ─ fold(): each member session of the scope's log → its facts in reported-outcomes.json, until it is - * │ finished (6 hours idle, or unattended at a drain); a finished one gets its outcomes (learnings recorded for it, commits + * │ finished (idle for teamai-finished-sessions's idle, or unattended at a drain); a finished one gets its outcomes (learnings recorded for it, commits * │ its member made, the open pull request holding one, as a report found it) and adds to its * │ (run mode, kind) rows * └ push confirmed ─ markReported(): flagged reported, and a `counted` session outcome event written @@ -18,7 +18,7 @@ import { RunModeSchema } from './launch-context.js'; import { appendEvent, dataHomeKey, readSessionOutcomes, SESSION_OUTCOME_RETENTION_MS, sessionRuns } from './dashboard-collector.js'; import { addCounts, uniqueOutcomes, type SessionOutcomeEvent, type SessionUsage, type WorkOutcome } from './outcomes.js'; import { interventionsEntry, promptTokensEntry, snapshotPathIn, type ReportedInterventions, type ReportedPromptTokens } from './session-owners.js'; -import { FINISHED_AFTER_MS } from './session-flow/session-work.js'; +import { SessionWork } from './session-flow/session-work.js'; import { getDataHome, type DashboardEvent, type LocalConfig, type UserOutcomeStats } from './types.js'; import { pathExists, readJson, writeJson } from './utils/fs.js'; import { checkoutForRepoAnchor, getCurrentBranch, getDefaultBranch, getHeadCommit } from './utils/git.js'; @@ -105,13 +105,13 @@ async function readBaseline(config: LocalConfig): Promise return parsed.success ? parsed.data : undefined; } -function isIdle(facts: Facts, now: number): boolean { - return Date.parse(facts.activeAt) + FINISHED_AFTER_MS <= now; +function isIdle(facts: Facts, now: number, idleMs: number): boolean { + return Date.parse(facts.activeAt) + idleMs <= now; } -/** Idle for 6 hours, or unattended at a drain: an ephemeral runner's sessions are counted before the job ends. */ -function isFinished(facts: Facts, { now, drain }: Pick): boolean { - return isIdle(facts, now) || (drain !== undefined && facts.runMode === 'unattended'); +/** Idle for `idleMs`, or unattended at a drain: an ephemeral runner's sessions are counted before the job ends. */ +function isFinished(facts: Facts, { now, drain }: Pick, idleMs: number): boolean { + return isIdle(facts, now, idleMs) || (drain !== undefined && facts.runMode === 'unattended'); } /** The facts the scope's member events give each session, merged over what an earlier report kept. */ @@ -273,6 +273,7 @@ async function lookUpPr({ cwd, branch, commits }: { cwd: string; branch: string; async function fold(input: FoldInput): Promise { const outcomes = await scopeOutcomes(input); const kept = await readBaseline(input.config); + const { ms: idleMs } = await SessionWork.finishedAfter(input.config); const baseline = kept ?? {}; // Written on the first report even with no session, so a later one never takes its sessions for an earlier release's. let changed = kept === undefined; @@ -284,7 +285,7 @@ async function fold(input: FoldInput): Promise { const facts = merged(entry?.facts, seen); // A drain's first report is an ephemeral runner's every report, so only idle sessions are an earlier release's. - if (kept === undefined && isIdle(facts, input.now)) { + if (kept === undefined && isIdle(facts, input.now, idleMs)) { baseline[sessionId] = { reported: true }; } else if (JSON.stringify(facts) !== JSON.stringify(entry?.facts)) { baseline[sessionId] = { reported: false, facts }; @@ -295,7 +296,7 @@ async function fold(input: FoldInput): Promise { if (changed) await writeJson(baselinePath(input.config), baseline); const pending = Object.entries(baseline).flatMap(([sessionId, entry]) => - (entry.reported ? [] : [{ sessionId, facts: entry.facts, isDue: isFinished(entry.facts, input) }])); + (entry.reported ? [] : [{ sessionId, facts: entry.facts, isDue: isFinished(entry.facts, input, idleMs) }])); // A session's pull request is looked up at each report until one is found, so one merged before the session is // finished still counts, and once more when it is; the answer is kept for a report whose push fails. diff --git a/src/session-share.ts b/src/session-share.ts index 2c25815b31..94506d088f 100644 --- a/src/session-share.ts +++ b/src/session-share.ts @@ -43,14 +43,15 @@ function listOf(items: readonly string[]): string { return items.length > 1 ? `${items.slice(0, -1).join(', ')} and ${items.slice(-1).join('')}` : items.join(''); } -function publishProposal(policy: SessionPolicies['attended']): Proposal { +/** The attended publish proposal under `policy`; the idle time of `config` is read only for `auto`, which names it. */ +async function publishProposal(config: LocalConfig, policy: SessionPolicies['attended']): Promise> { switch (policy.policy) { case 'ask': return { value: true, why: `${PUBLISH_WHY} Proposed because the attended publish policy (${SessionPolicy.setBy(policy.source)}) is ask. It publishes once the run starts, with no other approval. Set it false to skip publishing.` }; case 'off': return { value: false, why: `${PUBLISH_WHY} Not proposed: the attended publish policy (${SessionPolicy.setBy(policy.source)}) is off.` }; case 'auto': - return { value: false, why: `${PUBLISH_WHY} Not proposed: the attended publish policy is auto, so teamai publishes this session, scrubbed, once it has been idle 6 hours.` }; + return { value: false, why: `${PUBLISH_WHY} Not proposed: the attended publish policy is auto, so teamai publishes this session, scrubbed, once it has been idle for ${(await SessionWork.finishedAfter(config)).text}.` }; default: { const unhandled: never = policy.policy; @@ -101,7 +102,7 @@ async function propose(config: LocalConfig, { tool, sessionId }: { tool: string; } const [team, scoring] = await Promise.all([loadTeamConfig(config.repo.localPath), scopeScoring(config)]); - const publish = publishProposal(SessionPolicy.publish(config, team).policies.attended); + const publish = await publishProposal(config, SessionPolicy.publish(config, team).policies.attended); const source = { value: platform, why: 'This session\'s tool, as teamai session list names it.' }; if (!scoring.ok) return { session, source, publish, scorers: { value: [], why: `${SCORERS_WHY} None proposed: ${scoring.reason} ${NAME_ANY}` } }; @@ -117,7 +118,7 @@ async function propose(config: LocalConfig, { tool, sessionId }: { tool: string; case 'off': return { session, source, publish, scorers: { value: [], why: `${SCORERS_WHY} None proposed: the attended scoring policy (${SessionPolicy.setBy(policy.source)}) is off. ${NAME_ANY}` } }; case 'auto': - return { session, source, publish, scorers: { value: [], why: `${SCORERS_WHY} None proposed: the attended scoring policy is auto, so the background judge scores this session once it has been idle 6 hours. ${NAME_ANY}` } }; + return { session, source, publish, scorers: { value: [], why: `${SCORERS_WHY} None proposed: the attended scoring policy is auto, so the background judge scores this session once it has been idle for ${(await SessionWork.finishedAfter(config)).text}. ${NAME_ANY}` } }; default: { const unhandled: never = policy.policy; diff --git a/workflows/teamai-finished-sessions/README.md b/workflows/teamai-finished-sessions/README.md new file mode 100644 index 0000000000..b8940d57c5 --- /dev/null +++ b/workflows/teamai-finished-sessions/README.md @@ -0,0 +1,101 @@ +# teamai-finished-sessions + +English | [中文](README.zh-CN.md) + +Archives and scores this machine's finished sessions, as the team's session policies say, and starts again the judging that lapsed. + +| Started by | Inputs | +|---|---| +| A session start, in the background | `{"publish":"policy","scorers":[]}` | +| `teamai score --drain` | `{"publish":"policy","scorers":[]}` | +| `teamai workflow start teamai-finished-sessions` from your own terminal | Any. Only a run a person started may pass other inputs | +| An agent session | Never by name: `invoke: deny` | + +## How teamai's version runs + +```mermaid +flowchart TD + sessions["List the finished sessions to archive or judge: an attended one idle for idle, an unattended one once a drain reached it"] --> share + subgraph share [For each session. One that fails leaves the others] + one["Run teamai-share-session: publish it, then score it"] + end + share --> lapsed["List the judging that lapsed"] + lapsed --> rescore + subgraph rescore [For each lapse] + again["Run teamai-score with the scorers its marks hold"] + end + classDef wf fill:#3b6ea5,stroke:#2b5079,color:#fff; + class one,again wf; +``` + +Blue marks another default workflow, which a team can replace too. `WORKFLOW.yaml` holds the steps. A run ends after its `timeout`, 30 minutes, and what it cancels lapses, so the next run starts it again. + +## Files + +| File | What it is | +|---|---| +| `WORKFLOW.yaml` | The steps, the `timeout`, and the `output:` each step declares | +| `params.yaml` | How long an attended session is idle before it is finished | + +## Inputs + +| Input | Type | Meaning | +|---|---|---| +| `publish` | `policy \| always \| never` | `policy` lets `sessions.publish` decide for each session's run mode | +| `scorers` | `[string]` | The scorers that judge each finished session, unsampled. `[]` lets `scoring.sessions` decide | + +## Outputs + +None. Each session's publishing and scoring are recorded where `teamai-share-session` and `teamai-score` keep them. + +## Parameters + +| Key | Range | What it sets | +|---|---|---| +| `idle` | `10m`-`7d` | How long nothing happens in an attended session before it is finished, such as `6h` | + +The dashboard, the share proposals of `teamai-share-session` and the usage report's outcomes count a session finished at the same `idle`. `teamai session list --finished --idle ` overrides it for one listing. + +## Replace it + +Put only the files you change in `workflows/teamai-finished-sessions/` at the root of the team repo. Every other file, `WORKFLOW.yaml` included, stays teamai's and keeps getting teamai's updates. A team whose sessions are short, and wants them judged the same afternoon: + +```yaml +# workflows/teamai-finished-sessions/params.yaml +idle: 1h +``` + +Push the folder. After the next `teamai pull`, every reader above uses it. + +A `WORKFLOW.yaml` of your own in the folder replaces the steps and the `timeout`, and `params.yaml` still decides when a session is finished. A `workflows/teamai-finished-sessions.yaml` file replaces the folder whole, so teamai's `idle` applies. + +| Keep | Why | +|---|---| +| The inputs above | A session start and `teamai score --drain` pass them | +| A `sessions` step running `teamai session list --finished` | Its refusal of other inputs in a run no person started is what keeps an automatic start to the policies | + +## What pull refuses + +| A team folder that | Pull | +|---|---| +| Sets an `idle` that is not a duration from `10m` to `7d`, or a key the table above does not list | Names the file and the field. The version pull last took stays in force, teamai's until it took one | + +`teamai review --branch` fails such a folder before you merge it. + +## Requires + +| Needs | Without it | +|---|---| +| A scorer that sets `sessions: true` | Nothing is judged. Publishing still follows `sessions.publish` | +| A team repo kept by git | No session is archived, since an HTTP team repo keeps no session archive | + +## Settings that change it + +| Setting | Team `teamai.yaml` | Member config | Environment | Effect | +|---|---|---|---|---| +| Publishing | `sessions.publish` | `sessions.publish` | | Decides, per run mode, whether a finished session is archived | +| Scoring | `sessions` in `scorers/scoring.yaml` | `scoring.sessions` | | Decides, per run mode, whether a finished session is judged, up to `daily_limit` | +| Run mode | | | `TEAMAI_MODE`, then `CI` | Decides whether a session is attended, and finishes at `idle`, or unattended, and finishes at a drain | +| Driver | `workflows.driver` | `workflows.driver` | | Picks where `teamai score --drain` starts the run. Unset means this machine | + +The member config is `~/.teamai/projects//config.yaml` in a workspace, then `~/.teamai/config.yaml`. The environment wins over the member config, and the member config wins over the team. See [member settings](https://github.com/Tencent/teamai-cli/blob/main/docs/guide/reference.md#member-settings) for the defaults. diff --git a/workflows/teamai-finished-sessions/README.zh-CN.md b/workflows/teamai-finished-sessions/README.zh-CN.md new file mode 100644 index 0000000000..cb79794d2a --- /dev/null +++ b/workflows/teamai-finished-sessions/README.zh-CN.md @@ -0,0 +1,101 @@ +# teamai-finished-sessions + +[English](README.md) | 中文 + +按团队的会话策略归档并评判本机已结束的会话,并重新开始已失效的评判。 + +| 启动方式 | 输入 | +|---|---| +| 会话启动时,在后台 | `{"publish":"policy","scorers":[]}` | +| `teamai score --drain` | `{"publish":"policy","scorers":[]}` | +| 在你自己的终端运行 `teamai workflow start teamai-finished-sessions` | 任意。只有成员启动的运行可以传其他输入 | +| agent 会话 | 从不按名称启动:`invoke: deny` | + +## teamai 的版本如何运行 + +```mermaid +flowchart TD + sessions["列出要归档或评判的已结束会话:有人值守的会话空闲达到 idle,无人值守的会话在 drain 到达它时"] --> share + subgraph share ["逐个会话处理。一个失败不影响其他会话"] + one["运行 teamai-share-session:先发布,再评分"] + end + share --> lapsed["列出已失效的评判"] + lapsed --> rescore + subgraph rescore ["逐个失效项处理"] + again["用它的标记里的评分器运行 teamai-score"] + end + classDef wf fill:#3b6ea5,stroke:#2b5079,color:#fff; + class one,again wf; +``` + +蓝色表示另一个默认工作流,团队也可以替换它。`WORKFLOW.yaml` 保存这些步骤。一次运行在 `timeout`(30 分钟)后结束,被取消的部分会失效,下一次运行会重新开始它。 + +## 文件 + +| 文件 | 是什么 | +|---|---| +| `WORKFLOW.yaml` | 步骤、`timeout`,以及每个步骤声明的 `output:` | +| `params.yaml` | 有人值守的会话空闲多久后算作已结束 | + +## 输入 + +| 输入 | 类型 | 含义 | +|---|---|---| +| `publish` | `policy \| always \| never` | `policy` 表示按每个会话的运行模式由 `sessions.publish` 决定 | +| `scorers` | `[string]` | 评判每个已结束会话的评分器,不抽样。`[]` 表示由 `scoring.sessions` 决定 | + +## 输出 + +无。每个会话的发布和评分记录在 `teamai-share-session` 和 `teamai-score` 保存它们的地方。 + +## 参数 + +| 键 | 范围 | 设置什么 | +|---|---|---| +| `idle` | `10m`-`7d` | 有人值守的会话多久没有任何活动后算作已结束,例如 `6h` | + +dashboard、`teamai-share-session` 的分享提议和用量报告的产出,都按同一个 `idle` 把会话算作已结束。`teamai session list --finished --idle ` 只对这一次列出覆盖它。 + +## 替换它 + +在团队仓库根目录的 `workflows/teamai-finished-sessions/` 里只放你要改的文件。其他文件(包括 `WORKFLOW.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。会话都很短、希望当天下午就评判的团队: + +```yaml +# workflows/teamai-finished-sessions/params.yaml +idle: 1h +``` + +推送这个文件夹。下一次 `teamai pull` 之后,上面所有读取它的地方都用它。 + +文件夹里放你自己的 `WORKFLOW.yaml` 会替换步骤和 `timeout`,`params.yaml` 仍决定会话何时算作已结束。`workflows/teamai-finished-sessions.yaml` 单个文件会整体替换这个文件夹,此时用 teamai 的 `idle`。 + +| 保留 | 原因 | +|---|---| +| 上面的输入 | 会话启动和 `teamai score --drain` 会传这些输入 | +| 运行 `teamai session list --finished` 的 `sessions` 步骤 | 它会拒绝非成员启动的运行传入的其他输入,自动启动因此只能按策略处理 | + +## pull 拒绝什么 + +| 团队文件夹 | pull 的处理 | +|---|---| +| 设置的 `idle` 不是 `10m` 到 `7d` 之间的时长,或上表没有列出的键 | 指出文件和字段。pull 上一次接受的版本继续生效,还没接受过时用 teamai 的 | + +`teamai review --branch` 会在合并前让这样的文件夹不通过。 + +## 依赖 + +| 需要 | 没有时 | +|---|---| +| 设置了 `sessions: true` 的评分器 | 不评判任何会话。发布仍按 `sessions.publish` | +| 用 git 保存的团队仓库 | 不归档任何会话,因为 HTTP 团队仓库不保存会话存档 | + +## 影响它的设置 + +| 设置 | 团队 `teamai.yaml` | 成员配置 | 环境变量 | 作用 | +|---|---|---|---|---| +| 发布 | `sessions.publish` | `sessions.publish` | | 按运行模式决定已结束的会话是否归档 | +| 评分 | `scorers/scoring.yaml` 的 `sessions` | `scoring.sessions` | | 按运行模式决定已结束的会话是否评判,上限为 `daily_limit` | +| 运行模式 | | | `TEAMAI_MODE`,其次是 `CI` | 决定会话是有人值守(空闲达到 `idle` 时结束)还是无人值守(drain 到达时结束) | +| 驱动 | `workflows.driver` | `workflows.driver` | | 选出 `teamai score --drain` 在哪里启动运行。未设置表示本机 | + +成员配置在工作区里是 `~/.teamai/projects//config.yaml`,其次是 `~/.teamai/config.yaml`。环境变量优先于成员配置,成员配置优先于团队。默认值见[成员设置](https://github.com/Tencent/teamai-cli/blob/main/docs/guide/zh-CN/reference.md#成员设置)。 diff --git a/workflows/teamai-finished-sessions/WORKFLOW.yaml b/workflows/teamai-finished-sessions/WORKFLOW.yaml index f8c0fe4185..0bc44ab332 100644 --- a/workflows/teamai-finished-sessions/WORKFLOW.yaml +++ b/workflows/teamai-finished-sessions/WORKFLOW.yaml @@ -12,8 +12,8 @@ inputs: scorers: [string] steps: - id: sessions - # A session idle this long counts as finished. Only a run a person started may pass inputs other than policy and []. - command: teamai session list --finished --idle 6h --publish "$PUBLISH" --scorers "$SCORERS" --json + # A session idle for the idle of params.yaml counts as finished. Only a run a person started may pass inputs other than policy and []. + command: teamai session list --finished --publish "$PUBLISH" --scorers "$SCORERS" --json env: { PUBLISH: "{{ inputs.publish }}", SCORERS: "{{ inputs.scorers }}" } # demand: named when the inputs name the scorers, so daily_limit counts the session but never refuses it output: { list: [{ session: string, source: string, publish: boolean, scorers: [string], demand: string }] } diff --git a/workflows/teamai-finished-sessions/params.yaml b/workflows/teamai-finished-sessions/params.yaml new file mode 100644 index 0000000000..586c00e0c3 --- /dev/null +++ b/workflows/teamai-finished-sessions/params.yaml @@ -0,0 +1 @@ +idle: 6h From a47b97c87494e192ebbee40697b432b639653812 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:30:02 +0100 Subject: [PATCH 11/18] feat(hooks): word the friction reminder's reasons as texts of its folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the reasons the friction reminder names (you interrupted the AI twice, you rejected 1 tool call, ...) were English strings in code. A team with language: zh got them inside a Chinese sentence, and no team could reword them. What: the folder ships reason-interrupt, reason-tool-reject, reason-correction and reason-tool-error, in English and Chinese. They read {{ count }}, {{ times }} and {{ tool_calls }}, worded in the language of the file read: twice or 2 次, 1 tool call or 1 次工具调用. The reasons are joined with a comma of that language. The contract lists the new texts and their variables, so pull checks a team's replacement of them, and both READMEs list them. Proof: the default-hooks golden fixture now expects Chinese reasons for zh. It failed on all eight reason cases before the change and passes after. English and unset stay byte for byte what they were. The contribute-check, hook replacement and dashboard suites, the contribute e2e file, typecheck and lint pass. --- CHANGELOG.md | 2 +- hooks/teamai-friction-reminder/README.md | 4 +- .../teamai-friction-reminder/README.zh-CN.md | 4 +- .../reason-correction.md | 1 + .../reason-correction.zh.md | 1 + .../reason-interrupt.md | 1 + .../reason-interrupt.zh.md | 1 + .../reason-tool-error.md | 1 + .../reason-tool-error.zh.md | 1 + .../reason-tool-reject.md | 1 + .../reason-tool-reject.zh.md | 1 + .../dashboard-layered-resources.test.ts | 2 +- .../fixtures/default-hooks/golden.json | 16 +++---- src/contribute-check.ts | 48 +++++++++---------- src/default-folders.ts | 4 ++ 15 files changed, 50 insertions(+), 38 deletions(-) create mode 100644 hooks/teamai-friction-reminder/reason-correction.md create mode 100644 hooks/teamai-friction-reminder/reason-correction.zh.md create mode 100644 hooks/teamai-friction-reminder/reason-interrupt.md create mode 100644 hooks/teamai-friction-reminder/reason-interrupt.zh.md create mode 100644 hooks/teamai-friction-reminder/reason-tool-error.md create mode 100644 hooks/teamai-friction-reminder/reason-tool-error.zh.md create mode 100644 hooks/teamai-friction-reminder/reason-tool-reject.md create mode 100644 hooks/teamai-friction-reminder/reason-tool-reject.zh.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a275880f2..92785d472a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to this project will be documented in this file. See [standa - A team hook can be a folder, `hooks/[/]/HOOK.yaml` beside the scripts it runs, with the keys of a `hooks.yaml` entry except `id`, `roles` and `projects`; `${TEAMAI_HOOK_DIR}` in `command` names the folder. In single-repo, Claude Code's command names it through `$CLAUDE_PROJECT_DIR`. `requireTeamScripts` keeps a folder hook whose command names `${TEAMAI_HOOK_DIR}`. `${TEAMAI_HOOK_DIR}` in `hooks.yaml`, or an id that is both an entry and a folder, stops team hooks for the run, and `teamai review --branch` fails a branch with either, or with a `HOOK.yaml` that does not parse. A single-repo worktree's folders are removed on uninstall. Once a worktree is removed, the next pull of another worktree, or `teamai doctor --fix`, removes the team hook entries it left in HOME (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A team MCP server can be a folder, `mcp/[/]/MCP.yaml` beside the files it runs, with the keys of an `mcp.yaml` server except `name`, `roles` and `projects`, stdio only; `${TEAMAI_MCP_DIR}` in `command`, `args` and `env` names the folder for every tool `mcp/mcp.yaml` reaches. In single-repo each tool's MCP file holds the worktree's absolute path. `requires` and `sharing.mcp.allowedCommands` apply to the runner, as for any server. A folder with `transport: http` or `sse`, `${TEAMAI_MCP_DIR}` in `mcp.yaml`, or a name that is both a server there and a folder stops team MCP for the run and keeps what is installed (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A benchmark suite can be a folder, `benchmarks/[/]/SUITE.yaml`; `suite.yaml` keeps working. A directory holding both is invalid, and pull and `teamai benchmark try` name both files. Docs and messages show `SUITE.yaml` (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). -- teamai's hints keep their thresholds and texts in default hook folders the package ships: `hooks/teamai-friction-reminder/`, `teamai-mr-hint/`, `teamai-todowrite-hint/`, `teamai-env-hint/` and `teamai-package-hint/`, each with a `README.md` listing its parameters, ranges, variables and switches. A team replaces one with a folder of the same name at the root of its repo, holding only the files it changes: `params.yaml` holds the parameters, where a key left out keeps teamai's value, `HOOK.yaml` holds only `description`, and each text is `.md` with `{{ name }}` variables. Pull refuses a replacement that sets a parameter out of range or one the default does not take, reads a variable its text does not offer, leaves a text out, has a `command`, or sits in a namespace, names why, and keeps teamai's folder in force. Each hint reads the replacement in every tool, the older `teamai contribute-check`, `mr-hint` and `todowrite-hint` entry points included. With `language: en` or `zh`, a hint prints `.en.md` or `.zh.md` when its folder has one. With no replacement every hint prints what it did, except that a team with `language: zh` now sees the friction reminder in Chinese (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). +- teamai's hints keep their thresholds and texts in default hook folders the package ships: `hooks/teamai-friction-reminder/`, `teamai-mr-hint/`, `teamai-todowrite-hint/`, `teamai-env-hint/` and `teamai-package-hint/`, each with a `README.md` listing its parameters, ranges, variables and switches. A team replaces one with a folder of the same name at the root of its repo, holding only the files it changes: `params.yaml` holds the parameters, where a key left out keeps teamai's value, `HOOK.yaml` holds only `description`, and each text is `.md` with `{{ name }}` variables. Pull refuses a replacement that sets a parameter out of range or one the default does not take, reads a variable its text does not offer, leaves a text out, has a `command`, or sits in a namespace, names why, and keeps teamai's folder in force. Each hint reads the replacement in every tool, the older `teamai contribute-check`, `mr-hint` and `todowrite-hint` entry points included. With `language: en` or `zh`, a hint prints `.en.md` or `.zh.md` when its folder has one. The friction reminder's reasons are texts too, `reason-interrupt.md`, `reason-tool-reject.md`, `reason-correction.md` and `reason-tool-error.md`, with `{{ count }}`, `{{ times }}` and `{{ tool_calls }}` worded in the language of the file read. With no replacement every hint prints what it did, except that a team with `language: zh` now sees the friction reminder in Chinese, its reasons included (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A team rule can be a folder: `rules/[/]/RULE.md` beside the files it sends the agent to, named by its path as a rule file is. `RULE..md` gives the body for a `language` under the frontmatter of `RULE.md`. Pull copies the folder to each worktree's `.teamai/delivered/rules/`, which git ignores, and puts its path, with forward slashes, in place of `${TEAMAI_RULE_DIR}`: every tool reads it there, a tool that reads nothing outside the project by its path from the project root. A user-scope rule names `~/.teamai/rules/`, granted to Claude Code through `permissions.additionalDirectories` in `~/.claude/settings.json` (uninstall takes it back), and pull warns once for each other tool that may ask before reading it. Push proposes nothing from a folder's copies. teamai's recall rule is now the folder `rules/teamai-recall/`, which a team replaces with its own at the root of the team repo; pull refuses a replacement missing a line recall needs or holding a link, keeps teamai's, and `teamai review --branch` fails it. Upgrade every member before committing a rule folder (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - The dashboard's Resources page has a Resource layers panel: for the selected workspace, every definition of each workflow, hook, MCP server, benchmark suite and rule folder, with its layer (teamai's default, the team root, or a namespace), whether it is a file, folder or entry, what it replaces, and whether pull uses it, with what pull prints and the fix when it does not. A folder's row lists its files with size and executable mark, the total against a workflow folder's 1 MB limit, where pull delivers it, and the command it runs; a rule folder's names the language file its text comes from and where each tool reads `${TEAMAI_RULE_DIR}`. teamai's recall rule and its five default hooks are listed beside the team's replacements, the hooks with each `with:` parameter against teamai's, and a default workflow that cannot start against the team's workflows is shown as held. The Overview counts the problems and links there. Names, paths and sizes only, never a file's content or an env value (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - OpenClaw, Hermes and DeepSeek Harness write team MCP servers and HTTP-delivered models to their user config files, through the same pull, `teamai mcp list`, doctor and uninstall as other tools. Workspace MCP ownership and reporting stay with the workspace: uninstall keeps entries another workspace still owns and preserves member edits. Unreadable ownership records keep the config and retry records intact; removal counts include only entries actually deleted. Delivered models are added beside the member's settings, without changing the current model, primary or fallbacks. diff --git a/hooks/teamai-friction-reminder/README.md b/hooks/teamai-friction-reminder/README.md index 042307bbd0..86c290f38e 100644 --- a/hooks/teamai-friction-reminder/README.md +++ b/hooks/teamai-friction-reminder/README.md @@ -86,7 +86,9 @@ Push the folder. The next `teamai pull` checks your files with teamai's other fi | File | Variables | |---|---| | `problem.md`, `gap.md`, `action.md`, `action-gap.md` | None | -| `problem-reasons.md`, `gap-reasons.md` | `{{ reasons }}`, such as `you interrupted the AI twice`. It is always English | +| `problem-reasons.md`, `gap-reasons.md` | `{{ reasons }}`, the reason texts of the signals the session had, joined with commas, such as `you interrupted the AI twice, you rejected 1 tool call` | +| `reason-interrupt.md`, `reason-correction.md`, `reason-tool-error.md` | `{{ count }}`, such as `2`, and `{{ times }}`, such as `twice`, or `2 次` in a `.zh.md` file | +| `reason-tool-reject.md` | `{{ count }}`, `{{ times }}`, and `{{ tool_calls }}`, such as `1 tool call`, or `1 次工具调用` in a `.zh.md` file | | `task.md` | `{{ task }}`, the first prompt on one line, with secrets redacted and long prompts cut | | `action-share-learnings.md` | `{{ command }}`, the command that describes `teamai-share-learnings` | diff --git a/hooks/teamai-friction-reminder/README.zh-CN.md b/hooks/teamai-friction-reminder/README.zh-CN.md index 943d72132d..ada8ba93c8 100644 --- a/hooks/teamai-friction-reminder/README.zh-CN.md +++ b/hooks/teamai-friction-reminder/README.zh-CN.md @@ -86,7 +86,9 @@ weights: { reject: 30 } # interrupt and correction keep teamai's values | 文件 | 变量 | |---|---| | `problem.md`、`gap.md`、`action.md`、`action-gap.md` | 无 | -| `problem-reasons.md`、`gap-reasons.md` | `{{ reasons }}`,例如 `you interrupted the AI twice`。它总是英文 | +| `problem-reasons.md`、`gap-reasons.md` | `{{ reasons }}`,会话出现的各个信号的原因文本,用逗号连接,例如 `你打断了 AI 2 次,你拒绝了 1 次工具调用` | +| `reason-interrupt.md`、`reason-correction.md`、`reason-tool-error.md` | `{{ count }}`,例如 `2`;`{{ times }}`,`.zh.md` 文件中例如 `2 次`,英文文件中例如 `twice` | +| `reason-tool-reject.md` | `{{ count }}`、`{{ times }}`,以及 `{{ tool_calls }}`,`.zh.md` 文件中例如 `1 次工具调用`,英文文件中例如 `1 tool call` | | `task.md` | `{{ task }}`,第一条提示压成一行,密钥已打码,过长的提示会截断 | | `action-share-learnings.md` | `{{ command }}`,描述 `teamai-share-learnings` 的命令 | diff --git a/hooks/teamai-friction-reminder/reason-correction.md b/hooks/teamai-friction-reminder/reason-correction.md new file mode 100644 index 0000000000..7528bb8e29 --- /dev/null +++ b/hooks/teamai-friction-reminder/reason-correction.md @@ -0,0 +1 @@ +you corrected the AI {{ times }} diff --git a/hooks/teamai-friction-reminder/reason-correction.zh.md b/hooks/teamai-friction-reminder/reason-correction.zh.md new file mode 100644 index 0000000000..b005ca3122 --- /dev/null +++ b/hooks/teamai-friction-reminder/reason-correction.zh.md @@ -0,0 +1 @@ +你纠正了 AI {{ times }} diff --git a/hooks/teamai-friction-reminder/reason-interrupt.md b/hooks/teamai-friction-reminder/reason-interrupt.md new file mode 100644 index 0000000000..018c4a8aaa --- /dev/null +++ b/hooks/teamai-friction-reminder/reason-interrupt.md @@ -0,0 +1 @@ +you interrupted the AI {{ times }} diff --git a/hooks/teamai-friction-reminder/reason-interrupt.zh.md b/hooks/teamai-friction-reminder/reason-interrupt.zh.md new file mode 100644 index 0000000000..2d2613aaf8 --- /dev/null +++ b/hooks/teamai-friction-reminder/reason-interrupt.zh.md @@ -0,0 +1 @@ +你打断了 AI {{ times }} diff --git a/hooks/teamai-friction-reminder/reason-tool-error.md b/hooks/teamai-friction-reminder/reason-tool-error.md new file mode 100644 index 0000000000..f409929420 --- /dev/null +++ b/hooks/teamai-friction-reminder/reason-tool-error.md @@ -0,0 +1 @@ +the AI retried failing tools {{ times }} diff --git a/hooks/teamai-friction-reminder/reason-tool-error.zh.md b/hooks/teamai-friction-reminder/reason-tool-error.zh.md new file mode 100644 index 0000000000..aa19b84987 --- /dev/null +++ b/hooks/teamai-friction-reminder/reason-tool-error.zh.md @@ -0,0 +1 @@ +AI 重试失败的工具 {{ times }} diff --git a/hooks/teamai-friction-reminder/reason-tool-reject.md b/hooks/teamai-friction-reminder/reason-tool-reject.md new file mode 100644 index 0000000000..f2754c5666 --- /dev/null +++ b/hooks/teamai-friction-reminder/reason-tool-reject.md @@ -0,0 +1 @@ +you rejected {{ tool_calls }} diff --git a/hooks/teamai-friction-reminder/reason-tool-reject.zh.md b/hooks/teamai-friction-reminder/reason-tool-reject.zh.md new file mode 100644 index 0000000000..ff3df64cfa --- /dev/null +++ b/hooks/teamai-friction-reminder/reason-tool-reject.zh.md @@ -0,0 +1 @@ +你拒绝了 {{ tool_calls }} diff --git a/src/__tests__/dashboard-layered-resources.test.ts b/src/__tests__/dashboard-layered-resources.test.ts index ebe497fb0f..7028544731 100644 --- a/src/__tests__/dashboard-layered-resources.test.ts +++ b/src/__tests__/dashboard-layered-resources.test.ts @@ -320,7 +320,7 @@ describe('default hooks on the Resources page', () => { const reminder = await defaultHook('teamai-friction-reminder'); expect([reminder.state, reminder.texts.filter((text) => text.replaced).map((text) => text.text)]).toEqual([ - { kind: 'replaced', path: 'hooks/teamai-friction-reminder/', inherited: { teamaiVersion: PACKAGE_VERSION, own: 1, teamai: 18 } }, + { kind: 'replaced', path: 'hooks/teamai-friction-reminder/', inherited: { teamaiVersion: PACKAGE_VERSION, own: 1, teamai: 26 } }, ['task'], ]); }); diff --git a/src/__tests__/fixtures/default-hooks/golden.json b/src/__tests__/fixtures/default-hooks/golden.json index 40e0fe7d5a..50d2909946 100644 --- a/src/__tests__/fixtures/default-hooks/golden.json +++ b/src/__tests__/fixtures/default-hooks/golden.json @@ -40,22 +40,22 @@ "problem-reasons": { "unset": "[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nConsider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`).", "en": "[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nConsider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`).", - "zh": "[teamai] 这次会话可能有值得记录下来的问题:you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times。\n\n可以运行 `/teamai share what this session taught me`,总结这次会话学到的东西并分享给团队(也可以运行 `teamai skill get share`)。" + "zh": "[teamai] 这次会话可能有值得记录下来的问题:你打断了 AI 2 次,你拒绝了 1 次工具调用,你纠正了 AI 3 次,AI 重试失败的工具 6 次。\n\n可以运行 `/teamai share what this session taught me`,总结这次会话学到的东西并分享给团队(也可以运行 `teamai skill get share`)。" }, "problem-reasons-share-learnings": { "unset": "[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nOffer the person the teamai-share-learnings workflow: run `teamai workflow start --describe teamai-share-learnings --driver skill` and start it with the inputs it proposes, once the person confirms them.", "en": "[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nOffer the person the teamai-share-learnings workflow: run `teamai workflow start --describe teamai-share-learnings --driver skill` and start it with the inputs it proposes, once the person confirms them.", - "zh": "[teamai] 这次会话可能有值得记录下来的问题:you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times。\n\n向用户提议使用 teamai-share-learnings 工作流:运行 `teamai workflow start --describe teamai-share-learnings --driver skill`,等用户确认它建议的输入后,用这些输入启动它。" + "zh": "[teamai] 这次会话可能有值得记录下来的问题:你打断了 AI 2 次,你拒绝了 1 次工具调用,你纠正了 AI 3 次,AI 重试失败的工具 6 次。\n\n向用户提议使用 teamai-share-learnings 工作流:运行 `teamai workflow start --describe teamai-share-learnings --driver skill`,等用户确认它建议的输入后,用这些输入启动它。" }, "problem-reasons-task": { "unset": "[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nTask: Fix the flaky login test\n\nConsider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`).", "en": "[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nTask: Fix the flaky login test\n\nConsider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`).", - "zh": "[teamai] 这次会话可能有值得记录下来的问题:you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times。\n\n任务:Fix the flaky login test\n\n可以运行 `/teamai share what this session taught me`,总结这次会话学到的东西并分享给团队(也可以运行 `teamai skill get share`)。" + "zh": "[teamai] 这次会话可能有值得记录下来的问题:你打断了 AI 2 次,你拒绝了 1 次工具调用,你纠正了 AI 3 次,AI 重试失败的工具 6 次。\n\n任务:Fix the flaky login test\n\n可以运行 `/teamai share what this session taught me`,总结这次会话学到的东西并分享给团队(也可以运行 `teamai skill get share`)。" }, "problem-reasons-task-share-learnings": { "unset": "[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nTask: Fix the flaky login test\n\nOffer the person the teamai-share-learnings workflow: run `teamai workflow start --describe teamai-share-learnings --driver skill` and start it with the inputs it proposes, once the person confirms them.", "en": "[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nTask: Fix the flaky login test\n\nOffer the person the teamai-share-learnings workflow: run `teamai workflow start --describe teamai-share-learnings --driver skill` and start it with the inputs it proposes, once the person confirms them.", - "zh": "[teamai] 这次会话可能有值得记录下来的问题:you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times。\n\n任务:Fix the flaky login test\n\n向用户提议使用 teamai-share-learnings 工作流:运行 `teamai workflow start --describe teamai-share-learnings --driver skill`,等用户确认它建议的输入后,用这些输入启动它。" + "zh": "[teamai] 这次会话可能有值得记录下来的问题:你打断了 AI 2 次,你拒绝了 1 次工具调用,你纠正了 AI 3 次,AI 重试失败的工具 6 次。\n\n任务:Fix the flaky login test\n\n向用户提议使用 teamai-share-learnings 工作流:运行 `teamai workflow start --describe teamai-share-learnings --driver skill`,等用户确认它建议的输入后,用这些输入启动它。" }, "gap": { "unset": "[teamai] The current task is not covered by the team knowledge base.\n\nConsider running `/teamai share what this session taught me` to summarize what you learned, share it with your team, and fill the knowledge gap (or run `teamai skill get share`).", @@ -80,22 +80,22 @@ "gap-reasons": { "unset": "[teamai] The current task is not covered by the team knowledge base, and this session had signs of friction: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nConsider running `/teamai share what this session taught me` to summarize what you learned, share it with your team, and fill the knowledge gap (or run `teamai skill get share`).", "en": "[teamai] The current task is not covered by the team knowledge base, and this session had signs of friction: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nConsider running `/teamai share what this session taught me` to summarize what you learned, share it with your team, and fill the knowledge gap (or run `teamai skill get share`).", - "zh": "[teamai] 团队知识库还没有覆盖当前任务,而且这次会话有不顺利的迹象:you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times。\n\n可以运行 `/teamai share what this session taught me`,总结这次会话学到的东西,分享给团队,补上知识库的这块空白(也可以运行 `teamai skill get share`)。" + "zh": "[teamai] 团队知识库还没有覆盖当前任务,而且这次会话有不顺利的迹象:你打断了 AI 2 次,你拒绝了 1 次工具调用,你纠正了 AI 3 次,AI 重试失败的工具 6 次。\n\n可以运行 `/teamai share what this session taught me`,总结这次会话学到的东西,分享给团队,补上知识库的这块空白(也可以运行 `teamai skill get share`)。" }, "gap-reasons-share-learnings": { "unset": "[teamai] The current task is not covered by the team knowledge base, and this session had signs of friction: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nOffer the person the teamai-share-learnings workflow: run `teamai workflow start --describe teamai-share-learnings --driver skill` and start it with the inputs it proposes, once the person confirms them.", "en": "[teamai] The current task is not covered by the team knowledge base, and this session had signs of friction: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nOffer the person the teamai-share-learnings workflow: run `teamai workflow start --describe teamai-share-learnings --driver skill` and start it with the inputs it proposes, once the person confirms them.", - "zh": "[teamai] 团队知识库还没有覆盖当前任务,而且这次会话有不顺利的迹象:you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times。\n\n向用户提议使用 teamai-share-learnings 工作流:运行 `teamai workflow start --describe teamai-share-learnings --driver skill`,等用户确认它建议的输入后,用这些输入启动它。" + "zh": "[teamai] 团队知识库还没有覆盖当前任务,而且这次会话有不顺利的迹象:你打断了 AI 2 次,你拒绝了 1 次工具调用,你纠正了 AI 3 次,AI 重试失败的工具 6 次。\n\n向用户提议使用 teamai-share-learnings 工作流:运行 `teamai workflow start --describe teamai-share-learnings --driver skill`,等用户确认它建议的输入后,用这些输入启动它。" }, "gap-reasons-task": { "unset": "[teamai] The current task is not covered by the team knowledge base, and this session had signs of friction: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nTask: Fix the flaky login test\n\nConsider running `/teamai share what this session taught me` to summarize what you learned, share it with your team, and fill the knowledge gap (or run `teamai skill get share`).", "en": "[teamai] The current task is not covered by the team knowledge base, and this session had signs of friction: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nTask: Fix the flaky login test\n\nConsider running `/teamai share what this session taught me` to summarize what you learned, share it with your team, and fill the knowledge gap (or run `teamai skill get share`).", - "zh": "[teamai] 团队知识库还没有覆盖当前任务,而且这次会话有不顺利的迹象:you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times。\n\n任务:Fix the flaky login test\n\n可以运行 `/teamai share what this session taught me`,总结这次会话学到的东西,分享给团队,补上知识库的这块空白(也可以运行 `teamai skill get share`)。" + "zh": "[teamai] 团队知识库还没有覆盖当前任务,而且这次会话有不顺利的迹象:你打断了 AI 2 次,你拒绝了 1 次工具调用,你纠正了 AI 3 次,AI 重试失败的工具 6 次。\n\n任务:Fix the flaky login test\n\n可以运行 `/teamai share what this session taught me`,总结这次会话学到的东西,分享给团队,补上知识库的这块空白(也可以运行 `teamai skill get share`)。" }, "gap-reasons-task-share-learnings": { "unset": "[teamai] The current task is not covered by the team knowledge base, and this session had signs of friction: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nTask: Fix the flaky login test\n\nOffer the person the teamai-share-learnings workflow: run `teamai workflow start --describe teamai-share-learnings --driver skill` and start it with the inputs it proposes, once the person confirms them.", "en": "[teamai] The current task is not covered by the team knowledge base, and this session had signs of friction: you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times.\n\nTask: Fix the flaky login test\n\nOffer the person the teamai-share-learnings workflow: run `teamai workflow start --describe teamai-share-learnings --driver skill` and start it with the inputs it proposes, once the person confirms them.", - "zh": "[teamai] 团队知识库还没有覆盖当前任务,而且这次会话有不顺利的迹象:you interrupted the AI twice, you rejected 1 tool call, you corrected the AI 3 times, the AI retried failing tools 6 times。\n\n任务:Fix the flaky login test\n\n向用户提议使用 teamai-share-learnings 工作流:运行 `teamai workflow start --describe teamai-share-learnings --driver skill`,等用户确认它建议的输入后,用这些输入启动它。" + "zh": "[teamai] 团队知识库还没有覆盖当前任务,而且这次会话有不顺利的迹象:你打断了 AI 2 次,你拒绝了 1 次工具调用,你纠正了 AI 3 次,AI 重试失败的工具 6 次。\n\n任务:Fix the flaky login test\n\n向用户提议使用 teamai-share-learnings 工作流:运行 `teamai workflow start --describe teamai-share-learnings --driver skill`,等用户确认它建议的输入后,用这些输入启动它。" } }, "teamai-mr-hint": { diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 211bcc5e75..32d10320ff 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -567,36 +567,32 @@ function extractPromptSummary(events: DashboardEvent[]): string | undefined { return normalizePromptSummary(firstPrompt?.promptSummary); } -/** Turn non-zero friction signals into concrete, user-facing reasons. */ -function formatTimes(count: number): string { - if (count === 1) return 'once'; +/** `count` as the reason texts of `language` write it: `times` such as `twice` or `2 次`, and `tool_calls` such as `1 tool call`. */ +function countWords(count: number, language: NonNullable | null) { + if (language === 'zh') return { count: String(count), times: `${count} 次`, tool_calls: `${count} 次工具调用` }; - if (count === 2) return 'twice'; - - return `${count} times`; + return { + count: String(count), + times: count === 1 ? 'once' : count === 2 ? 'twice' : `${count} times`, + tool_calls: count === 1 ? '1 tool call' : `${count} tool calls`, + }; } -function formatFrictionReasons(friction: SessionFriction): string[] { - const reasons: string[] = []; +/** The friction signals of `friction` that happened, each as its reason text, in the language its file is written in. */ +function frictionReasons(friction: SessionFriction, reminder: DefaultFolder<'hooks', 'teamai-friction-reminder'>, language: TextLanguage): string[] { + const signals = [ + ['reason-interrupt', friction.interrupt], + ['reason-tool-reject', friction.toolReject], + ['reason-correction', friction.correction], + ['reason-tool-error', friction.toolError], + ] as const; - if (friction.interrupt > 0) { - reasons.push(`you interrupted the AI ${formatTimes(friction.interrupt)}`); - } - - if (friction.toolReject > 0) { - const toolCall = friction.toolReject === 1 ? 'tool call' : 'tool calls'; - reasons.push(`you rejected ${friction.toolReject} ${toolCall}`); - } + return signals.flatMap(([name, count]) => { + if (count <= 0) return []; + const text = reminder.text(name, language); - if (friction.correction > 0) { - reasons.push(`you corrected the AI ${formatTimes(friction.correction)}`); - } - - if (friction.toolError > 0) { - reasons.push(`the AI retried failing tools ${formatTimes(friction.toolError)}`); - } - - return reasons; + return [text.render(countWords(count, text.language ?? language ?? null))]; + }); } interface HintContext { @@ -615,7 +611,7 @@ const SHARE_LEARNINGS_DESCRIBE = 'teamai workflow start --describe teamai-share- * when there is one, and the action, separated by a blank line. */ function buildHint({ friction, promptSummary, isKnowledgeGap, offersShareLearnings }: HintContext, reminder: DefaultFolder<'hooks', 'teamai-friction-reminder'>, language: TextLanguage): string { - const reasons = formatFrictionReasons(friction).join(', '); + const reasons = frictionReasons(friction, reminder, language).join(language === 'zh' ? ',' : ', '); const headline = reasons ? reminder.text(isKnowledgeGap ? 'gap-reasons' : 'problem-reasons', language).render({ reasons }) diff --git a/src/default-folders.ts b/src/default-folders.ts index fe4982ca81..6890b583e4 100644 --- a/src/default-folders.ts +++ b/src/default-folders.ts @@ -109,6 +109,10 @@ const TEXTS = { action: {}, 'action-gap': {}, 'action-share-learnings': { variables: ['command'] }, + 'reason-interrupt': { variables: ['count', 'times'] }, + 'reason-tool-reject': { variables: ['count', 'times', 'tool_calls'] }, + 'reason-correction': { variables: ['count', 'times'] }, + 'reason-tool-error': { variables: ['count', 'times'] }, }, 'teamai-mr-hint': { 'offer-command': { variables: ['count', 'mr_lines', 'command'] }, 'offer-workflow': { variables: ['count', 'mr_lines', 'command'] } }, 'teamai-todowrite-hint': { message: {} }, From 1745b5afaa5c86d99a5e659dfafa615aa1abe997 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:33:58 +0100 Subject: [PATCH 12/18] feat(codebase): set how many docs --deep-enrich writes at once in params.yaml Why: `teamai codebase --deep-enrich` passed concurrency 5 from a constant in code. A team whose AI tool rate-limits it could only lower it by replacing the whole WORKFLOW.yaml and ignoring the input. What: teamai-codebase-deep-enrich ships params.yaml with concurrency: 5, from 1 to 20 in the DefaultFolders contract. The shortcut passes the team's value as the same input, so the workflow's input contract and every caller's copy keep working. teamai-import-from-repo keeps passing its own 5. The folder's READMEs gain Parameters and What pull refuses, and their "Replace it" now describes a folder holding only the files it changes, rather than a copy replacing teamai's as a whole. Proof: a codebase-deep-enrich case starts the run with concurrency 2 once the team's params.yaml says so. It failed before the change. A review-branch row shows a contracted folder holding only component.md passes and a typo fails. The touched suites, typecheck and lint pass. --- CHANGELOG.md | 1 + docs/guide/workflow.md | 2 +- docs/guide/zh-CN/workflow.md | 2 +- src/__tests__/codebase-deep-enrich.test.ts | 14 ++++++++ src/__tests__/review-branch.test.ts | 1 + src/codebase-cmd.ts | 7 ++-- src/default-folders.ts | 2 ++ .../teamai-codebase-deep-enrich/README.md | 34 ++++++++++++++++--- .../README.zh-CN.md | 34 ++++++++++++++++--- .../teamai-codebase-deep-enrich/params.yaml | 1 + 10 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 workflows/teamai-codebase-deep-enrich/params.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 92785d472a..93f85dade7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. See [standa - The gates `teamai recall promote` applies, `min_confidence`, `min_upvoted`, `min_users` and `min_age_days`, are the `params.yaml` of the default workflow folder `teamai-promote-learning`, shipped at the values the code held. A team tunes them with `workflows/teamai-promote-learning/params.yaml` at its root, holding only the keys it changes; teamai's `WORKFLOW.yaml` and its other values stay in force. The listing, the `promote-candidate` and `promote-publish` steps and the dashboard read the same file, so none offers a learning another rejects. Pull and `teamai review --branch` refuse a value out of range or a key the workflow does not take, naming the file and the field, and the version pull last took stays in force. A member on an older teamai keeps the old gates, so upgrade every member before tuning them (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - `teamai recall maintenance --update-quality` reads which entries it drafts, `min_recalled`, `max_upvoted` and `min_users`, and how many adopted learnings each prompt shows, `max_related`, from the `params.yaml` of the default workflow folder `teamai-draft-learning-updates`, shipped at the values the code held. A team changes them with `workflows/teamai-draft-learning-updates/params.yaml` at its root; the `--dry-run` listing, the `stale` and `draft-context` steps and the dashboard read the same file, and pull refuses a value out of range or an unknown key as it does for the promotion gates (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - How long an attended session is idle before it is finished is the `idle` of the default workflow folder `teamai-finished-sessions`'s `params.yaml`, `6h`, rather than `--idle 6h` in its steps and a constant elsewhere. A team sets it with `workflows/teamai-finished-sessions/params.yaml` at its root, from `10m` to `7d`, and the listing, the dashboard's session sharing panel, the share proposals and the usage report's outcomes all count a session finished at that time; the panel and the proposals name it. `teamai session list --finished --idle` still overrides it for one listing (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). +- `teamai codebase --deep-enrich` writes as many component docs at once as the `concurrency` of the default workflow folder `teamai-codebase-deep-enrich`'s `params.yaml`, `5`, rather than a constant in code. A team that hits its AI tool's rate limit sets `concurrency: 2` in `workflows/teamai-codebase-deep-enrich/params.yaml`, from 1 to 20; `teamai import` keeps the `5` that `teamai-import-from-repo` passes (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - A team changes one file of a teamai default hook, rule or workflow folder by keeping that file in a folder of the default's name; every other file stays teamai's and keeps its improvements. Contracts such as the recall rule's kept lines and a hook default's keys are checked on the resulting folder. A team file that replaces no file of the default: pull warns, names it and keeps teamai's file, and `teamai review --branch` fails. A run of such a folder records the teamai version it took files from, and `teamai list --json` marks each file's layer. - Agents and scorers can be folders. `agents/[/]/AGENT.md` is delivered like a rule folder and names its files with `${TEAMAI_AGENT_DIR}`; Claude Code gets the user-scope directory in `additionalDirectories`, and another tool that cannot read it gets a warning. `scorers/[/]/SCORER.md` names its files with `${TEAMAI_SCORER_DIR}`, and the judge reads them in its evidence directory. - `teamai update` records every TeamAI copy this machine runs from in `~/.teamai/installations.json`, and `teamai update` and `teamai doctor` list them. With `updatePolicy: auto` and `updateOtherInstallations: true`, an update of a running copy that is already current can update one other registered npm-global copy per run under its own `--prefix`; npm exiting 0 without changing that copy is a failure. Volta, vendored and local copies are listed with their manual remedy and are never passed to npm (for [#1753](https://github.com/Tencent/teamai-cli/issues/1753)). diff --git a/docs/guide/workflow.md b/docs/guide/workflow.md index 379013f2c0..02094311fb 100644 --- a/docs/guide/workflow.md +++ b/docs/guide/workflow.md @@ -1133,7 +1133,7 @@ teamai ships workflows a team gets without writing them: | `teamai-new-scorer` | writes a scorer with the person, tries it with `teamai-try-scorer`, and proposes `scorers/.md` to the team as a pull request from the [team workspace](#the-team-workspace). Inputs: `session`, `source` | always: `invoke: ask`, so an agent starts it once the person agrees | | `teamai-draft-learning-updates` | drafts an update of each doc, rule and skill the team recalls often but rarely adopts, as `.draft.md` beside it, through [`teamai recall maintenance --update-quality`](knowledge.md#knowledge-base-maintenance). Input: `knowledge_root`, the checkout that holds the entries, where the drafts land. Its `params.yaml` holds which entries it drafts. Private | never by name: `invoke: deny`. The command runs it from an agent session too | | `teamai-promote-learning` | promotes a learning to a skill, rule or doc: the AI picks the category, unless `--category` gives it, and rewrites the learning, which is pushed on a review branch with a pull request that says the category and why, through [`teamai recall promote `](knowledge.md#knowledge-base-maintenance). Inputs: `learning`, `category` (`''` to let the AI pick). Its `params.yaml` holds which learnings it takes. Private | always: `invoke: ask`, so an agent starts it once the person agrees | -| `teamai-codebase-deep-enrich` | writes a project's component docs, five at a time, then its architecture overview and graph docs, and with `publish` commits and pushes them to the team repo that holds `wiki_root`, through [`teamai codebase --deep-enrich`](knowledge.md#codebase-knowledge-graph). Inputs: `wiki_root`, `project`, `max_modules` (`0` for all), `concurrency`, `publish` (default `false`). Private | never by name: `invoke: deny`. The command runs it from an agent session too | +| `teamai-codebase-deep-enrich` | writes a project's component docs, five at a time unless its `params.yaml` sets `concurrency`, then its architecture overview and graph docs, and with `publish` commits and pushes them to the team repo that holds `wiki_root`, through [`teamai codebase --deep-enrich`](knowledge.md#codebase-knowledge-graph). Inputs: `wiki_root`, `project`, `max_modules` (`0` for all), `concurrency`, `publish` (default `false`). Private | never by name: `invoke: deny`. The command runs it from an agent session too | | `teamai-codebase-enrich` | asks what each module of a freshly extracted codebase does and which business domain it belongs to, and writes `_manifest.json` and `_domains.json` beside its evidence. Inputs: `wiki_root`, `project`. Private | never: `invoke: deny` | | `teamai-import-from-repo` | imports repositories or local checkouts into a teamwiki: extracts each one, runs `teamai-codebase-enrich` and `teamai-codebase-deep-enrich` on it, publishes it, then rebuilds and publishes the indexes once, through [`teamai import --dir`, `--from-repo`, `--from-repo-list` and `--from-org`](knowledge.md#codebase-knowledge-graph). Inputs: `wiki_root`, `mode`, `items`, `settings`, `skipped`, `concurrency`. Private | never by name: `invoke: deny`. The command runs it from an agent session too | | `teamai-import-rules` | imports the rules in the rules directory of every AI tool on this machine: runs `teamai-import-learnings` on them, then commits and pushes what it wrote, through [`teamai import --from-tools`](knowledge.md#importing-rules-and-iwiki-pages). Inputs: `dir`, `by_type`, `all`. Private | never by name: `invoke: deny`. The command runs it from an agent session too | diff --git a/docs/guide/zh-CN/workflow.md b/docs/guide/zh-CN/workflow.md index 19aae1c025..8dd89503bc 100644 --- a/docs/guide/zh-CN/workflow.md +++ b/docs/guide/zh-CN/workflow.md @@ -1133,7 +1133,7 @@ teamai 自带一些工作流,团队不用自己编写就能使用: | `teamai-new-scorer` | 与用户一起编写评分器,用 `teamai-try-scorer` 试用,再从[团队工作区](#团队工作区)把 `scorers/.md` 作为拉取请求提交给团队。输入:`session`、`source` | 始终:`invoke: ask`,agent 在用户同意后启动它 | | `teamai-draft-learning-updates` | 为团队经常召回却很少采用的每个 doc、rule 和 skill 起草更新,写成它旁边的 `.draft.md`,由 [`teamai recall maintenance --update-quality`](knowledge.md#知识库维护) 启动。输入:`knowledge_root`,存放这些条目的 checkout,草稿写在其中。它的 `params.yaml` 决定起草哪些条目。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | | `teamai-promote-learning` | 把一条经验晋升为 skill、rule 或 doc:AI 选择类别(`--category` 给出时除外)并改写经验,结果推送到评审分支,并附带说明类别和理由的拉取请求,由 [`teamai recall promote `](knowledge.md#知识库维护) 启动。输入:`learning`、`category`(`''` 表示由 AI 选择)。它的 `params.yaml` 决定接受哪些经验。私有 | 始终:`invoke: ask`,agent 在用户同意后启动它 | -| `teamai-codebase-deep-enrich` | 为项目的组件写设计文档(一次五个),然后写架构总览和图谱文档;设了 `publish` 时,把它们提交并推送到存放 `wiki_root` 的团队仓库,由 [`teamai codebase --deep-enrich`](knowledge.md#代码知识图谱) 启动。输入:`wiki_root`、`project`、`max_modules`(`0` 表示全部)、`concurrency`、`publish`(默认 `false`)。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | +| `teamai-codebase-deep-enrich` | 为项目的组件写设计文档(一次五个,除非它的 `params.yaml` 设置了 `concurrency`),然后写架构总览和图谱文档;设了 `publish` 时,把它们提交并推送到存放 `wiki_root` 的团队仓库,由 [`teamai codebase --deep-enrich`](knowledge.md#代码知识图谱) 启动。输入:`wiki_root`、`project`、`max_modules`(`0` 表示全部)、`concurrency`、`publish`(默认 `false`)。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | | `teamai-codebase-enrich` | 询问刚提取的代码库每个模块做什么、整体属于哪个业务域,并在它的 evidence 旁写入 `_manifest.json` 和 `_domains.json`。输入:`wiki_root`、`project`。私有 | 从不:`invoke: deny` | | `teamai-import-from-repo` | 把仓库或本地 checkout 导入 teamwiki:逐个提取,对它运行 `teamai-codebase-enrich` 和 `teamai-codebase-deep-enrich`,发布它,最后只重建并发布一次索引,由 [`teamai import --dir`、`--from-repo`、`--from-repo-list` 和 `--from-org`](knowledge.md#代码知识图谱) 启动。输入:`wiki_root`、`mode`、`items`、`settings`、`skipped`、`concurrency`。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | | `teamai-import-rules` | 导入本机每个 AI 工具规则目录里的规则:对它们运行 `teamai-import-learnings`,然后提交并推送写出的文件,由 [`teamai import --from-tools`](knowledge.md#导入规则和-iwiki-页面) 启动。输入:`dir`、`by_type`、`all`。私有 | 从不按名称启动:`invoke: deny`。该命令在 agent 会话中也能运行它 | diff --git a/src/__tests__/codebase-deep-enrich.test.ts b/src/__tests__/codebase-deep-enrich.test.ts index e6c90bd00e..d396b6270d 100644 --- a/src/__tests__/codebase-deep-enrich.test.ts +++ b/src/__tests__/codebase-deep-enrich.test.ts @@ -16,6 +16,7 @@ import { runShortcut } from '../workflow-commands.js'; import { autoDetectInit, NotInitializedError } from '../config.js'; import { LocalConfigSchema, TeamaiConfigSchema } from '../types.js'; import { createGit } from '../utils/git.js'; +import { syncScorersThenWorkflows } from '../scorers/team-sync.js'; const temporaryDirectories: string[] = []; @@ -696,6 +697,19 @@ describe('codebase deep-enrich in a team repo', () => { } }); + it('starts it as many components at a time as the team\'s params.yaml says', async () => { + const { clone } = await createTeamDeepEnrichFixture(); + const { localConfig } = await autoDetectInit(); + fs.mkdirSync(path.join(clone, 'workflows', 'teamai-codebase-deep-enrich'), { recursive: true }); + fs.writeFileSync(path.join(clone, 'workflows', 'teamai-codebase-deep-enrich', 'params.yaml'), 'concurrency: 2\n'); + await syncScorersThenWorkflows(localConfig); + vi.mocked(runShortcut).mockResolvedValue({ run: 'r1', outcome: 'failed' }); + + await codebaseCmd({ deepEnrich: true, project: 'auth' }); + + expect(runShortcut).toHaveBeenCalledWith(expect.objectContaining({ inputs: expect.objectContaining({ concurrency: 2 }) })); + }); + it('publishes nothing when the run did not succeed', async () => { const { clone, remote } = await createTeamDeepEnrichFixture(); vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/src/__tests__/review-branch.test.ts b/src/__tests__/review-branch.test.ts index aa39ecbd8d..1092605318 100644 --- a/src/__tests__/review-branch.test.ts +++ b/src/__tests__/review-branch.test.ts @@ -690,6 +690,7 @@ describe('reviewBranchDir — read-only health checks for a pushed branch (#776) { folder: 'hooks/teamai-friction-reminder', own: 'problem.md', typo: 'problems.md' }, { folder: 'rules/teamai-recall', own: 'block.md', typo: 'blocks.md' }, { folder: 'workflows/teamai-import-from-repo', own: 'narrative.md', typo: 'narative.md' }, + { folder: 'workflows/teamai-codebase-deep-enrich', own: 'component.md', typo: 'componet.md' }, ])('passes $folder/ holding only the file it changes, and fails it with a file that replaces nothing', async ({ folder, own, typo }) => { const put = (file: string): Promise => fse.outputFile(path.join(repoDir, ...folder.split('/'), file), '[acme] Ours.\n'); await put(own); diff --git a/src/codebase-cmd.ts b/src/codebase-cmd.ts index 709cd2f4b3..7bd32c3ef6 100644 --- a/src/codebase-cmd.ts +++ b/src/codebase-cmd.ts @@ -29,9 +29,6 @@ export interface CodebaseCmdOptions extends GlobalOptions { deepEnrich?: boolean; } -/** How many components a deep enrichment has in progress at once, the max_parallel of its fan-out. */ -const DEEP_ENRICH_CONCURRENCY = 5; - /** * Deep-enriches `project` of `target`, as `codebase --deep-enrich` and the hidden `deep-enrich` do, through a * teamai-codebase-deep-enrich run, under the import lock of the team repo when one holds the teamwiki, as every command @@ -48,8 +45,10 @@ export async function startDeepEnrich({ target, project, maxModules = 0, publish }): Promise { const { lockTeamwiki } = await import('./utils/teamwiki-root.js'); const { runShortcut } = await import('./workflow-commands.js'); + const { DefaultFolders } = await import('./default-folders.js'); + const { concurrency } = (await DefaultFolders.load({ kind: 'workflows', name: 'teamai-codebase-deep-enrich' }, target.team?.localConfig ?? null)).params; const releaseLock = await lockTeamwiki(target); - const given = { wiki_root: target.wikiRoot, project, max_modules: maxModules, concurrency: DEEP_ENRICH_CONCURRENCY }; + const given = { wiki_root: target.wikiRoot, project, max_modules: maxModules, concurrency }; // Only a publishing run passes publish, so a team's own teamai-codebase-deep-enrich need not declare it. const inputs = publish ? { ...given, publish } : given; diff --git a/src/default-folders.ts b/src/default-folders.ts index 6890b583e4..8e12c7fe9a 100644 --- a/src/default-folders.ts +++ b/src/default-folders.ts @@ -86,6 +86,7 @@ const PARAMS = { }, workflows: { 'teamai-finished-sessions': z.strictObject({ idle: duration('10m', '7d') }), + 'teamai-codebase-deep-enrich': z.strictObject({ concurrency: whole(1, 20) }), 'teamai-draft-learning-updates': z.strictObject({ min_recalled: whole(1, 1000), max_upvoted: whole(0, 1000), min_users: whole(1, 1000), max_related: whole(0, 20) }), 'teamai-promote-learning': z.strictObject({ min_confidence: ratio(), min_upvoted: whole(1, 1000), min_users: whole(1, 1000), min_age_days: whole(0, 3650) }), }, @@ -126,6 +127,7 @@ const TEXTS = { workflows: { 'teamai-draft-learning-updates': {}, 'teamai-finished-sessions': {}, + 'teamai-codebase-deep-enrich': {}, 'teamai-promote-learning': {}, }, } as const satisfies { readonly [TKind in keyof typeof PARAMS]: { readonly [TName in keyof (typeof PARAMS)[TKind]]: Readonly> } }; diff --git a/workflows/teamai-codebase-deep-enrich/README.md b/workflows/teamai-codebase-deep-enrich/README.md index 3ba9f9e4f7..ab98bbbb5d 100644 --- a/workflows/teamai-codebase-deep-enrich/README.md +++ b/workflows/teamai-codebase-deep-enrich/README.md @@ -40,6 +40,7 @@ Purple marks a prompt the AI tool answers. A component whose answer fails gets n | `component.md` | The prompt for each component's design doc | | `architecture.md` | The prompt for the architecture overview | | `scenarios.md` | The prompt for the scenario sequence diagrams | +| `params.yaml` | How many component docs `teamai codebase --deep-enrich` has the run write at once | A prompt reads `{{ inputs. }}`, `{{ item. }}` inside a step that runs for each component, and the outputs of the steps it needs, as `{{ steps..outputs. }}`. @@ -50,7 +51,7 @@ A prompt reads `{{ inputs. }}`, `{{ item. }}` inside a step that ru | `wiki_root` | `string` | The teamwiki directory | | `project` | `string` | The codebase, its directory under `evidence/code/` | | `max_modules` | `number` | How many components get a doc. `0` means every component | -| `concurrency` | `number` | How many component docs the run writes at once | +| `concurrency` | `number` | How many component docs the run writes at once. `teamai codebase --deep-enrich` passes the `concurrency` of `params.yaml` | | `publish` | `boolean` | Commit the docs and push them to the team repo that holds `wiki_root` | ## Outputs @@ -63,25 +64,48 @@ A prompt reads `{{ inputs. }}`, `{{ item. }}` inside a step that ru | `missing_components` | The components with no doc. The next run writes them | | `missing_architecture` | `true` when the architecture overview is missing | +## Parameters + +| Key | Range | What it sets | +|---|---|---| +| `concurrency` | 1-20 | How many component docs `teamai codebase --deep-enrich` and `teamai deep-enrich` have the run write at once. `teamai-import-from-repo` passes its own, `5` | + ## Replace it +Put only the files you change in `workflows/teamai-codebase-deep-enrich/` at the root of the team repo. Every other file, `WORKFLOW.yaml` included, stays teamai's and keeps getting teamai's updates. To add a section to each design doc, copy its prompt: + ```bash -cp -r "$(npm root -g)/teamai-cli/workflows/teamai-codebase-deep-enrich" workflows/ +mkdir -p workflows/teamai-codebase-deep-enrich +cp "$(npm root -g)/teamai-cli/workflows/teamai-codebase-deep-enrich/component.md" workflows/teamai-codebase-deep-enrich/ ``` -Then edit the prompts, such as adding this to `component.md`: +Then add this to `component.md`: ```markdown End each design doc with a "Failure modes" section that lists what the component does when a dependency is down. ``` -Push the folder. The next `teamai pull` uses the team's folder, as a whole, instead of teamai's. `teamai-import-from-repo` then runs the team's folder too. +To write fewer docs at once, against an AI tool's rate limit: + +```yaml +# workflows/teamai-codebase-deep-enrich/params.yaml +concurrency: 2 +``` + +Push the folder. After the next `teamai pull`, the command and `teamai-import-from-repo` run it with your files. A `workflows/teamai-codebase-deep-enrich.yaml` file replaces the folder whole, so teamai's parameters apply. | Keep | Why | |---|---| -| Every file `WORKFLOW.yaml` names | The team's folder replaces teamai's as a whole | | The inputs and outputs above | The callers pass those inputs and read those outputs | +## What pull refuses + +| A team folder that | Pull | +|---|---| +| Sets a value out of range, or a key the table above does not list, in `params.yaml` | Names the file and the field. The version pull last took stays in force, teamai's until it took one | + +`teamai review --branch` fails such a folder before you merge it. + ## Requires | Needs | Without it | diff --git a/workflows/teamai-codebase-deep-enrich/README.zh-CN.md b/workflows/teamai-codebase-deep-enrich/README.zh-CN.md index 104aa3c0e1..7aac8e830a 100644 --- a/workflows/teamai-codebase-deep-enrich/README.zh-CN.md +++ b/workflows/teamai-codebase-deep-enrich/README.zh-CN.md @@ -40,6 +40,7 @@ flowchart TD | `component.md` | 每个组件设计文档的提示词 | | `architecture.md` | 架构总览的提示词 | | `scenarios.md` | 场景时序图的提示词 | +| `params.yaml` | `teamai codebase --deep-enrich` 让运行同时写多少份组件文档 | 提示词读取 `{{ inputs. }}`;在为每个组件运行一次的步骤里读取 `{{ item. }}`;还以 `{{ steps..outputs. }}` 的形式,读取它依赖的步骤的输出。 @@ -50,7 +51,7 @@ flowchart TD | `wiki_root` | `string` | teamwiki 目录 | | `project` | `string` | 代码库,即它在 `evidence/code/` 下的目录 | | `max_modules` | `number` | 为多少个组件写文档。`0` 表示所有组件 | -| `concurrency` | `number` | 运行同时写多少份组件文档 | +| `concurrency` | `number` | 运行同时写多少份组件文档。`teamai codebase --deep-enrich` 传入 `params.yaml` 的 `concurrency` | | `publish` | `boolean` | 提交文档,并推送到存放 `wiki_root` 的团队仓库 | ## 输出 @@ -63,25 +64,48 @@ flowchart TD | `missing_components` | 没有文档的组件。下一次运行会补写 | | `missing_architecture` | 缺少架构总览时为 `true` | +## 参数 + +| 键 | 范围 | 设置什么 | +|---|---|---| +| `concurrency` | 1-20 | `teamai codebase --deep-enrich` 和 `teamai deep-enrich` 让运行同时写多少份组件文档。`teamai-import-from-repo` 传入它自己的值 `5` | + ## 替换它 +在团队仓库根目录的 `workflows/teamai-codebase-deep-enrich/` 里只放你要改的文件。其他文件(包括 `WORKFLOW.yaml`)仍用 teamai 的,并继续获得 teamai 的更新。要给每份设计文档加一节,复制它的提示词: + ```bash -cp -r "$(npm root -g)/teamai-cli/workflows/teamai-codebase-deep-enrich" workflows/ +mkdir -p workflows/teamai-codebase-deep-enrich +cp "$(npm root -g)/teamai-cli/workflows/teamai-codebase-deep-enrich/component.md" workflows/teamai-codebase-deep-enrich/ ``` -然后编辑提示词,例如在 `component.md` 里加上这一句: +然后在 `component.md` 里加上这一句: ```markdown End each design doc with a "Failure modes" section that lists what the component does when a dependency is down. ``` -推送这个文件夹。下一次 `teamai pull` 会用团队的文件夹整体代替 teamai 的。之后 `teamai-import-from-repo` 运行的也是团队的文件夹。 +为了不超出 AI 工具的速率限制,同时少写几份: + +```yaml +# workflows/teamai-codebase-deep-enrich/params.yaml +concurrency: 2 +``` + +推送这个文件夹。下一次 `teamai pull` 之后,命令和 `teamai-import-from-repo` 都用你的文件运行它。`workflows/teamai-codebase-deep-enrich.yaml` 单个文件会整体替换这个文件夹,此时用 teamai 的参数。 | 保留 | 原因 | |---|---| -| `WORKFLOW.yaml` 引用的每个文件 | 团队的文件夹整体替换 teamai 的 | | 上面的输入和输出 | 调用方会传这些输入、读这些输出 | +## pull 拒绝什么 + +| 团队文件夹 | pull 的处理 | +|---|---| +| 在 `params.yaml` 里设置了超出范围的值,或上表没有列出的键 | 指出文件和字段。pull 上一次接受的版本继续生效,还没接受过时用 teamai 的 | + +`teamai review --branch` 会在合并前让这样的文件夹不通过。 + ## 依赖 | 需要 | 没有时 | diff --git a/workflows/teamai-codebase-deep-enrich/params.yaml b/workflows/teamai-codebase-deep-enrich/params.yaml new file mode 100644 index 0000000000..9132961121 --- /dev/null +++ b/workflows/teamai-codebase-deep-enrich/params.yaml @@ -0,0 +1 @@ +concurrency: 5 From 9a17209ea99ea168f334c1650d744303e9662772 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:34:46 +0100 Subject: [PATCH 13/18] docs(workflows): describe a default folder replaced one file at a time The READMEs of teamai-import-from-mr and teamai-import-from-repo, and the changelog entry that introduced them, still told a team to copy the whole folder and said the copy replaces teamai's as a whole. Since a team folder inherits every file it leaves out, they now show copying only the prompt being changed, as the other default folders' READMEs do. --- CHANGELOG.md | 2 +- workflows/teamai-import-from-mr/README.md | 10 ++++++---- workflows/teamai-import-from-mr/README.zh-CN.md | 10 ++++++---- workflows/teamai-import-from-repo/README.md | 10 ++++++---- workflows/teamai-import-from-repo/README.zh-CN.md | 10 ++++++---- 5 files changed, 25 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f85dade7..108f2c7ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to this project will be documented in this file. See [standa - `teamai update` records every TeamAI copy this machine runs from in `~/.teamai/installations.json`, and `teamai update` and `teamai doctor` list them. With `updatePolicy: auto` and `updateOtherInstallations: true`, an update of a running copy that is already current can update one other registered npm-global copy per run under its own `--prefix`; npm exiting 0 without changing that copy is a failure. Volta, vendored and local copies are listed with their manual remedy and are never passed to npm (for [#1753](https://github.com/Tencent/teamai-cli/issues/1753)). - Workflows, hooks, rules, MCP servers and benchmark suites can be folders, as skills are: a directory under the type's directory, at any depth, that holds the type's marker (`WORKFLOW.yaml`, `HOOK.yaml`, `RULE.md`, `MCP.yaml` or `SUITE.yaml`), with the files the resource needs beside it. Only that exact name is a marker, so `rule.md` is read as before. A folder names its own files through `TEAMAI_WORKFLOW_DIR`, `${TEAMAI_HOOK_DIR}`, `${TEAMAI_RULE_DIR}` or `${TEAMAI_MCP_DIR}`, which are reserved team env names. A folder that is a symlink is not read, and pull and `teamai review --branch` name it. Pull copies a hook, rule or MCP folder beside the configuration that names it: `~/.teamai//` in user scope; in a workspace, each worktree's `.teamai/delivered//` for rules and MCP servers, which git ignores whatever `sharing.gitExclude` says, so a pull in one worktree never changes what another runs or reads; and for hooks, which every worktree of a remote team's workspace runs from the same entries, `~/.teamai/projects//hooks/`, or each single-repo worktree's `.teamai/delivered/hooks/`. It writes that path with forward slashes and no quotes. A rule folder keeps its path across versions, and a file a new version drops stays until the folder goes. A hook or MCP folder sits at `./`, the version taken from its files, `HOOK.yaml` or `MCP.yaml` included, and a version's files never change, so a tool whose configuration pull could not rewrite, or one the member excluded, keeps running the files it was written with. Every checkout of one commit gives the same version, since a file counts as git stores it, and pull copies none of the files git ignores. A folder holding a git submodule or a repository of its own is refused. In single-repo, editing only a script rewrites the folder's path in `.claude/settings.json`. Pull removes a folder once the team drops it and no configuration names it. Uninstall keeps a delivered folder that a file it leaves still names, in any worktree, and says which file. `SCORER.md` and `AGENT.md` are reserved as well. Upgrade every member before committing a folder: an older teamai skips it, or reads each of its files on its own (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787), [#1791](https://github.com/Tencent/teamai-cli/issues/1791)). - A workflow can be a folder, `workflows/[/]/WORKFLOW.yaml`, with the scripts, prompts and notes it needs. Every process a step starts gets `TEAMAI_WORKFLOW_DIR`, the run's own copy of the folder, which stays as the run started it whatever a later pull does. `workflow resume --claim` rebuilds that copy on another machine, and `doctor --fix` removes a copy no run and no pulled workflow uses; a run it swept past `workflows.retention` uses none. `prompt_file` and the new `answer_file` name a file of the folder. A workflow folder holds UTF-8 text only, 1 MB in all, and no symlink, or pull refuses it with `workflow_folder_invalid`; a file and a folder of one name leave the workflow nothing to run, and pull skips a `teamai-` folder that names no default, with a warning. `teamai status` counts a folder as one workflow, single-repo push sends every file of it, and a benchmark arm cannot run one yet (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). -- The defaults `teamai-import-from-mr`, `teamai-codebase-deep-enrich` and `teamai-import-from-repo` are workflow folders. What they ask the AI tool is in files of their own, `draft.en.md` and `draft.zh.md`, `component.md`, `architecture.md` and `scenarios.md`, and `narrative.md`, with the same text as before, and each folder has a `README.md` on its inputs, outputs and settings. To change a prompt, copy the folder from the installed package to the same path in the team repo, edit it and push; a team's `workflows/.yaml` still replaces the default as before. (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). +- The defaults `teamai-import-from-mr`, `teamai-codebase-deep-enrich` and `teamai-import-from-repo` are workflow folders. What they ask the AI tool is in files of their own, `draft.en.md` and `draft.zh.md`, `component.md`, `architecture.md` and `scenarios.md`, and `narrative.md`, with the same text as before, and each folder has a `README.md` on its inputs, outputs and settings. To change a prompt, copy that file from the installed package to the same path in the team repo, edit it and push; the folder's other files stay teamai's, and a team's `workflows/.yaml` still replaces the default as before. (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A team hook can be a folder, `hooks/[/]/HOOK.yaml` beside the scripts it runs, with the keys of a `hooks.yaml` entry except `id`, `roles` and `projects`; `${TEAMAI_HOOK_DIR}` in `command` names the folder. In single-repo, Claude Code's command names it through `$CLAUDE_PROJECT_DIR`. `requireTeamScripts` keeps a folder hook whose command names `${TEAMAI_HOOK_DIR}`. `${TEAMAI_HOOK_DIR}` in `hooks.yaml`, or an id that is both an entry and a folder, stops team hooks for the run, and `teamai review --branch` fails a branch with either, or with a `HOOK.yaml` that does not parse. A single-repo worktree's folders are removed on uninstall. Once a worktree is removed, the next pull of another worktree, or `teamai doctor --fix`, removes the team hook entries it left in HOME (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A team MCP server can be a folder, `mcp/[/]/MCP.yaml` beside the files it runs, with the keys of an `mcp.yaml` server except `name`, `roles` and `projects`, stdio only; `${TEAMAI_MCP_DIR}` in `command`, `args` and `env` names the folder for every tool `mcp/mcp.yaml` reaches. In single-repo each tool's MCP file holds the worktree's absolute path. `requires` and `sharing.mcp.allowedCommands` apply to the runner, as for any server. A folder with `transport: http` or `sse`, `${TEAMAI_MCP_DIR}` in `mcp.yaml`, or a name that is both a server there and a folder stops team MCP for the run and keeps what is installed (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A benchmark suite can be a folder, `benchmarks/[/]/SUITE.yaml`; `suite.yaml` keeps working. A directory holding both is invalid, and pull and `teamai benchmark try` name both files. Docs and messages show `SUITE.yaml` (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). diff --git a/workflows/teamai-import-from-mr/README.md b/workflows/teamai-import-from-mr/README.md index c9bf999953..72535e4e9d 100644 --- a/workflows/teamai-import-from-mr/README.md +++ b/workflows/teamai-import-from-mr/README.md @@ -65,21 +65,23 @@ A prompt reads the outputs of the steps it needs, as `{{ steps..outputs..outputs. Date: Thu, 12 Nov 2026 15:38:04 +0100 Subject: [PATCH 14/18] test(hooks): a team reason text reads {{ count }} No shipped reason text reads {{ count }}, so nothing showed a team's replacement gets it. The case replaces only reason-tool-reject.md and checks the reminder words that reason with the count and keeps teamai's reason-interrupt.md. With count emptied by hand, the case fails. --- src/__tests__/default-hook-replacements.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/__tests__/default-hook-replacements.test.ts b/src/__tests__/default-hook-replacements.test.ts index 38250d3cba..fac5d8eec6 100644 --- a/src/__tests__/default-hook-replacements.test.ts +++ b/src/__tests__/default-hook-replacements.test.ts @@ -59,14 +59,14 @@ const SHARE_LEARNINGS = 'Offer the person the teamai-share-learnings workflow: r const stdin = { session_id: 'replacement-session', cwd: os.tmpdir() }; -/** What the Stop hook prints to Claude Code for a session whose cached friction score is 30 after `toolCount` tool calls. */ -async function stopHint(toolCount: number): Promise { +/** What the Stop hook prints to Claude Code for a session whose cached friction score is 30 after `toolCount` tool calls, with `friction`. */ +async function stopHint(toolCount: number, friction = { interrupt: 0, toolReject: 0, correction: 0, toolError: 0 }): Promise { await writeContributeState(stdin.session_id, { contributed: false, smartScore: 30, toolCount, lastEvaluated: Date.now(), - friction: { interrupt: 0, toolReject: 0, correction: 0, toolError: 0 }, + friction, }); const stop = buildHandlerRegistry().find(({ handler }) => handler.name === 'contribute-check'); const output = await stop?.handler.execute(stdin, 'claude', member()); @@ -121,6 +121,15 @@ describe('a team replacement of teamai-friction-reminder', () => { }); }); + it('words a reason with its own reason-tool-reject.md, which may read {{ count }}', async () => { + await own('teamai-friction-reminder', { 'reason-tool-reject.md': 'refused {{ count }} calls\n' }); + + await reconcileTeamHooksForConfig(teamConfig, member()); + + expect(await stopHint(20, { interrupt: 2, toolReject: 3, correction: 0, toolError: 0 })) + .toBe(`[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, refused 3 calls.\n\n${SHARE_LEARNINGS}`); + }); + it('with a file teamai\'s folder does not have, warns naming it and keeps teamai\'s file in force', async () => { await own('teamai-friction-reminder', { 'problem.md': '[acme] Worth a write-up?\n', 'problems.md': '[acme] A typo\n' }); From 8221941226bed56899db5a4cf6ef2e153467fd7a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:52:37 +0100 Subject: [PATCH 15/18] fix(hooks): word a reason by the language of the file read, not the team's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zh team that replaced only reason-interrupt.md with English text got "you interrupted the AI 2 次": the base file reads as no language, and the team's language filled in for it. The count words now follow the picked file alone, so a base file is worded in English. A default-hook-replacements case for a zh team, holding only an English reason-interrupt.md, failed before the change and passes after; the golden Chinese reasons still pass. --- src/__tests__/default-hook-replacements.test.ts | 9 +++++++++ src/contribute-check.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/__tests__/default-hook-replacements.test.ts b/src/__tests__/default-hook-replacements.test.ts index fac5d8eec6..5bbd865314 100644 --- a/src/__tests__/default-hook-replacements.test.ts +++ b/src/__tests__/default-hook-replacements.test.ts @@ -171,6 +171,15 @@ describe('a team replacement of teamai-friction-reminder', () => { expect(await stopHint(20)).toBe(`[acme] 这次会话卡住过,要不要写下来?\n\n${SHARE_LEARNINGS_ZH}`); }); + it('words its own reason-interrupt.md, which has no .zh.md, in English beside teamai\'s Chinese reasons', async () => { + await own('teamai-friction-reminder', { 'reason-interrupt.md': 'you interrupted the AI {{ times }}\n' }); + + await reconcileTeamHooksForConfig(teamConfig, member()); + + expect(await stopHint(20, { interrupt: 2, toolReject: 1, correction: 0, toolError: 0 })) + .toBe(`[teamai] 这次会话可能有值得记录下来的问题:you interrupted the AI twice,你拒绝了 1 次工具调用。\n\n${SHARE_LEARNINGS_ZH}`); + }); + it('prints its own problem.md when it has no problem.zh.md, never teamai\'s', async () => { await replace('teamai-friction-reminder', { 'problem.md': '[acme] Worth a write-up?\n', 'problem.zh.md': null }); diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 32d10320ff..77df803044 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -591,7 +591,7 @@ function frictionReasons(friction: SessionFriction, reminder: DefaultFolder<'hoo if (count <= 0) return []; const text = reminder.text(name, language); - return [text.render(countWords(count, text.language ?? language ?? null))]; + return [text.render(countWords(count, text.language))]; }); } From ba13eee685fe15a62145c7112dc54a5c435764fa Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 15:52:37 +0100 Subject: [PATCH 16/18] fix(codebase): read deep-enrich concurrency from the member's scope for --output The run starts in the member's config whatever teamwiki it writes, but the concurrency came from the teamwiki's team repo, so an --output teamwiki got teamai's 5 even when the team set 2. The shortcut now reads the parameter from the member's config, read without writing, and teamai's where no teamai init serves the directory. The deep-enrich case now starts both a team teamwiki and an --output one under concurrency: 2; it gave [2, 5] before the change and [2, 2] after. --- src/__tests__/codebase-deep-enrich.test.ts | 6 ++++-- src/codebase-cmd.ts | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/__tests__/codebase-deep-enrich.test.ts b/src/__tests__/codebase-deep-enrich.test.ts index d396b6270d..f2d0e8a2d7 100644 --- a/src/__tests__/codebase-deep-enrich.test.ts +++ b/src/__tests__/codebase-deep-enrich.test.ts @@ -577,6 +577,7 @@ describe('codebase deep-enrich', () => { it('starts teamai-codebase-deep-enrich on an --output teamwiki', async () => { const root = createEnrichFixture(); + vi.mocked(autoDetectInit).mockRejectedValue(new NotInitializedError('teamai is not initialized. Run `teamai init` first.')); vi.mocked(runShortcut).mockResolvedValue({ run: 'r1', outcome: 'succeeded', outputs: { exit_code: 1 } }); await codebaseCmd({ deepEnrich: true, project: 'faketest', output: root }); @@ -697,7 +698,7 @@ describe('codebase deep-enrich in a team repo', () => { } }); - it('starts it as many components at a time as the team\'s params.yaml says', async () => { + it('starts it as many components at a time as the team\'s params.yaml says, on the team teamwiki and on an --output one', async () => { const { clone } = await createTeamDeepEnrichFixture(); const { localConfig } = await autoDetectInit(); fs.mkdirSync(path.join(clone, 'workflows', 'teamai-codebase-deep-enrich'), { recursive: true }); @@ -706,8 +707,9 @@ describe('codebase deep-enrich in a team repo', () => { vi.mocked(runShortcut).mockResolvedValue({ run: 'r1', outcome: 'failed' }); await codebaseCmd({ deepEnrich: true, project: 'auth' }); + await codebaseCmd({ deepEnrich: true, project: 'faketest', output: createEnrichFixture() }); - expect(runShortcut).toHaveBeenCalledWith(expect.objectContaining({ inputs: expect.objectContaining({ concurrency: 2 }) })); + expect(vi.mocked(runShortcut).mock.calls.map(([start]) => start.inputs.concurrency)).toEqual([2, 2]); }); it('publishes nothing when the run did not succeed', async () => { diff --git a/src/codebase-cmd.ts b/src/codebase-cmd.ts index 7bd32c3ef6..16bc9a7b63 100644 --- a/src/codebase-cmd.ts +++ b/src/codebase-cmd.ts @@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'; import chalk from 'chalk'; import { z } from 'zod'; -import type { GlobalOptions } from './types.js'; +import type { GlobalOptions, LocalConfig } from './types.js'; import type { WikiLintSeverity } from './codebase-wiki-lint.js'; import type { TeamwikiTarget } from './utils/teamwiki-root.js'; import type { ShortcutEnd } from './workflow-commands.js'; @@ -29,6 +29,18 @@ export interface CodebaseCmdOptions extends GlobalOptions { deepEnrich?: boolean; } +/** The config of the member running here, or null where no teamai init serves this directory. */ +async function memberScope(): Promise { + const { autoDetectInit, NotInitializedError } = await import('./config.js'); + + try { + return (await autoDetectInit(undefined, { dryRun: true, selfHeal: false, suppressMigrationNotice: true })).localConfig; + } catch (error) { + if (error instanceof NotInitializedError) return null; + throw error; + } +} + /** * Deep-enriches `project` of `target`, as `codebase --deep-enrich` and the hidden `deep-enrich` do, through a * teamai-codebase-deep-enrich run, under the import lock of the team repo when one holds the teamwiki, as every command @@ -46,7 +58,9 @@ export async function startDeepEnrich({ target, project, maxModules = 0, publish const { lockTeamwiki } = await import('./utils/teamwiki-root.js'); const { runShortcut } = await import('./workflow-commands.js'); const { DefaultFolders } = await import('./default-folders.js'); - const { concurrency } = (await DefaultFolders.load({ kind: 'workflows', name: 'teamai-codebase-deep-enrich' }, target.team?.localConfig ?? null)).params; + // The run is the member's whatever teamwiki it writes, so an --output teamwiki keeps the team's parameters too. + const scope = target.team?.localConfig ?? await memberScope(); + const { concurrency } = (await DefaultFolders.load({ kind: 'workflows', name: 'teamai-codebase-deep-enrich' }, scope)).params; const releaseLock = await lockTeamwiki(target); const given = { wiki_root: target.wikiRoot, project, max_modules: maxModules, concurrency }; // Only a publishing run passes publish, so a team's own teamai-codebase-deep-enrich need not declare it. From 1f3b1eb6a5e2d0fcda78dd5bd6848953e24358a9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 16:09:32 +0100 Subject: [PATCH 17/18] fix(recall): read the dashboard's maintenance gates only from a scope No command passes a team repo path to the knowledge report, so reading that path's own default folders served no reader. Without a scope, the report used it on the ~/.teamai fallback, where a leftover workflow folder could change the lists without pull having checked it. With no scope, the report now uses teamai's parameters, as before this change. --- src/__tests__/viz.test.ts | 13 ------------- src/default-folders.ts | 15 ++------------- src/viz.ts | 7 +------ 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/src/__tests__/viz.test.ts b/src/__tests__/viz.test.ts index 8a7ca2ea95..d3d0d2a8f3 100644 --- a/src/__tests__/viz.test.ts +++ b/src/__tests__/viz.test.ts @@ -357,19 +357,6 @@ describe('buildVizData with explicit --repo', () => { ]; expect(allTitles.some((t) => t.includes('TEAM ONLY skill'))).toBe(true); }); - - it('lists the promotion candidates the repo\'s own params.yaml for teamai-promote-learning admits', async () => { - tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-viz-repo-')); - const put = (rel: string, content: string): Promise => fs.mkdir(path.dirname(path.join(tmpRepo, rel)), { recursive: true }).then(() => fs.writeFile(path.join(tmpRepo, rel), content, 'utf-8')); - await put('learnings/retry-deploys.md', '---\ntitle: Retry deploys\n---\nRetry a failed deploy once.\n'); - await put('votes/alice.yaml', votesYaml({ 'retry-deploys': { recalled_count: 1, upvoted_count: 1, last_recalled_at: new Date().toISOString() } })); - const listed = async (): Promise => (await buildVizData(await resolveVizRoot({ repo: tmpRepo }))).maintenance.promote.map((candidate) => candidate.docId); - - const teamais = await listed(); - await put('workflows/teamai-promote-learning/params.yaml', 'min_confidence: 0\nmin_upvoted: 1\nmin_users: 1\nmin_age_days: 0\n'); - - expect([teamais, await listed()]).toEqual([[], ['retry-deploys']]); - }); }); // ─── Relation counts in the KB health view (#1562 part 2) ───────────────────── diff --git a/src/default-folders.ts b/src/default-folders.ts index 8e12c7fe9a..67599d4106 100644 --- a/src/default-folders.ts +++ b/src/default-folders.ts @@ -453,19 +453,8 @@ async function teamFiles(ref: { readonly kind: LoadedKind; readonly name: string * keeps it unless it changed after pull; then teamai's is read. */ async function load>(ref: DefaultFolderRef, scope: LocalConfig | null): Promise> { - return chooseInForce(ref, scope ? await teamFiles(ref, scope) : null); -} - -/** Default `ref` as the team repo at `teamRoot` holds it, for a reader of a repo no pull took, such as `teamai dashboard --repo`. */ -async function loadAt>(ref: DefaultFolderRef, teamRoot: string): Promise> { - const dir = path.join(teamRoot, ref.kind, ref.name); - const team = await FolderFiles.read(dir); - - return chooseInForce(ref, team.kind === 'files' ? { path: dir, files: [...team.files] } : null); -} - -async function chooseInForce>(ref: DefaultFolderRef, team: { path: string; files: FolderFile[] } | null): Promise> { const teamai = await shipped(ref); + const team = scope ? await teamFiles(ref, scope) : null; if (team === null) return teamai.folder; const folder = readFolder(ref, team.files, teamai.params); @@ -526,4 +515,4 @@ async function compare { const namespaces = paths.learningsNamespaces.ok ? paths.learningsNamespaces.namespaces : undefined; - // `--repo` names a team repo no pull took, so its own folders are read. - const params = async >(name: TName) => { - const ref = { kind: 'workflows', name } as const; - - return (await (paths.scope ? DefaultFolders.load(ref, paths.scope) : DefaultFolders.loadAt(ref, paths.knowledgeRoot))).params; - }; + const params = async >(name: TName) => (await DefaultFolders.load({ kind: 'workflows', name }, paths.scope ?? null)).params; const [promote, prune, stale] = await Promise.all([ params('teamai-promote-learning') From 29bf3316c87c5c7672da2efa17aba48b56ada23b Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 12 Nov 2026 16:24:04 +0100 Subject: [PATCH 18/18] docs(changelog): record moving default hook parameters out of with: as breaking 0.30.0 ships the default hook folders with their parameters in HOOK.yaml's with:, so a team that replaced one is refused after this change until it moves the keys to params.yaml. The hook folder entry keeps the wording 0.30.0 shipped, and the friction reasons as texts get their own line. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 108f2c7ade..fe78712e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ All notable changes to this project will be documented in this file. See [standa - A team hook can be a folder, `hooks/[/]/HOOK.yaml` beside the scripts it runs, with the keys of a `hooks.yaml` entry except `id`, `roles` and `projects`; `${TEAMAI_HOOK_DIR}` in `command` names the folder. In single-repo, Claude Code's command names it through `$CLAUDE_PROJECT_DIR`. `requireTeamScripts` keeps a folder hook whose command names `${TEAMAI_HOOK_DIR}`. `${TEAMAI_HOOK_DIR}` in `hooks.yaml`, or an id that is both an entry and a folder, stops team hooks for the run, and `teamai review --branch` fails a branch with either, or with a `HOOK.yaml` that does not parse. A single-repo worktree's folders are removed on uninstall. Once a worktree is removed, the next pull of another worktree, or `teamai doctor --fix`, removes the team hook entries it left in HOME (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A team MCP server can be a folder, `mcp/[/]/MCP.yaml` beside the files it runs, with the keys of an `mcp.yaml` server except `name`, `roles` and `projects`, stdio only; `${TEAMAI_MCP_DIR}` in `command`, `args` and `env` names the folder for every tool `mcp/mcp.yaml` reaches. In single-repo each tool's MCP file holds the worktree's absolute path. `requires` and `sharing.mcp.allowedCommands` apply to the runner, as for any server. A folder with `transport: http` or `sse`, `${TEAMAI_MCP_DIR}` in `mcp.yaml`, or a name that is both a server there and a folder stops team MCP for the run and keeps what is installed (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - A benchmark suite can be a folder, `benchmarks/[/]/SUITE.yaml`; `suite.yaml` keeps working. A directory holding both is invalid, and pull and `teamai benchmark try` name both files. Docs and messages show `SUITE.yaml` (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). -- teamai's hints keep their thresholds and texts in default hook folders the package ships: `hooks/teamai-friction-reminder/`, `teamai-mr-hint/`, `teamai-todowrite-hint/`, `teamai-env-hint/` and `teamai-package-hint/`, each with a `README.md` listing its parameters, ranges, variables and switches. A team replaces one with a folder of the same name at the root of its repo, holding only the files it changes: `params.yaml` holds the parameters, where a key left out keeps teamai's value, `HOOK.yaml` holds only `description`, and each text is `.md` with `{{ name }}` variables. Pull refuses a replacement that sets a parameter out of range or one the default does not take, reads a variable its text does not offer, leaves a text out, has a `command`, or sits in a namespace, names why, and keeps teamai's folder in force. Each hint reads the replacement in every tool, the older `teamai contribute-check`, `mr-hint` and `todowrite-hint` entry points included. With `language: en` or `zh`, a hint prints `.en.md` or `.zh.md` when its folder has one. The friction reminder's reasons are texts too, `reason-interrupt.md`, `reason-tool-reject.md`, `reason-correction.md` and `reason-tool-error.md`, with `{{ count }}`, `{{ times }}` and `{{ tool_calls }}` worded in the language of the file read. With no replacement every hint prints what it did, except that a team with `language: zh` now sees the friction reminder in Chinese, its reasons included (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). +- teamai's hints keep their thresholds and texts in default hook folders the package ships: `hooks/teamai-friction-reminder/`, `teamai-mr-hint/`, `teamai-todowrite-hint/`, `teamai-env-hint/` and `teamai-package-hint/`, each with a `README.md` listing its parameters, ranges, variables and switches. A team replaces one with a folder of the same name at the root of its repo: `HOOK.yaml` holds `description` and `with:`, where a key left out keeps teamai's value, and each text is `.md` with `{{ name }}` variables. Pull refuses a replacement that sets a parameter out of range or one the default does not take, reads a variable its text does not offer, leaves a text out, has a `command`, or sits in a namespace, names why, and keeps teamai's folder in force. Each hint reads the replacement in every tool, the older `teamai contribute-check`, `mr-hint` and `todowrite-hint` entry points included. With `language: en` or `zh`, a hint prints `.en.md` or `.zh.md` when its folder has one. With no replacement every hint prints what it did, except that a team with `language: zh` now sees the friction reminder in Chinese (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). +- The friction reminder's reasons are texts of `hooks/teamai-friction-reminder/`, `reason-interrupt.md`, `reason-tool-reject.md`, `reason-correction.md` and `reason-tool-error.md`, with `{{ count }}`, `{{ times }}` and `{{ tool_calls }}` worded in the language of the file read. A team rewords one with a file of that name in its folder, and a team with `language: zh` now sees them in Chinese rather than in English inside the Chinese reminder (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - A team rule can be a folder: `rules/[/]/RULE.md` beside the files it sends the agent to, named by its path as a rule file is. `RULE..md` gives the body for a `language` under the frontmatter of `RULE.md`. Pull copies the folder to each worktree's `.teamai/delivered/rules/`, which git ignores, and puts its path, with forward slashes, in place of `${TEAMAI_RULE_DIR}`: every tool reads it there, a tool that reads nothing outside the project by its path from the project root. A user-scope rule names `~/.teamai/rules/`, granted to Claude Code through `permissions.additionalDirectories` in `~/.claude/settings.json` (uninstall takes it back), and pull warns once for each other tool that may ask before reading it. Push proposes nothing from a folder's copies. teamai's recall rule is now the folder `rules/teamai-recall/`, which a team replaces with its own at the root of the team repo; pull refuses a replacement missing a line recall needs or holding a link, keeps teamai's, and `teamai review --branch` fails it. Upgrade every member before committing a rule folder (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - The dashboard's Resources page has a Resource layers panel: for the selected workspace, every definition of each workflow, hook, MCP server, benchmark suite and rule folder, with its layer (teamai's default, the team root, or a namespace), whether it is a file, folder or entry, what it replaces, and whether pull uses it, with what pull prints and the fix when it does not. A folder's row lists its files with size and executable mark, the total against a workflow folder's 1 MB limit, where pull delivers it, and the command it runs; a rule folder's names the language file its text comes from and where each tool reads `${TEAMAI_RULE_DIR}`. teamai's recall rule and its five default hooks are listed beside the team's replacements, the hooks with each `with:` parameter against teamai's, and a default workflow that cannot start against the team's workflows is shown as held. The Overview counts the problems and links there. Names, paths and sizes only, never a file's content or an env value (for [#1787](https://github.com/Tencent/teamai-cli/issues/1787)). - OpenClaw, Hermes and DeepSeek Harness write team MCP servers and HTTP-delivered models to their user config files, through the same pull, `teamai mcp list`, doctor and uninstall as other tools. Workspace MCP ownership and reporting stay with the workspace: uninstall keeps entries another workspace still owns and preserves member edits. Unreadable ownership records keep the config and retry records intact; removal counts include only entries actually deleted. Delivered models are added beside the member's settings, without changing the current model, primary or fallbacks. @@ -146,6 +147,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes +- **A default hook's parameters live in `params.yaml`, not in `HOOK.yaml`'s `with:`.** `hooks/teamai-friction-reminder/` and `hooks/teamai-mr-hint/` ship `params.yaml`, and a team folder holding only its own `params.yaml` keeps teamai's `HOOK.yaml` and texts. A key it leaves out keeps teamai's value. Pull refuses a team `HOOK.yaml` that still has `with:` and names `params.yaml`, and until the keys move there teamai's folder is in force, so move them before members upgrade (for [#1829](https://github.com/Tencent/teamai-cli/issues/1829)). - **A team folder named like a teamai default holds only the files it changes.** `hooks/teamai-/`, `rules/teamai-/` and `workflows/teamai-/` at the team root take every file they leave out, marker included, from teamai's default, and a file of the team's replaces teamai's of that name with its language variants. A lone `rules/teamai-recall/.md`, which pull delivered as a rule of its own, is now part of teamai's recall rule. Have every member upgrade before you commit a folder that holds only some files: an older teamai reads `rules/teamai-recall/block.md` as an always-on rule of its own. - **`TEAMAI_AGENT_DIR` is a reserved team env name.** It names an agent folder's own copy, so a team value for it is ignored and pull warns about it. - **A skill can be one file, so a file under `skills/` can be a skill.** `skills/[/].md` whose frontmatter has a `description` is delivered as `/SKILL.md`. Notes without one, such as `NOTES.md` or `README.md`, stay notes.