toolroll 0.9.84 → 0.9.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +94 -1193
  3. package/dist/agent-fence.d.ts +3 -3
  4. package/dist/agent-fence.js +3 -3
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-channel.d.ts +10 -1
  11. package/dist/chat-channel.js +35 -5
  12. package/dist/chat-decide.d.ts +16 -4
  13. package/dist/chat-decide.js +239 -31
  14. package/dist/chat-messages.d.ts +5 -0
  15. package/dist/chat-messages.js +1 -1
  16. package/dist/chat-quiet.js +3 -2
  17. package/dist/chat-review.d.ts +4 -0
  18. package/dist/chat-review.js +13 -4
  19. package/dist/chat-terms.d.ts +9 -0
  20. package/dist/chat-terms.js +46 -0
  21. package/dist/contracts/console-api.d.ts +2 -1
  22. package/dist/contracts/console-api.js +2 -1
  23. package/dist/contracts/lead-tools.d.ts +30 -0
  24. package/dist/contracts/lead-tools.js +3 -0
  25. package/dist/contracts/youiyoux-request.d.ts +248 -0
  26. package/dist/contracts/youiyoux-request.js +152 -0
  27. package/dist/flow-cadence.d.ts +8 -2
  28. package/dist/flow-cadence.js +16 -3
  29. package/dist/flow-card-task.d.ts +9 -0
  30. package/dist/flow-card-task.js +15 -6
  31. package/dist/integrations-ui.js +9 -4
  32. package/dist/integrations.d.ts +9 -1
  33. package/dist/integrations.js +43 -2
  34. package/dist/landed-cleanup.d.ts +4 -0
  35. package/dist/landed-cleanup.js +3 -2
  36. package/dist/lead-capabilities.js +2 -1
  37. package/dist/lead-cli.js +3 -1
  38. package/dist/lead-tools.d.ts +2 -0
  39. package/dist/lead-tools.js +30 -4
  40. package/dist/lead.d.ts +2 -0
  41. package/dist/lead.js +3 -3
  42. package/dist/operate.d.ts +1 -2
  43. package/dist/operate.js +11 -15
  44. package/dist/project-activity.d.ts +20 -0
  45. package/dist/project-activity.js +23 -0
  46. package/dist/project-cli.d.ts +1 -0
  47. package/dist/project-cli.js +36 -19
  48. package/dist/project-knowledge.d.ts +3 -3
  49. package/dist/project-outcomes.d.ts +119 -0
  50. package/dist/project-outcomes.js +79 -0
  51. package/dist/result-actions.d.ts +4 -0
  52. package/dist/result-actions.js +41 -9
  53. package/dist/result-review.d.ts +9 -0
  54. package/dist/result-review.js +45 -5
  55. package/dist/server/console.css +12 -0
  56. package/dist/server/pages.js +11 -1
  57. package/dist/server/render-chat.js +3 -2
  58. package/dist/server/render-pages.d.ts +2 -2
  59. package/dist/server/render-pages.js +7 -2
  60. package/dist/server/render-results.d.ts +2 -0
  61. package/dist/server/render-results.js +20 -7
  62. package/dist/server/render-tasks.d.ts +1 -0
  63. package/dist/server/render-tasks.js +15 -5
  64. package/dist/server/route-table.js +1 -0
  65. package/dist/server/settings.js +18 -1
  66. package/dist/server/task-views.js +6 -0
  67. package/dist/server/tasks.js +42 -6
  68. package/dist/store.d.ts +46 -3
  69. package/dist/store.js +165 -6
  70. package/dist/task-source.d.ts +13 -1
  71. package/dist/task-source.js +17 -1
  72. package/dist/telegram-lead.js +7 -5
  73. package/dist/telegram.js +1 -1
  74. package/dist/toolroll-update.js +2 -2
  75. package/dist/webhooks.d.ts +6 -0
  76. package/dist/webhooks.js +25 -0
  77. package/dist/work-index.d.ts +4 -0
  78. package/dist/work-index.js +32 -0
  79. package/dist/youiyoux-intake.d.ts +234 -0
  80. package/dist/youiyoux-intake.js +908 -0
  81. package/package.json +11 -8
@@ -0,0 +1,23 @@
1
+ export const activityAge = (ms) => ms === null ? 'Unknown' : ms < 3600000 ? `${Math.floor(ms / 60000)}m` : ms < 86400000 ? `${(ms / 3600000).toFixed(1)}h` : `${(ms / 86400000).toFixed(1)}d`;
2
+ export const activityMoney = (usd) => usd === null ? 'Unknown' : `$${usd.toFixed(2)}`;
3
+ export const ACTIVITY_ACTORS = { human: 'Human', lead: 'Lead', landed: 'Automatically landed', 'machine-or-unknown': 'Machine or unknown' };
4
+ export const activityScope = (report) => `Current Ready backlog; completions in the last ${report.days} days. Completion does not mean checks passed or work deployed.`;
5
+ export const activityLimit = (report) => `Partial report: only the ${report.limit} most recently updated eligible families are included. Counts and coverage below describe this sample.`;
6
+ export function activityMetrics(report) {
7
+ const ready = report.families.filter(one => one.state === 'ready');
8
+ const complete = report.families.filter(one => one.state === 'completed');
9
+ const metrics = [
10
+ { label: 'Ready', value: report.ready.count === 0 ? '0' : `${report.ready.count} · Oldest ${activityAge(report.ready.oldestFinishedAgeMs)}`,
11
+ detail: `Age is measured from the latest result finishing, a proxy for Ready entry. ${report.ready.dated}/${report.ready.count} have usable timestamps.`, rows: ready },
12
+ { label: 'Finished to decision', value: report.completed.count === 0 ? 'No completed families' : `${activityAge(report.finishedToDecision.meanMs)} average`,
13
+ detail: `${report.finishedToDecision.samples}/${report.completed.count} completed families have usable timestamps. Elapsed time to the saved completion decision, not human work time.`, rows: complete },
14
+ { label: 'Explicit revisions', value: report.revisions.perCompletedFamily === null ? 'No completed families' : `${report.revisions.perCompletedFamily.toFixed(1)} per completed family`,
15
+ detail: `${report.revisions.count} revisions across ${report.revisions.samples} completed families. Retries are attempts, not revisions.`, rows: complete },
16
+ { label: 'Recorded spend', value: report.completed.count === 0 ? 'No completed families' : `${activityMoney(report.cost.recordedPerCompletedFamilyUsd)} per completed family · ${report.cost.knownRuns}/${report.cost.runs} costs known`,
17
+ detail: `${activityMoney(report.cost.recordedUsd)} in API charges across ${report.cost.families} completed families; ${report.cost.fullyCostedFamilies}/${report.cost.families} fully costed.${report.cost.subscriptionRuns > 0 ? ` ${report.cost.subscriptionRuns} subscription runs are plan usage, not charges${report.cost.subscriptionReportedUsd === null ? '' : ` (${activityMoney(report.cost.subscriptionReportedUsd)} reported)`}.` : ''} Missing or unreported costs are unknown. All family attempts count once, including planning and retries.`, rows: complete },
18
+ { label: 'Completed families', value: String(report.completed.count),
19
+ detail: `${Object.entries(report.completed.actors).map(([key, count]) => `${ACTIVITY_ACTORS[key]}: ${count}`).join(' · ')}. Scouts: ${report.completed.scouts}. Failed checks: ${report.completed.failedChecks}.`, rows: complete },
20
+ ...(report.incompleteHistory > 0 ? [{ label: 'Incomplete history', value: String(report.incompleteHistory), detail: 'Excluded from completion averages; their family relationships cannot be established.', rows: report.families.filter(one => one.state === 'history-unavailable') }] : []),
21
+ ];
22
+ return report.completed.count === 0 ? metrics.filter(one => one.label === 'Ready' || one.label === 'Completed families' || one.label === 'Incomplete history') : metrics;
23
+ }
@@ -12,6 +12,7 @@ export declare function runProjectCommand(positional: readonly string[], flags:
12
12
  write: (line: string) => void;
13
13
  json: boolean;
14
14
  registryFile?: string;
15
+ now?: Date;
15
16
  }): Promise<number>;
16
17
  /** Only known credential-aware adapters consume the remembered reference.
17
18
  * Explicit credentials always win; no legacy action gains implicit authority. */
@@ -8,6 +8,7 @@ import { configPath, loadRepos } from './repos.js';
8
8
  import { authenticateCoordinator } from './coordinator.js';
9
9
  import { canonicalProject } from './project.js';
10
10
  import { envelopeJson } from './envelope.js';
11
+ import { projectOutcomes } from './project-outcomes.js';
11
12
  export const projectProfilePath = (databaseFile) => join(dirname(databaseFile), 'cli-project.json');
12
13
  export function readProjectProfile(databaseFile) {
13
14
  const file = projectProfilePath(databaseFile);
@@ -25,11 +26,29 @@ export function readProjectProfile(databaseFile) {
25
26
  throw new Error('The saved CLI project is invalid. Select it again with project use.');
26
27
  return { version: 1, repo: p['repo'], tokenFile: p['tokenFile'] };
27
28
  }
29
+ function scopedCredentialRefusal(store, tokenFile, repo) {
30
+ let token;
31
+ try {
32
+ const info = statSync(tokenFile);
33
+ if (!info.isFile() || info.size > 16_384)
34
+ return { reason: 'unauthenticated', message: 'The scoped credential file is unavailable.' };
35
+ token = readFileSync(tokenFile, 'utf8').trim();
36
+ }
37
+ catch {
38
+ return { reason: 'unauthenticated', message: 'The scoped credential file is unavailable.' };
39
+ }
40
+ const auth = authenticateCoordinator(store, token);
41
+ if (!auth.ok)
42
+ return { reason: 'unauthenticated', message: 'The scoped credential is invalid or revoked.' };
43
+ if (!auth.who.repos.includes(repo))
44
+ return { reason: 'not-found', message: 'That project is unavailable to this credential.' };
45
+ return null;
46
+ }
28
47
  export async function runProjectCommand(positional, flags, context) {
29
48
  const action = positional[0] ?? 'show', command = `project ${action}`;
30
49
  const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === 'usage' ? 2 : 3; };
31
- if (!['use', 'show'].includes(action) || positional.length > (action === 'use' ? 2 : 1))
32
- return fail('usage', 'Use project use <saved checkout path> [--token-file PATH], or project show.');
50
+ if (!['use', 'show', 'activity'].includes(action) || positional.length > (action === 'use' ? 2 : 1))
51
+ return fail('usage', 'Use project use <saved checkout path> [--token-file PATH], project show, or project activity [--json].');
33
52
  const allowed = new Set(['db', 'json', ...(action === 'use' ? ['token-file'] : [])]);
34
53
  for (const name of flags.keys())
35
54
  if (!allowed.has(name))
@@ -57,23 +76,9 @@ export async function runProjectCommand(positional, flags, context) {
57
76
  if (credential !== undefined && (typeof credential !== 'string' || !credential || /[\x00-\x1f]/.test(credential)))
58
77
  return fail('usage', '--token-file requires a path.');
59
78
  const tokenFile = typeof credential === 'string' ? resolve(credential) : null;
60
- if (tokenFile !== null) {
61
- let token;
62
- try {
63
- const info = statSync(tokenFile);
64
- if (!info.isFile() || info.size > 16_384)
65
- return fail('unauthenticated', 'The scoped credential file is unavailable.');
66
- token = readFileSync(tokenFile, 'utf8').trim();
67
- }
68
- catch {
69
- return fail('unauthenticated', 'The scoped credential file is unavailable.');
70
- }
71
- const auth = authenticateCoordinator(context.store, token);
72
- if (!auth.ok)
73
- return fail('unauthenticated', 'The scoped credential is invalid or revoked.');
74
- if (!auth.who.repos.includes(repo))
75
- return fail('not-found', 'That project is unavailable to this credential.');
76
- }
79
+ const refused = tokenFile === null ? null : scopedCredentialRefusal(context.store, tokenFile, repo);
80
+ if (refused)
81
+ return fail(refused.reason, refused.message);
77
82
  profile = { version: 1, repo, tokenFile };
78
83
  const file = projectProfilePath(context.databaseFile), temp = `${file}.${randomBytes(6).toString('hex')}.tmp`;
79
84
  mkdirSync(dirname(file), { recursive: true });
@@ -82,6 +87,18 @@ export async function runProjectCommand(positional, flags, context) {
82
87
  }
83
88
  else
84
89
  profile = readProjectProfile(context.databaseFile);
90
+ if (action === 'activity') {
91
+ if (profile === null || !repos.includes(profile.repo))
92
+ return fail('not-found', 'Select a saved project with project use first.');
93
+ const refused = profile.tokenFile === null ? null : scopedCredentialRefusal(context.store, profile.tokenFile, profile.repo);
94
+ if (refused)
95
+ return fail(refused.reason, refused.message);
96
+ // Like assignment brief, an uncredentialed local database read has the
97
+ // local operator's access. A saved scoped credential is always re-proved.
98
+ const result = projectOutcomes(context.store, profile.repo, context.now ?? new Date(), { principal: 'operator', repos: [profile.repo] });
99
+ context.write(context.json ? envelopeJson({ ok: true, command, result }) : JSON.stringify(result, null, 2));
100
+ return 0;
101
+ }
85
102
  const result = { profile, saved: profile !== null && repos.includes(profile.repo) };
86
103
  context.write(context.json ? envelopeJson({ ok: true, command, result }) : profile === null ? 'No project selected. Use project use <saved checkout path>.' : `${profile.repo}${result.saved ? '' : ' (no longer saved)'}\n${profile.tokenFile === null ? 'Uses your existing local sign-in for operator actions.' : `Scoped credential reference: ${profile.tokenFile}`}`);
87
104
  return 0;
@@ -23,17 +23,18 @@ export declare function knowledgeVersion(store: Store, repo: string, actor: stri
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
25
  title?: never;
26
+ content?: never;
26
27
  revision: number;
27
28
  stale: boolean;
28
29
  notice: string;
29
30
  decision?: never;
30
- content?: never;
31
31
  history?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
36
  title?: never;
37
+ content?: never;
37
38
  stale?: never;
38
39
  decision: {
39
40
  repo: string;
@@ -50,7 +51,6 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
50
51
  id: number;
51
52
  };
52
53
  notice: string;
53
- content?: never;
54
54
  revision?: never;
55
55
  history?: never;
56
56
  instructions?: never;
@@ -69,9 +69,9 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
69
69
  decisions?: never;
70
70
  } | {
71
71
  title?: never;
72
+ content?: never;
72
73
  stale?: never;
73
74
  decision?: never;
74
- content?: never;
75
75
  revision: number;
76
76
  history: {
77
77
  revision: number;
@@ -0,0 +1,119 @@
1
+ import type { Store } from './store.js';
2
+ import type { WorkSummaryAccess } from './work-summary.js';
3
+ export declare const ACTIVITY_DAYS = 60;
4
+ export declare const ACTIVITY_LIMIT = 500;
5
+ export type CompletionActor = 'human' | 'lead' | 'landed' | 'machine-or-unknown';
6
+ export type ActivityFamily = {
7
+ root: string;
8
+ task: string;
9
+ title: string;
10
+ run: number | null;
11
+ href: string;
12
+ state: 'ready' | 'completed' | 'history-unavailable';
13
+ kind: string;
14
+ finishedAt: string | null;
15
+ completedAt: string | null;
16
+ actor: CompletionActor | null;
17
+ checks: string;
18
+ revisions: number | null;
19
+ elapsedMs: number | null;
20
+ runs: number;
21
+ /** Runs whose amount is known (reported-usage.ts runAmountOf): API dollars or subscription usage. */
22
+ knownCostRuns: number;
23
+ subscriptionRuns: number;
24
+ /** API dollars only; subscription usage is never added to them. */
25
+ recordedUsd: number | null;
26
+ subscriptionReportedUsd: number | null;
27
+ /** Every attempt billed in known API dollars; otherwise the family's dollar total is unknown. */
28
+ totalUsd: number | null;
29
+ };
30
+ export type ProjectOutcomes = {
31
+ asOf: string;
32
+ since: string;
33
+ days: number;
34
+ limit: number;
35
+ truncated: boolean;
36
+ ready: {
37
+ count: number;
38
+ oldestFinishedAgeMs: number | null;
39
+ dated: number;
40
+ };
41
+ completed: {
42
+ count: number;
43
+ actors: Record<CompletionActor, number>;
44
+ scouts: number;
45
+ failedChecks: number;
46
+ };
47
+ finishedToDecision: {
48
+ meanMs: number | null;
49
+ samples: number;
50
+ };
51
+ revisions: {
52
+ count: number;
53
+ perCompletedFamily: number | null;
54
+ samples: number;
55
+ };
56
+ cost: {
57
+ recordedUsd: number | null;
58
+ recordedPerCompletedFamilyUsd: number | null;
59
+ runs: number;
60
+ knownRuns: number;
61
+ subscriptionRuns: number;
62
+ subscriptionReportedUsd: number | null;
63
+ fullyCostedFamilies: number;
64
+ families: number;
65
+ };
66
+ incompleteHistory: number;
67
+ families: ActivityFamily[];
68
+ };
69
+ /** Callers supply their current authenticated ceiling. A path alone grants no access. */
70
+ export declare function projectOutcomes(store: Store, project: string, now: Date, access: WorkSummaryAccess): ProjectOutcomes;
71
+ /** The lead's page of one report: every aggregate unchanged, family rows in pages bounded by count AND by their
72
+ * serialized size, so the reply always fits the tool-result cap (lead.ts, converse.ts LEAD_TOOL_RESULT_CAP_BYTES) and
73
+ * the lead reads further with nextOffset. Long ids and titles are shortened; an over-long link is left out. */
74
+ export declare const ACTIVITY_LEAD_PAGE = 10;
75
+ export declare const ACTIVITY_LEAD_PAGE_BYTES = 12000;
76
+ export declare function projectOutcomesPage(report: ProjectOutcomes, offset?: number): {
77
+ asOf: string;
78
+ since: string;
79
+ days: number;
80
+ limit: number;
81
+ truncated: boolean;
82
+ ready: {
83
+ count: number;
84
+ oldestFinishedAgeMs: number | null;
85
+ dated: number;
86
+ };
87
+ completed: {
88
+ count: number;
89
+ actors: Record<CompletionActor, number>;
90
+ scouts: number;
91
+ failedChecks: number;
92
+ };
93
+ finishedToDecision: {
94
+ meanMs: number | null;
95
+ samples: number;
96
+ };
97
+ revisions: {
98
+ count: number;
99
+ perCompletedFamily: number | null;
100
+ samples: number;
101
+ };
102
+ cost: {
103
+ recordedUsd: number | null;
104
+ recordedPerCompletedFamilyUsd: number | null;
105
+ runs: number;
106
+ knownRuns: number;
107
+ subscriptionRuns: number;
108
+ subscriptionReportedUsd: number | null;
109
+ fullyCostedFamilies: number;
110
+ families: number;
111
+ };
112
+ incompleteHistory: number;
113
+ families: (Omit<ActivityFamily, "href"> & {
114
+ href: string | null;
115
+ })[];
116
+ familiesTotal: number;
117
+ familiesOffset: number;
118
+ nextOffset: number | null;
119
+ };
@@ -0,0 +1,79 @@
1
+ /** Read-only Activity. A completion is a saved decision, never a claim of
2
+ * passing checks, deployment, review labor, or improved productivity. */
3
+ import { chatResultHref } from './chat-controls.js';
4
+ import { workOutcomeRows } from './work-index.js';
5
+ import { COMPLETION_BY_PERSON, LANDED_ACTOR } from './result-completion.js';
6
+ export const ACTIVITY_DAYS = 60;
7
+ export const ACTIVITY_LIMIT = 500;
8
+ const text = (value) => value == null ? null : String(value);
9
+ const duration = (from, to) => {
10
+ const delta = from === null ? NaN : Date.parse(to) - Date.parse(from);
11
+ return Number.isFinite(delta) && delta >= 0 ? delta : null;
12
+ };
13
+ const actorOf = (actor, detail, byLead) => actor === LANDED_ACTOR ? 'landed'
14
+ : byLead || actor?.startsWith('lead:') ? 'lead'
15
+ : actor?.startsWith('operator:') && detail === COMPLETION_BY_PERSON ? 'human' : 'machine-or-unknown';
16
+ /** Callers supply their current authenticated ceiling. A path alone grants no access. */
17
+ export function projectOutcomes(store, project, now, access) {
18
+ if (access.repos !== null && !access.repos.includes(project))
19
+ throw new Error('Project activity is outside your access.');
20
+ const asOf = now.toISOString(), since = new Date(now.getTime() - ACTIVITY_DAYS * 86400000).toISOString();
21
+ const rows = workOutcomeRows(store, now, access, project, since, ACTIVITY_LIMIT);
22
+ const families = rows.slice(0, ACTIVITY_LIMIT).map(row => {
23
+ const state = row['code'] === 'complete' ? 'completed' : row['code'] === 'ready-to-check' ? 'ready' : 'history-unavailable';
24
+ const finishedAt = text(row['finished_at']), completedAt = text(row['checked_at']);
25
+ const runs = Number(row['runs']), apiRuns = Number(row['api_runs']), subscriptionRuns = Number(row['subscription_runs']);
26
+ const recordedUsd = apiRuns > 0 && Number.isFinite(Number(row['api_microusd'])) ? Number(row['api_microusd']) / 1_000_000 : null;
27
+ const subscriptionReportedUsd = row['subscription_usd'] != null && Number.isFinite(Number(row['subscription_usd'])) ? Number(row['subscription_usd']) : null;
28
+ const knownCostRuns = apiRuns + subscriptionRuns;
29
+ const task = String(row['task_id']);
30
+ return { root: String(row['root_id']), task, title: String(row['title']), run: row['result_id'] == null ? null : Number(row['result_id']),
31
+ href: row['result_id'] == null ? `/t/${encodeURIComponent(task)}` : chatResultHref(task, Number(row['result_id'])), state, kind: text(row['result_role']) ?? 'unknown',
32
+ finishedAt, completedAt, actor: state === 'completed' ? actorOf(text(row['checked_actor']), text(row['checked_detail']), Number(row['completed_by_lead']) === 1) : null,
33
+ checks: text(row['check_status']) ?? 'not-recorded', revisions: state === 'history-unavailable' ? null : Number(row['version_count']) - 1,
34
+ elapsedMs: duration(finishedAt, completedAt ?? asOf), runs, knownCostRuns, subscriptionRuns, recordedUsd, subscriptionReportedUsd,
35
+ totalUsd: runs > 0 && runs === apiRuns ? recordedUsd : null };
36
+ });
37
+ const ready = families.filter(one => one.state === 'ready'), completed = families.filter(one => one.state === 'completed');
38
+ const times = completed.flatMap(one => one.elapsedMs === null ? [] : [one.elapsedMs]);
39
+ const ages = ready.flatMap(one => one.elapsedMs === null ? [] : [one.elapsedMs]);
40
+ const actors = { human: 0, lead: 0, landed: 0, 'machine-or-unknown': 0 };
41
+ for (const one of completed)
42
+ actors[one.actor]++;
43
+ const knownRuns = completed.reduce((sum, one) => sum + one.knownCostRuns, 0);
44
+ const recordedUsd = completed.some(one => one.recordedUsd !== null) ? completed.reduce((sum, one) => sum + (one.recordedUsd ?? 0), 0) : null;
45
+ const subscriptionRuns = completed.reduce((sum, one) => sum + one.subscriptionRuns, 0);
46
+ const subscriptionReportedUsd = completed.some(one => one.subscriptionReportedUsd !== null) ? completed.reduce((sum, one) => sum + (one.subscriptionReportedUsd ?? 0), 0) : null;
47
+ const revisions = completed.reduce((sum, one) => sum + (one.revisions ?? 0), 0);
48
+ return { asOf, since, days: ACTIVITY_DAYS, limit: ACTIVITY_LIMIT, truncated: rows.length > ACTIVITY_LIMIT,
49
+ ready: { count: ready.length, oldestFinishedAgeMs: ages.length ? Math.max(...ages) : null, dated: ages.length },
50
+ completed: { count: completed.length, actors, scouts: completed.filter(one => one.kind === 'scout').length, failedChecks: completed.filter(one => one.checks === 'failed').length },
51
+ finishedToDecision: { meanMs: times.length ? times.reduce((a, b) => a + b, 0) / times.length : null, samples: times.length },
52
+ revisions: { count: revisions, perCompletedFamily: completed.length ? revisions / completed.length : null, samples: completed.length },
53
+ cost: { recordedUsd, recordedPerCompletedFamilyUsd: completed.length && recordedUsd !== null ? recordedUsd / completed.length : null,
54
+ runs: completed.reduce((sum, one) => sum + one.runs, 0), knownRuns, subscriptionRuns, subscriptionReportedUsd, fullyCostedFamilies: completed.filter(one => one.totalUsd !== null).length, families: completed.length },
55
+ incompleteHistory: families.filter(one => one.state === 'history-unavailable').length, families };
56
+ }
57
+ /** The lead's page of one report: every aggregate unchanged, family rows in pages bounded by count AND by their
58
+ * serialized size, so the reply always fits the tool-result cap (lead.ts, converse.ts LEAD_TOOL_RESULT_CAP_BYTES) and
59
+ * the lead reads further with nextOffset. Long ids and titles are shortened; an over-long link is left out. */
60
+ export const ACTIVITY_LEAD_PAGE = 10;
61
+ export const ACTIVITY_LEAD_PAGE_BYTES = 12_000;
62
+ const shorten = (value, max) => [...value].length > max ? `${[...value].slice(0, max - 1).join('')}…` : value;
63
+ const embeddedBytes = (value) => Buffer.byteLength(JSON.stringify(JSON.stringify(value)), 'utf8');
64
+ export function projectOutcomesPage(report, offset = 0) {
65
+ const start = Math.max(0, Math.min(Number.isSafeInteger(offset) ? offset : 0, report.families.length));
66
+ const page = [];
67
+ const base = { ...report, families: page, familiesTotal: report.families.length, familiesOffset: start, nextOffset: null };
68
+ for (const one of report.families.slice(start, start + ACTIVITY_LEAD_PAGE)) {
69
+ const row = { ...one, root: shorten(one.root, 200), task: shorten(one.task, 200), title: shorten(one.title, 80), href: Buffer.byteLength(one.href) > 512 ? null : one.href };
70
+ page.push(row);
71
+ if (page.length > 1 && embeddedBytes(base) > ACTIVITY_LEAD_PAGE_BYTES) {
72
+ page.pop();
73
+ break;
74
+ }
75
+ }
76
+ const next = start + page.length;
77
+ base.nextOffset = next < report.families.length ? next : null;
78
+ return base;
79
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "./store.js";
2
+ import { type FeedbackImage } from "./result-review.js";
2
3
  export type RevisionResult = {
3
4
  ok: true;
4
5
  id: string;
@@ -8,6 +9,8 @@ export type RevisionResult = {
8
9
  message: string;
9
10
  };
10
11
  type RevisionInput = Parameters<typeof createResultRevision>[2];
12
+ /** Re-prove image custody and current result at each write. No file paths supplied by callers. */
13
+ export declare function feedbackImageProblem(store: Store, root: string, runId: number, image: FeedbackImage | null | undefined): string | null;
11
14
  /** One user action: add the typed change and seal exactly the displayed
12
15
  * saved notes. A refusal rolls back the note; a replay returns the same task. */
13
16
  export declare function requestResultChanges(store: Store, evidenceRoot: string, input: RevisionInput & {
@@ -15,6 +18,7 @@ export declare function requestResultChanges(store: Store, evidenceRoot: string,
15
18
  path: string;
16
19
  line: string;
17
20
  request: string | null;
21
+ image?: FeedbackImage | null;
18
22
  }, now: Date): RevisionResult;
19
23
  export declare function createResultRevision(store: Store, evidenceRoot: string, input: {
20
24
  run: number;
@@ -1,13 +1,33 @@
1
+ import { verifyResultImage } from "./chat-evidence.js";
1
2
  /** Shared result-to-revision action for the result form and chat.
2
3
  * The caller authenticates; this service checks project access and the exact
3
4
  * displayed note batch, then uses the existing atomic revision/approval boundary.
4
5
  */
5
6
  import { createHash, randomBytes } from "node:crypto";
6
7
  import { EVIDENCE_CAPS, readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
7
- import { isRevisionFeedback, parseRevisionBatch, revisionSourceOf, commentSourceKey, REQUEST_TOKEN } from "./result-review.js";
8
+ import { parseFeedbackImage, sameFeedbackImage, isRevisionFeedback, parseRevisionBatch, revisionSourceOf, commentSourceKey, REQUEST_TOKEN } from "./result-review.js";
8
9
  import { validateNote, hasForbiddenControls } from "./decision.js";
9
10
  import { TASK_TEXT_LIMITS, validateTaskText } from "./task-text.js";
10
11
  import { modeFilingCoverage } from "./scope.js";
12
+ /** Re-prove image custody and current result at each write. No file paths supplied by callers. */
13
+ export function feedbackImageProblem(store, root, runId, image) {
14
+ if (image == null)
15
+ return null;
16
+ const parsed = parseFeedbackImage(image);
17
+ if (!parsed || image.run !== runId)
18
+ return "Choose a screenshot from this result. Your draft is unchanged.";
19
+ const run = store.getRun(runId);
20
+ const task = run === null ? null : store.externalIdFor(run.taskRef);
21
+ const repo = run === null ? null : store.refById(run.taskRef)?.repo;
22
+ if (!task || !repo)
23
+ return "This screenshot is no longer available. Your draft is unchanged.";
24
+ const family = store.taskFamilyOf(task, [repo], false);
25
+ const latest = store.runsFor(run.taskRef).find(one => ["builder", "repair", "scout"].includes(one.role) && one.outcome !== null);
26
+ if (family?.problem !== null || family.current.id !== task || latest?.id !== runId)
27
+ return "A newer result is current. Open it before commenting on a screenshot. Your draft is unchanged.";
28
+ const verified = verifyResultImage(store, root, [repo], { taskId: task, ...image });
29
+ return verified.ok ? null : "This screenshot is unavailable or changed. Restore it or remove it from your feedback. Your draft is unchanged.";
30
+ }
11
31
  /** One user action: add the typed change and seal exactly the displayed
12
32
  * saved notes. A refusal rolls back the note; a replay returns the same task. */
13
33
  export function requestResultChanges(store, evidenceRoot, input, now) {
@@ -15,8 +35,8 @@ export function requestResultChanges(store, evidenceRoot, input, now) {
15
35
  return store.transact(() => store.savepoint(() => {
16
36
  const fail = (status, message) => { throw new ChangeRequestRefusal(status, message); };
17
37
  if (!input.note.trim()) {
18
- if (input.path.trim() || input.line.trim())
19
- return fail(400, "Describe the change before attaching a file or line.");
38
+ if (input.path.trim() || input.line.trim() || input.image != null)
39
+ return fail(400, "Describe the change before attaching an image, file or line.");
20
40
  if (!input.batch)
21
41
  return fail(400, "Describe a change or use a saved note.");
22
42
  return createResultRevision(store, evidenceRoot, input, now);
@@ -43,13 +63,19 @@ export function requestResultChanges(store, evidenceRoot, input, now) {
43
63
  if (!artifact || !readVerifiedArtifact(evidenceRoot, artifact).ok)
44
64
  return fail(409, "The saved changes could not be verified. Restore the result before requesting changes.");
45
65
  const sourceKey = commentSourceKey(input.actor, input.request);
46
- let noteId = store.addDiffComment({ artifactId: artifact.id, runId: run.id, path, line, note: valid.note, author: input.actor, sourceKey }, now);
47
- if (noteId === null) {
48
- const earlier = store.diffCommentBySourceKey(sourceKey);
49
- if (!earlier || earlier.run !== run.id || earlier.path !== path || earlier.line !== line || earlier.note !== valid.note)
50
- return fail(409, "This submission already saved different feedback. Reopen the result and try again.");
51
- noteId = earlier.id;
66
+ const earlier = store.diffCommentBySourceKey(sourceKey);
67
+ if (earlier && (earlier.run !== run.id || earlier.path !== path || earlier.line !== line || earlier.note !== valid.note || !sameFeedbackImage(earlier.image, input.image)))
68
+ return fail(409, "This submission already saved different feedback. Reopen the result and try again.");
69
+ // An exact consumed replay only returns its existing revision. Every new
70
+ // use proves the image before insertion, so malformed targets refuse cleanly.
71
+ if (earlier?.consumedBy == null) {
72
+ const problem = feedbackImageProblem(store, evidenceRoot, run.id, input.image);
73
+ if (problem !== null)
74
+ return fail(409, problem);
52
75
  }
76
+ const noteId = earlier?.id ?? store.addDiffComment({ artifactId: artifact.id, runId: run.id, path, line, note: valid.note, author: input.actor, sourceKey, image: input.image ?? null }, now);
77
+ if (noteId === null)
78
+ return fail(409, "This feedback changed while saving. Your draft is unchanged; reopen the result.");
53
79
  const result = createResultRevision(store, evidenceRoot, { ...input, batch: [...new Set([...batch, noteId])].sort((a, b) => a - b).join(",") }, now);
54
80
  if (!result.ok)
55
81
  return fail(result.status, result.message);
@@ -141,6 +167,11 @@ export function createResultRevision(store, evidenceRoot, input, now) {
141
167
  }
142
168
  if (batch.some(one => !isRevisionFeedback(one)))
143
169
  return fail(409, "Review observations are not requested changes. Use Request change to add your feedback first.");
170
+ for (const one of batch) {
171
+ const problem = feedbackImageProblem(store, evidenceRoot, id, one.image);
172
+ if (problem !== null)
173
+ return fail(409, problem);
174
+ }
144
175
  const comments = batch;
145
176
  // Keep the subject recognizable, including when revising a revision.
146
177
  // Reserve the suffix before truncating, at whole grapheme boundaries.
@@ -180,6 +211,7 @@ export function createResultRevision(store, evidenceRoot, input, now) {
180
211
  note: one.note,
181
212
  author: one.author,
182
213
  createdAt: one.createdAt,
214
+ ...(one.image == null ? {} : { image: one.image }),
183
215
  })),
184
216
  };
185
217
  const briefBytes = Buffer.from(JSON.stringify(brief, null, 2), "utf8");
@@ -19,6 +19,14 @@
19
19
  * selected tab, and the reading position in this tab's sessionStorage,
20
20
  * keyed by the server-named account, the task, and the run. */
21
21
  import { type ResultFacts, type PublicationFacts, type DisplayStatus } from "./workspace-ui.js";
22
+ /** Optional context for feedback. The sealed diff remains the revision authority. */
23
+ export type FeedbackImage = {
24
+ run: number;
25
+ artifact: number;
26
+ sha256: string;
27
+ };
28
+ export declare function parseFeedbackImage(value: unknown): FeedbackImage | null | false;
29
+ export declare function sameFeedbackImage(a: FeedbackImage | null | undefined, b: FeedbackImage | null | undefined): boolean;
22
30
  /** Add re-read evidence health to the existing result/review projection.
23
31
  * This is presentation only: the recorded verdict and acceptance stay intact. */
24
32
  export declare function evidenceResultStatusOf(result: ResultFacts, publication: PublicationFacts, evidence: EvidenceHealth): DisplayStatus;
@@ -49,6 +57,7 @@ export type ResultScreenshot = {
49
57
  path: string;
50
58
  caption: string;
51
59
  artifactId: number;
60
+ sha256?: string;
52
61
  problem: string | null;
53
62
  };
54
63
  export type ResultEvidenceInput = {
@@ -19,6 +19,26 @@
19
19
  * selected tab, and the reading position in this tab's sessionStorage,
20
20
  * keyed by the server-named account, the task, and the run. */
21
21
  import { resultStatusOf } from "./workspace-ui.js";
22
+ export function parseFeedbackImage(value) {
23
+ if (value === undefined || value === null || value === "")
24
+ return null;
25
+ try {
26
+ const image = typeof value === "string" ? JSON.parse(value) : value;
27
+ if (image === null || typeof image !== "object" || Array.isArray(image)
28
+ || !Number.isSafeInteger(image.run) || image.run < 1
29
+ || !Number.isSafeInteger(image.artifact) || image.artifact < 1
30
+ || typeof image.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(image.sha256)
31
+ || Object.keys(image).some(key => !["run", "artifact", "sha256"].includes(key)))
32
+ return false;
33
+ return { run: image.run, artifact: image.artifact, sha256: image.sha256 };
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ export function sameFeedbackImage(a, b) {
40
+ return (a == null && b == null) || (a != null && b != null && a.run === b.run && a.artifact === b.artifact && a.sha256 === b.sha256);
41
+ }
22
42
  /** Add re-read evidence health to the existing result/review projection.
23
43
  * This is presentation only: the recorded verdict and acceptance stay intact. */
24
44
  export function evidenceResultStatusOf(result, publication, evidence) {
@@ -155,6 +175,8 @@ export function resultFactsFromHtml(html) {
155
175
  * page. Matched exactly — never a bare open redirect. */
156
176
  export function resultReturnTarget(raw, runId, tasks = []) {
157
177
  if (raw !== null && raw !== undefined) {
178
+ if (raw === `/r/${runId}?record=1`)
179
+ return raw;
158
180
  const review = /^\/review\?result=[A-Za-z0-9._~%-]{1,200}(?:&run=([0-9]{1,15}))?(?:&tab=(?:summary|changes|checks))?$/.exec(raw);
159
181
  if (review !== null && (review[1] === undefined || Number(review[1]) === runId))
160
182
  return raw;
@@ -299,6 +321,22 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
299
321
  document.addEventListener('click',function(ev){var link=ev.target&&ev.target.closest?ev.target.closest('a[href="#request-changes"]'):null;if(link)openForm();});
300
322
  if(form){
301
323
  var noteBox=form.querySelector('[name=note]'),pathBox=form.querySelector('[name=path]'),lineBox=form.querySelector('[name=line]'),pin=form.querySelector('details.result-pin');
324
+ var imageBox=form.querySelector('[name=image]'),imagePreview=form.querySelector('[data-feedback-image]');
325
+ function showImage(){
326
+ if(!imageBox||!imagePreview)return;
327
+ imagePreview.hidden=!imageBox.value;
328
+ var img=imagePreview.querySelector('img'),caption=imagePreview.querySelector('figcaption'),selected=null;
329
+ document.querySelectorAll('[data-comment-image]').forEach(function(button){if(button.getAttribute('data-comment-image')===imageBox.value)selected=button;});
330
+ img.hidden=!selected;
331
+ if(selected){var identity=JSON.parse(imageBox.value);img.src='/r/'+identity.run+'/evidence/'+identity.artifact;img.alt=selected.getAttribute('data-image-caption');caption.textContent=img.alt+' · Screenshot '+identity.artifact;}
332
+ else{img.removeAttribute('src');caption.textContent='This screenshot is unavailable. Restore it or remove it from your feedback.';}
333
+ }
334
+ document.addEventListener('click',function(event){
335
+ var button=event.target&&event.target.closest?event.target.closest('[data-comment-image],[data-remove-image]'):null;
336
+ if(!button||!imageBox)return;
337
+ event.preventDefault();imageBox.value=button.getAttribute('data-comment-image')||'';showImage();openForm();save();
338
+ if(noteBox){noteBox.dispatchEvent(new Event('input',{bubbles:true}));noteBox.scrollIntoView({block:'center',behavior:'instant'});noteBox.focus();}
339
+ });
302
340
  var limit=document.getElementById('comment-note-limit'),requestBox=form.querySelector('[name=request]');
303
341
  function updateActions(){
304
342
  var typed=!!(noteBox&&noteBox.value.trim()),batch=form.querySelector('[name=batch]');
@@ -333,7 +371,7 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
333
371
  // A fresh request identity: 16 random bytes as hex, the server's own shape.
334
372
  function mint(){var bytes=new Uint8Array(16);try{crypto.getRandomValues(bytes);}catch(e){for(var i=0;i<16;i++)bytes[i]=Math.floor(Math.random()*256);}
335
373
  var hex='';for(var j=0;j<16;j++)hex+=(bytes[j]<16?'0':'')+bytes[j].toString(16);return hex;}
336
- function payload(){return JSON.stringify([noteBox?noteBox.value:'',pathBox?pathBox.value:'',lineBox?lineBox.value:'']);}
374
+ function payload(){return JSON.stringify([noteBox?noteBox.value:'',pathBox?pathBox.value:'',lineBox?lineBox.value:'',imageBox?imageBox.value:'']);}
337
375
  // What the current token was last submitted with; null = never sent.
338
376
  var sent=null,bound=null;
339
377
  // The server refused this token for a different note or another result:
@@ -347,7 +385,7 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
347
385
  var focusNote=function(){setTimeout(function(){noteBox.focus({preventScroll:true});},0);};
348
386
  if(document.readyState==='complete')focusNote();else window.addEventListener('load',focusNote);
349
387
  }
350
- if(saved&&typeof saved.note==='string'&&saved.note.length<=2000){
388
+ if(saved&&typeof saved.note==='string'&&saved.note.length<=(noteBox?noteBox.maxLength:4000)){
351
389
  if(noteBox&&!noteBox.value)noteBox.value=saved.note;
352
390
  if(saved.note)openForm();
353
391
  if(pathBox&&!pathBox.value&&typeof saved.path==='string')pathBox.value=saved.path;
@@ -355,7 +393,9 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
355
393
  if(pin&&(pathBox&&pathBox.value||lineBox&&lineBox.value))pin.open=true;
356
394
  if(typeof saved.request==='string'&&/^[a-f0-9]{32}$/.test(saved.request)&&requestBox)requestBox.value=saved.request;
357
395
  if(typeof saved.sent==='string')sent=saved.sent;
358
- bound=JSON.stringify([saved.note,saved.path||'',saved.line||'']);
396
+ if(imageBox&&typeof saved.image==='string'&&saved.image.length<250)imageBox.value=saved.image;
397
+ showImage();
398
+ bound=JSON.stringify([saved.note,saved.path||'',saved.line||'',saved.image||'']);
359
399
  if(noteBox)noteBox.dispatchEvent(new Event('input'));
360
400
  }
361
401
  function save(){
@@ -365,8 +405,8 @@ export const RESULT_REVIEW_SCRIPT = String.raw `
365
405
  var current=payload();
366
406
  if((bound!==null&&current!==bound)||(sent!==null&&current!==sent)){sent=null;if(requestBox)requestBox.value=mint();}
367
407
  bound=current;
368
- if(!note&&!path&&!line){write(draftKey,null);return;}
369
- write(draftKey,{note:note,path:path,line:line,request:requestBox?requestBox.value:'',sent:sent,at:Date.now()});
408
+ if(!note&&!path&&!line&&!(imageBox&&imageBox.value)){write(draftKey,null);return;}
409
+ write(draftKey,{note:note,path:path,line:line,image:imageBox?imageBox.value:'',request:requestBox?requestBox.value:'',sent:sent,at:Date.now()});
370
410
  if(!storage&&limit)limit.textContent='Draft stays on this page only. Browser storage is unavailable.';
371
411
  }
372
412
  form.addEventListener('input',save);
@@ -2730,3 +2730,15 @@ html.so-swap::view-transition-old(*):only-child { animation: so-card-out 160ms e
2730
2730
  html.so-swap::view-transition-group(*), html.so-swap::view-transition-new(*), html.so-swap::view-transition-old(*) { animation: none !important; }
2731
2731
  .worker.flash { animation: none; }
2732
2732
  }
2733
+
2734
+ /* Exact-image feedback shares the existing comment form. */
2735
+ .result-image { margin: 0; min-width: 0; }
2736
+ .result-image > button { margin-top: .5rem; min-height: 44px; white-space: nowrap; }
2737
+ .feedback-image { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; margin: .5rem 0; min-width: 0; }
2738
+ .feedback-image[hidden], .feedback-image img[hidden] { display: none; }
2739
+ .feedback-image img { width: 5rem; max-height: 5rem; object-fit: contain; border: 1px solid var(--border); border-radius: var(--radius); }
2740
+ .feedback-image figcaption { flex: 1 1 12rem; overflow-wrap: anywhere; font-size: .875rem; }
2741
+ .feedback-image button { min-height: 44px; white-space: nowrap; }
2742
+
2743
+ .result-feedback-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
2744
+ .result-feedback-actions button { min-height: 44px; white-space: nowrap; }
@@ -1,3 +1,4 @@
1
+ import { projectOutcomes } from '../project-outcomes.js';
1
2
  import { steerControlsFor } from "../steer-controls.js";
2
3
  import { canReveal, cdCommandOf, localCheckoutOf } from "../checkout-proof.js";
3
4
  import { instanceHttpAnswer } from "../console-instance.js";
@@ -967,7 +968,16 @@ export function createPagesHandlers(runtime) {
967
968
  peeks[one.path] = peekOf(one.path);
968
969
  for (const path of candidates)
969
970
  peeks[path] = peekOf(path);
970
- return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined));
971
+ let activity;
972
+ if (open !== null && !returnTo.startsWith("/tasks/new") && visible(open) && store.accountCanAccess(who.name, open)) {
973
+ try {
974
+ activity = { project: projectName(open), report: projectOutcomes(store, open, now, { principal: 'operator', repos: [open] }) };
975
+ }
976
+ catch {
977
+ activity = { project: projectName(open), report: null };
978
+ }
979
+ }
980
+ return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined, activity));
971
981
  }
972
982
  async function instanceEdge(ctx) {
973
983
  const { request, response, url } = ctx;