toolroll 0.9.84 → 0.9.86

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 (132) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +94 -1193
  3. package/dist/agent-fence.d.ts +9 -5
  4. package/dist/agent-fence.js +6 -5
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/assignment.js +4 -3
  8. package/dist/browser/workspace.css +1 -1
  9. package/dist/browser/workspace.js +33 -31
  10. package/dist/browser-workspace.d.ts +45 -0
  11. package/dist/chat-actions.d.ts +11 -1
  12. package/dist/chat-actions.js +53 -1
  13. package/dist/chat-channel.d.ts +10 -1
  14. package/dist/chat-channel.js +36 -5
  15. package/dist/chat-decide.d.ts +16 -4
  16. package/dist/chat-decide.js +239 -31
  17. package/dist/chat-messages.d.ts +5 -0
  18. package/dist/chat-messages.js +1 -1
  19. package/dist/chat-quiet.js +6 -4
  20. package/dist/chat-review.d.ts +4 -0
  21. package/dist/chat-review.js +13 -4
  22. package/dist/chat-terms.d.ts +9 -0
  23. package/dist/chat-terms.js +46 -0
  24. package/dist/claim.d.ts +3 -0
  25. package/dist/claim.js +4 -1
  26. package/dist/contracts/chat-actions.d.ts +9 -0
  27. package/dist/contracts/chat-actions.js +3 -0
  28. package/dist/contracts/console-api.d.ts +6 -2
  29. package/dist/contracts/console-api.js +6 -2
  30. package/dist/contracts/flow-propose.d.ts +4 -1
  31. package/dist/contracts/flow-propose.js +1 -1
  32. package/dist/contracts/flow.d.ts +41 -0
  33. package/dist/contracts/flow.js +4 -2
  34. package/dist/contracts/lead-tools.d.ts +51 -0
  35. package/dist/contracts/lead-tools.js +5 -0
  36. package/dist/contracts/store-json.d.ts +2 -0
  37. package/dist/contracts/trigger-payloads.d.ts +20 -0
  38. package/dist/contracts/trigger-payloads.js +4 -0
  39. package/dist/contracts/youiyoux-request.d.ts +248 -0
  40. package/dist/contracts/youiyoux-request.js +152 -0
  41. package/dist/coordinator.js +1 -0
  42. package/dist/deploy-gate.js +4 -0
  43. package/dist/dispatch.d.ts +3 -0
  44. package/dist/dispatch.js +4 -1
  45. package/dist/flow-browser.d.ts +103 -0
  46. package/dist/flow-browser.js +204 -0
  47. package/dist/flow-cadence.d.ts +8 -2
  48. package/dist/flow-cadence.js +16 -3
  49. package/dist/flow-card-task.d.ts +9 -0
  50. package/dist/flow-card-task.js +15 -6
  51. package/dist/flow-code.d.ts +5 -1
  52. package/dist/flow-code.js +4 -1
  53. package/dist/flow-engine.js +16 -0
  54. package/dist/flow-live.js +9 -1
  55. package/dist/flow-pull-request.js +15 -7
  56. package/dist/flow-share.js +3 -1
  57. package/dist/flow-steps.d.ts +9 -1
  58. package/dist/flow-steps.js +55 -5
  59. package/dist/flow-triggers.js +55 -0
  60. package/dist/flows-cli.d.ts +19 -0
  61. package/dist/flows-cli.js +47 -2
  62. package/dist/flows-ui.js +11 -2
  63. package/dist/flows.js +5 -2
  64. package/dist/integrations-ui.js +9 -4
  65. package/dist/integrations.d.ts +9 -1
  66. package/dist/integrations.js +43 -2
  67. package/dist/landed-cleanup.d.ts +4 -0
  68. package/dist/landed-cleanup.js +3 -2
  69. package/dist/lead-capabilities.js +2 -1
  70. package/dist/lead-cli.js +3 -1
  71. package/dist/lead-tools.d.ts +2 -0
  72. package/dist/lead-tools.js +65 -10
  73. package/dist/lead-voice.js +16 -5
  74. package/dist/lead.d.ts +2 -0
  75. package/dist/lead.js +3 -3
  76. package/dist/operate.d.ts +2 -3
  77. package/dist/operate.js +226 -21
  78. package/dist/project-activity.d.ts +20 -0
  79. package/dist/project-activity.js +23 -0
  80. package/dist/project-cli.d.ts +1 -0
  81. package/dist/project-cli.js +36 -19
  82. package/dist/project-delete.js +7 -3
  83. package/dist/project-knowledge.d.ts +3 -3
  84. package/dist/project-outcomes.d.ts +119 -0
  85. package/dist/project-outcomes.js +79 -0
  86. package/dist/pull-request-flow.js +35 -4
  87. package/dist/result-actions.d.ts +4 -0
  88. package/dist/result-actions.js +41 -9
  89. package/dist/result-follow-ups.js +4 -0
  90. package/dist/result-review.d.ts +9 -0
  91. package/dist/result-review.js +45 -5
  92. package/dist/scope.d.ts +5 -0
  93. package/dist/scope.js +7 -0
  94. package/dist/server/chat.js +4 -2
  95. package/dist/server/console.css +41 -0
  96. package/dist/server/flows.js +15 -0
  97. package/dist/server/pages.js +11 -1
  98. package/dist/server/render-chat.js +3 -2
  99. package/dist/server/render-pages.d.ts +15 -2
  100. package/dist/server/render-pages.js +52 -4
  101. package/dist/server/render-results.d.ts +2 -0
  102. package/dist/server/render-results.js +20 -7
  103. package/dist/server/render-tasks.d.ts +9 -0
  104. package/dist/server/render-tasks.js +32 -11
  105. package/dist/server/route-table.js +12 -0
  106. package/dist/server/settings.js +18 -1
  107. package/dist/server/task-views.js +11 -0
  108. package/dist/server/tasks.d.ts +10 -0
  109. package/dist/server/tasks.js +95 -7
  110. package/dist/settings-proofs.js +1 -0
  111. package/dist/store.d.ts +116 -5
  112. package/dist/store.js +385 -24
  113. package/dist/surface.js +18 -2
  114. package/dist/task-archive.d.ts +10 -0
  115. package/dist/task-archive.js +16 -0
  116. package/dist/task-control.d.ts +1 -1
  117. package/dist/task-source.d.ts +13 -1
  118. package/dist/task-source.js +17 -1
  119. package/dist/telegram-lead.js +7 -5
  120. package/dist/telegram.js +1 -1
  121. package/dist/tidy.d.ts +130 -0
  122. package/dist/tidy.js +503 -0
  123. package/dist/toolroll-update.js +2 -2
  124. package/dist/webhooks.d.ts +6 -0
  125. package/dist/webhooks.js +25 -0
  126. package/dist/work-index.d.ts +28 -0
  127. package/dist/work-index.js +82 -28
  128. package/dist/workspace-ui.d.ts +1 -1
  129. package/dist/workspace-ui.js +3 -2
  130. package/dist/youiyoux-intake.d.ts +234 -0
  131. package/dist/youiyoux-intake.js +908 -0
  132. package/package.json +11 -8
@@ -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
+ }
@@ -14,7 +14,7 @@
14
14
  * read must show the PR open, on the accepted commit, with checks passing; GitHub's head-commit match guards
15
15
  * the race; the branch is deleted; the merge commit and who merged are recorded in the ledger.
16
16
  */
17
- import { createHash } from "node:crypto";
17
+ import { createHash, randomUUID } from "node:crypto";
18
18
  import { run as execRun } from "./exec.js";
19
19
  import { bodyHashOf, publicationBody, publicationGrantOf } from "./publish.js";
20
20
  import { failingChecks, summarizeChecks } from "./pulls.js";
@@ -23,7 +23,7 @@ import { requestResultChanges } from "./result-actions.js";
23
23
  import { revisionSourceOf } from "./result-review.js";
24
24
  import { authenticateApprover, personProof } from "./scope.js";
25
25
  import { BRANCH_PREFIX, headWithin } from "./names.js";
26
- import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
26
+ import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, refArchived, releaseTaskRef } from "./store.js";
27
27
  import { belongsToRelease } from "./release-authority.js";
28
28
  import { autoReleaseSealed } from "./release-auto.js";
29
29
  import { fullCheckGate, requestFollowUpChecks } from "./result-follow-ups.js";
@@ -292,6 +292,9 @@ export async function followPullRequests(store, seen, options) {
292
292
  continue;
293
293
  const pr = publication.prNumber;
294
294
  const taskId = taskIdOf(store, publication);
295
+ // A put-away task (v145) gets no automatic revision, merge or ready notice: restoring it resumes following.
296
+ if (refArchived(store, publication.taskRef))
297
+ continue;
295
298
  if (one.remoteState === "MERGED") {
296
299
  updateFollow(store, publication.id, { merge_commit: one.mergeCommit ?? "", merged_by: "GitHub", merged_at: clock().toISOString() }, clock());
297
300
  continue;
@@ -397,6 +400,8 @@ export async function mergePullRequest(store, input) {
397
400
  // The nightly check of main (release-once.md, part 3) is a check only: it never merges.
398
401
  const mergedRun = store.getRun(input.runId);
399
402
  const mergedTask = mergedRun === null ? null : store.refById(mergedRun.taskRef)?.externalId ?? null;
403
+ if (mergedRun !== null && refArchived(store, mergedRun.taskRef))
404
+ return refuse("archived", "This task is put away. Restore it before merging.");
400
405
  if (mergedTask !== null && store.handle.prepare("SELECT 1 AS hit FROM nightly_check WHERE task = ?").get(mergedTask) !== undefined)
401
406
  return refuse("check-only", "The nightly check of main is only a check; it never merges.");
402
407
  // A release's task merges only through `toolroll release` (release-once.md), which proves the live release, reserves
@@ -470,8 +475,34 @@ export async function mergePullRequest(store, input) {
470
475
  return fail("moved", "The pull request has commits that weren't part of the completed result, so it won't be merged from here.");
471
476
  if (before.checks !== "passing")
472
477
  return fail("checks", before.checks === "failing" ? "Checks are failing, so it can't merge." : before.checks === "running" ? "Checks are still running. Merge once they pass." : "No checks have reported yet. Merge once they pass.");
473
- const merged = await exec("gh", ["pr", "merge", pr, "--repo", publication.githubRepo, `--${method}`, "--match-head-commit", publication.headSha, "--delete-branch"], { timeoutMs: EXEC_TIMEOUT_MS, env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" } });
474
- const after = await view();
478
+ // The merge is reserved with the put-away check in one write (v145): a put away that committed first refuses it, and
479
+ // one that comes after waits for GitHub's answer (the reservation counts as work going on).
480
+ // One merge at a time, each owning its reservation by its token: a second caller is refused, and each releases only its own.
481
+ const token = randomUUID();
482
+ const reserved = store.transact(() => {
483
+ if (refArchived(store, publication.taskRef))
484
+ return "archived";
485
+ if (store.handle.prepare("SELECT 1 FROM hold WHERE task_ref = ? AND owner_id LIKE 'project-delete:%' AND (until IS NULL OR until > ?)").get(publication.taskRef, clock().toISOString()) !== undefined)
486
+ return "deleting";
487
+ if (store.handle.prepare("SELECT 1 FROM merge_in_flight WHERE publication = ? AND at > ?").get(publication.id, new Date(clock().getTime() - 15 * 60_000).toISOString()) !== undefined)
488
+ return "merging";
489
+ store.handle.prepare("INSERT OR REPLACE INTO merge_in_flight (publication, task_ref, token, by, at) VALUES (?, ?, ?, ?, ?)").run(publication.id, publication.taskRef, token, input.by, clock().toISOString());
490
+ return "ok";
491
+ });
492
+ if (reserved === "archived")
493
+ return refuse("archived", "This task is put away. Restore it before merging.");
494
+ if (reserved === "deleting")
495
+ return refuse("deleting", "This task's project is being deleted, so nothing merges.");
496
+ if (reserved === "merging")
497
+ return refuse("merging", "This pull request is already being merged. Wait for that to finish.");
498
+ let merged, after;
499
+ try {
500
+ merged = await exec("gh", ["pr", "merge", pr, "--repo", publication.githubRepo, `--${method}`, "--match-head-commit", publication.headSha, "--delete-branch"], { timeoutMs: EXEC_TIMEOUT_MS, env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" } });
501
+ after = await view();
502
+ }
503
+ finally {
504
+ store.handle.prepare("DELETE FROM merge_in_flight WHERE publication = ? AND token = ?").run(publication.id, token);
505
+ }
475
506
  if (after?.state === "MERGED") {
476
507
  record(after.commit, input.by);
477
508
  return { ok: true, commit: after.commit };
@@ -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");
@@ -2,6 +2,7 @@ import { checkoutRunner, ranAtLine, widestRan } from "./invoke.js";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { refArchived } from "./task-archive.js";
5
6
  import { belongsToRelease } from "./release-authority.js";
6
7
  import { run as execRun } from "./exec.js";
7
8
  import { hardenedGit } from "./teardown-git.js";
@@ -126,6 +127,9 @@ function checkTarget(store, runId, level) {
126
127
  return refuse("no-commit", "This result has no finished commit to check.");
127
128
  if (ref.repo === null)
128
129
  return refuse("unplaced", "This result isn't in a project, so there is no check to run.");
130
+ // A put-away task (v145) runs no checks: asked for or already waiting, it is refused until restored.
131
+ if (refArchived(store, ref.id))
132
+ return refuse("archived", "This task is put away. Restore it first.");
129
133
  const chosen = checkCommandFor(store, ref.repo, level);
130
134
  if (chosen.command === null)
131
135
  return refuse("no-check", `${ref.repo} has no approved ${level === "quick" ? "quick or full" : "full"} check. Set one in Settings → Projects → Checks.`);
@@ -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);
package/dist/scope.d.ts CHANGED
@@ -597,6 +597,11 @@ export type ApproveResult = {
597
597
  | {
598
598
  ok: false;
599
599
  reason: "machine-proof";
600
+ }
601
+ /** v145 (task-tidy.md): the task is put away; it is restored before anyone can approve it. */
602
+ | {
603
+ ok: false;
604
+ reason: "archived";
600
605
  };
601
606
  /**
602
607
  * Whether this name-and-token pair is a person the store knows. Shared by
package/dist/scope.js CHANGED
@@ -48,6 +48,7 @@ import { z } from "zod";
48
48
  import {} from "./contracts/acceptance-terms.js";
49
49
  import { parseContract } from "./contracts/contract.js";
50
50
  import { readAcceptance, readScopeTerms, storedRubricSchema } from "./contracts/scope.js";
51
+ import { taskArchived } from "./task-archive.js";
51
52
  import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
52
53
  import { NO_READINESS, exactModelId, legOf, projectRoute, routeDigestOf, routeFromJson, routeJsonProblem, routeWords, } from "./phase-routing.js";
53
54
  /** Same shape as a runner's credential, for the same reasons. */
@@ -1074,6 +1075,9 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1074
1075
  const scope = store.getScope(taskId);
1075
1076
  if (scope === null)
1076
1077
  return { ok: false, reason: "no-scope" };
1078
+ // A put-away task (v145) is restored first; sealScopeApproval refuses it too, on every road.
1079
+ if (taskArchived(store, taskId))
1080
+ return { ok: false, reason: "archived" };
1077
1081
  // The digest is required, not optional. An operator reads a scope, somebody
1078
1082
  // rewrites it, and an approval that did not name what it saw would agree to
1079
1083
  // the new one in silence — which is the whole failure this guards.
@@ -1472,6 +1476,9 @@ export function liveReleaseOf(store, taskId, action, now = new Date()) {
1472
1476
  return { applies: false };
1473
1477
  const { authority: a, carry } = found;
1474
1478
  const refuse = (why) => ({ applies: true, ok: false, why, authority: a });
1479
+ // A put-away task (v145, task-tidy.md), or a put-away anchor, deploys, merges and carries nothing: restore it first.
1480
+ if (taskArchived(store, taskId) || taskArchived(store, a.anchorTask))
1481
+ return refuse("the release task is put away");
1475
1482
  const newest = newestReleaseGeneration(store, a.repo, a.version, a.pr);
1476
1483
  if (newest === null || newest.id !== a.id)
1477
1484
  return refuse("a newer approval of this release replaced this one");
@@ -389,9 +389,11 @@ export function createChatHandlers(runtime) {
389
389
  const id = proposalId, saved = store.getLeadProposal(id), savedAction = saved?.kind === 'action' ? sharedActionPayload(saved.payload) : null;
390
390
  if (saved && savedAction && saved.state !== 'pending' && (sharedConversation !== null || store.getLeadThread(saved.thread)?.approver === principal.name) && principal.repos.includes(savedAction.repo) && store.accountCanAccess(principal.name, savedAction.repo)) {
391
391
  const task = typeof saved.outcome?.['taskId'] === 'string' ? saved.outcome['taskId'] : typeof savedAction.request['task'] === 'string' ? savedAction.request['task'] : null;
392
- const destination = task ? taskHref(task) : `/settings/${savedAction.operation.startsWith('skill_') ? 'skills' : 'knowledge'}?repo=${encodeURIComponent(savedAction.repo)}`;
392
+ // A flow action leads back to its flow (a page path of our own making, never the proposal's words).
393
+ const flowId = savedAction.operation.startsWith('flow_') && Number.isSafeInteger(savedAction.request['flow']) ? Number(savedAction.request['flow']) : null;
394
+ const destination = task ? taskHref(task) : flowId !== null ? `/flows/${flowId}` : `/settings/${savedAction.operation.startsWith('skill_') ? 'skills' : 'knowledge'}?repo=${encodeURIComponent(savedAction.repo)}`;
393
395
  const said = typeof saved.outcome?.['said'] === 'string' ? saved.outcome['said'] : saved.state === 'dismissed' ? 'Action dismissed.' : saved.state === 'expired' ? 'This proposal expired. Ask for a fresh proposal.' : 'This action has no completed outcome yet.';
394
- return sendScreen(response, 200, screen('Action outcome', html `<h1>${savedAction.title}</h1><p>${said}</p><a class="button-link" href="${destination}">${task ? 'Open task' : 'Open project settings'}</a>`, { chrome: chromeFor(savedAction.repo, 'chat') }));
396
+ return sendScreen(response, 200, screen('Action outcome', html `<h1>${savedAction.title}</h1><p>${said}</p><a class="button-link" href="${destination}">${task ? 'Open task' : flowId !== null ? 'Open flow' : 'Open project settings'}</a>`, { chrome: chromeFor(savedAction.repo, 'chat') }));
395
397
  }
396
398
  const review = mintSharedActionReview(store, principal, id, evidenceRoot, clock()), action = review.payload;
397
399
  const terms = action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`);