toolroll 0.9.8 → 0.9.10
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.
- package/CHANGELOG.md +30 -0
- package/dist/assignment-brief.d.ts +2 -0
- package/dist/assignment-brief.js +2 -1
- package/dist/assignment-presentation.js +3 -1
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +5 -0
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +42 -8
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-crew.js +4 -0
- package/dist/browser-workspace.d.ts +40 -0
- package/dist/builder.d.ts +2 -0
- package/dist/builder.js +114 -8
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +45 -8
- package/dist/chat-quiet.d.ts +10 -4
- package/dist/chat-quiet.js +87 -22
- package/dist/chat-voice.d.ts +68 -0
- package/dist/chat-voice.js +175 -0
- package/dist/check-progress.js +4 -3
- package/dist/checkout-cleanup.d.ts +40 -2
- package/dist/checkout-cleanup.js +129 -9
- package/dist/desktop-update.d.ts +52 -0
- package/dist/desktop-update.js +147 -5
- package/dist/discord-chat.js +1 -0
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +8 -0
- package/dist/first-run.d.ts +1 -1
- package/dist/first-run.js +7 -5
- package/dist/lead-context.d.ts +1 -1
- package/dist/lead-context.js +4 -2
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +21 -24
- package/dist/needs-you.d.ts +55 -0
- package/dist/needs-you.js +83 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +122 -15
- package/dist/plane-review.js +4 -0
- package/dist/process-recovery-eligibility.d.ts +1 -0
- package/dist/project-delete.js +1 -0
- package/dist/project-learning.d.ts +3 -3
- package/dist/project-learning.js +28 -17
- package/dist/proof.d.ts +3 -1
- package/dist/proof.js +4 -2
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/result-completion.d.ts +4 -0
- package/dist/result-completion.js +11 -0
- package/dist/review-context.d.ts +3 -0
- package/dist/review-context.js +45 -5
- package/dist/run-closeout.d.ts +89 -0
- package/dist/run-closeout.js +302 -0
- package/dist/serve.d.ts +15 -1
- package/dist/serve.js +129 -14
- package/dist/setup-guide.d.ts +2 -2
- package/dist/shared-deps.d.ts +94 -0
- package/dist/shared-deps.js +365 -0
- package/dist/slack-chat.js +2 -0
- package/dist/storage.js +2 -0
- package/dist/store.d.ts +60 -0
- package/dist/store.js +207 -30
- package/dist/task-status.d.ts +18 -4
- package/dist/task-status.js +35 -18
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +13 -5
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +6 -5
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +93 -43
- package/dist/test-temp.d.ts +19 -0
- package/dist/test-temp.js +98 -0
- package/dist/toolroll-update-ui.js +7 -2
- package/dist/toolroll-update.d.ts +21 -1
- package/dist/toolroll-update.js +135 -5
- package/dist/work-index.d.ts +11 -0
- package/dist/work-index.js +103 -16
- package/dist/work-summary.d.ts +3 -1
- package/dist/workspace-ui.js +1 -1
- package/dist/worktree.d.ts +16 -0
- package/dist/worktree.js +47 -0
- package/package.json +1 -1
package/dist/browser-crew.js
CHANGED
|
@@ -10,6 +10,10 @@ export function browserWorkActionHref(item) {
|
|
|
10
10
|
return `/d/${decisionId}`;
|
|
11
11
|
if (action.code === 'inspect-decisions')
|
|
12
12
|
return '/';
|
|
13
|
+
if (action.code === 'sign-in')
|
|
14
|
+
return '/settings#providers';
|
|
15
|
+
if (action.code === 'confirm-stopped')
|
|
16
|
+
return `/t/${encodeURIComponent(item.rootId)}#confirm-stopped`;
|
|
13
17
|
if (action.code === 'open-result' && runId !== null)
|
|
14
18
|
return chatResultHref(taskId, runId);
|
|
15
19
|
if (action.code === 'open-pr' && /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+$/.test(item.publicationUrl ?? ''))
|
|
@@ -342,6 +342,16 @@ export type BrowserTaskView = {
|
|
|
342
342
|
statusHtml: string;
|
|
343
343
|
/** The approval ceremony or its updated terms, exactly as signed. */
|
|
344
344
|
approval: string;
|
|
345
|
+
/** Confirm it stopped, behind the password, when the status asks for it. */
|
|
346
|
+
confirmStopped?: {
|
|
347
|
+
action: string;
|
|
348
|
+
run: number;
|
|
349
|
+
checked?: boolean;
|
|
350
|
+
} | null;
|
|
351
|
+
/** Build again, in place, when the status asks for it (a result built to an earlier plan). */
|
|
352
|
+
rebuild?: {
|
|
353
|
+
action: string;
|
|
354
|
+
} | null;
|
|
345
355
|
/** Server cards that may need a person now (stop/resume, scope prompt, plan, live attempt). */
|
|
346
356
|
lead: {
|
|
347
357
|
key: string;
|
|
@@ -450,9 +460,34 @@ export type BrowserResultPanel = {
|
|
|
450
460
|
request: string | null;
|
|
451
461
|
/** A browser session may attach feedback to this result's sealed diff. */
|
|
452
462
|
canRequest: boolean;
|
|
463
|
+
/** Needs you: the one action that resolves it, first and filled; `confirm` posts behind the password. */
|
|
464
|
+
need: BrowserNeedAction | null;
|
|
453
465
|
/** The feedback section holds only the closed form (no notes, revisions or history). */
|
|
454
466
|
requestQuiet: boolean;
|
|
455
467
|
};
|
|
468
|
+
/** A Needs you action: a link, (Confirm it stopped) a form behind the password, (Build again) one
|
|
469
|
+
* button, or (on the result itself) Accept, which records the person's acceptance; `note` asks why
|
|
470
|
+
* when the evidence disagrees. */
|
|
471
|
+
export type BrowserNeedAction = {
|
|
472
|
+
label: string;
|
|
473
|
+
href: string | null;
|
|
474
|
+
confirm: {
|
|
475
|
+
action: string;
|
|
476
|
+
run: number;
|
|
477
|
+
returnTo: string;
|
|
478
|
+
checked?: boolean;
|
|
479
|
+
} | null;
|
|
480
|
+
accept?: {
|
|
481
|
+
action: string;
|
|
482
|
+
run: number;
|
|
483
|
+
returnTo: string;
|
|
484
|
+
note: string | null;
|
|
485
|
+
} | null;
|
|
486
|
+
/** Build again (a result built to an earlier plan): one button, the task page's requeue. */
|
|
487
|
+
rebuild?: {
|
|
488
|
+
action: string;
|
|
489
|
+
} | null;
|
|
490
|
+
};
|
|
456
491
|
export type BrowserResultView = {
|
|
457
492
|
kind: 'result';
|
|
458
493
|
results: {
|
|
@@ -891,6 +926,11 @@ export type BrowserCatchUpItem = {
|
|
|
891
926
|
tone: StatusTone;
|
|
892
927
|
detail: string;
|
|
893
928
|
at: string;
|
|
929
|
+
/** Needs you: the one action that resolves it. */
|
|
930
|
+
action?: {
|
|
931
|
+
label: string;
|
|
932
|
+
href: string;
|
|
933
|
+
} | null;
|
|
894
934
|
};
|
|
895
935
|
/** Plan-window use stands in for spend: subscriptions don't bill per run. */
|
|
896
936
|
export type BrowserHome = {
|
package/dist/builder.d.ts
CHANGED
|
@@ -369,6 +369,8 @@ export declare const WIP_COMMIT_WORDS = "Work in progress: the agent stopped bef
|
|
|
369
369
|
/** The short turn a stopped agent's own session is resumed with. */
|
|
370
370
|
export declare function handoffResumePrompt(done: string): string;
|
|
371
371
|
export declare const SERVICE_STOP_WORDS = "Toolroll stopped while this task was building.";
|
|
372
|
+
/** The plain words for images left out of a commit. */
|
|
373
|
+
export declare function evidenceImagesNotice(paths: readonly string[]): string;
|
|
372
374
|
/**
|
|
373
375
|
* What a non-zero agent exit means, in words a person can act on: the
|
|
374
376
|
* harness's own result line names the ending (a turn ceiling, an error
|
package/dist/builder.js
CHANGED
|
@@ -59,11 +59,23 @@ import { TELEGRAM_TOKEN_ENVS } from "./names.js";
|
|
|
59
59
|
import { OPENROUTER_ENV_KEY, auditOf, ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
60
60
|
import { openLiveLog } from "./live.js";
|
|
61
61
|
import { CheckProgressTracker } from "./check-progress.js";
|
|
62
|
-
import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
|
|
62
|
+
import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
|
|
63
63
|
import { PROOF_LIMITS, parseProof, serializeProof, adjudicate, artifactManifestOnly, sameDiffStatFacts } from "./proof.js";
|
|
64
64
|
import { captureReviewContext } from "./review-context.js";
|
|
65
65
|
import { authoritySnapshotDigest, classifyRevisionAuthority, isMilestoneRegression, milestonesOf, parseExecutionPlanDocument, parsePlanRevisionProposal, parseProgressSnapshot, renderExecutionPlanDocument, PROGRESS_LIMITS, REVISION_LIMITS, } from "./plan.js";
|
|
66
66
|
import { maybeSettleRepairChain } from "./dispose.js";
|
|
67
|
+
import { detachShared, linkInto, linkedKey, promoteInstall, readyCopy, sharedDepsRoot, sharingFor } from "./shared-deps.js";
|
|
68
|
+
/** Where this store keeps shared dependencies; null for an in-memory store, which shares nothing. */
|
|
69
|
+
function sharedDepsRootOf(store) {
|
|
70
|
+
const file = store.databaseFile();
|
|
71
|
+
return file === null ? null : sharedDepsRoot(file);
|
|
72
|
+
}
|
|
73
|
+
/** The one line an agent needs when its node_modules is a shared copy: it is read-only, and how to get its own. */
|
|
74
|
+
function sharedDepsBrief(store, worktree) {
|
|
75
|
+
const root = sharedDepsRootOf(store);
|
|
76
|
+
return root === null || linkedKey(worktree, root) === null ? "" :
|
|
77
|
+
"node_modules here links a shared, read-only install. If you change package.json or the lockfile, replace it with your own install first: `rm -rf node_modules && npm install`.\n";
|
|
78
|
+
}
|
|
67
79
|
/**
|
|
68
80
|
* The native shell for an operator-approved repository command. There are
|
|
69
81
|
* exactly two consumers: dependency setup before an agent starts, and the
|
|
@@ -668,6 +680,20 @@ export async function build(store, request) {
|
|
|
668
680
|
message: "runner custody lapsed before the setup spawn — the lease, the runner, or its repo binding no longer stands",
|
|
669
681
|
};
|
|
670
682
|
}
|
|
683
|
+
// Shared dependencies: a checkout whose lockfile already has an installed
|
|
684
|
+
// copy links it instead of installing its own; a setup that is about to
|
|
685
|
+
// run never reaches through an old link into a shared copy.
|
|
686
|
+
const depsRoot = sharedDepsRootOf(store);
|
|
687
|
+
const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted });
|
|
688
|
+
if (depsRoot !== null)
|
|
689
|
+
detachShared(worktree, depsRoot);
|
|
690
|
+
if (depsRoot !== null && sharing !== null) {
|
|
691
|
+
const ready = readyCopy(depsRoot, sharing.key);
|
|
692
|
+
if (ready !== null && linkInto(worktree, ready) !== null) {
|
|
693
|
+
store.stampWorktreeSetup(worktree, setupWanted.digest);
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
671
697
|
const runSetup = request.setup ?? run;
|
|
672
698
|
const shell = approvedCommandShell(setupWanted.command);
|
|
673
699
|
// Setup runs under the stop watch (v52), owned by this run: an
|
|
@@ -700,6 +726,9 @@ export async function build(store, request) {
|
|
|
700
726
|
message: `the approved setup for ${leased.repo} ${made.timedOut ? `ran past ${Math.round(setupWanted.timeoutMs / 60_000)}m` : `exited ${made.code}`} — ${redactSecretText(firstLine(made.stderr)) || "no stderr"}; no agent spawns in a checkout whose setup failed`,
|
|
701
727
|
};
|
|
702
728
|
}
|
|
729
|
+
if (depsRoot !== null && sharing !== null) {
|
|
730
|
+
promoteInstall({ root: depsRoot, repo: leased.repo, worktree, ...sharing, setupDigest: setupWanted.digest, now: (request.clock ?? (() => now))() });
|
|
731
|
+
}
|
|
703
732
|
store.stampWorktreeSetup(worktree, setupWanted.digest);
|
|
704
733
|
}
|
|
705
734
|
return null;
|
|
@@ -1128,7 +1157,7 @@ export async function build(store, request) {
|
|
|
1128
1157
|
// replacement for.
|
|
1129
1158
|
milestones.length === 0 || planRevisionHash === null
|
|
1130
1159
|
? null
|
|
1131
|
-
: { revision: planRevisionNumber, hash: planRevisionHash, milestones, progress, proposal }, scope === null ? [] : flowGoalCuts(store, scope.taskId, scope.goal)) + `\nCanonical signed rubric: ${rubric}. Its statement fields are exact; evidence requirements are separate fields. Do not edit this input. The lead or user reads these criteria directly; no restatement is needed.\n
|
|
1160
|
+
: { revision: planRevisionNumber, hash: planRevisionHash, milestones, progress, proposal }, scope === null ? [] : flowGoalCuts(store, scope.taskId, scope.goal)) + `\nCanonical signed rubric: ${rubric}. Its statement fields are exact; evidence requirements are separate fields. Do not edit this input. The lead or user reads these criteria directly; no restatement is needed.\n` + sharedDepsBrief(store, worktree);
|
|
1132
1161
|
// THE HELD BRANCH (Phase 2, v2 S0d + v6 W8): ownership transfers to the
|
|
1133
1162
|
// coordinator at the spawn point. Everything build() armed that its
|
|
1134
1163
|
// finally would have cleared is torn down or handed over HERE — the pulse
|
|
@@ -1786,7 +1815,7 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1786
1815
|
// result is that commit, sealed below against the task's original base.
|
|
1787
1816
|
const made = finishesKeptWork
|
|
1788
1817
|
? await keptWorkResult(captured, baseRevision, handoff.conclusion)
|
|
1789
|
-
: await commit(git, worktree, branch, taskId, scope, handoff.conclusion, request.attended === undefined ? scope?.candidate ?? null : null);
|
|
1818
|
+
: await commit(git, worktree, branch, taskId, scope, handoff.conclusion, request.attended === undefined ? scope?.candidate ?? null : null, words => store.addRunNote(request.runId, "Toolroll", words, clock()));
|
|
1790
1819
|
if (made.ok && made.parked === undefined && made.committed) {
|
|
1791
1820
|
const newHead = await git(GIT, ["--no-optional-locks", "rev-parse", "HEAD"], { cwd: worktree });
|
|
1792
1821
|
if (newHead.code === 0) {
|
|
@@ -2221,8 +2250,48 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2221
2250
|
checkLog.push(attemptLog(label, configured.command, result));
|
|
2222
2251
|
return result;
|
|
2223
2252
|
};
|
|
2224
|
-
const
|
|
2225
|
-
|
|
2253
|
+
const ownInstallIfChanged = async () => {
|
|
2254
|
+
const depsRoot = sharedDepsRootOf(store);
|
|
2255
|
+
if (depsRoot === null || repo === null)
|
|
2256
|
+
return null;
|
|
2257
|
+
const linked = linkedKey(worktree, depsRoot);
|
|
2258
|
+
const live = linked === null ? null : store.liveWorktreeSetup(repo);
|
|
2259
|
+
if (linked === null || live === null)
|
|
2260
|
+
return null;
|
|
2261
|
+
if (sharingFor({ repo, worktree, setup: live })?.key === linked && readyCopy(depsRoot, linked) !== null)
|
|
2262
|
+
return null;
|
|
2263
|
+
if (!store.proveRunnerCustodyForSpawn(runId, now()))
|
|
2264
|
+
return "custody-lost";
|
|
2265
|
+
detachShared(worktree, depsRoot);
|
|
2266
|
+
const label = "Own install · approved project setup (package.json or lockfile changed)";
|
|
2267
|
+
const setupShell = approvedCommandShell(live.command);
|
|
2268
|
+
const installed = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, request.setup ?? run), setupShell.file, setupShell.args, {
|
|
2269
|
+
cwd: worktree,
|
|
2270
|
+
timeoutMs: live.timeoutMs,
|
|
2271
|
+
processGroup: true,
|
|
2272
|
+
owner: runOwnerTag(store, runId),
|
|
2273
|
+
beforeSpawn: () => !stopRequestedFor(store, runId, request.shouldStop),
|
|
2274
|
+
onSpawn: pid => {
|
|
2275
|
+
request.onProviderSpawn?.(pid);
|
|
2276
|
+
if (stopRequestedFor(store, runId, request.shouldStop))
|
|
2277
|
+
throw new Error("the attempt was stopped before spawn custody completed");
|
|
2278
|
+
},
|
|
2279
|
+
envAllowlist: SETUP_ENV_ALLOWLIST,
|
|
2280
|
+
omitEnv: SETUP_ENV_DENYLIST,
|
|
2281
|
+
}));
|
|
2282
|
+
checkOutcomes.push(attemptOutcome(label, installed));
|
|
2283
|
+
noteCheckSuite(label, installed);
|
|
2284
|
+
checkLog.push(attemptLog(label, live.command, installed));
|
|
2285
|
+
if (installed.notFound || installed.timedOut || installed.code !== 0)
|
|
2286
|
+
return "own-install-failed";
|
|
2287
|
+
return await sealedTreeState(request.git ?? run) === "clean" ? null : "setup-changed-files";
|
|
2288
|
+
};
|
|
2289
|
+
const ownInstall = await ownInstallIfChanged();
|
|
2290
|
+
const first = ownInstall === null ? await runVerification("Project check · attempt 1") : null;
|
|
2291
|
+
if (first === null) {
|
|
2292
|
+
verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: ownInstall ?? "own-install-failed" };
|
|
2293
|
+
}
|
|
2294
|
+
else if (first.notFound || first.timedOut) {
|
|
2226
2295
|
verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: first.timedOut ? "timed-out" : "spawn-failed" };
|
|
2227
2296
|
}
|
|
2228
2297
|
else if (!verificationExecutableMissing(first)) {
|
|
@@ -2419,7 +2488,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2419
2488
|
now,
|
|
2420
2489
|
});
|
|
2421
2490
|
if (captureResult !== null) {
|
|
2422
|
-
reviewContext = captureResult.inventory.coverage.map(one => ({ id: one.id, state: one.state, inherited: one.inherited, items: one.items, gaps: one.gaps, priorSupport: one.priorSupport }));
|
|
2491
|
+
reviewContext = captureResult.inventory.coverage.map(one => ({ id: one.id, state: one.state, inherited: one.inherited, items: one.items, gaps: one.gaps, priorSupport: one.priorSupport, ...(one.assets === undefined ? {} : { assets: one.assets }) }));
|
|
2423
2492
|
}
|
|
2424
2493
|
}
|
|
2425
2494
|
catch {
|
|
@@ -3230,9 +3299,12 @@ flowCuts = []) {
|
|
|
3230
3299
|
" changed-file inventory or final check results. This applies to no-change",
|
|
3231
3300
|
" results too. Put useful caveats in the short handoff; do not invent evidence.",
|
|
3232
3301
|
" Run focused checks for your edits. The machine runs the approved full check.",
|
|
3302
|
+
" Save screenshots and journey output only under evidence/ (the run's",
|
|
3303
|
+
" evidence folder). They are never committed: images added there are left",
|
|
3304
|
+
" out of the commit, so do not save them anywhere else in the repository.",
|
|
3233
3305
|
" If screenshots are required, capture the actual candidate and list them in",
|
|
3234
3306
|
` ${proof}: { "version": 1, "screenshots": [`,
|
|
3235
|
-
' { "path": "<
|
|
3307
|
+
' { "path": "<evidence/… PNG or JPEG>", "caption": "<what it shows>" } ] }.',
|
|
3236
3308
|
` List at most ${PROOF_LIMITS.screenshots} screenshots, with paths/captions under ${PROOF_LIMITS.evidenceRef} UTF-8 bytes,`,
|
|
3237
3309
|
" on one line each; no absolute paths or dot segments. Images must be real,",
|
|
3238
3310
|
" at least 320 by 200 pixels. A list is optional; missing required images",
|
|
@@ -3355,7 +3427,7 @@ function fence(text) {
|
|
|
3355
3427
|
* code and concluded the task needed no change has done its job, and turning
|
|
3356
3428
|
* that into a failure would teach the loop to prefer writing something.
|
|
3357
3429
|
*/
|
|
3358
|
-
async function commit(git, worktree, branch, taskId, scope, summary, preparedCandidate = null) {
|
|
3430
|
+
async function commit(git, worktree, branch, taskId, scope, summary, preparedCandidate = null, notice) {
|
|
3359
3431
|
const status = await git(GIT, ["--no-optional-locks", "status", "--porcelain"], { cwd: worktree });
|
|
3360
3432
|
if (status.code !== 0) {
|
|
3361
3433
|
return { ok: false, reason: "commit-failure", message: firstLine(status.stderr) };
|
|
@@ -3375,6 +3447,20 @@ async function commit(git, worktree, branch, taskId, scope, summary, preparedCan
|
|
|
3375
3447
|
const add = await git(GIT, ["add", "-A", "--", ".", `:!${LEASE_MARKER}`, ":!STANDING-ORDERS-*", ":!NIGHTORDERS-*"], { cwd: worktree });
|
|
3376
3448
|
if (add.code !== 0)
|
|
3377
3449
|
return { ok: false, reason: "commit-failure", message: firstLine(add.stderr) };
|
|
3450
|
+
// Screenshots belong to the run's evidence, not the branch. Images added
|
|
3451
|
+
// under evidence/ are unstaged and left on disk, where the proof can still
|
|
3452
|
+
// name them; an exact prepared candidate is compared whole instead.
|
|
3453
|
+
if (preparedCandidate === null) {
|
|
3454
|
+
const stripped = await stripEvidenceImages(git, worktree);
|
|
3455
|
+
if (!stripped.ok)
|
|
3456
|
+
return { ok: false, reason: "commit-failure", message: stripped.message };
|
|
3457
|
+
if (stripped.notice !== null) {
|
|
3458
|
+
notice?.(stripped.notice);
|
|
3459
|
+
summary = `${summary}\n\n${stripped.notice}`;
|
|
3460
|
+
if (!stripped.staged)
|
|
3461
|
+
return { ok: true, committed: false, branch, summary };
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3378
3464
|
// Setup can create untracked files as well as edit tracked manifests.
|
|
3379
3465
|
// Re-prove the whole staged tree after the normal path exclusions, before
|
|
3380
3466
|
// committing, so neither can widen an exact prepared candidate.
|
|
@@ -3409,6 +3495,26 @@ async function commit(git, worktree, branch, taskId, scope, summary, preparedCan
|
|
|
3409
3495
|
}
|
|
3410
3496
|
return { ok: true, committed: true, branch, summary };
|
|
3411
3497
|
}
|
|
3498
|
+
/** The plain words for images left out of a commit. */
|
|
3499
|
+
export function evidenceImagesNotice(paths) {
|
|
3500
|
+
return `Left ${paths.length} image${paths.length === 1 ? "" : "s"} under ${WORKTREE_EVIDENCE_DIR} out of the commit: screenshots belong in the run's evidence, not the repository.`;
|
|
3501
|
+
}
|
|
3502
|
+
/** Unstage images newly added under evidence/; the files stay on disk. */
|
|
3503
|
+
async function stripEvidenceImages(git, worktree) {
|
|
3504
|
+
const added = await git(GIT, ["--no-optional-locks", "diff", "--cached", "--name-only", "-z", "--no-renames", "--diff-filter=A", "--", WORKTREE_EVIDENCE_DIR], { cwd: worktree });
|
|
3505
|
+
if (added.code !== 0)
|
|
3506
|
+
return { ok: false, message: firstLine(added.stderr) };
|
|
3507
|
+
const images = added.stdout.split("\0").filter(path => path !== "" && isImagePath(path));
|
|
3508
|
+
if (images.length === 0)
|
|
3509
|
+
return { ok: true, notice: null, staged: true };
|
|
3510
|
+
for (let at = 0; at < images.length; at += 200) {
|
|
3511
|
+
const removed = await git(GIT, ["rm", "--cached", "--quiet", "--", ...images.slice(at, at + 200).map(path => `:(literal)${path}`)], { cwd: worktree });
|
|
3512
|
+
if (removed.code !== 0)
|
|
3513
|
+
return { ok: false, message: firstLine(removed.stderr) };
|
|
3514
|
+
}
|
|
3515
|
+
const rest = await git(GIT, ["--no-optional-locks", "diff", "--cached", "--quiet"], { cwd: worktree });
|
|
3516
|
+
return { ok: true, notice: evidenceImagesNotice(images), staged: rest.code !== 0 };
|
|
3517
|
+
}
|
|
3412
3518
|
/**
|
|
3413
3519
|
* `claude --output-format json` returns an envelope: the result is the
|
|
3414
3520
|
* summary, and the session id is what lets a malformed park be repaired by
|
package/dist/chat-controls.d.ts
CHANGED
package/dist/chat-controls.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export const CHAT_CONTROLS = {
|
|
5
5
|
task: { label: "Open task", target: "task" },
|
|
6
6
|
approval: { label: "Review & start", target: "task" },
|
|
7
|
-
planning: { label: "
|
|
7
|
+
planning: { label: "Approve plan", target: "task" },
|
|
8
8
|
recovery: { label: "Review recovery options", target: "task" },
|
|
9
9
|
cancel: { label: "Cancel task", target: "task" },
|
|
10
10
|
result: { label: "Open result", target: "task" },
|
|
@@ -76,6 +76,11 @@ export type ChatContent = {
|
|
|
76
76
|
label: string;
|
|
77
77
|
path: string;
|
|
78
78
|
};
|
|
79
|
+
/** More link buttons after `link`: [Look first] beside [Merge] or [Mark complete]. */
|
|
80
|
+
also?: Array<{
|
|
81
|
+
label: string;
|
|
82
|
+
path: string;
|
|
83
|
+
}>;
|
|
79
84
|
/** A flow decision's buttons ride this part (v88): minted when it is planned. */
|
|
80
85
|
flow?: {
|
|
81
86
|
card: number;
|
package/dist/chat-delivery.js
CHANGED
|
@@ -9,7 +9,8 @@ import { answerChatFlowPrompt, applyChatFlowTap, flowDecisionParts } from "./cha
|
|
|
9
9
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
10
10
|
import { triggerConfigOf } from "./flow-triggers.js";
|
|
11
11
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
12
|
-
import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView
|
|
12
|
+
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
13
|
+
import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
|
|
13
14
|
import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
|
|
14
15
|
import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
|
|
15
16
|
import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
|
|
@@ -582,7 +583,7 @@ function planQuietCard(options, binding, notification, now) {
|
|
|
582
583
|
if (notification.taskRef === null || notification.taskId === null)
|
|
583
584
|
return;
|
|
584
585
|
const card = store.chatCardFor(`${state.channel}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
|
|
585
|
-
const view = quietCardView(store, card.tasks, now, options.evidenceRoot);
|
|
586
|
+
const view = quietCardView(store, card.tasks, now, options.evidenceRoot, binding.approver);
|
|
586
587
|
if (view === null)
|
|
587
588
|
return;
|
|
588
589
|
const content = { text: view.text, link: view.link, ...(card.tasks.length === 1 ? { task: notification.taskId } : {}) };
|
|
@@ -602,6 +603,37 @@ function planQuietCard(options, binding, notification, now) {
|
|
|
602
603
|
if (part !== undefined)
|
|
603
604
|
store.setChatCardMessage(card.id, String(part.id), shown);
|
|
604
605
|
}
|
|
606
|
+
/** Any update for this person joins their open batch (two minutes from its first), and the batch's one message
|
|
607
|
+
* is planned once, then repainted in place as more updates land. `notification`: an update that is not a finished
|
|
608
|
+
* result (null for one that is). False when nothing in the batch reads as a line, so the update goes out on its own. */
|
|
609
|
+
function planFinished(options, binding, taskRef, run, now, notification = null) {
|
|
610
|
+
const { state, store, identity } = options;
|
|
611
|
+
const batch = store.chatBatchFor(`${state.channel}:${binding.id}`, taskRef, run, now, BATCH_MS, notification);
|
|
612
|
+
const view = finishedView(store, batch, now, options.evidenceRoot, binding.approver);
|
|
613
|
+
if (view === null)
|
|
614
|
+
return false;
|
|
615
|
+
// A single update keeps its task and run, so a reply to it names that work.
|
|
616
|
+
const single = batch.items.length === 1 ? batch.items[0] : null;
|
|
617
|
+
const task = single === null ? null : store.refById(single.taskRef)?.externalId ?? null;
|
|
618
|
+
const content = { text: view.text, link: view.link, ...(view.also === undefined ? {} : { also: view.also }),
|
|
619
|
+
...(task === null ? {} : { task }), ...(single?.run == null ? {} : { run: single.run }) };
|
|
620
|
+
const shown = chatHash(JSON.stringify(content));
|
|
621
|
+
if (batch.message !== null) {
|
|
622
|
+
if (batch.digest === shown)
|
|
623
|
+
return true;
|
|
624
|
+
state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(JSON.stringify(content), now.toISOString(), Number(batch.message));
|
|
625
|
+
store.setChatBatchMessage(batch.id, batch.message, shown);
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
const id = chatHash(`${state.channel}:batch:${binding.id}:${batch.id}`);
|
|
629
|
+
state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
|
|
630
|
+
ts: "", thread: "", payload: "{}", created: now.toISOString() });
|
|
631
|
+
state.plan(id, [content], now);
|
|
632
|
+
const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
|
|
633
|
+
if (part !== undefined)
|
|
634
|
+
store.setChatBatchMessage(batch.id, String(part.id), shown);
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
605
637
|
/** One progress card per exact result; separate urgent facts retain their own review link. */
|
|
606
638
|
export async function planChatNotifications(options) {
|
|
607
639
|
const state = options.state, { store, identity } = options, bindings = state.bindings(identity.installation).filter(one => state.live(one));
|
|
@@ -645,15 +677,19 @@ export async function planChatNotifications(options) {
|
|
|
645
677
|
if (!needsPerson(notification))
|
|
646
678
|
return;
|
|
647
679
|
if (notification.kind === "run-finished" && run) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
state.plan(id, [{ text: ping.text, task: notification.taskId, run: run.id, link: ping.link }], now);
|
|
680
|
+
// Finished work goes out only as the batch line; one left out on purpose (a release check, a replaced
|
|
681
|
+
// task, the reader's own completion) stays quiet rather than falling through to another message.
|
|
682
|
+
planFinished(options, binding, notification.taskRef, run.id, now);
|
|
652
683
|
return;
|
|
653
684
|
}
|
|
685
|
+
// Every other update for this person within two minutes joins the same one message.
|
|
686
|
+
else if (notification.kind !== "run-finished" && joinsBatch(notification)) {
|
|
687
|
+
if (planFinished(options, binding, notification.taskRef, run?.id ?? null, now, notification.id))
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
654
690
|
}
|
|
655
691
|
if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
|
|
656
|
-
const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
|
|
692
|
+
const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot, binding.approver);
|
|
657
693
|
const content = {
|
|
658
694
|
text: card.text,
|
|
659
695
|
task: notification.taskId,
|
|
@@ -726,7 +762,8 @@ export async function planChatNotifications(options) {
|
|
|
726
762
|
});
|
|
727
763
|
state.plan(id, [
|
|
728
764
|
{
|
|
729
|
-
|
|
765
|
+
// Never the task's id or a "— revision" suffix (chat-voice.ts).
|
|
766
|
+
text: chatText(phoneText(notification.body === "" ? notification.subject : `${notification.subject}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
|
|
730
767
|
...(notification.taskId ? { task: notification.taskId } : {}),
|
|
731
768
|
...(run ? { run: run.id } : {}),
|
|
732
769
|
...(notification.link
|
package/dist/chat-quiet.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ProgressEntity } from "./telegram-progress.js";
|
|
2
2
|
import { type PhoneTaskLink } from "./telegram-status.js";
|
|
3
|
-
import { type
|
|
3
|
+
import { type ChatBatch, type Notification, type Store } from "./store.js";
|
|
4
|
+
/** `also`: more buttons after `link`, such as [Look first] beside [Merge]. */
|
|
4
5
|
export type QuietView = {
|
|
5
6
|
text: string;
|
|
6
7
|
entities: ProgressEntity[];
|
|
7
8
|
link: PhoneTaskLink;
|
|
9
|
+
also?: PhoneTaskLink[];
|
|
8
10
|
};
|
|
9
11
|
export declare function needsPerson(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient">): boolean;
|
|
10
12
|
/** A fact about one placed task: in quiet mode it lands on that task's card. */
|
|
@@ -14,9 +16,13 @@ export declare function isTaskFact(row: Pick<Notification, "taskRef" | "taskId"
|
|
|
14
16
|
project: string;
|
|
15
17
|
};
|
|
16
18
|
/** The card's words now: one task's progress card, or one line per task when several were filed together. */
|
|
17
|
-
export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string): QuietView | null;
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string, viewer?: string): QuietView | null;
|
|
20
|
+
/** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
|
|
21
|
+
* except a fact that must stand alone. */
|
|
22
|
+
export declare function joinsBatch(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient" | "taskRef">): boolean;
|
|
23
|
+
/** One person's update message: one task says what happened with its next step; several updates within two
|
|
24
|
+
* minutes become "4 tasks finished: …" (or "3 updates: …") with one Open button. Null when none can be read. */
|
|
25
|
+
export declare function finishedView(store: Store, batch: Pick<ChatBatch, "items">, now: Date, root?: string, viewer?: string): QuietView | null;
|
|
20
26
|
/** This computer's local day and time, which is what a person means by "the evening". */
|
|
21
27
|
export declare function localDay(now: Date): string;
|
|
22
28
|
export declare function localTime(now: Date): string;
|
package/dist/chat-quiet.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Delivery receipts, claims and retries stay with each transport; the
|
|
6
6
|
* console's activity keeps every fact whatever a chat showed. */
|
|
7
7
|
import { assignmentOf } from "./assignment.js";
|
|
8
|
-
import { chatControlHref } from "./chat-controls.js";
|
|
8
|
+
import { chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
9
9
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
10
|
-
import { assignmentStatusFacts, headlineEmoji,
|
|
10
|
+
import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-status.js";
|
|
11
|
+
import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions, READY_ACTIONS } from "./chat-voice.js";
|
|
11
12
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
12
13
|
import { isLifecycleNotification } from "./store.js";
|
|
13
14
|
/** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
|
|
@@ -49,25 +50,27 @@ const BEFORE_RUN = {
|
|
|
49
50
|
"task-requeued": before("Queued"),
|
|
50
51
|
"task-cancelled": before("Stopped"),
|
|
51
52
|
};
|
|
52
|
-
function taskLine(store, taskRef, now, root) {
|
|
53
|
+
function taskLine(store, taskRef, now, root, viewer) {
|
|
53
54
|
const ref = store.refById(taskRef);
|
|
54
55
|
if (ref === null || ref.repo === null)
|
|
55
56
|
return null;
|
|
56
57
|
const task = store.getTask(ref.externalId);
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// A short human title: never "— revision" or an id (chat-voice.ts).
|
|
59
|
+
const title = chatTitle(store, ref.externalId);
|
|
60
|
+
// Replaced, never "Cancelled": the card says a newer task took over and links to it, without either id.
|
|
59
61
|
const successor = task?.state === "cancelled" ? store.replacementOf(ref.externalId) : null;
|
|
60
62
|
if (successor !== null) {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
63
|
+
const words = "Replaced by a newer task";
|
|
64
|
+
const heading = `${headlineEmoji("Stopped")} ${words}`;
|
|
65
|
+
const text = [title, heading, "", projectLabel(ref.repo)].join("\n");
|
|
66
|
+
return { title, icon: headlineEmoji("Stopped"), status: words, project: ref.repo, view: {
|
|
64
67
|
text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
65
|
-
link: { label:
|
|
68
|
+
link: { label: "Open the new task", path: chatControlHref("task", successor) },
|
|
66
69
|
} };
|
|
67
70
|
}
|
|
68
71
|
const run = cardRun(store, taskRef);
|
|
69
72
|
if (run !== null) {
|
|
70
|
-
const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root);
|
|
73
|
+
const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root, viewer);
|
|
71
74
|
const heading = view.entities[1] === undefined ? "" : view.text.slice(view.entities[1].offset, view.entities[1].offset + view.entities[1].length);
|
|
72
75
|
const split = heading.indexOf(" ");
|
|
73
76
|
return { title, icon: heading.slice(0, split), status: heading.slice(split + 1), project: ref.repo, view };
|
|
@@ -75,15 +78,15 @@ function taskLine(store, taskRef, now, root) {
|
|
|
75
78
|
const fact = store.latestTaskFact(taskRef);
|
|
76
79
|
const words = task?.state === "cancelled" ? BEFORE_RUN["task-cancelled"] : BEFORE_RUN[fact?.kind ?? ""] ?? BEFORE_RUN["task-filed"];
|
|
77
80
|
const heading = `${words.icon} ${words.status}`;
|
|
78
|
-
const text = [title, heading, "",
|
|
81
|
+
const text = [title, heading, "", projectLabel(ref.repo)].join("\n");
|
|
79
82
|
return { title, icon: words.icon, status: words.status, project: ref.repo, view: {
|
|
80
83
|
text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
|
|
81
84
|
link: { label: "Open task", path: chatControlHref("task", ref.externalId) },
|
|
82
85
|
} };
|
|
83
86
|
}
|
|
84
87
|
/** The card's words now: one task's progress card, or one line per task when several were filed together. */
|
|
85
|
-
export function quietCardView(store, taskRefs, now, root) {
|
|
86
|
-
const lines = taskRefs.map(ref => taskLine(store, ref, now, root)).filter((one) => one !== null);
|
|
88
|
+
export function quietCardView(store, taskRefs, now, root, viewer) {
|
|
89
|
+
const lines = taskRefs.map(ref => taskLine(store, ref, now, root, viewer)).filter((one) => one !== null);
|
|
87
90
|
if (lines.length === 0)
|
|
88
91
|
return null;
|
|
89
92
|
if (lines.length === 1)
|
|
@@ -93,14 +96,76 @@ export function quietCardView(store, taskRefs, now, root) {
|
|
|
93
96
|
const text = [heading, ...lines.map(one => `${one.icon} ${phoneText(one.title, 60)} · ${one.status}`), "", projects.join(", ")].join("\n");
|
|
94
97
|
return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/tasks" } };
|
|
95
98
|
}
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
/** Facts that carry their own controls or must stand alone (a security alert, a decision, a flow card, a
|
|
100
|
+
* screenshot, an acceptance packet) are never folded into a batch. */
|
|
101
|
+
const OWN_MESSAGE_KINDS = new Set(["security-release", "secret-detected", "flow-decision", "flow-card", "acceptance-ready", "acceptance-evidence"]);
|
|
102
|
+
/** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
|
|
103
|
+
* except a fact that must stand alone. */
|
|
104
|
+
export function joinsBatch(row) {
|
|
105
|
+
return row.taskRef !== null && row.recipient === null && needsPerson(row) && !OWN_MESSAGE_KINDS.has(row.kind) && !/^decision:\d+$/.test(row.dedupeKey);
|
|
106
|
+
}
|
|
107
|
+
/** A few words for an update in a list, and how it counts: "has a plan to review", "failed". */
|
|
108
|
+
function updatePhrase(row) {
|
|
109
|
+
if (row.kind === "plan-ready")
|
|
110
|
+
return { phrase: "has a plan to review", headline: "Needs you" };
|
|
111
|
+
if (row.kind === "report-ready")
|
|
112
|
+
return { phrase: "has a report to read", headline: "Update" };
|
|
113
|
+
if (row.kind === "stale-approval" || row.kind === "approval-withdrawn")
|
|
114
|
+
return { phrase: "needs a fresh approval", headline: "Needs you" };
|
|
115
|
+
if (row.pushClass === "merge" || row.kind === "pull-request-ready")
|
|
116
|
+
return { phrase: "is ready to merge", headline: "Update" };
|
|
117
|
+
if (/fail|exhausted|stalled|fenced/.test(row.kind))
|
|
118
|
+
return { phrase: "failed", headline: "Failed" };
|
|
119
|
+
return { phrase: "needs you", headline: "Needs you" };
|
|
120
|
+
}
|
|
121
|
+
/** One task's line in a batch: a finished result in a person's words with its real next step, or another
|
|
122
|
+
* update in its own words with its own link. */
|
|
123
|
+
function lineOf(store, item, now, root, viewer) {
|
|
124
|
+
const ref = store.refById(item.taskRef);
|
|
125
|
+
if (ref === null || ref.repo === null)
|
|
126
|
+
return null;
|
|
127
|
+
const summary = chatTitle(store, ref.externalId);
|
|
128
|
+
const row = item.notification === null ? null : store.notificationById(item.notification);
|
|
129
|
+
if (row !== null && row.kind !== "run-finished") {
|
|
130
|
+
// Never the task's id or a "— revision" suffix; the title once, in front unless the words already name it.
|
|
131
|
+
const words = chatText(phoneText(row.body === "" ? row.subject : `${row.subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
|
|
132
|
+
const { phrase, headline } = updatePhrase(row);
|
|
133
|
+
return { also: [], link: row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link },
|
|
134
|
+
fact: { summary, headline, checks: null, report: false, completedBy: null, update: { words: mentions(words, summary) ? words : `${summary} · ${words}`, phrase } } };
|
|
135
|
+
}
|
|
136
|
+
const run = item.run === null ? null : store.getRun(item.run);
|
|
137
|
+
if (run === null)
|
|
138
|
+
return null;
|
|
139
|
+
const card = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root, viewer);
|
|
140
|
+
const checks = card.facts.checks ?? null;
|
|
141
|
+
const pullRequest = card.facts.pullRequest?.state === "open";
|
|
142
|
+
const fact = {
|
|
143
|
+
summary,
|
|
144
|
+
headline: card.status.headline,
|
|
145
|
+
checks: checks === null ? null : checks.level === "off" && checks.status !== "passed" ? "off"
|
|
146
|
+
: checks.status === "passed" || checks.status === "failed" || checks.status === "not-run" ? checks.status : null,
|
|
147
|
+
report: card.facts.report === true,
|
|
148
|
+
completedBy: card.facts.completedBy ?? null,
|
|
149
|
+
pullRequest,
|
|
150
|
+
// The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
|
|
151
|
+
lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
|
|
152
|
+
};
|
|
153
|
+
// A result ready for a person offers its real next step, then a look first.
|
|
154
|
+
if (asksToFinish(fact))
|
|
155
|
+
return { fact, also: [{ label: READY_ACTIONS.look, path: chatResultHref(ref.externalId, run.id, "changes") }],
|
|
156
|
+
link: pullRequest ? { label: READY_ACTIONS.merge, path: `/t/${encodeURIComponent(ref.externalId)}#merge` } : { label: READY_ACTIONS.complete, path: chatResultHref(ref.externalId, run.id) } };
|
|
157
|
+
return { fact, link: card.link, also: [] };
|
|
158
|
+
}
|
|
159
|
+
/** One person's update message: one task says what happened with its next step; several updates within two
|
|
160
|
+
* minutes become "4 tasks finished: …" (or "3 updates: …") with one Open button. Null when none can be read. */
|
|
161
|
+
export function finishedView(store, batch, now, root, viewer) {
|
|
162
|
+
const lines = batch.items.map(one => lineOf(store, one, now, root, viewer)).filter((one) => one !== null);
|
|
163
|
+
if (lines.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
const text = batchLine(lines.map(one => one.fact));
|
|
166
|
+
if (lines.length > 1)
|
|
167
|
+
return { text, entities: [], link: { label: "Open", path: "/tasks" } };
|
|
168
|
+
return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }) };
|
|
104
169
|
}
|
|
105
170
|
// ---- the evening digest ----------------------------------------------------------
|
|
106
171
|
const FAILURE = /fail|exhausted|fenced/;
|
|
@@ -118,7 +183,7 @@ export function eveningDigestText(store, account, now, root) {
|
|
|
118
183
|
const since = new Date(now.getTime() - 86_400_000).toISOString();
|
|
119
184
|
const recent = new Date(now.getTime() - 14 * 86_400_000).toISOString();
|
|
120
185
|
const visible = (row) => row.project !== null && store.accountCanAccess(account, row.project);
|
|
121
|
-
const title = (taskId) =>
|
|
186
|
+
const title = (taskId) => chatTitle(store, taskId);
|
|
122
187
|
const finished = new Map(), failed = new Map(), waits = new Map();
|
|
123
188
|
const facts = store.taskFactsSince(recent).filter(visible);
|
|
124
189
|
for (const row of facts) {
|